Compare commits
No commits in common. "a67e0e7d7596c972244309b2a0779d3335c9905b" and "a3dfa8a3371e59464ebe1bbde73e1de6ee71a2d3" have entirely different histories.
a67e0e7d75
...
a3dfa8a337
@ -528,8 +528,8 @@ modules:
|
||||
- id: intent-catalog
|
||||
name: 意图目录
|
||||
area: backend
|
||||
status: in_progress
|
||||
progress: 15
|
||||
status: planned
|
||||
progress: 0
|
||||
updated: 2026-10-04
|
||||
note: "两层模型(人持有的意图目录 .blueprint/catalog.md + 蓝图正文),书装意图、指向事实;卡侧 intent 钉版,agent 编译可判定 /goal,确认卡兼作最小任务简报,回执写进提交。IC1 设计已确认(最小文件优先方案);IC2–IC4 只新增文档、可与 P8 并行,门控与接线等 P8 收口后做,IC13 按实测数据决定去留。"
|
||||
position: [4000, 950]
|
||||
|
||||
@ -331,16 +331,13 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
|
||||
- review: "2026-10-03 独立只读审查 bd4ebaf,结论 fix_then_merge,10 条发现处置:M1 [中] MCP 入参原样落库(正斜杠、尾部分隔符、长路径前缀、8.3、`..`),scan_unregistered_projects 按字面比较会自相矛盾 → 已修:本体对 Windows 目录 canonicalize 后去长路径前缀落库,register_project_at 回执 path 为落库值、名称缺省按规范形式取目录名(相对路径不 canonicalize,交本体拒绝);scan 的已登记判定与父目录去重改用 registration_path_key。M2 [中] UI 不选目录的概念占位被取消,超出 P8f 范围且与 mcp-server 模块记录的用户用法冲突 → 已恢复原行为:本体新增 RootRequirement,UI 入口有效路径为空时只写 profile+workspace、不校验目录、不查重复,MCP 入口仍拒绝;是否改为显式占位概念交统筹会话问用户(需求澄清),本 PR 不预设答案。L1 [低] 已有登记两列都比,残留列误拦 → 已修:按 platform 只取有效根目录(registration_root),查询补 platform 列。L2 [低] 8.3、`..`、末尾点等漏判 → 已修:新目录在写事务外另算 canonical 键,两键比对已有行的字面键(已有行不读磁盘,写锁内无 I/O)。L3 [低] mount_repo 检查在插 profile 之后且无事务 → 已修:抽 mount_repo_records(连接注入),检查前移、整体 IMMEDIATE 事务;前端先 gitClone 再 mount 的白克隆属 UI 范围,未改。L4 [低] 路径比较键多份实现 → 部分修:registration_path_key 改为复用 mcp_inject::normalize_dsh_root + strip_verbatim_prefix(两函数改 pub(crate));normalize_trust_path、WSL 发行版查询与 UNC 换算的第三份拷贝统一抽 crate::path_key 转后续小修(会动 P4a/P8d 热点 mcp_inject.rs)。I1 [信息] update_project 仍可造同目录登记 → 转后续小修:UI 编辑整表提交,须「有效根目录实际变化才检查 + 排除自身」,否则存量重复登记(beast-mode)连改名都会被拦,单独做更稳。I2 [信息] project_root_with_conn 对 UNC 形式的 wsl_path 错误拼接(已有缺陷)→ 不在本 PR 改(mcp_inject.rs 热点),转后续小修;卡片与代码注释里「同口径」改为只指取列与 Linux 路径换算。I3 [信息] 重登目录遗留旧 context.json 无提示 → 已修:register_project_at 只读检查,project_id 不同时回执与摘要带 warning(不沿用旧 ID)。I4 [信息] 分发分支无测试 → 已修:分发抽成 register_project_call(conn, args),测试按 schema 声明的参数名构造入参;IMMEDIATE 并发仍无测试(见没验什么③)。"
|
||||
- mcp_verify: "PR #37 合并(55dc637);P8e 合并后经全局 MCP 实测链路 register_project → plan_onboarding → onboard_project 得 status=done(证据同 P8e mcp_verify);同目录换写法再登记被拒并给出已有 project_id。"
|
||||
|
||||
### 🔵 P8g — UI 一键接入
|
||||
- status: in_progress
|
||||
### 📋 P8g — UI 一键接入
|
||||
- status: todo
|
||||
- complexity: M
|
||||
- files: src/components/dashboard/OnboardPanel.tsx, src/components/dashboard/onboardView.ts, src/components/dashboard/onboardView.test.ts, src/components/dashboard/ProjectModal.tsx, src/components/dashboard/BatchAddModal.tsx, src/components/manage/NewProjectModal.tsx, src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts
|
||||
- depends: P8e
|
||||
- acceptance: "agent_verify: [pnpm typecheck && pnpm test] 视图纯函数:blocked 非空时确认按钮禁用并列出文件和原因;not_installed 和 not_applicable 显示为灰色说明、不显示为红色失败;bypassPermissions 和 Codex 信任单独列出;确认时携带 expected_digest。human_verify: ① 用临时 git 仓库新建项目,弹出接入预览;② 确认后 30 秒内看到分步结果、L1 矩阵和「待你提交的 N 个文件」;③ 另试一次在预览里取消,目录零改动;④ 在该目录新开 Claude Code、Codex、DSH 桌面 GUI 会话调 LJ_PROBE_NONEXISTENT,得「开发需求未登记」。"
|
||||
- 说明:新建 / 模板创建成功后弹出「接入预览」,create_project 本身保持无副作用;批量添加只提供批量入口、不自动接入;BlueprintModal 的「应用接入包」改为先 plan 再 apply。
|
||||
- 实现(2026-10-04,分支 feat/agent-client-parity-p8g,统筹会话主线程直做——两次委派卡死于一次性生成大文件):onboardView.ts 视图纯函数(计划预览、确认请求、报告、L1 矩阵;判定全取后端字段);OnboardPanel.tsx 全局挂载于 App、经 useUIStore.openOnboard 打开(新建弹窗保存即卸载,面板须独立存在),状态机 planning → preview → applying → report,过期响应按请求序号丢弃、接入中不可关闭,refused 附新计划可回预览重新确认;plan.rs 的 BlockedItem 序列化附派生字段 skippable(可跳过判定只在本体,反序列化忽略、不进 digest),commands.ts 同步;入口:ProjectModal 新建且按 platform 的有效根目录非空才弹(概念占位不弹),NewProjectModal 模板创建成功自动弹、完成页可再开,BatchAddModal 添加后列出有目录的新项目逐个「一键接入」,BlueprintModal「施加 Buff」改为同一面板(buff 已激活时另给「重新检查接入」),关闭后按报告刷新 buff 与 init prompt;project-context.yaml 同步 skippable 与 UI 投影。
|
||||
- 有意偏差:"① 呈现块拆到 OnboardSections.tsx(files 外新增,控制单文件规模)。② Toast 层级 z-50 → z-[70](审查 ②,否则接入提示被面板遮住)。③ 本地 confirm 函数改名 applyConfirmed:confirmUsage.test.ts 扫描 confirm( 防同步确认框,属正确的守卫。"
|
||||
- review: "2026-10-04 独立只读审查 5b6c197,结论 merge,5 条:① [低] 新建入口按任一路径非空判是否弹面板,与后端按 platform 取有效根目录不同口径 → 已修。② [低] Toast z-50 被面板 z-[60] 遮住 → 已修。③ [低] 已有 permissions 又取消 bypass 时文案称会写 default → 已改文案(如实说明两种情况)。④ [信息] L1 格子着色在前端写死 codex_project_untrusted=待你操作,轻度复制判定 → 转后续小修(L1 格子带 pending_user 字段)。⑤ [信息] onboard_project 抛错后「取消」提示仍称不写入 → 已修(错误阶段换提示)。审查核实:确认带 plan.digest 与生成计划时的选项;refused 回预览时 planned 与新计划同一组选项(apply.rs 用请求选项重算);四条 agent_verify 有实断言。"
|
||||
|
||||
### ✅ P9 — DSH 桌面 GUI 按工作区连项目地址(会话级插件)
|
||||
- status: done
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 意图目录(intent-catalog)
|
||||
|
||||
> status: in_progress | created: 2026-10-04 | 设计:docs/tasks/2026-10-04-intent-catalog-design.md(IC1 ✅,用户 2026-10-04 确认)| 指南:docs/ai-context/intent-catalog-guide.md(IC2 ✅)| 下一步:IC3(首批章节由用户定稿)
|
||||
> status: planned | created: 2026-10-04 | 设计:docs/tasks/2026-10-04-intent-catalog-design.md(IC1 ✅,用户 2026-10-04 确认)| 下一步:IC2
|
||||
> 来源:2026-10-03~04 与用户讨论(/goal 与 Loop Engineering 操作手册、目录/正文模型、承重墙),以及侦察工作流 wf_6dc3756a-b72 的重叠与冲突扫描。
|
||||
|
||||
## 概述
|
||||
@ -103,8 +103,8 @@ agent 开发子项目时躲不掉的现象。每面墙都要回答四个问题
|
||||
- acceptance: "agent_verify: [读文件] 设计文档含方案选择、设计规格 1–7、13 问化解表、W1–W14 四问表、受影响文件、系统边界变更、风险点、拆解建议、度量与退出、立法草案、没验什么。human_verify: 用户确认设计(含 5 个待拍板问题),确认后模块转 planned 并追加 IC2–IC13。"
|
||||
- notes: 2026-10-04 评审团工作流 wf_60e4c430-bb1 出稿(最小文件优先方案胜出,两位评委 25/30、24/30),设计文档已写,等用户确认。产出 `docs/tasks/<日期>-intent-catalog-design.md`,以 W1–W14 为纲、书模型为体,逐条化解上面的待设计问题,写明收益度量与退出条件;完成后交用户确认,确认后模块转 planned,再用 /splitter 拆成 S/M 卡。
|
||||
|
||||
### ✅ IC2 — 新增 intent-catalog-guide.md:目录语法、修订钉版、goal 编译七步、确认卡与回执、离线检查
|
||||
- status: done
|
||||
### 📋 IC2 — 新增 intent-catalog-guide.md:目录语法、修订钉版、goal 编译七步、确认卡与回执、离线检查
|
||||
- status: todo
|
||||
- complexity: S
|
||||
- files: docs/ai-context/intent-catalog-guide.md
|
||||
- depends: IC1
|
||||
|
||||
@ -1,212 +0,0 @@
|
||||
# 意图目录编写与使用指南
|
||||
|
||||
> 适用对象:所有 agent(Claude Code、Codex、DSH 等)和人。本文件是意图目录语法与 goal 编译流程的**单一来源**。
|
||||
> 模块:`.blueprint/modules/intent-catalog.md`。设计依据:`docs/tasks/2026-10-04-intent-catalog-design.md`(临时文件,IC3 后删除)。
|
||||
> 试用期(P8 收口前)只用文档和 shell 单行命令;门控脚本 `intent-gate.cjs` 在 Phase 4 落地后替换「离线检查」一节。
|
||||
|
||||
意图目录 `.blueprint/catalog.md` 是蓝图之上由人持有的一层:每章写一个意图方向、怎么判断做对了、不做什么。任务卡单向挂到章上,agent 据此把意图编译成评估器能判定的 /goal。**书装意图,指向事实**:代码、状态、规格、凭据都不进目录,目录里只放指针。
|
||||
|
||||
| 角色 | 写什么 | 不写什么 |
|
||||
|---|---|---|
|
||||
| 人 | 章的意图、人看、不做;大版本修订;确认小版本、书签切换和 goal 确认卡 | 卡的状态(由 agent 判定);机器坐标的具体命令 |
|
||||
| agent | 机器坐标指针、小版本提案、goal 编译、收口回执、卡上的 `- 终验:` 记录 | 章的完成状态(只能推导);替人改意图 |
|
||||
| 炼境 | 无,只经 get_project_file 只读提供同一份文件 | 不编译 goal、不选模型、不判定完成 |
|
||||
|
||||
## 目录语法
|
||||
|
||||
**位置与骨架**:`.blueprint/catalog.md`,放在 modules/ 之外,依次为引言行、书签、各章、墓碑、附录。
|
||||
|
||||
```markdown
|
||||
# 意图目录
|
||||
> catalog_version: 1.0 | 前言 → path:AGENTS.md#项目定位 | 语法 → path:docs/ai-context/intent-catalog-guide.md
|
||||
|
||||
## 书签
|
||||
- 当前 → path:docs/tasks/2026-10-03-coordination.md#0. 方针与本阶段终点
|
||||
|
||||
## INT-01 <章名:一个意图方向>
|
||||
- rev: 1.0
|
||||
- 意图: <一句话,人的原意>
|
||||
- 人看: <人在投影上看到什么就算做对了>
|
||||
- 机器: <可断言坐标的类型化指针,例如 card:<module>#<卡键>>
|
||||
- 不做: <边界;多条用「;」分隔>
|
||||
|
||||
## INT-09(已撤销)
|
||||
- 撤销: YYYY-MM-DD → module:<id>#决策记录
|
||||
|
||||
## 附录
|
||||
- <名称> → <类型化指针>
|
||||
```
|
||||
|
||||
**章导语字段**:
|
||||
|
||||
| 字段 | 必填 | 含义 |
|
||||
|---|---|---|
|
||||
| rev | 是 | 该章最后一次变更时的目录版本,不大于 catalog_version |
|
||||
| 意图 | 是 | 人的原意,一句话 |
|
||||
| 人看 | 是 | 人看的投影坐标(对应 human_verify) |
|
||||
| 机器 | 是 | agent 可断言的坐标,只写指针,不写命令全文 |
|
||||
| 不做 | 是 | 边界,agent 扩范围时的刹车 |
|
||||
| 风险 | 否 | 低、中、高,或指向不可逆操作的说明 |
|
||||
| 假设 | 否 | 指向既有的证伪落点(某张 spike 卡或复盘),目录里不存证伪状态 |
|
||||
| 证据 | 否 | 指向已有证据(卡上的 verify 记录、PR) |
|
||||
| 墙 | 否 | 关联的承重墙编号,例如 `W12,W13` |
|
||||
|
||||
**防膨胀(硬限制)**:
|
||||
|
||||
- 每个字段只写一行,不超过 120 字;
|
||||
- 每个章块不超过 12 行;
|
||||
- 全文件不超过 200 行、8KB;
|
||||
- 禁止出现 `status`、`progress`、`doc`、`files`、`acceptance`、`model` 键,也不写模型名;
|
||||
- 不设文件内修订历史,修订历史看 git log。
|
||||
|
||||
**类型化指针**(多个指针用「;」分隔):
|
||||
|
||||
| 前缀 | 写法 | 校验方式 |
|
||||
|---|---|---|
|
||||
| path: | `path:<相对路径>[#标题前缀]` | 文件存在;有 `#` 时标题能匹配 |
|
||||
| module: | `module:<id>[#段名]` | `.blueprint/modules/<id>.md` 存在 |
|
||||
| card: | `card:<module>#<卡键>` | 该模块里存在这张卡;卡键取标题去掉状态前缀后的第一个 token,例如 `IC6`、`P8e` |
|
||||
| spec: | `spec:<路径>#标题` | 指向子项目已有的规格(例如 `.specify/specs/...`),只指向不复制 |
|
||||
| cmd: | `cmd:<命令或工具名>` | 只校验格式,不执行 |
|
||||
| W | `W1`–`W14` | 承重墙编号,定义见模块文件「承重墙」节 |
|
||||
|
||||
**ID 规则**:章用两位数 `INT-NN`,永不复用。撤销的章保留墓碑 `## INT-NN(已撤销)`,注意编号后面紧跟全角括号、没有空格。
|
||||
|
||||
**卡侧挂靠(单向)**:
|
||||
|
||||
- 卡上可选写一行 `- intent: INT-NN@X.Y`,放在 `- complexity:` 后面,表示这张卡服务哪一章、编译自目录哪一版。
|
||||
- 模块头 blockquote 可选写 `| intent: INT-NN` 作为整个模块的默认挂靠,不带版本,影响分析里计为低置信。
|
||||
- 章的「机器:」只是验收坐标,不登记成员名单。反向索引现算,不存储。
|
||||
|
||||
**终验记录**:卡上另起一行 `- 终验: YYYY-MM-DD 通过|否决:<理由>`,由 agent 记录人在会话里给出的裁决。不要写进 `- notes:`,因为多条 notes 后一条会覆盖前一条。
|
||||
|
||||
**解析器约束**:模块 md 里,「任务卡」段以外一律不用 `###`。未识别的 `##` 段下出现 `###` 会被当成任务卡。
|
||||
|
||||
## 修订与钉版
|
||||
|
||||
**版本**:引言里写一个 `catalog_version: X.Y`;每章有自己的 `rev`。这套版本号与 manifest.iteration 互不借号。
|
||||
|
||||
| 级别 | 什么时候 | 怎么做 | 版本变化 |
|
||||
|---|---|---|---|
|
||||
| 补丁 | 只改正文(卡、模块),目录不动 | agent 自主完成,照常走需求对齐协议 | 无 |
|
||||
| 小版本 | 改某章的人看、机器或不做;由正文反推修改目录 | agent 提出 S 级三行确认卡,附 diff 和 base blob,人说「确认」 | Y+1;被改章的 rev = 新版本 |
|
||||
| 大版本 | 增删章节、调整方向 | 人主导,agent 协助起草 | X+1、Y 归零;涉及的章更新 rev;删掉的章留墓碑;按 T4 检查 AGENTS.md「项目定位」 |
|
||||
|
||||
**提案**:附上目录 diff 和 base blob,base blob 用下面的命令取:
|
||||
|
||||
```bash
|
||||
git rev-parse --short HEAD:.blueprint/catalog.md
|
||||
```
|
||||
|
||||
人确认后提交,提交前再取一次 blob。如果已经不等于 base,说明目录被别人改过,必须重新提案,不能硬合。
|
||||
|
||||
**提交格式**:
|
||||
|
||||
- 首行:`docs(intent): <改了什么>`
|
||||
- body:一句理由,必要时写来源,例如某次终验否决、某张卡的设计发现。
|
||||
- 末尾:带 `Feature-Confirmed: true`。
|
||||
- 目录改动单独小提交,不和代码混在一起。
|
||||
|
||||
**钉版**:卡写 `- intent: INT-NN@X.Y`;goal 标签写 `INT-NN@X.Y#<blob7>`。
|
||||
|
||||
**影响分析**:
|
||||
|
||||
| 判定 | 条件 | 处理 |
|
||||
|---|---|---|
|
||||
| stale | 卡钉的版本低于章 rev,且卡未完成 | 复核 acceptance。没变化就改钉,算补丁,并在简报里报告;有变化就按小版本走 |
|
||||
| orphan | 卡钉在已撤销的章上 | 停下找人,决定改挂哪一章还是撤卡 |
|
||||
| low_confidence | 卡只有模块默认挂靠,没有卡侧钉版 | 编译 goal 前补写卡侧 intent |
|
||||
|
||||
**在制品保护**:钉旧版的 🔵 卡照常做完,只复核 acceptance。章被撤销,或新的「不做」覆盖了这张卡时,停下找人。任何情况下都不自动改写卡。
|
||||
|
||||
## goal 编译
|
||||
|
||||
本流程只依赖读文件和 git,不依赖任何特定客户端或模型。Claude 的 /architect 只是可选包装,任何 agent 都可以照着手工执行。产出是一张目标确认卡加一段 /goal 文本。
|
||||
|
||||
1. **定位**
|
||||
- 读 `.lian-jing/context.json` 取 project_id;记下当前分支、worktree 路径和 `git rev-parse --short HEAD`。
|
||||
- 要求 `git status --porcelain -- .blueprint/catalog.md` 输出为空:目录有未提交改动时不编译。
|
||||
- 取 catalog blob:`git rev-parse --short HEAD:.blueprint/catalog.md`。
|
||||
2. **读书签**:书签指向当前章(P8 期间指向 coordination §0 的终点)。读该章的导语和 rev。
|
||||
3. **校验**:跑「离线检查」一节的全部命令(Phase 4 后改为 `node .claude/hooks/intent-gate.cjs --report INT-NN --impact`)。只要出现悬空指针、stale 或 orphan、防膨胀超限,就先修或先提案,不往下编译。
|
||||
4. **收卡**
|
||||
- 取挂在本章、标题前缀为 📋 或 🔵 的卡。
|
||||
- 没有可执行 agent_verify 的卡,列入「须先补 acceptance」,不进 goal。
|
||||
5. **合成终点**
|
||||
- 取各卡 agent_verify 的合取,改写成在会话里能判定的形式:贴出命令和预期输出、纳入卡前缀都是 ✅、回执已写入。
|
||||
- human_verify 不进终点,单列为终验待办。
|
||||
- 过程词黑名单命中数必须为 0:「按方针」「符合设计」「设计已完成」「基本完成」,以及任何无法从会话输出判定的形容词。
|
||||
6. **写停止条件**:遇到以下任一情况必须停下找人:
|
||||
- human_verify;
|
||||
- 写运行中的数据库;
|
||||
- 删除文件、数据或分支;
|
||||
- 凭据或机器状态变更;
|
||||
- 超出纳入卡或本章范围;
|
||||
- 需要修改目录;
|
||||
- 出现 stale 或 orphan;
|
||||
- 同一问题失败 3 次。
|
||||
7. **输出**:一张目标确认卡(格式见下一节),加 /goal 文本,首行带同样的标签。人读完亲手粘贴就算确认;要改就重新编译。agent 不得自行启动 goal,也不得静默改写 goal 文本。
|
||||
|
||||
## 确认卡与回执
|
||||
|
||||
**目标确认卡**(兼作最小任务简报,新会话只读它就能开工):
|
||||
|
||||
```text
|
||||
[INT-NN@X.Y#<blob7>] 目标确认卡
|
||||
项目: <project_id> | 分支/worktree: <branch> @ <path> | HEAD: <sha7>
|
||||
章意图: <一句> | 不做: <…>
|
||||
纳入卡: <卡键>(来源 agent_verify 原文)、<卡键>(…)
|
||||
终点(合取): 1) <命令> → <预期输出> 2) … 3) 纳入卡均 ✅ 且已写 Goal-Receipt
|
||||
终验待办: <各卡 human_verify>
|
||||
停止找人: <第 6 步清单>
|
||||
能力入口: <校验命令,例如 pnpm typecheck / cargo test;可用的 MCP 工具>
|
||||
没验什么: <编译时已知的盲区>
|
||||
作废: 分支变化或 catalog blob ≠ <blob7>
|
||||
```
|
||||
|
||||
由非 Claude 客户端编译时,在提交 body 里另加一行 `Compiled-By: <客户端名>`,用于跨 agent 验证。
|
||||
|
||||
**收口回执**:goal 收口时写进收口提交的 body;M 级同时复制到 PR body;炼境在线时可以再调一次 append_project_note。
|
||||
|
||||
```text
|
||||
Goal-Receipt: INT-NN@X.Y#<blob7>
|
||||
Goal-Cards: <纳入卡键列表>
|
||||
Goal-Result: 判对|误判|中止(<原因>)
|
||||
Goal-Rounds: <轮数>
|
||||
Goal-Interventions: <人工介入次数>
|
||||
Goal-Tokens: <客户端可得时填,否则 n/a>
|
||||
Not-Verified: <没验什么>
|
||||
Human-Pending: <待人终验项>
|
||||
```
|
||||
|
||||
回执不写进模块决策记录,因为 UI 会把它当决策展示;也不要手改 `.blueprint/notes-archive.jsonl`,它以炼境 DB 为事实源,会被整文件重写。
|
||||
|
||||
## 离线检查
|
||||
|
||||
试用期的检查全部是 shell 单行命令,在仓库根目录执行。期望输出写在每条后面的注释里。
|
||||
|
||||
```bash
|
||||
# 1. 版本行恰好一行
|
||||
grep -c '^> catalog_version: ' .blueprint/catalog.md # → 1
|
||||
# 2. 有效章数(墓碑不计)
|
||||
grep -cE '^## INT-[0-9]{2} ' .blueprint/catalog.md # → 章数
|
||||
# 3. 体量
|
||||
wc -l < .blueprint/catalog.md; wc -c < .blueprint/catalog.md # → ≤200 行、≤8192 字节
|
||||
# 4. 禁用键与模型名
|
||||
grep -cE '^- (status|progress|doc|files|acceptance|model):' .blueprint/catalog.md # → 0
|
||||
# 5. path 指针可解析(没有输出即通过)
|
||||
grep -oE 'path:[^ #;|]+' .blueprint/catalog.md | sed 's/^path://' | sort -u | while read -r p; do test -e "$p" || echo "悬空: $p"; done
|
||||
# 6. module 指针可解析(没有输出即通过)
|
||||
grep -oE 'module:[a-z0-9-]+' .blueprint/catalog.md | sed 's/^module://' | sort -u | while read -r m; do test -e ".blueprint/modules/$m.md" || echo "悬空: $m"; done
|
||||
# 7. 卡侧 intent 引用的章都存在且未撤销(没有输出即通过)
|
||||
comm -23 <(grep -hoE '^- intent: INT-[0-9]{2}' .blueprint/modules/*.md | grep -oE 'INT-[0-9]{2}' | sort -u) <(grep -oE '^## INT-[0-9]{2} ' .blueprint/catalog.md | grep -oE 'INT-[0-9]{2}' | sort -u)
|
||||
# 8. 钉版与章 rev 对照(人工比对:钉的版本低于 rev 且卡未完成,即为 stale)
|
||||
grep -hoE 'intent: INT-[0-9]{2}@[0-9]+\.[0-9]+' .blueprint/modules/*.md | sort | uniq -c
|
||||
awk '/^## INT-[0-9]{2} /{c=$2} /^- rev:/{print c, $3}' .blueprint/catalog.md
|
||||
# 9. 改模块 md 前后的任务卡计数应相等(新增卡时等于新增数),防止误写 ###
|
||||
cat .blueprint/modules/*.md | grep -c '^### '
|
||||
# 10. goal 文本的过程词黑名单(把 /goal 文本存成临时文件后检查)
|
||||
grep -cE '按方针|符合设计|设计已完成|基本完成' <goal 文本文件> # → 0
|
||||
```
|
||||
|
||||
card: 指针目前只能人工核对,到模块文件里搜索卡键,Phase 4 后由门控自动校验。指针中带 `#标题` 的部分,也是同样的人工核对方式。
|
||||
File diff suppressed because one or more lines are too long
@ -121,27 +121,13 @@ impl BlockReason {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BlockedItem {
|
||||
pub target: String,
|
||||
pub reason: BlockReason,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 序列化时附派生字段 skippable(= BlockReason::skippable):UI 与 agent 据此决定是否给「跳过」,
|
||||
/// 可跳过判定只在本体一处(P8g,AGENTS.md「边界单点实现」)。反序列化忽略该字段;digest 不含它。
|
||||
impl Serialize for BlockedItem {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut item = serializer.serialize_struct("BlockedItem", 4)?;
|
||||
item.serialize_field("target", &self.target)?;
|
||||
item.serialize_field("reason", &self.reason)?;
|
||||
item.serialize_field("detail", &self.detail)?;
|
||||
item.serialize_field("skippable", &self.reason.skippable())?;
|
||||
item.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置。
|
||||
/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | unavailable
|
||||
/// | manual_kept | other_project | needs_human(后三者阻断确认,见 machine_step_blocker)。
|
||||
@ -1054,28 +1040,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 阻断项序列化带派生字段 skippable(P8g 界面据此给「跳过」,不在前端复制判定);反序列化忽略它。
|
||||
#[test]
|
||||
fn blocked_item_serializes_skippable_from_reason() {
|
||||
for (reason, skippable) in [
|
||||
(BlockReason::ManualEntry, true),
|
||||
(BlockReason::SharedKey, true),
|
||||
(BlockReason::MixedFile, false),
|
||||
(BlockReason::IdentityConflict, false),
|
||||
(BlockReason::NeedsHuman, false),
|
||||
] {
|
||||
let item = BlockedItem {
|
||||
target: "t".into(),
|
||||
reason,
|
||||
detail: "d".into(),
|
||||
};
|
||||
let value = serde_json::to_value(&item).unwrap();
|
||||
assert_eq!(value["skippable"], skippable, "{reason:?}");
|
||||
assert_eq!(value["target"], "t");
|
||||
assert_eq!(serde_json::from_value::<BlockedItem>(value).unwrap(), item);
|
||||
}
|
||||
}
|
||||
|
||||
/// 身份冲突、混合文件与需人工处理的项不可跳过(P8d 审查 ②):即使列进 skip_targets 也照旧阻断。
|
||||
#[test]
|
||||
fn identity_conflict_and_needs_human_cannot_be_skipped() {
|
||||
|
||||
@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
|
||||
import { OverviewPage } from "./components/overview/OverviewPage";
|
||||
import { Dashboard } from "./components/dashboard/Dashboard";
|
||||
import { ProjectModal } from "./components/dashboard/ProjectModal";
|
||||
import { OnboardPanel } from "./components/dashboard/OnboardPanel";
|
||||
import { ResourcesPage } from "./components/resources/ResourcesPage";
|
||||
import { ToolsPage } from "./components/tools/ToolsPage";
|
||||
import { SettingsPage } from "./components/settings/SettingsPage";
|
||||
@ -76,7 +75,6 @@ export default function App() {
|
||||
</main>
|
||||
|
||||
<ProjectModal />
|
||||
<OnboardPanel />
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,47 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { batchCreateProjects, getSpaces, scanSubfolders, type OnboardStatus, type SubFolder } from "../../lib/commands";
|
||||
import { batchCreateProjects, getSpaces, scanSubfolders, type SubFolder } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { Dialog } from "../ui/Dialog";
|
||||
|
||||
/** 本次添加成功的项目:批量只给入口、不自动接入(P8g),每个项目各自预览确认 */
|
||||
interface AddedProject {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 本次面板里的接入结果;cancelled = 打开后未执行接入 */
|
||||
onboard?: OnboardStatus | "cancelled";
|
||||
}
|
||||
|
||||
const ONBOARD_STATUS_LABEL: Record<OnboardStatus | "cancelled", string> = {
|
||||
done: "✓ 接入完成",
|
||||
partial: "⚠ 部分完成",
|
||||
failed: "✗ 预检失败",
|
||||
refused: "⚠ 已拒绝,需重新确认",
|
||||
busy: "… 正在接入中",
|
||||
cancelled: "未接入",
|
||||
};
|
||||
|
||||
function AddedProjects({ added, onOnboard }: { added: AddedProject[]; onOnboard: (project: AddedProject) => void }) {
|
||||
return (
|
||||
<div className="px-6 py-4 space-y-2">
|
||||
<p className="text-sm text-gray-700">已添加 {added.length} 个项目。一键接入需逐个预览并确认,不会自动写入子项目:</p>
|
||||
{added.map((project) => (
|
||||
<div key={project.id} className="flex items-center gap-3 border border-gray-100 rounded-lg px-3 py-2">
|
||||
<span className="flex-1 min-w-0 truncate text-sm text-gray-800">{project.name}</span>
|
||||
<span className="text-xs text-gray-500">{project.onboard ? ONBOARD_STATUS_LABEL[project.onboard] : "未接入"}</span>
|
||||
<button
|
||||
onClick={() => onOnboard(project)}
|
||||
className="px-3 py-1 rounded-lg border border-blue-200 text-xs text-blue-700 hover:bg-blue-50 shrink-0"
|
||||
>
|
||||
{project.onboard === "done" ? "重新检查" : "一键接入"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Windows 路径转 WSL 路径(仅 /mnt/x/... 形式,不处理 UNC)
|
||||
function winToWsl(p: string): string {
|
||||
const norm = p.replace(/\\/g, "/");
|
||||
@ -59,19 +22,7 @@ interface FolderItem extends SubFolder {
|
||||
|
||||
export function BatchAddModal({ onClose }: { onClose: () => void }) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||||
const qc = useQueryClient();
|
||||
const [added, setAdded] = useState<AddedProject[] | null>(null);
|
||||
|
||||
const onboardOne = (project: AddedProject) =>
|
||||
openOnboard({
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
onClosed: (report) =>
|
||||
setAdded((prev) =>
|
||||
prev?.map((p) => (p.id === project.id ? { ...p, onboard: report?.status ?? p.onboard ?? "cancelled" } : p)) ?? prev,
|
||||
),
|
||||
});
|
||||
|
||||
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
|
||||
const currentSpace = spaces.find((s) => s.is_current);
|
||||
@ -152,12 +103,7 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) {
|
||||
} else {
|
||||
showToast(`成功添加 ${selected.length} 个项目 ✓`);
|
||||
}
|
||||
// 没有目录的登记(概念占位)不给接入入口
|
||||
const created = payload
|
||||
.filter((p) => !skipped.includes(p.id) && (p.win_path || p.wsl_path))
|
||||
.map((p) => ({ id: p.id, name: p.name }));
|
||||
if (created.length > 0) setAdded(created);
|
||||
else onClose();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
@ -167,19 +113,6 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const checkedCount = items.filter((i) => i.checked).length;
|
||||
|
||||
if (added) {
|
||||
return (
|
||||
<Dialog title="批量添加项目" onClose={onClose} size="lg">
|
||||
<AddedProjects added={added} onOnboard={onboardOne} />
|
||||
<div className="flex justify-end px-6 py-4 border-t border-gray-100">
|
||||
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50">
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="批量添加项目" onClose={onClose} size="lg">
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
getBlueprintStatus,
|
||||
syncBlueprintRules,
|
||||
generateBlueprintPrompt,
|
||||
applyOnboardingPack,
|
||||
getCommitStats,
|
||||
removeBlueprintBuff,
|
||||
getBuffStatus,
|
||||
@ -30,10 +31,10 @@ import {
|
||||
type BlueprintStatus,
|
||||
type SyncResult,
|
||||
type BuffStatus,
|
||||
type TemplateResult,
|
||||
type HookActivation,
|
||||
type CommitStats,
|
||||
type OnboardReport,
|
||||
} from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
@ -53,6 +54,19 @@ const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string }
|
||||
abandoned: { color: "#52525B", bg: "#FAFAFA", label: "已退役" },
|
||||
};
|
||||
|
||||
// 接入包模板分发五态(对应 onboarding.rs TemplateResult.action)
|
||||
// label 为 null 时展示后端 detail(updated/appended/failed 的 detail 含版本信息)
|
||||
const TEMPLATE_ACTION_STYLE: Record<
|
||||
string,
|
||||
{ icon: string; color: string; label: string | null }
|
||||
> = {
|
||||
written: { icon: "✓", color: "text-green-600", label: "已写入" },
|
||||
updated: { icon: "↑", color: "text-blue-600", label: null },
|
||||
appended: { icon: "+", color: "text-blue-600", label: null },
|
||||
skipped: { icon: "○", color: "text-gray-400", label: "已存在" },
|
||||
failed: { icon: "✗", color: "text-red-600", label: null },
|
||||
};
|
||||
|
||||
const TASK_PREFIX: Record<string, string> = {
|
||||
done: "✅",
|
||||
in_progress: "🔵",
|
||||
@ -1044,9 +1058,8 @@ function GovernancePanel({
|
||||
const [buffError, setBuffError] = useState<string | null>(null);
|
||||
const [buffInitPrompt, setBuffInitPrompt] = useState<string | null>(null);
|
||||
const [buffInitCopied, setBuffInitCopied] = useState(false);
|
||||
/** 最近一次一键接入的结果摘要(详情在接入面板里看过) */
|
||||
const [onboardSummary, setOnboardSummary] = useState<string | null>(null);
|
||||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||||
const [packTemplates, setPackTemplates] = useState<TemplateResult[] | null>(null);
|
||||
const [packHook, setPackHook] = useState<HookActivation | null>(null);
|
||||
|
||||
const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@ -1054,29 +1067,28 @@ function GovernancePanel({
|
||||
const [promptError, setPromptError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
/** 接入面板关闭后:未执行接入不动;执行过则刷新 buff,尚无蓝图时给 init prompt */
|
||||
const afterOnboard = async (report: OnboardReport | null) => {
|
||||
if (!report) return;
|
||||
setOnboardSummary(`${report.status}:${report.summary}`);
|
||||
refetchBuff();
|
||||
if (report.status !== "done" && report.status !== "partial") return;
|
||||
// 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化
|
||||
if (bpStatus?.status === "none") {
|
||||
try {
|
||||
setBuffInitPrompt(await generateBlueprintPrompt(projectPath, "init"));
|
||||
} catch (e) {
|
||||
setBuffError(String(e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 「应用接入包」改为一键接入:先 plan 预览、人确认后再 apply(P8g),与新建项目走同一面板
|
||||
const handleApplyBuff = () => {
|
||||
const handleApplyBuff = async () => {
|
||||
if (!projectId) return;
|
||||
setBuffLoading(true);
|
||||
setBuffError(null);
|
||||
setBuffInitPrompt(null);
|
||||
setOnboardSummary(null);
|
||||
openOnboard({ projectId, projectName, onClosed: (report) => void afterOnboard(report) });
|
||||
setPackTemplates(null);
|
||||
setPackHook(null);
|
||||
try {
|
||||
const report = await applyOnboardingPack(projectPath, projectId);
|
||||
setPackTemplates(report.templates);
|
||||
setPackHook(report.hook);
|
||||
refetchBuff();
|
||||
// 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化
|
||||
if (bpStatus?.status === "none") {
|
||||
const p = await generateBlueprintPrompt(projectPath, "init").catch(() => null);
|
||||
if (p) setBuffInitPrompt(p);
|
||||
}
|
||||
} catch (e) {
|
||||
setBuffError(String(e));
|
||||
} finally {
|
||||
setBuffLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveBuff = async () => {
|
||||
@ -1086,7 +1098,8 @@ function GovernancePanel({
|
||||
await removeBlueprintBuff(projectPath);
|
||||
refetchBuff();
|
||||
setBuffInitPrompt(null);
|
||||
setOnboardSummary(null);
|
||||
setPackTemplates(null);
|
||||
setPackHook(null);
|
||||
} catch (e) {
|
||||
setBuffError(String(e));
|
||||
} finally {
|
||||
@ -1283,7 +1296,7 @@ function GovernancePanel({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||
一键接入:先预览将写入的接入包文件(AGENTS.md / lefthook / git-workflow 等)与本机三端配置,确认后再写入,并挂 buff 自动监听 git 提交、采集飞轮数据。
|
||||
应用接入包:种入 AGENTS.md / lefthook / git-workflow 标准模板(不覆盖已有),并挂 buff 自动监听 git 提交、采集飞轮数据。
|
||||
</p>
|
||||
{buffStatus?.last_sync_at && (
|
||||
<p className="text-[10px] text-gray-400">上次同步:{buffStatus.last_sync_at}</p>
|
||||
@ -1291,27 +1304,54 @@ function GovernancePanel({
|
||||
{buffError && (
|
||||
<p className="text-[11px] text-red-600 font-mono break-all">{buffError}</p>
|
||||
)}
|
||||
{onboardSummary && (
|
||||
<p className="text-[10px] text-gray-500 break-all">最近一次一键接入:{onboardSummary}</p>
|
||||
{packTemplates && (
|
||||
<div className="space-y-0.5 pt-0.5">
|
||||
<p className="text-[10px] text-gray-500 font-semibold">模板分发</p>
|
||||
{packTemplates.map((t) => {
|
||||
const style =
|
||||
TEMPLATE_ACTION_STYLE[t.action] ?? TEMPLATE_ACTION_STYLE.failed;
|
||||
return (
|
||||
<div key={t.name}>
|
||||
<p className="text-[10px] font-mono flex items-center gap-1">
|
||||
<span className={style.color}>{style.icon}</span>
|
||||
<span className="text-gray-600 truncate flex-1">{t.name}</span>
|
||||
<span className="text-gray-400">{style.label ?? t.detail}</span>
|
||||
</p>
|
||||
{t.warning && (
|
||||
<p className="text-[10px] text-amber-600 leading-relaxed pl-3">⚠ {t.warning}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{packHook && (
|
||||
<p className="text-[10px] font-mono flex items-center gap-1">
|
||||
<span
|
||||
className={
|
||||
packHook.status === "activated"
|
||||
? "text-green-600"
|
||||
: packHook.status === "missing_binary"
|
||||
? "text-amber-500"
|
||||
: "text-red-500"
|
||||
}
|
||||
>
|
||||
{packHook.status === "activated" ? "✓" : packHook.status === "missing_binary" ? "⚠" : "✗"}
|
||||
</span>
|
||||
<span className="text-gray-600 truncate flex-1">lefthook hook</span>
|
||||
<span className="text-gray-400 truncate" title={packHook.detail}>
|
||||
{packHook.detail}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{buffStatus?.status === "active" ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleApplyBuff}
|
||||
disabled={buffLoading || !projectId}
|
||||
title={!projectId ? "需要传入 projectId" : "重新计划并预览,确认后补齐缺失项"}
|
||||
className="flex-1 py-1.5 rounded-lg border border-purple-200 text-purple-700 text-xs hover:bg-purple-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
重新检查接入
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemoveBuff}
|
||||
disabled={buffLoading}
|
||||
className="flex-1 py-1.5 rounded-lg border border-red-200 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{buffLoading ? "处理中…" : "撤销 Buff"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveBuff}
|
||||
disabled={buffLoading}
|
||||
className="w-full py-1.5 rounded-lg border border-red-200 text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{buffLoading ? "处理中…" : "撤销 Buff"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleApplyBuff}
|
||||
@ -1319,7 +1359,7 @@ function GovernancePanel({
|
||||
title={!projectId ? "需要传入 projectId" : undefined}
|
||||
className="w-full py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
⚡ 一键接入(先预览再确认)
|
||||
{buffLoading ? "施加中…" : "⚡ 施加 Buff"}
|
||||
</button>
|
||||
)}
|
||||
{/* 已有蓝图时施加 Buff:直接激活,无需初始化 */}
|
||||
|
||||
@ -1,189 +0,0 @@
|
||||
// 一键接入面板(agent-client-parity P8g):plan_onboarding 预览 → 人确认 → onboard_project → 分步结果。
|
||||
// 全局挂载在 App,由 useUIStore.openOnboard 打开。判定全部来自后端,映射见 onboardView.ts。
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { onboardProject, planOnboarding, type OnboardReport, type OnboardingPlan } from "../../lib/commands";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import {
|
||||
DEFAULT_SELECTION,
|
||||
buildPlanView,
|
||||
buildReportView,
|
||||
confirmRequest,
|
||||
selectionOptions,
|
||||
setBypass,
|
||||
toggleSkip,
|
||||
type OnboardSelection,
|
||||
} from "./onboardView";
|
||||
import { PlanPreview, ReportResult } from "./OnboardSections";
|
||||
|
||||
type Phase =
|
||||
| { kind: "planning" }
|
||||
| { kind: "preview"; plan: OnboardingPlan; planned: OnboardSelection }
|
||||
| { kind: "applying"; plan: OnboardingPlan; planned: OnboardSelection }
|
||||
| { kind: "report"; report: OnboardReport }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function OnboardPanel() {
|
||||
const target = useUIStore((s) => s.onboardTarget);
|
||||
if (!target) return null;
|
||||
// key:换项目即重置面板状态
|
||||
return <OnboardPanelBody key={target.projectId} />;
|
||||
}
|
||||
|
||||
function OnboardPanelBody() {
|
||||
const target = useUIStore((s) => s.onboardTarget)!;
|
||||
const closeOnboard = useUIStore((s) => s.closeOnboard);
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const qc = useQueryClient();
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "planning" });
|
||||
const [selection, setSelection] = useState<OnboardSelection>(DEFAULT_SELECTION);
|
||||
const lastReport = useRef<OnboardReport | null>(null);
|
||||
const requestSeq = useRef(0);
|
||||
|
||||
const plan = useCallback(
|
||||
async (chosen: OnboardSelection) => {
|
||||
const seq = ++requestSeq.current;
|
||||
setPhase({ kind: "planning" });
|
||||
try {
|
||||
const next = await planOnboarding(target.projectId, selectionOptions(chosen));
|
||||
if (seq === requestSeq.current) setPhase({ kind: "preview", plan: next, planned: chosen });
|
||||
} catch (e) {
|
||||
if (seq === requestSeq.current) setPhase({ kind: "error", message: String(e) });
|
||||
}
|
||||
},
|
||||
[target.projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void plan(DEFAULT_SELECTION);
|
||||
}, [plan]);
|
||||
|
||||
const applyConfirmed = async () => {
|
||||
if (phase.kind !== "preview") return;
|
||||
const request = confirmRequest(phase.plan, phase.planned, selection);
|
||||
if (!request) return;
|
||||
const seq = ++requestSeq.current;
|
||||
setPhase({ kind: "applying", plan: phase.plan, planned: phase.planned });
|
||||
try {
|
||||
const report = await onboardProject(request.projectId, request.expectedDigest, request.confirmed, request.options);
|
||||
if (seq !== requestSeq.current) return;
|
||||
lastReport.current = report;
|
||||
setPhase({ kind: "report", report });
|
||||
showToast(buildReportView(report).toast, 4000);
|
||||
void qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
} catch (e) {
|
||||
if (seq === requestSeq.current) setPhase({ kind: "error", message: String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
/** refused 附当前计划:回到预览,按新计划重新确认(选项沿用后端拒绝时的那组) */
|
||||
const reviewReplan = (next: OnboardingPlan) => {
|
||||
if (phase.kind !== "report") return;
|
||||
setPhase({ kind: "preview", plan: next, planned: selection });
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (phase.kind === "applying") return; // 写入进行中不允许关闭,避免误以为已取消
|
||||
requestSeq.current += 1;
|
||||
const report = lastReport.current;
|
||||
closeOnboard();
|
||||
target.onClosed?.(report);
|
||||
};
|
||||
|
||||
const title = `一键接入${target.projectName ? ` · ${target.projectName}` : ""}`;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl mx-4 flex flex-col" style={{ maxHeight: "88vh" }}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<h2 className="text-base font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={close}
|
||||
disabled={phase.kind === "applying"}
|
||||
className="text-gray-400 hover:text-gray-600 text-xl leading-none disabled:opacity-30"
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-4 text-sm">
|
||||
{phase.kind === "planning" && <p className="text-gray-400">正在生成接入计划(只读,不写任何文件)…</p>}
|
||||
{phase.kind === "error" && (
|
||||
<p className="text-red-600 font-mono text-xs break-all">❌ {phase.message}</p>
|
||||
)}
|
||||
{(phase.kind === "preview" || phase.kind === "applying") && (
|
||||
<PlanPreview
|
||||
view={buildPlanView(phase.plan, phase.planned, selection)}
|
||||
disabled={phase.kind === "applying"}
|
||||
onToggleSkip={(item) => setSelection((s) => toggleSkip(s, item))}
|
||||
onBypass={(on) => setSelection((s) => setBypass(s, on))}
|
||||
/>
|
||||
)}
|
||||
{phase.kind === "report" && (
|
||||
<ReportResult view={buildReportView(phase.report)} onReviewReplan={reviewReplan} />
|
||||
)}
|
||||
</div>
|
||||
<PanelFooter
|
||||
phase={phase}
|
||||
selection={selection}
|
||||
onCancel={close}
|
||||
onReplan={() => void plan(selection)}
|
||||
onConfirm={() => void applyConfirmed()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btnCls = "px-4 py-2 rounded-lg text-sm border transition-colors disabled:opacity-50";
|
||||
|
||||
function PanelFooter({
|
||||
phase,
|
||||
selection,
|
||||
onCancel,
|
||||
onReplan,
|
||||
onConfirm,
|
||||
}: {
|
||||
phase: Phase;
|
||||
selection: OnboardSelection;
|
||||
onCancel: () => void;
|
||||
onReplan: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const view = phase.kind === "preview" ? buildPlanView(phase.plan, phase.planned, selection) : null;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100 shrink-0">
|
||||
{view?.confirm.reason && <p className="flex-1 text-xs text-amber-700">{view.confirm.reason}</p>}
|
||||
{phase.kind === "applying" && (
|
||||
<p className="flex-1 text-xs text-gray-500">正在接入:首次可能 30 秒以上,完成前请勿关闭…</p>
|
||||
)}
|
||||
{phase.kind === "report" ? (
|
||||
<button onClick={onCancel} className={`${btnCls} border-gray-200 text-gray-700 hover:bg-gray-50`}>
|
||||
关闭
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={phase.kind === "applying"}
|
||||
className={`${btnCls} border-gray-200 text-gray-600 hover:bg-gray-50`}
|
||||
title={phase.kind === "error" ? "关闭面板;若错误发生在确认接入之后,可能已有部分写入,请重新计划核对" : "取消不写入任何文件"}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
{(phase.kind === "error" || (view && (view.needsReplan || view.blockingCount > 0))) && (
|
||||
<button onClick={onReplan} className={`${btnCls} border-blue-200 text-blue-700 hover:bg-blue-50`}>
|
||||
{phase.kind === "error" ? "重试" : "重新计划"}
|
||||
</button>
|
||||
)}
|
||||
{(phase.kind === "preview" || phase.kind === "applying") && (
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={phase.kind === "applying" || !view?.confirm.enabled}
|
||||
className={`${btnCls} border-transparent bg-blue-600 text-white hover:bg-blue-700`}
|
||||
>
|
||||
{phase.kind === "applying" ? "接入中…" : "确认接入"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,277 +0,0 @@
|
||||
// 一键接入面板的呈现块(P8g):计划预览与接入结果。只渲染 onboardView 的视图模型,不做判定。
|
||||
import type { ReactNode } from "react";
|
||||
import type { OnboardBlockedItem, OnboardingPlan } from "../../lib/commands";
|
||||
import { PARITY_CHECKS, CHECK_LABEL, type OnboardTone, type PlanView, type ReportView } from "./onboardView";
|
||||
|
||||
const TONE_CLS: Record<OnboardTone, string> = {
|
||||
ok: "text-green-700 bg-green-50 border-green-200",
|
||||
info: "text-blue-700 bg-blue-50 border-blue-200",
|
||||
muted: "text-gray-500 bg-gray-50 border-gray-200",
|
||||
pending: "text-violet-700 bg-violet-50 border-violet-200",
|
||||
warn: "text-amber-700 bg-amber-50 border-amber-200",
|
||||
danger: "text-red-700 bg-red-50 border-red-200",
|
||||
};
|
||||
|
||||
function ToneBadge({ tone, children }: { tone: OnboardTone; children: ReactNode }) {
|
||||
return (
|
||||
<span className={`inline-block shrink-0 text-[10px] px-1.5 py-0.5 rounded border ${TONE_CLS[tone]}`}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, extra, children }: { title: string; extra?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-semibold text-gray-500">{title}</p>
|
||||
{extra}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, tone, badge, detail, mono }: { label: ReactNode; tone: OnboardTone; badge: string; detail?: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-xs">
|
||||
<span className={`flex-1 min-w-0 truncate text-gray-700 ${mono ? "font-mono" : ""}`}>{label}</span>
|
||||
{detail && <span className="max-w-[55%] text-gray-400 truncate" title={detail}>{detail}</span>}
|
||||
<ToneBadge tone={tone}>{badge}</ToneBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 计划预览 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PlanPreview({
|
||||
view,
|
||||
disabled,
|
||||
onToggleSkip,
|
||||
onBypass,
|
||||
}: {
|
||||
view: PlanView;
|
||||
disabled: boolean;
|
||||
onToggleSkip: (item: OnboardBlockedItem) => void;
|
||||
onBypass: (on: boolean) => void;
|
||||
}) {
|
||||
const { fileCounts } = view;
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">
|
||||
将写入 <span className="font-mono text-gray-700">{view.root}</span>
|
||||
<span className="ml-2 text-gray-300 font-mono" title={view.digest}>digest {view.digest.slice(0, 8)}</span>
|
||||
</p>
|
||||
|
||||
{view.blocked.length > 0 && (
|
||||
<Section title="阻断项" extra={view.blockingCount > 0 && <ToneBadge tone="danger">{view.blockingCount} 项阻断确认</ToneBadge>}>
|
||||
{view.blocked.map((row) => (
|
||||
<div key={`${row.target}:${row.item.reason}`} className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[row.tone]}`}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono flex-1 min-w-0 truncate">{row.target}</span>
|
||||
<span>{row.reasonLabel}</span>
|
||||
<ToneBadge tone={row.tone}>{row.stateLabel}</ToneBadge>
|
||||
{row.toggleLabel && (
|
||||
<button
|
||||
onClick={() => onToggleSkip(row.item)}
|
||||
disabled={disabled}
|
||||
className="px-2 py-0.5 rounded border border-current text-[11px] hover:bg-white/60 disabled:opacity-50"
|
||||
>
|
||||
{row.toggleLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] opacity-80 break-all">{row.detail}</p>
|
||||
<p className="text-[11px] opacity-70">{row.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="需要你留意">
|
||||
<div className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[view.bypass.tone]}`}>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
{view.bypass.toggleable && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={view.bypass.selected}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onBypass(e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{view.bypass.label}</span>
|
||||
</label>
|
||||
<p className="text-[11px] opacity-80">{view.bypass.detail}</p>
|
||||
</div>
|
||||
<div className={`rounded-lg border p-2 space-y-1 ${TONE_CLS[view.codexTrust.tone]}`}>
|
||||
<p className="text-xs font-medium">{view.codexTrust.label}</p>
|
||||
{view.codexTrust.detail && <p className="text-[11px] opacity-80 break-all">{view.codexTrust.detail}</p>}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={`项目文件(${view.files.length})`}
|
||||
extra={<span className="text-[11px] text-gray-400">新建 {fileCounts.create} · 更新 {fileCounts.update} · 不变 {fileCounts.noop}</span>}
|
||||
>
|
||||
{view.files.map((file) => (
|
||||
<div key={file.path} className="space-y-0.5">
|
||||
<Row label={file.path} mono tone={file.tone} badge={file.actionLabel} />
|
||||
{file.warning && <p className="text-[11px] text-amber-700 pl-2">⚠ {file.warning}</p>}
|
||||
{file.diff && file.action !== "noop" && (
|
||||
<details className="pl-2">
|
||||
<summary className="text-[11px] text-gray-400 cursor-pointer">查看差异</summary>
|
||||
<pre className="text-[10px] bg-gray-50 border border-gray-100 rounded p-2 overflow-x-auto max-h-48">{file.diff}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="本机配置(不进仓库)">
|
||||
{view.machineSteps.map((step) => (
|
||||
<Row key={step.step} label={step.label} tone={step.tone} badge={step.statusLabel} detail={step.detail} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{view.nextActions.length > 0 && (
|
||||
<Section title="接入后由你完成(不阻断)">
|
||||
{view.nextActions.map((action) => (
|
||||
<Row key={action.action} label={action.label} tone={action.tone} badge="待办" detail={action.detail} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 接入结果 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function L1Matrix({ l1 }: { l1: NonNullable<ReportView["l1"]> }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-gray-400">
|
||||
<th className="text-left font-normal py-1 pr-2">检查项</th>
|
||||
{l1.columns.map((column) => (
|
||||
<th key={column.client} className="text-left font-normal py-1 px-1">
|
||||
{column.label}
|
||||
{column.version && <span className="ml-1 text-gray-300 font-mono">{column.version}</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PARITY_CHECKS.map((check) => (
|
||||
<tr key={check} className="border-t border-gray-50">
|
||||
<td className="py-1 pr-2 text-gray-600">
|
||||
{check} {CHECK_LABEL[check]}
|
||||
</td>
|
||||
{l1.columns.map((column) => {
|
||||
const cell = column.cells[check];
|
||||
return (
|
||||
<td key={column.client} className="py-1 px-1" title={`${cell.evidenceLabel}:${cell.detail}`}>
|
||||
<ToneBadge tone={cell.tone}>{cell.label}</ToneBadge>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{l1.note && <p className="text-[10px] text-gray-400 mt-1">{l1.note}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportResult({
|
||||
view,
|
||||
onReviewReplan,
|
||||
}: {
|
||||
view: ReportView;
|
||||
onReviewReplan: (plan: OnboardingPlan) => void;
|
||||
}) {
|
||||
const { commit } = view;
|
||||
return (
|
||||
<>
|
||||
<div className={`rounded-lg border p-3 space-y-1 ${TONE_CLS[view.tone]}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{view.statusLabel}</span>
|
||||
<span className="text-[11px] opacity-70">用时 {view.durationLabel}</span>
|
||||
</div>
|
||||
<p className="text-xs">{view.summary}</p>
|
||||
{view.reason && <p className="text-xs break-all">原因:{view.reason}</p>}
|
||||
{view.replan && (
|
||||
<button
|
||||
onClick={() => onReviewReplan(view.replan!)}
|
||||
className="mt-1 px-3 py-1 rounded border border-current text-xs hover:bg-white/60"
|
||||
>
|
||||
查看当前计划并重新确认
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view.steps.length > 0 && (
|
||||
<Section title="分步结果">
|
||||
{view.steps.map((step) => (
|
||||
<Row
|
||||
key={step.step}
|
||||
label={step.note ? `${step.label}(${step.note})` : step.label}
|
||||
tone={step.tone}
|
||||
badge={step.statusLabel}
|
||||
detail={step.detail}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{view.l1 && (
|
||||
<Section title="三端检查(L1 零模型)" extra={view.l1.allApplicableReady && <ToneBadge tone="ok">适用格全部就绪</ToneBadge>}>
|
||||
<L1Matrix l1={view.l1} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{commit.state === "available" && (
|
||||
<Section title={commit.title}>
|
||||
{commit.files.map((file) => (
|
||||
<p key={file.path} className="text-xs font-mono text-gray-700">
|
||||
<span className="text-gray-400 mr-2 whitespace-pre">{file.code}</span>
|
||||
{file.path}
|
||||
</p>
|
||||
))}
|
||||
{commit.otherWarning && <p className="text-[11px] text-amber-700">⚠ {commit.otherWarning}</p>}
|
||||
{commit.suggestedMessage && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
建议提交信息:<span className="font-mono text-gray-700">{commit.suggestedMessage}</span>
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{commit.state === "unavailable" && <p className="text-[11px] text-gray-400">无法列出改动:{commit.reason}</p>}
|
||||
{view.head && <p className={`text-[11px] ${view.head.unchanged ? "text-gray-400" : "text-amber-700"}`}>{view.head.label}</p>}
|
||||
|
||||
{(view.pendingUser.length > 0 || view.nextActions.length > 0) && (
|
||||
<Section title="待你操作">
|
||||
{view.pendingUser.map((row) => (
|
||||
<Row key={row.item} label={row.item} tone={row.tone} badge={row.label} detail={row.detail} />
|
||||
))}
|
||||
{view.nextActions.map((action) => (
|
||||
<Row key={action.action} label={action.label} tone={action.tone} badge="待办" detail={action.detail} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{view.completion.length > 0 && (
|
||||
<details>
|
||||
<summary className="text-xs text-gray-500 cursor-pointer">
|
||||
完成判定明细({view.completion.filter((row) => row.ready).length}/{view.completion.length} 就绪)
|
||||
</summary>
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{view.completion.map((row) => (
|
||||
<Row key={`${row.source}:${row.item}`} label={row.item} tone={row.tone} badge={row.label} detail={row.detail} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400">{view.l3}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -19,7 +19,6 @@ export function ProjectModal() {
|
||||
const editTarget = useUIStore((s) => s.editTarget);
|
||||
const closeEdit = useUIStore((s) => s.closeEdit);
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces });
|
||||
@ -94,12 +93,6 @@ const pickFolder = async (field: "wsl_path" | "win_path") => {
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
qc.invalidateQueries({ queryKey: ["tagMaps"] });
|
||||
closeEdit();
|
||||
// create_project 本身无副作用;选了目录的新项目弹出接入预览(P8g),由人确认后才写入。不选目录的概念占位不接入
|
||||
// 与后端登记同口径:按 platform 只看有效根目录那一列(另一列残留不算)
|
||||
const effectiveRoot = form.platform === "wsl" ? form.wsl_path : form.win_path;
|
||||
if (isNew && effectiveRoot?.trim()) {
|
||||
openOnboard({ projectId: form.id!, projectName: form.name?.trim() });
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`❌ ${e}`);
|
||||
} finally {
|
||||
|
||||
@ -1,290 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ClientParityL1Report,
|
||||
OnboardBlockedItem,
|
||||
OnboardReport,
|
||||
OnboardingPlan,
|
||||
ParityCell,
|
||||
ParityCheck,
|
||||
} from "../../lib/commands";
|
||||
import {
|
||||
DEFAULT_SELECTION,
|
||||
buildPlanView,
|
||||
buildReportView,
|
||||
confirmRequest,
|
||||
l1MatrixView,
|
||||
selectionOptions,
|
||||
setBypass,
|
||||
toggleSkip,
|
||||
type OnboardSelection,
|
||||
} from "./onboardView";
|
||||
|
||||
const manualCodex: OnboardBlockedItem = {
|
||||
target: ".codex/config.toml",
|
||||
reason: "manual_entry",
|
||||
detail: "已有手写的 lian-jing 条目",
|
||||
skippable: true,
|
||||
};
|
||||
|
||||
const mixedAgents: OnboardBlockedItem = {
|
||||
target: "AGENTS.md",
|
||||
reason: "mixed_file",
|
||||
detail: "托管块外有未提交改动",
|
||||
skippable: false,
|
||||
};
|
||||
|
||||
function plan(overrides: Partial<OnboardingPlan> = {}): OnboardingPlan {
|
||||
return {
|
||||
project_id: "p1",
|
||||
root: "E:\\code-project\\demo",
|
||||
files: [
|
||||
{ path: "AGENTS.md", action: "create", content_sha256: "a" },
|
||||
{ path: ".claude/settings.json", action: "update", content_sha256: "b", before_sha256: "c" },
|
||||
{ path: ".lian-jing/context.json", action: "noop", content_sha256: "d" },
|
||||
],
|
||||
machine_changes: [
|
||||
{ step: "token", status: "apply", detail: "将生成项目 token" },
|
||||
{ step: "claude", status: "noop", detail: "已配置" },
|
||||
{ step: "codex", status: "not_installed", detail: "未找到 codex" },
|
||||
{ step: "dsh", status: "not_applicable", detail: "控制面项目" },
|
||||
],
|
||||
blocked: [],
|
||||
skipped: [],
|
||||
bypass_permissions: true,
|
||||
codex_trust: "untrusted",
|
||||
next_actions: [{ action: "codex_trust", detail: "在 Codex 里信任 E:\\code-project\\demo" }],
|
||||
digest: "digest-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const sel = (overrides: Partial<OnboardSelection> = {}): OnboardSelection => ({ ...DEFAULT_SELECTION, ...overrides });
|
||||
|
||||
describe("buildPlanView 阻断与确认", () => {
|
||||
it("blocked 非空时确认禁用,并逐项列出文件与原因", () => {
|
||||
const view = buildPlanView(plan({ blocked: [mixedAgents, manualCodex] }), sel(), sel());
|
||||
expect(view.confirm.enabled).toBe(false);
|
||||
expect(view.confirm.reason).toContain("2 项阻断");
|
||||
expect(view.blockingCount).toBe(2);
|
||||
expect(view.blocked.map((row) => [row.target, row.reasonLabel, row.detail, row.state])).toEqual([
|
||||
["AGENTS.md", "混合文件:托管块外有未提交改动", "托管块外有未提交改动", "blocking"],
|
||||
[".codex/config.toml", "手写同名条目", "已有手写的 lian-jing 条目", "blocking"],
|
||||
]);
|
||||
expect(view.blocked.every((row) => row.tone === "danger")).toBe(true);
|
||||
expect(confirmRequest(plan({ blocked: [mixedAgents] }), sel(), sel())).toBeNull();
|
||||
});
|
||||
|
||||
it("只有后端标 skippable 的项给「跳过」,不可跳过的项切换无效", () => {
|
||||
const view = buildPlanView(plan({ blocked: [mixedAgents, manualCodex] }), sel(), sel());
|
||||
expect(view.blocked[0].toggleLabel).toBeUndefined();
|
||||
expect(view.blocked[0].hint).toContain("禁止字符串手术");
|
||||
expect(view.blocked[1].toggleLabel).toBe("跳过");
|
||||
expect(toggleSkip(sel(), mixedAgents)).toEqual(sel());
|
||||
expect(toggleSkip(sel(), manualCodex).skipTargets).toEqual([".codex/config.toml"]);
|
||||
expect(toggleSkip(sel({ skipTargets: [".codex/config.toml"] }), manualCodex).skipTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it("选了跳过但未重新计划时确认禁用;按跳过重新计划后可确认", () => {
|
||||
const chosen = toggleSkip(sel(), manualCodex);
|
||||
const pending = buildPlanView(plan({ blocked: [manualCodex] }), sel(), chosen);
|
||||
expect(pending.needsReplan).toBe(true);
|
||||
expect(pending.blocked[0].state).toBe("skip_pending");
|
||||
expect(pending.confirm).toEqual({ enabled: false, reason: "选项已改:须按当前选择重新计划后才能确认" });
|
||||
|
||||
const replanned = buildPlanView(plan({ blocked: [], skipped: [manualCodex] }), chosen, chosen);
|
||||
expect(replanned.confirm.enabled).toBe(true);
|
||||
expect(replanned.blocked[0]).toMatchObject({ state: "skipped", tone: "muted", toggleLabel: "取消跳过" });
|
||||
expect(replanned.blockingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("确认请求携带计划 digest 与生成计划时的选项", () => {
|
||||
const planned = sel({ skipTargets: [".codex/config.toml", ".codex/config.toml"] });
|
||||
const request = confirmRequest(plan({ skipped: [manualCodex], digest: "digest-xyz" }), planned, planned);
|
||||
expect(request).toEqual({
|
||||
projectId: "p1",
|
||||
expectedDigest: "digest-xyz",
|
||||
confirmed: true,
|
||||
options: { bypass_permissions: true, skip_targets: [".codex/config.toml"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("selectionOptions 去重排序,计划与应用两次逐字一致", () => {
|
||||
expect(selectionOptions(sel({ skipTargets: ["b", "a", "b"] }))).toEqual({
|
||||
bypass_permissions: true,
|
||||
skip_targets: ["a", "b"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPlanView 灰色说明与单列项", () => {
|
||||
it("not_installed / not_applicable 显示为灰色说明,不是红色失败", () => {
|
||||
const view = buildPlanView(plan(), sel(), sel());
|
||||
const byStep = Object.fromEntries(view.machineSteps.map((row) => [row.step, row]));
|
||||
expect(byStep.codex).toMatchObject({ statusLabel: "未安装", tone: "muted" });
|
||||
expect(byStep.dsh).toMatchObject({ statusLabel: "不适用", tone: "muted" });
|
||||
expect(byStep.token).toMatchObject({ label: "项目 MCP token", statusLabel: "将写入", tone: "info" });
|
||||
expect(view.machineSteps.some((row) => row.tone === "danger")).toBe(false);
|
||||
expect(view.fileCounts).toEqual({ create: 1, update: 1, noop: 1 });
|
||||
});
|
||||
|
||||
it("bypassPermissions 单独列出:将写入可取消,取消后须重新计划", () => {
|
||||
const view = buildPlanView(plan(), sel(), sel());
|
||||
expect(view.bypass).toMatchObject({ willWrite: true, toggleable: true, tone: "warn" });
|
||||
expect(view.bypass.label).toContain("bypassPermissions");
|
||||
|
||||
const off = setBypass(sel(), false);
|
||||
expect(buildPlanView(plan(), sel(), off).confirm.enabled).toBe(false);
|
||||
const cancelled = buildPlanView(plan({ bypass_permissions: false }), off, off);
|
||||
expect(cancelled.bypass).toMatchObject({ willWrite: false, selected: false, toggleable: true });
|
||||
expect(cancelled.bypass.label).toBe("已按项目取消 bypassPermissions");
|
||||
expect(confirmRequest(plan({ bypass_permissions: false }), off, off)?.options.bypass_permissions).toBe(false);
|
||||
|
||||
const existing = buildPlanView(plan({ bypass_permissions: false }), sel(), sel());
|
||||
expect(existing.bypass).toMatchObject({ toggleable: false, tone: "muted" });
|
||||
});
|
||||
|
||||
it("Codex 信任单独列出:未信任为「待你操作」而非阻断", () => {
|
||||
const view = buildPlanView(plan(), sel(), sel());
|
||||
expect(view.codexTrust).toMatchObject({ state: "untrusted", tone: "pending" });
|
||||
expect(view.codexTrust.detail).toContain("信任");
|
||||
expect(view.confirm.enabled).toBe(true);
|
||||
expect(buildPlanView(plan({ codex_trust: "not_installed", next_actions: [] }), sel(), sel()).codexTrust.tone).toBe("muted");
|
||||
});
|
||||
});
|
||||
|
||||
function cell(state: ParityCell["state"], extra: Partial<ParityCell> = {}): ParityCell {
|
||||
return { state, evidence: "protocol", detail: `${state} detail`, ...extra };
|
||||
}
|
||||
|
||||
function cells(fill: (check: ParityCheck) => ParityCell): Record<ParityCheck, ParityCell> {
|
||||
return Object.fromEntries((["A", "B", "C", "D", "E", "F"] as ParityCheck[]).map((c) => [c, fill(c)])) as Record<
|
||||
ParityCheck,
|
||||
ParityCell
|
||||
>;
|
||||
}
|
||||
|
||||
const l1: ClientParityL1Report = {
|
||||
project_id: "p1",
|
||||
tier: "l1",
|
||||
probe_spec_version: 1,
|
||||
profile: "project",
|
||||
expected_route: "http://127.0.0.1:27190/mcp/projects/p1",
|
||||
expected_tools: 12,
|
||||
nonce: "n",
|
||||
clients: [
|
||||
{ client: "claude", version: "2.1.90", route: null, cells: cells(() => cell("ready")), duration_ms: 1 },
|
||||
{
|
||||
client: "codex",
|
||||
version: "0.50",
|
||||
route: null,
|
||||
cells: cells((c) => (c < "C" ? cell("ready") : cell("fail", { cause: "codex_project_untrusted" }))),
|
||||
duration_ms: 1,
|
||||
},
|
||||
{ client: "dsh", version: null, route: null, cells: cells(() => cell("not_installed")), duration_ms: 1 },
|
||||
],
|
||||
all_applicable_ready: false,
|
||||
duration_ms: 3,
|
||||
note: "",
|
||||
};
|
||||
|
||||
function report(overrides: Partial<OnboardReport> = {}): OnboardReport {
|
||||
return {
|
||||
project_id: "p1",
|
||||
status: "done",
|
||||
summary: "9 步完成",
|
||||
bypass_permissions: true,
|
||||
steps: [
|
||||
{ step: "project_files", status: "applied", detail: "写入 14 个文件" },
|
||||
{ step: "dsh", status: "not_applicable", reason: "not_installed", detail: "未安装 DSH" },
|
||||
{ step: "codex", status: "blocked", dep: "token", detail: "token 未就绪" },
|
||||
],
|
||||
completion: [
|
||||
{ item: "Claude MCP 已配置", source: "clients", ready: true, detail: "", pending_user: false },
|
||||
{ item: "Codex 信任", source: "l1", ready: false, detail: "待信任", pending_user: true },
|
||||
],
|
||||
l1,
|
||||
l3: "L3 尚未提供",
|
||||
written_files: ["AGENTS.md"],
|
||||
repo_changes: {
|
||||
state: "available",
|
||||
status_porcelain: [],
|
||||
diff_stat: "2 files changed",
|
||||
onboard_files: [
|
||||
{ path: "AGENTS.md", code: "??" },
|
||||
{ path: ".claude/settings.json", code: " M" },
|
||||
],
|
||||
other_changes: 1,
|
||||
},
|
||||
head: { before: "abc", after: "abc", unchanged: true },
|
||||
suggested_commit_message: "chore(lian-jing): 一键接入",
|
||||
next_actions: [{ action: "commit", detail: "git add -- AGENTS.md" }],
|
||||
duration_ms: 7900,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReportView", () => {
|
||||
it("分步结果:not_applicable 灰色、blocked 注明依赖", () => {
|
||||
const view = buildReportView(report());
|
||||
expect(view.steps.map((row) => [row.step, row.statusLabel, row.tone, row.note])).toEqual([
|
||||
["project_files", "已写入", "ok", undefined],
|
||||
["dsh", "不适用", "muted", "未安装"],
|
||||
["codex", "未执行", "warn", "依赖「项目 MCP token」未完成"],
|
||||
]);
|
||||
expect(view.toast).toBe("一键接入接入完成 ✓");
|
||||
expect(view.durationLabel).toBe("7.9 秒");
|
||||
});
|
||||
|
||||
it("待你提交的 N 个文件与 Codex 信任单列,不计入完成", () => {
|
||||
const view = buildReportView(report());
|
||||
expect(view.commit).toMatchObject({ state: "available", title: "待你提交的 2 个文件", otherChanges: 1 });
|
||||
expect(view.commit.state === "available" && view.commit.otherWarning).toContain("不要 git add -A");
|
||||
expect(view.completion.map((row) => row.item)).toEqual(["Claude MCP 已配置"]);
|
||||
expect(view.pendingUser).toEqual([
|
||||
{ item: "Codex 信任", source: "l1", ready: false, label: "待你操作", tone: "pending", detail: "待信任" },
|
||||
]);
|
||||
expect(view.head).toMatchObject({ unchanged: true, tone: "ok" });
|
||||
});
|
||||
|
||||
it("refused 附当前计划供重新确认,提示以 ❌ 开头且不报告改动", () => {
|
||||
const replan = plan({ digest: "digest-2" });
|
||||
const view = buildReportView(
|
||||
report({ status: "refused", reason: "digest 不符", plan: replan, repo_changes: undefined, l1: undefined }),
|
||||
);
|
||||
expect(view.replan).toBe(replan);
|
||||
expect(view.tone).toBe("warn");
|
||||
expect(view.toast).toBe("❌ 一键接入已拒绝(零写入):digest 不符");
|
||||
expect(view.commit).toEqual({ state: "none" });
|
||||
expect(view.l1).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("l1MatrixView", () => {
|
||||
it("未安装为灰色,Codex 未信任为「待你信任」而非红色失败", () => {
|
||||
const view = l1MatrixView(l1);
|
||||
const [claude, codex, dsh] = view.columns;
|
||||
expect(claude.cells.F).toMatchObject({ label: "就绪", tone: "ok" });
|
||||
expect(codex.cells.A.tone).toBe("ok");
|
||||
expect(codex.cells.C).toMatchObject({ label: "待你信任", tone: "pending", cause: "codex_project_untrusted" });
|
||||
expect(dsh.cells.A).toMatchObject({ label: "未安装", tone: "muted" });
|
||||
expect(Object.values(dsh.cells).every((c) => c.tone === "muted")).toBe(true);
|
||||
expect(dsh.label).toBe("DSH");
|
||||
});
|
||||
|
||||
it("依赖步骤未完成的格注明步骤;真实失败为红色", () => {
|
||||
const report = {
|
||||
...l1,
|
||||
clients: [
|
||||
{
|
||||
...l1.clients[0],
|
||||
cells: cells((c) =>
|
||||
c === "A" ? cell("blocked", { cause: "dependency", dep: "claude" }) : cell("fail", { cause: "auth_failed" }),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
const [claude] = l1MatrixView(report).columns;
|
||||
expect(claude.cells.A).toMatchObject({ label: "未检查:Claude Code 步骤未完成", tone: "warn" });
|
||||
expect(claude.cells.F).toMatchObject({ label: "失败", tone: "danger" });
|
||||
});
|
||||
});
|
||||
@ -1,672 +0,0 @@
|
||||
// 一键接入(agent-client-parity P8g)视图模型:把 OnboardingPlan / OnboardReport 转成界面要展示的结构。
|
||||
// 只做呈现映射,不复制本体判定(AGENTS.md「边界单点实现」):阻断与否看 plan.blocked、能否跳过看
|
||||
// blocked[].skippable、完成与否看 report.status 与 completion[].pending_user,全部由后端给出。
|
||||
// 界面自己只管一件事:当前选择(bypass / 跳过项)与生成计划时所用的选择是否一致——不一致必须重新计划,
|
||||
// 因为 onboardProject 须带同一组选项与 digest(否则后端 refused)。
|
||||
|
||||
import type {
|
||||
ClientParityL1Report,
|
||||
CodexTrust,
|
||||
OnboardBlockReason,
|
||||
OnboardBlockedItem,
|
||||
OnboardCompletionItem,
|
||||
OnboardFileAction,
|
||||
OnboardNextAction,
|
||||
OnboardOptions,
|
||||
OnboardPendingFile,
|
||||
OnboardReport,
|
||||
OnboardStatus,
|
||||
OnboardStepReport,
|
||||
OnboardingPlan,
|
||||
ParityCause,
|
||||
ParityCell,
|
||||
ParityCellState,
|
||||
ParityCheck,
|
||||
ParityEvidence,
|
||||
} from "../../lib/commands";
|
||||
|
||||
/** 呈现色调。muted 是灰色说明(未安装 / 不适用 / 已跳过 / 不变),不是失败;pending 是「待你操作」 */
|
||||
export type OnboardTone = "ok" | "info" | "muted" | "pending" | "warn" | "danger";
|
||||
|
||||
// ── 选项:计划与应用必须一致 ──────────────────────────────────────────────────
|
||||
|
||||
export interface OnboardSelection {
|
||||
/** 勾选 = 保持默认:项目 .claude/settings.json 还没有 permissions 时写入 bypassPermissions */
|
||||
bypassPermissions: boolean;
|
||||
/** 人逐项选「跳过」的阻断项 target(只对 skippable 的项生效) */
|
||||
skipTargets: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_SELECTION: OnboardSelection = { bypassPermissions: true, skipTargets: [] };
|
||||
|
||||
function normalizeTargets(targets: readonly string[]): string[] {
|
||||
return [...new Set(targets)].sort();
|
||||
}
|
||||
|
||||
/** 选择 → 传给 planOnboarding / onboardProject 的选项(跳过项去重排序,两次调用逐字一致) */
|
||||
export function selectionOptions(selection: OnboardSelection): OnboardOptions {
|
||||
return {
|
||||
bypass_permissions: selection.bypassPermissions,
|
||||
skip_targets: normalizeTargets(selection.skipTargets),
|
||||
};
|
||||
}
|
||||
|
||||
export function sameSelection(a: OnboardSelection, b: OnboardSelection): boolean {
|
||||
if (a.bypassPermissions !== b.bypassPermissions) return false;
|
||||
const x = normalizeTargets(a.skipTargets);
|
||||
const y = normalizeTargets(b.skipTargets);
|
||||
return x.length === y.length && x.every((target, i) => target === y[i]);
|
||||
}
|
||||
|
||||
/** 切换某阻断项的「跳过」。后端没标 skippable 的项不响应(只能中止或处理后重新计划) */
|
||||
export function toggleSkip(selection: OnboardSelection, item: OnboardBlockedItem): OnboardSelection {
|
||||
if (!item.skippable) return selection;
|
||||
const has = selection.skipTargets.includes(item.target);
|
||||
return {
|
||||
...selection,
|
||||
skipTargets: has
|
||||
? selection.skipTargets.filter((target) => target !== item.target)
|
||||
: [...selection.skipTargets, item.target],
|
||||
};
|
||||
}
|
||||
|
||||
export function setBypass(selection: OnboardSelection, bypassPermissions: boolean): OnboardSelection {
|
||||
return { ...selection, bypassPermissions };
|
||||
}
|
||||
|
||||
// ── 文案映射(只翻译后端给的枚举,不推断) ─────────────────────────────────────
|
||||
|
||||
interface Badge {
|
||||
label: string;
|
||||
tone: OnboardTone;
|
||||
}
|
||||
|
||||
const FILE_ACTION: Record<OnboardFileAction, Badge> = {
|
||||
create: { label: "新建", tone: "info" },
|
||||
update: { label: "更新", tone: "info" },
|
||||
noop: { label: "不变", tone: "muted" },
|
||||
};
|
||||
|
||||
const BLOCK_REASON_LABEL: Record<OnboardBlockReason, string> = {
|
||||
mixed_file: "混合文件:托管块外有未提交改动",
|
||||
identity_conflict: "身份冲突:目录已属于别的项目",
|
||||
manual_entry: "手写同名条目",
|
||||
shared_key: "共享项目键:生效条目属于别的项目",
|
||||
needs_human: "需人工处理",
|
||||
};
|
||||
|
||||
/** 不可跳过的项怎么办(可跳过的项统一给「跳过 / 中止」) */
|
||||
const BLOCK_REASON_HINT: Record<OnboardBlockReason, string> = {
|
||||
mixed_file: "先在子项目提交或丢弃块外改动,再重新计划(禁止字符串手术);或中止",
|
||||
identity_conflict: "核对 .lian-jing/context.json 的归属后重新计划;或中止",
|
||||
manual_entry: "处理后重新计划;或中止",
|
||||
shared_key: "处理后重新计划;或中止",
|
||||
needs_human: "按说明处理后重新计划;或中止",
|
||||
};
|
||||
|
||||
export const STEP_LABEL: Record<string, string> = {
|
||||
identity: "项目身份",
|
||||
project_files: "项目文件",
|
||||
git_hooks: "Git hooks(lefthook install)",
|
||||
product_group_buff: "产品组与 buff",
|
||||
token: "项目 MCP token",
|
||||
shim: "稳定 helper 入口",
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
dsh: "DSH",
|
||||
};
|
||||
|
||||
/** 计划里本机七步的预计动作 */
|
||||
const MACHINE_STATUS: Record<string, Badge> = {
|
||||
noop: { label: "已就绪", tone: "ok" },
|
||||
apply: { label: "将写入", tone: "info" },
|
||||
not_installed: { label: "未安装", tone: "muted" },
|
||||
not_applicable: { label: "不适用", tone: "muted" },
|
||||
unavailable: { label: "无法判定", tone: "warn" },
|
||||
manual_kept: { label: "手写同名条目", tone: "warn" },
|
||||
other_project: { label: "属于别的项目", tone: "warn" },
|
||||
needs_human: { label: "需人工处理", tone: "danger" },
|
||||
};
|
||||
|
||||
const CODEX_TRUST: Record<CodexTrust, Badge> = {
|
||||
trusted: { label: "Codex 已信任该目录", tone: "ok" },
|
||||
untrusted: { label: "待你操作:在 Codex 里信任该目录", tone: "pending" },
|
||||
not_installed: { label: "未安装 Codex", tone: "muted" },
|
||||
unknown: { label: "待你确认:无法判断 Codex 是否信任该目录", tone: "pending" },
|
||||
};
|
||||
|
||||
export const NEXT_ACTION_LABEL: Record<string, string> = {
|
||||
codex_trust: "在 Codex 里信任该目录",
|
||||
commit: "提交接入文件",
|
||||
gitea_repo: "建 Gitea 仓库",
|
||||
ci: "配置 CI",
|
||||
flywheel: "飞轮数据",
|
||||
toolchain: "准备工具链",
|
||||
delivery_profile: "交付配置",
|
||||
lefthook: "安装 lefthook",
|
||||
restart_dsh_gui: "重启 DSH 桌面 GUI",
|
||||
claude_trust: "Claude 首次打开目录时确认信任",
|
||||
reopen_sessions: "重开已打开的会话",
|
||||
repo_layout: "仓库布局说明",
|
||||
};
|
||||
|
||||
// ── 计划预览 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FileRow {
|
||||
path: string;
|
||||
action: OnboardFileAction;
|
||||
actionLabel: string;
|
||||
tone: OnboardTone;
|
||||
diff?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface MachineStepRow {
|
||||
step: string;
|
||||
label: string;
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
tone: OnboardTone;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** blocking 阻断中;skip_pending 已选跳过、待重新计划;skipped 计划已按跳过生成;unskip_pending 已取消跳过、待重新计划 */
|
||||
export type BlockedRowState = "blocking" | "skip_pending" | "skipped" | "unskip_pending";
|
||||
|
||||
export interface BlockedRow {
|
||||
item: OnboardBlockedItem;
|
||||
target: string;
|
||||
reasonLabel: string;
|
||||
detail: string;
|
||||
/** 来自后端 skippable:为 true 才给「跳过」按钮 */
|
||||
skippable: boolean;
|
||||
state: BlockedRowState;
|
||||
stateLabel: string;
|
||||
tone: OnboardTone;
|
||||
hint: string;
|
||||
/** 可跳过的项的按钮文案;不可跳过为空(只能中止或处理后重新计划) */
|
||||
toggleLabel?: string;
|
||||
}
|
||||
|
||||
export interface BypassView {
|
||||
/** 本次计划会新写入 permissions.defaultMode=bypassPermissions(后端 plan.bypass_permissions) */
|
||||
willWrite: boolean;
|
||||
/** 当前选择:勾选 = 保持默认写入 */
|
||||
selected: boolean;
|
||||
/** 生成本计划时的选择 */
|
||||
planned: boolean;
|
||||
label: string;
|
||||
detail: string;
|
||||
tone: OnboardTone;
|
||||
/** 会写入或已按项目取消时给开关;项目已有 permissions 时开关无意义,不给 */
|
||||
toggleable: boolean;
|
||||
}
|
||||
|
||||
export interface CodexTrustView {
|
||||
state: CodexTrust;
|
||||
label: string;
|
||||
tone: OnboardTone;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface NextActionRow {
|
||||
action: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
tone: OnboardTone;
|
||||
}
|
||||
|
||||
export interface PlanView {
|
||||
projectId: string;
|
||||
root: string;
|
||||
digest: string;
|
||||
files: FileRow[];
|
||||
fileCounts: Record<OnboardFileAction, number>;
|
||||
machineSteps: MachineStepRow[];
|
||||
/** plan.blocked 与 plan.skipped 合并展示 */
|
||||
blocked: BlockedRow[];
|
||||
/** 仍阻断确认的项数(= plan.blocked.length) */
|
||||
blockingCount: number;
|
||||
bypass: BypassView;
|
||||
codexTrust: CodexTrustView;
|
||||
nextActions: NextActionRow[];
|
||||
/** 当前选择与生成计划时不同:必须重新计划才能确认 */
|
||||
needsReplan: boolean;
|
||||
confirm: { enabled: boolean; reason?: string };
|
||||
}
|
||||
|
||||
const BLOCKED_STATE: Record<BlockedRowState, Badge> = {
|
||||
blocking: { label: "阻断", tone: "danger" },
|
||||
skip_pending: { label: "将跳过(需重新计划)", tone: "warn" },
|
||||
skipped: { label: "已跳过:保留现状,该端不写", tone: "muted" },
|
||||
unskip_pending: { label: "将恢复阻断(需重新计划)", tone: "warn" },
|
||||
};
|
||||
|
||||
function blockedRow(item: OnboardBlockedItem, inPlanSkipped: boolean, selected: boolean): BlockedRow {
|
||||
const skippable = item.skippable === true;
|
||||
const state: BlockedRowState = inPlanSkipped
|
||||
? selected ? "skipped" : "unskip_pending"
|
||||
: skippable && selected ? "skip_pending" : "blocking";
|
||||
const badge = BLOCKED_STATE[state];
|
||||
return {
|
||||
item,
|
||||
target: item.target,
|
||||
reasonLabel: BLOCK_REASON_LABEL[item.reason] ?? item.reason,
|
||||
detail: item.detail,
|
||||
skippable,
|
||||
state,
|
||||
stateLabel: badge.label,
|
||||
tone: badge.tone,
|
||||
hint: skippable
|
||||
? "可逐项「跳过」:保留现状、该端不写(跳过不等于该端可用);或中止"
|
||||
: BLOCK_REASON_HINT[item.reason] ?? "处理后重新计划;或中止",
|
||||
toggleLabel: skippable ? (selected ? "取消跳过" : "跳过") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function bypassView(plan: OnboardingPlan, planned: OnboardSelection, selection: OnboardSelection): BypassView {
|
||||
const base = { willWrite: plan.bypass_permissions, selected: selection.bypassPermissions, planned: planned.bypassPermissions };
|
||||
if (plan.bypass_permissions) {
|
||||
return {
|
||||
...base,
|
||||
label: "将写入 permissions.defaultMode=bypassPermissions",
|
||||
detail: "项目 .claude/settings.json 还没有 permissions:按炼境默认写入,Claude Code 在本项目跳过权限确认。可按项目取消(取消后须重新计划)。",
|
||||
tone: "warn",
|
||||
toggleable: true,
|
||||
};
|
||||
}
|
||||
if (!planned.bypassPermissions) {
|
||||
return {
|
||||
...base,
|
||||
label: "已按项目取消 bypassPermissions",
|
||||
detail:
|
||||
"不写入 bypassPermissions。settings.json 缺 permissions 时会写显式 defaultMode=default 记住选择(之后的重注入不会补回);已有 permissions 则原样不动。",
|
||||
tone: "muted",
|
||||
toggleable: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
label: "不新写入 bypassPermissions",
|
||||
detail: "项目 .claude/settings.json 已有 permissions 设置,本次不改。",
|
||||
tone: "muted",
|
||||
toggleable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function nextActionRow(action: OnboardNextAction): NextActionRow {
|
||||
return {
|
||||
action: action.action,
|
||||
label: NEXT_ACTION_LABEL[action.action] ?? action.action,
|
||||
detail: action.detail,
|
||||
tone: action.action === "codex_trust" ? "pending" : "info",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划 → 预览视图。planned 是生成本计划时的选择,selection 是界面当前的选择。
|
||||
* 确认按钮:plan.blocked 非空一律禁用(后端也会 refused);选择改过未重新计划也禁用。
|
||||
*/
|
||||
export function buildPlanView(
|
||||
plan: OnboardingPlan,
|
||||
planned: OnboardSelection,
|
||||
selection: OnboardSelection,
|
||||
): PlanView {
|
||||
const selected = new Set(selection.skipTargets);
|
||||
const fileCounts: Record<OnboardFileAction, number> = { create: 0, update: 0, noop: 0 };
|
||||
const files = plan.files.map((file): FileRow => {
|
||||
fileCounts[file.action] = (fileCounts[file.action] ?? 0) + 1;
|
||||
const badge = FILE_ACTION[file.action] ?? { label: file.action, tone: "warn" };
|
||||
return {
|
||||
path: file.path,
|
||||
action: file.action,
|
||||
actionLabel: badge.label,
|
||||
tone: file.warning ? "warn" : badge.tone,
|
||||
diff: file.diff,
|
||||
warning: file.warning,
|
||||
};
|
||||
});
|
||||
const machineSteps = plan.machine_changes.map((step): MachineStepRow => {
|
||||
const badge = MACHINE_STATUS[step.status] ?? { label: step.status, tone: "warn" };
|
||||
return {
|
||||
step: step.step,
|
||||
label: STEP_LABEL[step.step] ?? step.step,
|
||||
status: step.status,
|
||||
statusLabel: badge.label,
|
||||
tone: badge.tone,
|
||||
detail: step.detail,
|
||||
};
|
||||
});
|
||||
const blocked = [
|
||||
...plan.blocked.map((item) => blockedRow(item, false, selected.has(item.target))),
|
||||
...(plan.skipped ?? []).map((item) => blockedRow(item, true, selected.has(item.target))),
|
||||
];
|
||||
const trust = CODEX_TRUST[plan.codex_trust] ?? { label: plan.codex_trust, tone: "warn" };
|
||||
const trustAction = plan.next_actions.find((action) => action.action === "codex_trust");
|
||||
const needsReplan = !sameSelection(planned, selection);
|
||||
const blockingCount = plan.blocked.length;
|
||||
const reason = needsReplan
|
||||
? "选项已改:须按当前选择重新计划后才能确认"
|
||||
: blockingCount > 0
|
||||
? `有 ${blockingCount} 项阻断,确认已禁用:可跳过的项选「跳过」后重新计划,其余处理后重新计划或中止`
|
||||
: undefined;
|
||||
return {
|
||||
projectId: plan.project_id,
|
||||
root: plan.root,
|
||||
digest: plan.digest,
|
||||
files,
|
||||
fileCounts,
|
||||
machineSteps,
|
||||
blocked,
|
||||
blockingCount,
|
||||
bypass: bypassView(plan, planned, selection),
|
||||
codexTrust: {
|
||||
state: plan.codex_trust,
|
||||
label: trust.label,
|
||||
tone: trust.tone,
|
||||
detail: trustAction?.detail ?? (plan.codex_trust === "trusted" ? "Codex 会加载项目 .codex/config.toml" : ""),
|
||||
},
|
||||
nextActions: plan.next_actions.map(nextActionRow),
|
||||
needsReplan,
|
||||
confirm: { enabled: reason === undefined, reason },
|
||||
};
|
||||
}
|
||||
|
||||
export interface OnboardConfirmRequest {
|
||||
projectId: string;
|
||||
/** 用户确认过的计划 digest */
|
||||
expectedDigest: string;
|
||||
confirmed: true;
|
||||
/** 生成该计划时的选项(不是界面当前选择) */
|
||||
options: OnboardOptions;
|
||||
}
|
||||
|
||||
/** 确认接入的调用参数;确认按钮应禁用时返回 null(不发请求) */
|
||||
export function confirmRequest(
|
||||
plan: OnboardingPlan,
|
||||
planned: OnboardSelection,
|
||||
selection: OnboardSelection,
|
||||
): OnboardConfirmRequest | null {
|
||||
if (!buildPlanView(plan, planned, selection).confirm.enabled) return null;
|
||||
return {
|
||||
projectId: plan.project_id,
|
||||
expectedDigest: plan.digest,
|
||||
confirmed: true,
|
||||
options: selectionOptions(planned),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 接入报告 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS: Record<OnboardStatus, Badge> = {
|
||||
done: { label: "接入完成", tone: "ok" },
|
||||
partial: { label: "部分完成", tone: "warn" },
|
||||
failed: { label: "未执行:预检失败(零写入)", tone: "danger" },
|
||||
refused: { label: "已拒绝(零写入)", tone: "warn" },
|
||||
busy: { label: "该项目正在接入中(零写入)", tone: "info" },
|
||||
};
|
||||
|
||||
const STEP_STATUS: Record<OnboardStepReport["status"], Badge> = {
|
||||
converged: { label: "已一致", tone: "ok" },
|
||||
applied: { label: "已写入", tone: "ok" },
|
||||
skipped: { label: "已跳过", tone: "muted" },
|
||||
not_applicable: { label: "不适用", tone: "muted" },
|
||||
blocked: { label: "未执行", tone: "warn" },
|
||||
failed: { label: "失败", tone: "danger" },
|
||||
needs_human: { label: "需人工处理", tone: "warn" },
|
||||
};
|
||||
|
||||
/** 步骤 reason 的常见取值(其余原样展示) */
|
||||
const STEP_REASON: Record<string, string> = {
|
||||
not_installed: "未安装",
|
||||
control_plane: "控制面项目",
|
||||
wsl: "WSL 项目",
|
||||
manual_kept: "保留手写配置",
|
||||
other_project: "属于别的项目",
|
||||
deferred: "延后",
|
||||
};
|
||||
|
||||
export interface StepRow {
|
||||
step: string;
|
||||
label: string;
|
||||
status: OnboardStepReport["status"];
|
||||
statusLabel: string;
|
||||
tone: OnboardTone;
|
||||
/** reason 或 dep 的说明 */
|
||||
note?: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CompletionRow {
|
||||
item: string;
|
||||
source: OnboardCompletionItem["source"];
|
||||
ready: boolean;
|
||||
label: string;
|
||||
tone: OnboardTone;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const PARITY_CHECKS: ParityCheck[] = ["A", "B", "C", "D", "E", "F"];
|
||||
|
||||
export const CHECK_LABEL: Record<ParityCheck, string> = {
|
||||
A: "项目规则",
|
||||
B: "lian-jing skill",
|
||||
C: "MCP 工具集",
|
||||
D: "get_diagnostics",
|
||||
E: "列出开发变量",
|
||||
F: "敏感读取认证链",
|
||||
};
|
||||
|
||||
const CELL_STATE: Record<ParityCellState, Badge> = {
|
||||
ready: { label: "就绪", tone: "ok" },
|
||||
fail: { label: "失败", tone: "danger" },
|
||||
unknown: { label: "未知", tone: "warn" },
|
||||
not_installed: { label: "未安装", tone: "muted" },
|
||||
not_applicable: { label: "不适用", tone: "muted" },
|
||||
blocked: { label: "未检查", tone: "warn" },
|
||||
timeout: { label: "超时", tone: "danger" },
|
||||
};
|
||||
|
||||
export const EVIDENCE_LABEL: Record<ParityEvidence, string> = {
|
||||
static: "静态",
|
||||
client_introspection: "客户端自省",
|
||||
protocol: "协议重放",
|
||||
session_event: "会话事件",
|
||||
server_trace: "服务端轨迹",
|
||||
};
|
||||
|
||||
/** 只等用户在客户端里操作的归因(与后端 completion 的 pending_user 同口径,仅用于着色) */
|
||||
const PENDING_USER_CAUSES: ParityCause[] = ["codex_project_untrusted"];
|
||||
|
||||
export interface L1CellView {
|
||||
state: ParityCellState;
|
||||
label: string;
|
||||
tone: OnboardTone;
|
||||
evidence: ParityEvidence;
|
||||
evidenceLabel: string;
|
||||
cause?: ParityCause;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface L1ColumnView {
|
||||
client: string;
|
||||
label: string;
|
||||
version: string | null;
|
||||
route: string | null;
|
||||
cells: Record<ParityCheck, L1CellView>;
|
||||
}
|
||||
|
||||
export interface L1MatrixView {
|
||||
profile: ClientParityL1Report["profile"];
|
||||
allApplicableReady: boolean;
|
||||
note: string;
|
||||
columns: L1ColumnView[];
|
||||
}
|
||||
|
||||
function cellView(cell: ParityCell): L1CellView {
|
||||
const badge = CELL_STATE[cell.state] ?? { label: cell.state, tone: "warn" };
|
||||
const pendingUser = cell.state !== "ready" && cell.cause !== undefined && PENDING_USER_CAUSES.includes(cell.cause);
|
||||
const label = pendingUser
|
||||
? "待你信任"
|
||||
: cell.state === "blocked" && cell.dep
|
||||
? `未检查:${STEP_LABEL[cell.dep] ?? cell.dep} 步骤未完成`
|
||||
: badge.label;
|
||||
return {
|
||||
state: cell.state,
|
||||
label,
|
||||
tone: pendingUser ? "pending" : badge.tone,
|
||||
evidence: cell.evidence,
|
||||
evidenceLabel: EVIDENCE_LABEL[cell.evidence] ?? cell.evidence,
|
||||
cause: cell.cause,
|
||||
detail: cell.detail,
|
||||
};
|
||||
}
|
||||
|
||||
export function l1MatrixView(report: ClientParityL1Report): L1MatrixView {
|
||||
return {
|
||||
profile: report.profile,
|
||||
allApplicableReady: report.all_applicable_ready,
|
||||
note: report.note,
|
||||
columns: report.clients.map((column) => {
|
||||
const cells = {} as Record<ParityCheck, L1CellView>;
|
||||
for (const check of PARITY_CHECKS) {
|
||||
const cell = column.cells[check];
|
||||
cells[check] = cell
|
||||
? cellView(cell)
|
||||
: { state: "unknown", label: "缺失", tone: "warn", evidence: "static", evidenceLabel: EVIDENCE_LABEL.static, detail: "报告未给出该格" };
|
||||
}
|
||||
return {
|
||||
client: column.client,
|
||||
label: STEP_LABEL[column.client] ?? column.client,
|
||||
version: column.version,
|
||||
route: column.route,
|
||||
cells,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type CommitView =
|
||||
| {
|
||||
state: "available";
|
||||
/** 「待你提交的 N 个文件」 */
|
||||
title: string;
|
||||
files: OnboardPendingFile[];
|
||||
otherChanges: number;
|
||||
/** 有非接入改动时提醒勿混进接入提交 */
|
||||
otherWarning?: string;
|
||||
suggestedMessage?: string;
|
||||
diffStat: string;
|
||||
}
|
||||
| { state: "unavailable"; reason: string }
|
||||
/** 未执行写入(refused / busy / failed)时不报告改动 */
|
||||
| { state: "none" };
|
||||
|
||||
export interface ReportView {
|
||||
status: OnboardStatus;
|
||||
statusLabel: string;
|
||||
tone: OnboardTone;
|
||||
summary: string;
|
||||
reason?: string;
|
||||
steps: StepRow[];
|
||||
/** 计入「完成」的项(不含 pending_user) */
|
||||
completion: CompletionRow[];
|
||||
/** 「待你操作」:只等你在客户端里操作、不计入完成判定的项(目前只有 Codex 信任) */
|
||||
pendingUser: CompletionRow[];
|
||||
l1?: L1MatrixView;
|
||||
l3: string;
|
||||
bypassWritten: boolean;
|
||||
writtenFiles: string[];
|
||||
commit: CommitView;
|
||||
nextActions: NextActionRow[];
|
||||
head?: { unchanged: boolean; label: string; tone: OnboardTone };
|
||||
/** refused 且附当前计划:须回到预览、按新计划重新确认 */
|
||||
replan?: OnboardingPlan;
|
||||
durationLabel: string;
|
||||
/** 结果提示:失败类状态以「❌」开头 */
|
||||
toast: string;
|
||||
}
|
||||
|
||||
function stepRow(step: OnboardStepReport): StepRow {
|
||||
const badge = STEP_STATUS[step.status] ?? { label: step.status, tone: "warn" };
|
||||
let note: string | undefined;
|
||||
if (step.status === "blocked") {
|
||||
note = `依赖「${STEP_LABEL[step.dep] ?? step.dep}」未完成`;
|
||||
} else if ("reason" in step && step.reason) {
|
||||
note = STEP_REASON[step.reason] ?? step.reason;
|
||||
}
|
||||
return {
|
||||
step: step.step,
|
||||
label: STEP_LABEL[step.step] ?? step.step,
|
||||
status: step.status,
|
||||
statusLabel: badge.label,
|
||||
tone: badge.tone,
|
||||
note,
|
||||
detail: step.detail,
|
||||
};
|
||||
}
|
||||
|
||||
function completionRow(item: OnboardCompletionItem): CompletionRow {
|
||||
const pending = !item.ready && item.pending_user;
|
||||
return {
|
||||
item: item.item,
|
||||
source: item.source,
|
||||
ready: item.ready,
|
||||
label: item.ready ? "就绪" : pending ? "待你操作" : "未就绪",
|
||||
tone: item.ready ? "ok" : pending ? "pending" : "danger",
|
||||
detail: item.detail,
|
||||
};
|
||||
}
|
||||
|
||||
function commitView(report: OnboardReport): CommitView {
|
||||
const changes = report.repo_changes;
|
||||
if (!changes) return { state: "none" };
|
||||
if (changes.state === "unavailable") return { state: "unavailable", reason: changes.reason };
|
||||
return {
|
||||
state: "available",
|
||||
title: `待你提交的 ${changes.onboard_files.length} 个文件`,
|
||||
files: changes.onboard_files,
|
||||
otherChanges: changes.other_changes,
|
||||
otherWarning:
|
||||
changes.other_changes > 0
|
||||
? `另有 ${changes.other_changes} 处不属于接入的改动:只 add 上面的文件,不要 git add -A`
|
||||
: undefined,
|
||||
suggestedMessage: report.suggested_commit_message,
|
||||
diffStat: changes.diff_stat,
|
||||
};
|
||||
}
|
||||
|
||||
const FAILURE_STATUSES: OnboardStatus[] = ["failed", "refused", "busy"];
|
||||
|
||||
/** 报告 → 结果视图(分步结果、L1 矩阵、待你操作、待提交文件、next_actions) */
|
||||
export function buildReportView(report: OnboardReport): ReportView {
|
||||
const badge = STATUS[report.status] ?? { label: report.status, tone: "warn" };
|
||||
const completion = report.completion.map((item) => ({ item, row: completionRow(item) }));
|
||||
const failure = FAILURE_STATUSES.includes(report.status);
|
||||
return {
|
||||
status: report.status,
|
||||
statusLabel: badge.label,
|
||||
tone: badge.tone,
|
||||
summary: report.summary,
|
||||
reason: report.reason,
|
||||
steps: report.steps.map(stepRow),
|
||||
completion: completion.filter(({ item }) => !item.pending_user).map(({ row }) => row),
|
||||
pendingUser: completion.filter(({ item }) => item.pending_user).map(({ row }) => row),
|
||||
l1: report.l1 ? l1MatrixView(report.l1) : undefined,
|
||||
l3: report.l3,
|
||||
bypassWritten: report.bypass_permissions,
|
||||
writtenFiles: report.written_files ?? [],
|
||||
commit: commitView(report),
|
||||
nextActions: report.next_actions.map(nextActionRow),
|
||||
head: report.head
|
||||
? report.head.unchanged
|
||||
? { unchanged: true, label: "HEAD 未变(炼境不 add、不 commit)", tone: "ok" }
|
||||
: { unchanged: false, label: "HEAD 已变化:写入期间有别的提交,请核对", tone: "warn" }
|
||||
: undefined,
|
||||
replan: report.status === "refused" ? report.plan : undefined,
|
||||
durationLabel: `${(report.duration_ms / 1000).toFixed(1)} 秒`,
|
||||
toast: failure
|
||||
? `❌ 一键接入${badge.label}:${report.reason ?? report.summary}`
|
||||
: `一键接入${badge.label} ✓`,
|
||||
};
|
||||
}
|
||||
@ -28,10 +28,7 @@ const EXEC_STEPS = [
|
||||
|
||||
export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
const showToast = useUIStore((s) => s.showToast);
|
||||
const openOnboard = useUIStore((s) => s.openOnboard);
|
||||
const qc = useQueryClient();
|
||||
/** 模板创建成功的项目 ID:自动弹接入预览(P8g),取消后可从完成页再次打开 */
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
|
||||
// ── Step 1 ────────────────────────────────────────────────────────────────
|
||||
const [platform, setPlatform] = useState<"win" | "wsl">("win");
|
||||
@ -131,7 +128,7 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
const variantCmd = selectedVariants.length > 0
|
||||
? selectedVariants[selectedVariantIdx]?.initCommands
|
||||
: undefined;
|
||||
const result = await createProjectFromTemplate(
|
||||
await createProjectFromTemplate(
|
||||
selectedTemplateId,
|
||||
parentPath,
|
||||
projectName.trim(),
|
||||
@ -141,8 +138,6 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
setStep("done");
|
||||
setCreatedId(result.projectId);
|
||||
openOnboard({ projectId: result.projectId, projectName: projectName.trim() });
|
||||
} catch (e) {
|
||||
setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]);
|
||||
setHasError(true);
|
||||
@ -454,14 +449,6 @@ export function NewProjectModal({ spaceId, onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{step === "done" && createdId && (
|
||||
<button
|
||||
onClick={() => openOnboard({ projectId: createdId, projectName: projectName.trim() })}
|
||||
className="px-4 py-2 text-sm border border-blue-200 rounded-lg text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
一键接入
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={step === "executing"}
|
||||
|
||||
@ -7,7 +7,7 @@ export function Toast() {
|
||||
const isError = toast.startsWith("❌");
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-10 left-1/2 -translate-x-1/2 z-[70] text-sm px-4 py-2 rounded-lg shadow-lg animate-fade-in flex items-center gap-2 max-w-[80vw] ${
|
||||
<div className={`fixed bottom-10 left-1/2 -translate-x-1/2 z-50 text-sm px-4 py-2 rounded-lg shadow-lg animate-fade-in flex items-center gap-2 max-w-[80vw] ${
|
||||
isError ? "bg-red-900 text-white pointer-events-auto" : "bg-gray-900 text-white pointer-events-none"
|
||||
}`}>
|
||||
<span className="break-all">{toast}</span>
|
||||
|
||||
@ -1770,8 +1770,6 @@ export interface OnboardBlockedItem {
|
||||
target: string;
|
||||
reason: OnboardBlockReason;
|
||||
detail: string;
|
||||
/** 本体派生(BlockReason::skippable):可在确认时逐项「跳过」;界面据此给按钮,不自行判定 */
|
||||
skippable: boolean;
|
||||
}
|
||||
|
||||
/** 本机七步:token、shim、claude、codex、dsh、product_group_buff、git_hooks */
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { OnboardReport, Project } from "../lib/commands";
|
||||
|
||||
/** 一键接入预览的目标(agent-client-parity P8g):新建、模板创建、批量添加、蓝图治理都打开同一面板 */
|
||||
export interface OnboardTarget {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
/** 面板关闭时回调;未执行接入(取消 / 计划失败)时 report 为 null */
|
||||
onClosed?: (report: OnboardReport | null) => void;
|
||||
}
|
||||
import type { Project } from "../lib/commands";
|
||||
|
||||
interface UIState {
|
||||
// Toast
|
||||
@ -18,11 +10,6 @@ interface UIState {
|
||||
editTarget: Project | null | undefined; // undefined = closed, null = new, obj = edit
|
||||
openEdit: (project?: Project | null) => void;
|
||||
closeEdit: () => void;
|
||||
|
||||
// 一键接入面板(全局挂载:新建弹窗保存后即卸载,面板须独立存在)
|
||||
onboardTarget: OnboardTarget | null;
|
||||
openOnboard: (target: OnboardTarget) => void;
|
||||
closeOnboard: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
@ -35,8 +22,4 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
editTarget: undefined,
|
||||
openEdit: (project) => set({ editTarget: project ?? null }),
|
||||
closeEdit: () => set({ editTarget: undefined }),
|
||||
|
||||
onboardTarget: null,
|
||||
openOnboard: (target) => set({ onboardTarget: target }),
|
||||
closeOnboard: () => set({ onboardTarget: null }),
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user