dsh-plan-completion 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ # GitHub 发布评估 · 会话计划补全插件(2026-11-02)
2
+
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。当前未发布修复已接入鉴权、收紧模型路由及诊断数据,详见 [security.md](security.md) 和 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
+
5
+ > **2026-11-02 晚更新:P0 清单已全部完成,状态从「不满足全 PASS」→「可按 GitHub 源码仓库发布」。**
6
+ >
7
+ > | 项 | 状态 |
8
+ > |---|---|
9
+ > | B1 git 仓库 | ✅ 已 `git init` + 提交(见 git log) |
10
+ > | B2 LICENSE | ✅ MIT(`LICENSE`) |
11
+ > | B3 包元数据 | ✅ `name=dsh-plan-completion`、license/repository/keywords/engines/scripts/author;`private:false`;profile 已完成改名并验证运行 |
12
+ > | B4 本地绝对路径 | ✅ README/config 已无绝对路径;发布版 patch 不含 `debugDir` |
13
+ > | B5 调试路由鉴权/收敛 | ✅ 默认关闭(`enableDebugRoutes:false`),生产路由仅 state/debug/diag/replan/warm |
14
+ > | B6 运行时数据不入库 | ✅ `.gitignore` 排除 research/*.jsonl 等;仓库仅含源码+文档+脱敏截图 |
15
+ > | B7 CI | ✅ `.github/workflows/test.yml` 已启用(GitHub Actions,Node 20/22 矩阵,push/PR 触发;README 带 badge) |
16
+ > | B8 CHANGELOG/CONTRIBUTING | ✅ 已补 |
17
+ >
18
+ > 发布状态:GitHub 仓库 public、`main` 已推送、tag `v0.1.0` + Release 已创建;GitHub Actions 已启用并 **CI 全绿**(Node 20/22 双 job success);**npm 已发布 `dsh-plan-completion@0.1.0`**(https://www.npmjs.com/package/dsh-plan-completion,shasum `274044abe5906d0551802033b4dfe16ac05e45`,integrity `sha512-Q3bf67…Ka7D9w==`,与发布输出一致)。
19
+ > 包名改名已完成并在本机 profile 验证(`remove @local/...` + `install <repo>`,运行状态与计划在重装后保持)。
20
+
21
+ 结论先说:**代码与文档质量达到"可以开源",但当前状态"不满足全 PASS"**。
22
+ 按下面的 P0 清单做完(约 1 天工作量),即可公开;P1 是加分项。
23
+
24
+ 评估范围:`<repo>`(插件源码,含 78 个单测)+ 两份设计/交付文档 + 实验归档。
25
+ 结论依据:代码走查、`node --test` 实测、目录/元数据检查、敏感信息扫描。
26
+
27
+ ---
28
+
29
+ ## 一、总览评分
30
+
31
+ | 维度 | 状态 | 说明 |
32
+ |---|---|---|
33
+ | 代码质量 | ✅ PASS | Host/Client 分层清晰,纯函数模块(plan/prompt/evidence/heuristics/config/store/job)可单测 |
34
+ | 测试 | ✅ PASS | `node --test`:**78 tests / 78 pass / 0 fail**(含 pipeline 集成测试) |
35
+ | 文档 | 🟡 部分 | README/交付说明/设计文档齐全,但 README 有 6 处本地绝对路径、缺 CHANGELOG/CONTRIBUTING |
36
+ | 安全 | 🟡 部分 | 源码无密钥/密码(已扫描);但 6 个调试路由无鉴权、默认写本地日志目录 |
37
+ | 隐私/数据 | 🟡 部分 | 运行日志与实验档包含 sessionId、UA、对话摘要片段;不应进公开仓库 |
38
+ | 包元数据 | ❌ FAIL | `private:true`、`name=@local/...`、无 license/repository/keywords/engines/author |
39
+ | 仓库工程 | ❌ FAIL | 工作区**不是 git 仓库**;无 LICENSE、.gitignore、CI workflow、CHANGELOG |
40
+ | 可移植性 | 🟡 部分 | 默认 `debugDir` 可配置(已改),但 patch 示例仍带本地路径;README 路径需泛化 |
41
+ | 视觉/演示资产 | 🟡 部分 | 有真机截图(归档目录),但仓库内无 screenshots/demo 目录 |
42
+ | 许可证合规 | ❌ FAIL | 无 LICENSE 文件,无法合法声明开源许可 |
43
+
44
+ **当前结论:6 项 PASS / 4 项部分 / 4 项 FAIL → 不满足"全 PASS"。**
45
+
46
+ ---
47
+
48
+ ## 二、P0 阻塞项(发布前必须修)
49
+
50
+ | # | 阻塞项 | 现状 | 修复动作 |
51
+ |---|---|---|---|
52
+ | B1 | 无 git 仓库 | 工作区不是 repo | 只把插件目录+文档纳入仓库:`git init` → `.gitignore` → 首次提交(建议独立 repo,不要把 research/、其他项目带进去) |
53
+ | B2 | 无 LICENSE | 缺失 | 选 MIT(或 Apache-2.0)→ `LICENSE` + package.json `license` 字段 |
54
+ | B3 | package.json 元数据不全 | `private:true`、`@local/` 前缀、无 license/repository/author/keywords/engines | 改名 `dsh-plan-completion`(或保留 scoped),补全字段;若只做 GitHub 源码分发可保留 private=false 但不发 npm |
55
+ | B4 | 本地绝对路径 | README 与文档中存在本地绝对路径;`cordis.patch.yml` 有本地 `debugDir` | README/文档改成相对路径/占位符;发布版 patch 去掉 `debugDir`(代码已支持 env 覆盖) |
56
+ | B5 | 调试路由无鉴权 | `/plan-completion/experiment|autojob|steps|reset|diag|theme` | 发布版:默认关闭调试路由(config `enableDebugRoutes:false`),或加来源校验(`connection.requestRejection`) |
57
+ | B6 | 运行时数据/实验档不应入库 | `research/plan-completion-runtime.jsonl`(含 sessionId/UA/对话片段)、`research/archive` | `.gitignore` 排除 `research/`、`*.jsonl`、实验/样本 JSON;如需展示数据,脱敏后放 `docs/` |
58
+ | B7 | 无 CI | — | `.github/workflows/test.yml`:Node 20/22 矩阵跑 `node --test test/*.test.js`;可选 markdown lint |
59
+ | B8 | 缺 CHANGELOG / CONTRIBUTING | — | 加 `CHANGELOG.md`(0.1.0 初始)+ `CONTRIBUTING.md`(本地安装/调试命令) |
60
+
61
+ ## 三、P1 建议(发布后 1~2 周)
62
+
63
+ 1. 仓库结构建议:
64
+ ```
65
+ dsh-plan-completion/ # 根即插件包(保持现在结构)
66
+ ├── index.js client.js host/ locale/ test/
67
+ ├── cordis.patch.yml package.json icon.svg
68
+ ├── README.md LICENSE CHANGELOG.md CONTRIBUTING.md .gitignore
69
+ ├── docs/ # 设计计划 v1.2、二期计划、交付说明
70
+ ├── docs/screenshots/ # 建议新增 3 张(浅色/暗色/计划页)
71
+ └── .github/workflows/test.yml
72
+ ```
73
+ 2. 安全:调试路由默认 off + 请求来源校验;README 增加「隐私说明:插件会把会话摘要发送给模型」。
74
+ 3. 质量:把 client 纯逻辑抽出来补测试(目标 ≥120 用例);host 生命周期(重载不重复注册)加回归测试。
75
+ 4. 发布:打 tag `v0.1.0` + GitHub Release notes(引用交付说明);可选 npm publish(需 dsh 生态约定)。
76
+ 5. 版本要求:package.json 增加 `engines.dsh` 或 README 明确最低 DSH 版本(本插件依赖 `dsh.client/slots/webServer/llm/commands` 契约)。
77
+
78
+ ## 四、已具备的发布优势
79
+
80
+ - **契约经过探测验证**:槽位、InputActions、同源 HTTP、斜杠命令均在真机日志中有证据(`research/p0-contract.md`)。
81
+ - **可量化验收**:两轮隔离测试 + 6 条真机样本 + 78 单测 + 配置生效日志,README/交付说明可复现。
82
+ - **成本与预算有结论**:稳态 ~1.2k tokens/回合、patch 输出 75~113 tokens,有明确默认值与依据。
83
+ - **无第三方运行时依赖**:只用 Node 标准库 + React(宿主提供),发布门槛低。
84
+ - **面向失败的设计**:模型失败走启发式 fallback、CAS 版本冲突、用户编辑优先、超时降级。
85
+
86
+ ## 五、发布命令草稿(确认后执行)
87
+
88
+ ```powershell
89
+ cd <repo>
90
+ git init
91
+ # 先补 LICENSE / .gitignore / CI / CHANGELOG / 元数据 / 移除本地路径与调试路由默认值
92
+ git add .
93
+ git commit -m "feat: initial release v0.1.0"
94
+ git remote add origin https://github.com/<you>/dsh-plan-completion.git
95
+ git push -u origin main
96
+ git tag v0.1.0 && git push --tags
97
+ ```
98
+
99
+ > 建议:**先把 P0 B1~B8 全部打勾,再公开仓库**;否则许可证与数据问题会成为不可逆的历史提交。
100
+
101
+
Binary file
Binary file
Binary file
@@ -0,0 +1,57 @@
1
+ # 安全与隐私边界
2
+
3
+ 本页描述 **v0.1.1** 的安全与隐私边界。npm `0.1.0` 为旧版本(无鉴权、含原始诊断字段),请以 registry 最新版为准。
4
+
5
+ ## 请求访问
6
+
7
+ 所有 `/plan-completion/*` 路由在读取请求体或执行操作前调用 DSH 的
8
+ `connection.requestRejection(req)`。宿主负责 Host/Origin 检查及 cookie/API peer 鉴权;
9
+ 无凭据、来源不受信任时返回 401/403,服务不可用或契约异常时返回 503,不退回“仅检查 loopback”。
10
+ 插件不反射任意 Origin、不开放跨来源凭据 CORS。
11
+
12
+ 默认只有 `state`、`replan`、`warm`。只有显式开启 `enableDebugRoutes: true` 才注册
13
+ `debug`、`diag`、`theme`、`experiment`、`autojob`、`steps`、`reset`,这些端点仍须鉴权。
14
+ DSH 是当前用户的可信宿主;本插件没有额外的多租户/逐会话 ACL。获得合法 DSH peer
15
+ 权限的客户端仍能访问该宿主内的计划,不应把这一机制当作多人共享服务的隔离边界。
16
+
17
+ 已核对本机安装版宿主源码:WebServer 不替插件鉴权;Electron 的 dsh-app HTTP 代理会
18
+ 去掉 renderer 的 Origin 并注入宿主 cookie,因此客户端继续使用 same-origin fetch。
19
+ 回归测试使用真实插件入口和模拟宿主服务;没有替换运行中的 DSH 或开展真实浏览器攻击测试。
20
+
21
+ ## 哪些内容会发送给模型
22
+
23
+ 默认 `modelCalls: true`。计划生成会把目标、近期用户/助手消息、工具错误及已有步骤证据
24
+ 组成提示词,经 DSH llm 服务发送到**宿主当前默认 provider/model**。它不一定是历史会话
25
+ 原先使用的模型,也不保证是本地模型。长度裁剪不是敏感信息脱敏;不要假定正文里的密码、
26
+ 个人信息或业务数据会被自动识别和移除。
27
+
28
+ 修复后不再遍历其他供应商挑选更快模型,也不缓存已过时的默认选择;无可用默认路由时使用启发式。
29
+ 调试实验的评审也使用同一已选路线,结果不再声称来自独立跨模型评审。
30
+ `modelCalls: false` 可关闭**本插件**的全部模型调用并使用启发式,不会关闭 DSH 主 agent 的模型调用。
31
+
32
+ `warmBackfill: true` 会在打开旧会话时读取持久化历史以生成计划,可能触发模型请求。
33
+ 设为 false 可关闭历史回填;若希望插件不向模型传输任何会话内容,同时设置 modelCalls: false。
34
+
35
+ ## 本地日志、诊断及截图
36
+
37
+ - debugDir 默认为空,不写运行日志;设置目录或 DSH_PLAN_COMPLETION_DEBUG_DIR 后才启用。
38
+ - 运行日志仅保留允许列表中的操作元数据;会话标识使用截短 SHA-256。
39
+ 不保留模型正文、原始错误、配置内容、请求 URL/查询串、UA 或客户端请求体。
40
+ 会话哈希仍是可关联的假名标识,日志不是匿名统计。
41
+ - 客户端不再发送 DOM 正文、补全建议文本或完整页面 URL。
42
+ 诊断端点再次按白名单保留布局尺寸和布尔值,忽略额外正文。
43
+ - 显式运行调试实验时,内存结果和 debugDir 下的实验 JSON 仍可能含计划、建议、
44
+ 模型评审原文等内容。应按会话内容保护,不要把 research/debug 文件提交或公开。
45
+ 这些文件不加密,也没有自动保留期限。
46
+ - 仓库现有四张截图未见密钥/cookie 等凭据,但包含昵称头像、费用用量、模型与开发任务内容。
47
+ 这类演示信息已经公开;后续公开演示宜换为虚构样本或仅截取插件区域。
48
+
49
+ ## 本次验证范围
50
+
51
+ 原代码的未鉴权访问和隐式跨 provider 发送,均用合成数据、离线 mock 复现。
52
+ 修复后的测试覆盖未授权/错误来源/鉴权服务缺失、诊断默认关闭、日志内容排除、
53
+ 默认模型切换、关闭模型调用,以及计划状态和前端生命周期。
54
+
55
+ 当前文本文件扫描未匹配常见私钥、GitHub/API/AWS 密钥模式;这不是完整 Git 历史扫描,
56
+ 也不能证明不存在所有形式的秘密。没有在真实模型端点、生产 DSH/Electron 或 npm 发布环境
57
+ 上执行端到端验收;本次运行结果不可替代部署后的兼容性验证。
package/host/README.md ADDED
@@ -0,0 +1,443 @@
1
+ # `host/` 接线指南(给 index.js / review / 后续维护用)
2
+
3
+ 本目录是「会话计划补全」插件的 **Host 纯逻辑层**:纯 ESM、零依赖、**不 import 任何 DSH 包**。
4
+ 所有与运行时交互(LLM、存储、发布、时钟、取消)都通过 index.js 注入的**端口**完成,
5
+ 因此 `node --test` 可以在没有 DSH 的环境里直接跑。
6
+
7
+ ---
8
+
9
+ ## 0. 文件清单
10
+
11
+ | 文件 | 职责 |
12
+ |---|---|
13
+ | `config.js` | 所有默认参数(双截止线 2500/6000/8000ms、并发 2、步骤 3~7、保留 5 版、prompt 上限等)。index.js 可用插件 Config 覆盖后传入。 |
14
+ | `text.js` | 文本清洗:单行化、去 Markdown、≤40 字、危险命令过滤、提问检测。 |
15
+ | `plan.js` | Plan 数据模型:`normalizePlan` 校验、`patchPlan`/`rewritePlan`/`applyUserEdit` 版本规则、`statusFor`、`mayRewrite`。 |
16
+ | `prompt.js` | `SYSTEM_PROMPT` + `buildPatchPrompt`/`buildRewritePrompt`/`buildRepairPrompt` + `parseModelOutput`。 |
17
+ | `heuristics.js` | 模型不可用时的 `heuristicSuggestion`(失败工具/下一步/提问决策/验收)与 `fallbackPlan`(3~5 步骨架)。 |
18
+ | `evidence.js` | `collectFromEvents`:SessionEvent[] → 证据对象(纯函数,不改输入)。 |
19
+ | `job.js` | `runJob` 完整流水线(双截止线、CAS、降级)+ `createQueue`(同会话串行/合并/幂等/全局并发闸)。**当前 index.js 走内联流水线,job.js 为后续重构备选**(见 §7)。 |
20
+ | `store.js` | `createMemoryStore()`(同步,一期默认)+ `createFileStore(dir)`(异步、原子写,预留)。 |
21
+
22
+ 测试在 `../test/*.test.js`。
23
+
24
+ ---
25
+
26
+ ## 1. 运行测试
27
+
28
+ ```powershell
29
+ # 标准方式(Lead/CI 用这个;Node 绝对路径按本机)
30
+ node `
31
+ --test dsh-plan-completion/test
32
+ ```
33
+
34
+ 当前结果:**6 个测试文件 / 75 tests / 75 pass / 0 fail**(Node v24.21.0)。
35
+
36
+ > 受限 shell 备注:本会话早期沙箱下 `node --test <目录>` 会因测试 runner 子进程
37
+ > `spawn EPERM` 失败,可用 `--test-isolation=none` + 显式文件列表绕过:
38
+ >
39
+ > ```powershell
40
+ > $node = 'node'
41
+ > $files = Get-ChildItem 'dsh-plan-completion\test\*.test.js' | ForEach-Object { $_.FullName }
42
+ > & $node --test --test-isolation=none @files
43
+ > ```
44
+ >
45
+ > 完全权限的 shell 不需要这个 workaround。文件存储测试用 `os.tmpdir()`,不写工作区。
46
+
47
+ ---
48
+
49
+ ## 2. index.js 接线指南
50
+
51
+ ### 2.1 import(导出名已与 Lead 契约对齐)
52
+
53
+ ```js
54
+ import {
55
+ normalizePlan, statusFor, patchPlan, rewritePlan, applyUserEdit,
56
+ createSeedPlan, mayRewrite,
57
+ } from './host/plan.js'
58
+ import { buildPatchPrompt, buildRewritePrompt, SYSTEM_PROMPT, parseModelOutput } from './host/prompt.js'
59
+ import { heuristicSuggestion, fallbackPlan } from './host/heuristics.js'
60
+ import { collectFromEvents } from './host/evidence.js'
61
+ import { createQueue, runJob } from './host/job.js'
62
+ import { createMemoryStore } from './host/store.js'
63
+ ```
64
+
65
+ ### 2.2 端口(index.js 注入;运行时已确认可用的能力)
66
+
67
+ ```js
68
+ const ports = {
69
+ // ctx.get('llm').stream({ provider, model, system, messages, maxTokens, temperature, signal })
70
+ // 消费 text-delta 拼成字符串后返回「模型原文」(不要在这里解析 JSON)
71
+ callModel: async ({ system, messages, maxTokens, temperature, signal }) => rawText,
72
+
73
+ // store.put 返回 boolean
74
+ commit: async (plan, { cas }) => store.put(plan, { cas }),
75
+
76
+ // 写内存 stateBySession + 供 HTTP GET /plan-completion/state 读取
77
+ publish: async ({ plan, suggestion, turn /*, 以及下面 §2.6 的附加字段 */ }) => { /* ... */ },
78
+
79
+ now: () => Date.now(),
80
+
81
+ signal: abortController.signal, // 可选;/replan 超时/会话归档时 abort
82
+ }
83
+ ```
84
+
85
+ `callModel` 的返回**只接受模型原始文本**(string)。若你的适配器返回 `{text}` 对象,
86
+ `job.js` 的 `extractModelText` 也能兼容,但请优先返回 string。
87
+
88
+ ### 2.3 事件订阅(probe-verify 实测契约)
89
+
90
+ ```js
91
+ ctx.on('session/event', (session, event) => { // 注意是双参:session 在前,event 在后
92
+ if (event.type !== 'turn/end') return
93
+
94
+ const sessionId = session.id
95
+ const events = session.snapshotEvents() // readonly SessionEvent[](或 session.ownEvents())
96
+ const plan = store.get(sessionId) // 计划存储 key=sessionId
97
+
98
+ const evidence = collectFromEvents({
99
+ events,
100
+ plan,
101
+ turn: event.data.turn, // turn/end.data = { turn, reason: { kind } }
102
+ sessionId,
103
+ })
104
+ // evidence.interrupted === true 表示 reason.kind 是 aborted/interrupted/error
105
+ // 设计 §3.6「非错误/中断结束」才显示建议;计划是否更新由你决定(建议过滤掉发布即可)
106
+
107
+ // ↓ 进入内联流水线(与 job.runJob 等价),见 §2.4
108
+ })
109
+ ```
110
+
111
+ SessionEvent 字段(实测):`{ type, seq, time, data }`,**`seq` 在顶层**;
112
+ `turn/end.data.reason.kind ∈ completed|aborted|blocked|error|max-tokens|interrupted|forked`。
113
+
114
+ ### 2.4 内联流水线(index.js 现状;runJob 内部步骤相同)
115
+
116
+ ```js
117
+ async function runTurnJob({ sessionId, turn, plan, evidence }) {
118
+ const t0 = ports.now()
119
+ const commitDeadlineMs = 6000
120
+ const suggestionDeadlineMs = 2500
121
+ const watermark = evidence.watermark // {turn, seq},seq 来自事件顶层 seq
122
+
123
+ // 1) 构造提示:有 plan → patch;冷启动/强制 → rewrite
124
+ const prompt = plan
125
+ ? buildPatchPrompt(evidence)
126
+ : buildRewritePrompt(evidence)
127
+
128
+ // 2) 调模型(失败/JSON 非法 → 修复重发 1 次;超 6000ms 放弃)
129
+ const raw = await ports.callModel({ system: prompt.system, messages: prompt.messages,
130
+ maxTokens: prompt.maxTokens, temperature: prompt.temperature, signal: ports.signal })
131
+ if (ports.now() - t0 > commitDeadlineMs) return { committed: false, published: false, reason: 'plan-deadline' }
132
+ let parsed = parseModelOutput(raw)
133
+ if (!parsed.ok) {
134
+ const repair = buildRepairPrompt(prompt, raw, parsed.error)
135
+ const raw2 = await ports.callModel({ system: repair.system, messages: repair.messages,
136
+ maxTokens: repair.maxTokens, temperature: repair.temperature, signal: ports.signal })
137
+ parsed = parseModelOutput(raw2)
138
+ }
139
+
140
+ // 3) 校验 + 版本 bump + CAS 落盘
141
+ let nextPlan = null, committed = false, baseReason = 'ok'
142
+ if (parsed.ok) {
143
+ const out = parsed.output // {op,reason,changes,steps,intent,suggestion,alternates}
144
+ let applied
145
+ if (out.op === 'rewrite' || !plan) {
146
+ applied = rewritePlan(plan ?? null, out, { reason: out.reason, watermark, now: ports.now(), force: /* /replan */ false, sessionId })
147
+ } else if (out.op === 'patch') {
148
+ applied = patchPlan(plan, out.changes, { reason: out.reason, watermark, now: ports.now() })
149
+ } else {
150
+ applied = { ok: true, plan: null, changed: false } // op=none
151
+ }
152
+ if (applied.ok && applied.plan && applied.changed !== false) {
153
+ committed = await ports.commit(applied.plan, { cas: plan ? plan.version : null })
154
+ if (!committed) baseReason = 'cas-conflict'
155
+ else nextPlan = applied.plan
156
+ }
157
+ } else {
158
+ baseReason = 'model-invalid'
159
+ }
160
+
161
+ // 4) 建议:模型建议 → 没有就用启发式;双截止线 + 与上一条去重
162
+ let suggestion = parsed.ok ? parsed.output.suggestion : ''
163
+ if (!suggestion) {
164
+ const heur = heuristicSuggestion({ ...evidence, plan: nextPlan ?? plan })
165
+ suggestion = heur.ok ? heur.text : ''
166
+ }
167
+ const published = Boolean(suggestion) &&
168
+ ports.now() - t0 <= suggestionDeadlineMs &&
169
+ suggestion !== lastSuggestion
170
+ if (published) await ports.publish({ plan: nextPlan ?? plan, suggestion, turn, sessionId, version: (nextPlan ?? plan)?.version ?? null, watermark, alternates: parsed.ok ? parsed.output.alternates : [], op: parsed.ok ? parsed.output.op : 'none', fallback: !parsed.ok })
171
+
172
+ return { committed, published, reason: baseReason === 'ok' && committed && published ? 'ok' : baseReason, plan: nextPlan }
173
+ }
174
+ ```
175
+
176
+ 如果不想自己维护这段,可以直接用 `runJob`(见 §7),它的返回是
177
+ `{ok, committed, published, reason, plan, suggestion, op, version, fallback, attempts, durationMs, error, publishSkip?}`。
178
+
179
+ ### 2.5 冷启动(计划不存在时)
180
+
181
+ - 第一次 `turn/end` 且 `store.get(sessionId) === null` 时用 `buildRewritePrompt`;
182
+ - 模型失败/输出非法时,用 `fallbackPlan(evidence.intent)`(3~5 步骨架)+
183
+ `createSeedPlan({ sessionId, intent, steps, watermark, reason: 'seed-fallback' })` 落版本 `0.0`;
184
+ - `runJob` 已内置这条降级链,内联实现可照抄 §2.4 的 else 分支。
185
+
186
+ ### 2.6 HTTP 路由(GET /plan-completion/state?sessionId=…)
187
+
188
+ `publish` 的 payload 建议整份存入 `stateBySession.set(sessionId, payload)`,GET 路由直接返回;
189
+ 结构(`publish` 会传的超集,按需取用):
190
+
191
+ ```jsonc
192
+ {
193
+ "plan": { /* §3.4 完整 Plan;commit 失败时为 last-good */ },
194
+ "suggestion": "≤40 字单行建议", // 空字符串表示本回合无建议
195
+ "turn": 9,
196
+ "sessionId": "s_7f3a",
197
+ "version": { "gen": 2, "rev": 3 },
198
+ "watermark": { "turn": 9, "seq": 902 },
199
+ "alternates": ["备选1", "备选2"],
200
+ "op": "patch", // 本次模型 op:none|patch|rewrite
201
+ "fallback": false, // true = 建议来自启发式
202
+ "kind": "turn", // turn | force-rewrite
203
+ "at": 1762000000000
204
+ }
205
+ ```
206
+
207
+ 客户端展示条件(非 running/草稿空/无 pendingInteraction/非错误结束/当前会话/未 dismissed/未重复)
208
+ 由 client-ui 用这份 payload + 自身状态判断;`evidence.interrupted` 可在 Host 侧先过滤发布。
209
+
210
+ ### 2.7 用户操作
211
+
212
+ | 操作 | 推荐用法 |
213
+ |---|---|
214
+ | 手动编辑计划 | `applyUserEdit(plan, {steps, intent?}, {reason:'user edit'})` → `commit(next, {cas: plan.version})`;之后 `mayRewrite(plan) === false`,模型自动 rewrite 会被拒,只能 patch。 |
215
+ | `/replan [说明]` | 用 `buildRewritePrompt(evidence, {force:true})`;`rewritePlan(plan, out, {..., force:true})` 覆盖用户版并**重置 `editedByUser=false`**。 |
216
+ | `/roadmap` | 直接返回 `store.get(sessionId)`。 |
217
+ | `/roadmap clear` | `store.clear(sessionId)`。 |
218
+ | 历史版本 | `store.history(sessionId)`(旧→新);`store.prune(5)` 保留最近 5 版。 |
219
+
220
+ ---
221
+
222
+ ## 3. 数据契约
223
+
224
+ ### 3.1 Plan(与设计文档 §3.4 一致)
225
+
226
+ ```jsonc
227
+ {
228
+ "sessionId": "s_7f3a",
229
+ "version": { "gen": 2, "rev": 3 },
230
+ "status": "active", // active | completed | archived
231
+ "intent": "给 Vue 项目加登录页,接入现有鉴权",
232
+ "steps": [
233
+ { "id": "s1", "title": "摸清现有路由与鉴权结构", "status": "done",
234
+ "source": "model", "evidence": ["read:src/router.ts"] },
235
+ { "id": "s2", "title": "新增 Login.vue 与路由", "status": "doing", "source": "model" },
236
+ { "id": "s3", "title": "接入 token 存储与请求拦截器", "status": "todo", "source": "user" }
237
+ ],
238
+ "updatedAt": 1762000000000,
239
+ "reason": "第2步完成(已创建 Login.vue)",
240
+ "revisionOf": { "gen": 2, "rev": 2 },
241
+ "watermark": { "turn": 7, "seq": 214 },
242
+ "editedByUser": false,
243
+ "stats": { "calls": 12, "tokensIn": 18321, "tokensOut": 2014 } // 可选
244
+ }
245
+ ```
246
+
247
+ 约束:`steps` 3~7;`status ∈ {todo,doing,done,dropped}`;`source ∈ {model,user}`;
248
+ 版本规则 `rewrite → gen+1, rev=0`、`patch → gen.rev+1`、首版 `0.0`;每次 bump 必带 `reason`。
249
+ `normalizePlan` 会丢弃未知字段、补默认值,非法输入返回 `{ok:false, code, error}`(永不 throw)。
250
+
251
+ ### 3.2 evidence(`collectFromEvents` 输出)
252
+
253
+ ```jsonc
254
+ {
255
+ "ok": true,
256
+ "sessionId": "s_7f3a",
257
+ "turn": 9,
258
+ "seq": 902, "watermark": { "turn": 9, "seq": 902 },
259
+ "intent": "…", // plan.intent,没有则首条用户消息
260
+ "plan": { /* 传入的 plan 副本,未传为 null */ },
261
+ "lastAssistantText": "…", // 最后一条 assistant 正文(只取 text block,截断 1500 字)
262
+ "toolCalls": [{ "name": "write", "ok": true, "callId": "c2", "path": "src/views/Login.vue" }],
263
+ "failedTools": [{ "name": "bash", "ok": false, "callId": "c3", "error": "EACCES: permission denied" }],
264
+ "lastUserMessages": ["…", "…"], // 最近 2 条(跨回合),各截断 500 字
265
+ "firstUserMessage": "…",
266
+ "recentMessages": [{ "role": "user", "text": "…" }],
267
+ "todoSteps": [{ "id": "s3", "title": "…", "status": "todo" }],
268
+ "files": ["src/views/Login.vue"],
269
+ "hasQuestion": false,
270
+ "turnEnded": true, "turnEndReason": "completed", "turnEndError": "", "interrupted": false,
271
+ "counts": { "events": 108, "toolCalls": 3, "failedTools": 1, "messages": 6, "userMessages": 9 }
272
+ }
273
+ ```
274
+
275
+ 事件按实测契约解析:`turn/start|turn/end|user/message|assistant/message|tool/call|tool/result`;
276
+ 正文只取 `{type:'text'}` content block(reasoning/tool-call 块不进入证据);
277
+ 工具失败以 `tool/result.message.isError === true` 为准,工具名按 `callId` 回填到 `tool/call`。
278
+ `events` 直接用 `session.snapshotEvents()` 或 `session.ownEvents()` 的返回值。
279
+
280
+ ### 3.3 模型输出 JSON(prompt 要求的 schema)
281
+
282
+ ```jsonc
283
+ // patch
284
+ { "op": "patch", "reason": "第2步完成",
285
+ "changes": [ { "id": "s2", "status": "done", "evidence": ["write:src/views/Login.vue"] } ],
286
+ "suggestion": "…≤40字…", "alternates": ["…"] }
287
+ // rewrite
288
+ { "op": "rewrite", "reason": "阶段边界", "intent": "…",
289
+ "steps": [ { "id": "s1", "title": "里程碑", "status": "todo" } ],
290
+ "suggestion": "…", "alternates": ["…"] }
291
+ // 没有实质变化
292
+ { "op": "none", "reason": "本回合无实质变化", "suggestion": "…" }
293
+ ```
294
+
295
+ `parseModelOutput(text)` 返回:
296
+
297
+ ```js
298
+ { ok: true,
299
+ output: { op:'none'|'patch'|'rewrite', reason, changes, steps, intent, suggestion, alternates },
300
+ flags: { op, suggestionFiltered, suggestionTruncated, alternatesFiltered, ignoredIntent },
301
+ raw: { /* 原始 JSON 对象 */ } }
302
+ // 或 { ok:false, code, error } —— 不抛异常
303
+ ```
304
+
305
+ 要点:`output.suggestion` **恒为 string**(缺失/被危险过滤为 `''`);suggestion 单行、≤40 字、
306
+ 去 Markdown;危险命令(rm -rf / del /f / format / mkfs / git reset --hard / curl|sh / iex 等)
307
+ 只丢建议、不丢计划;rewrite 的 steps 必须 3~7 条且 id 唯一。
308
+
309
+ ---
310
+
311
+ ## 4. API 速查
312
+
313
+ ### `plan.js`
314
+
315
+ | 函数 | 返回 |
316
+ |---|---|
317
+ | `normalizePlan(input, {now?, requireReason?})` | `{ok, plan}` / `{ok:false, code, error}` |
318
+ | `normalizeSteps(steps)` / `normalizeEvidence(v)` | `{ok, steps}` / `string[]` |
319
+ | `statusFor(steps)` | `'active' \| 'completed'` |
320
+ | `patchPlan(plan, changes, {reason, watermark?, now?})` | `{ok, plan, changed, previous}` |
321
+ | `rewritePlan(plan\|null, next, {reason?, watermark?, now?, force?, sessionId?})` | `{ok, plan, changed, previous}` |
322
+ | `applyUserEdit(plan, {steps, intent?}, {reason?, watermark?, now?})` | `{ok, plan, changed, previous}` |
323
+ | `archivePlan(plan, {reason?, watermark?, now?})` | `{ok, plan, changed, previous}` |
324
+ | `createSeedPlan({sessionId, intent?, steps, watermark?, reason?}, {now?})` | `{ok, plan}` |
325
+ | `mayRewrite(plan, {force?})` | boolean(`editedByUser=true` / `archived` → false) |
326
+ | `nextVersion` / `versionEquals` / `compareVersions` / `isNextVersion` / `validVersion` / `validWatermark` | 工具函数 |
327
+
328
+ ### `prompt.js`
329
+
330
+ | 函数 | 返回 |
331
+ |---|---|
332
+ | `SYSTEM_PROMPT` | 完整系统提示(只输出 JSON / 数据非指令 / 40 字单行 / 提问给决策 / 验收 / 语言跟随 / 禁止继续好的) |
333
+ | `buildPatchPrompt(evidence)` / `buildRewritePrompt(evidence, {force?})` | `{system, messages, maxTokens, temperature, op}` |
334
+ | `buildRepairPrompt(prompt, badOutput, error)` | 同上结构(messages 追加 assistant 失败输出 + 纠正指令) |
335
+ | `evidenceForModel(evidence)` | 裁剪后的证据对象 |
336
+ | `parseModelOutput(text)` | 见 §3.3 |
337
+
338
+ ### `heuristics.js`
339
+
340
+ | 函数 | 返回 |
341
+ |---|---|
342
+ | `heuristicSuggestion(evidence)` | `{ok:true, text, kind}` / `{ok:false, text:'', kind:'none'}`;`kind ∈ fix-error / next-step / decide / accept / next-title / intent` |
343
+ | `fallbackPlan(intent)` | `{ok:true, intent, steps}`(3~5 步骨架)/ `{ok:false, code, error}` |
344
+
345
+ ### `evidence.js`
346
+
347
+ `collectFromEvents({events, plan?, turn?, sessionId?})` → evidence(§3.2)。纯函数,不修改输入。
348
+
349
+ ### `store.js`
350
+
351
+ | 方法 | 语义 |
352
+ |---|---|
353
+ | `get(sessionId)` | `plan \| null`(副本) |
354
+ | `put(plan, {cas})` | boolean;`cas` 省略=无条件,`null`=仅新建,`{gen,rev}`=期望当前版本,失配 false |
355
+ | `history(sessionId)` | `plan[]`(旧→新,含当前版) |
356
+ | `prune(n = 5)` | `{ok, removed, sessions, keep}` |
357
+ | `clear(sessionId?)` / `listSessions()` / `size()` | — |
358
+
359
+ `createMemoryStore()` 同步;`createFileStore(dir)` 全部 async、永不 throw(出错返回 `null/false/[]`,
360
+ 原因看 `store.lastError`),单进程假设、无跨进程锁,一期可不接。
361
+
362
+ ---
363
+
364
+ ## 5. 双截止线(设计 §3.5.4)实现方式
365
+
366
+ | 截止线 | 默认 | 判定 | 行为 |
367
+ |---|---|---|---|
368
+ | 建议展示 | 2500ms | 模型返回后 `now() - t0` | 超过 → 不发布建议,计划照常提交(`reason: 'suggestion-deadline'`) |
369
+ | 计划落盘 | 6000ms | 模型返回后 `now() - t0` | 超过 → 放弃、保留 last-good(`reason: 'plan-deadline'`) |
370
+ | 强制重写 | 8000ms | 同上(`forceRewrite`/`kind:'force-rewrite'`) | 超时返回 `userMessage: '重写失败,已保留原计划'` |
371
+
372
+ `runJob` 用注入的 `now()` 做**确定性判定**(测试用假时钟,不需要真的等 6 秒);
373
+ 同时内部用 `setTimeout` 兜底「模型调用真正挂起」的情况并 `abort` 注入的 signal。
374
+ `commitDeadlineMs` / `suggestionDeadlineMs` 可由 `runJob({..., commitDeadlineMs, suggestionDeadlineMs})`
375
+ 或 job 字段覆盖(默认 6000/2500,强制重写 8000)。
376
+
377
+ ---
378
+
379
+ ## 6. 与设计文档的差异 / 实现取舍(review 重点)
380
+
381
+ 1. **`/replan` 强制重写后重置 `editedByUser=false`**:设计未写明。理由:`/replan` 是用户主动要求
382
+ 覆盖,若不重置会让「用户版锁」永久生效;普通模型自动 rewrite 仍被 `mayRewrite` 拒绝。
383
+ 2. **rewrite 防抖取「且」**:设计写「两次 rewrite ≥ 3 回合或 5 分钟」(有歧义),实现为
384
+ **≥3 回合且 ≥5 分钟** 才允许自动 rewrite(`rewriteAllowed`);范围内的小 diff 由 `runJob`
385
+ 降级成 `patchPlan`(`diffToChanges` 可 patch 时)。
386
+ 3. **patch 无实质变化仍 bump rev**:符合验收「patch → rev+1」,但返回 `changed:false`;
387
+ 调用方(或 `runJob`)据此**跳过 commit**。内联流水线请检查 `applied.changed !== false`。
388
+ 4. **`op:'none'` 也要求 `reason` 非空**:输出 schema 统一;`none` 不变更版本。
389
+ 5. **suggestion 超 40 字是截断而不是整条丢弃**;危险形态只丢建议、计划照常提交(`flags.suggestionFiltered`)。
390
+ 6. **`maxTokens` 取 patch 800 / rewrite 1200**:设计预算「输出 <300 tokens」是理想值,真机实测 3~7 步 JSON
391
+ (尤其长 reason + 多 changes)在 400/700 会被截断成非法 JSON;index.js 侧同样把下限调到 800/1200,
392
+ 并配合「parse 失败 → `buildRepairPrompt` 重发 1 次」。`test/job.test.js` 覆盖了「首轮截断 → 修复轮成功提交」
393
+ 与「超长 reason + 7 条 changes」用例。
394
+ 7. **证据的消息取全量最近 2 条(跨回合)**,工具调用/失败只取本回合(与「本回合工具调用名与失败项 +
395
+ 最近 2 条用户消息」一致)。
396
+ 8. **`runJob` 返回的 `reason`** 是结构化枚举(`ok / suggestion-deadline / plan-deadline / model-invalid /
397
+ model-error / cas-conflict / commit-error / no-op / rewrite-locked / rewrite-debounced / superseded / aborted /
398
+ internal`…),`committed`/`published` 才是主判据;`plan` 仅在提交成功时为新版,否则 `null`。
399
+ 9. **`createQueue` 幂等键**:`sessionId:turn`;强制重写追加 `:replan` 后缀,避免与同回合 turn/end 撞键。
400
+ 10. **`fallbackPlan` 返回 `{ok, intent, steps}` 而非 Plan**:Plan 由 `createSeedPlan` 生成并统一校验。
401
+ 11. **file store 为预留接口**(异步、单进程、无锁);一期 index.js 用插件内存 Map + 可选 JSON 文件即可。
402
+ 12. **`job.js` 当前未接线**:Lead 的 index.js 自己实现了内联流水线(与 webServer 路由/事件订阅耦合),
403
+ `runJob`/`createQueue`/`createRunner` 保留为后续重构备选,接口与内联流水线同构。
404
+
405
+ ---
406
+
407
+ ## 7. job.js 队列(后续重构备选)
408
+
409
+ 当前 index.js 走内联流水线。若之后改成队列化,直接用:
410
+
411
+ ```js
412
+ const { queue, submit } = createRunner({ ports, limit: 2 }) // limit = 全局并发闸(默认 2)
413
+ const res = await submit({
414
+ sessionId, turn: event.data.turn,
415
+ kind: 'turn', // 或 'force-rewrite' / forceRewrite: true
416
+ watermark: evidence.watermark,
417
+ plan: store.get(sessionId),
418
+ evidence,
419
+ lastSuggestion, // 去重
420
+ rewriteGate: { lastRewriteTurn, lastRewriteAt }, // 防抖(可选)
421
+ })
422
+ // res: {ok, committed, published, reason, plan, suggestion, op, version, fallback, attempts, durationMs, error}
423
+ ```
424
+
425
+ `createQueue` 保证:同会话串行;运行中到来的作业合并成一条 pending(`mergeJobs`,取最新
426
+ watermark/evidence,强制重写优先);完成后只再跑一次最新作业;幂等键去重(默认 10 分钟窗口);
427
+ 跨会话 `globalGate(limit)` 并发闸(in-flight ≤2);`close()` 取消未开始的合并作业;
428
+ `idle()` 等全部空闲。执行器签名 `execute(job, runtime)`,`runtime.isSuperseded()` 表示运行期间
429
+ 是否已有更新作业在排队(据此丢弃迟到建议)。
430
+
431
+ > 也可以只用 `createQueue`(自定义 execute),或只用 `runJob`(自管队列)。
432
+
433
+ ---
434
+
435
+ ## 8. 防御性约定
436
+
437
+ - 所有公共函数**永不 throw**:非法输入返回 `{ok:false, code, error}`;解析模型输出/证据采集
438
+ 均包 try/catch。
439
+ - 纯函数不修改输入:`normalizePlan`/`patchPlan`/`rewritePlan`/`collectFromEvents`/`evidenceForModel`
440
+ 都返回新对象,测试里有输入快照断言。
441
+ - store 的 `get`/`history` 返回副本,改返回值不会污染存储。
442
+ - 任何失败都不阻断用户发送 / Tab 填入(Host 只负责建议与计划,不碰 agent loop)。
443
+