@ifrankwang/openspec-agents 0.133.2 → 0.135.0

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.
@@ -29,7 +29,7 @@ capabilities: ["orchestrator"]
29
29
  2. 若未初始化,调用初始化工具(如 `mcp__opx__orch_init`)传入 `change_id` / `task_group_id`;
30
30
  3. 若 worktree 未就绪,调用 `mcp__opx__orch_set_worktree`(或列表中对应工具);
31
31
  4. 再次调用状态查询工具获取权威「下一步」并严格按返回执行。
32
- - 用户仅给出 change 名(未指定任务组)时的约定语义:表示以简单模式串行实施该 change 的全部任务组。先读取 `openspec/changes/<change 名>/tasks.md` 获取任务组清单与顺序(该文件是编排规划清单,非被编排项目的业务源码,也是初始化前唯一允许读取的项目文件):未初始化时从清单第一个任务组开始,以显式 `mode="simple"` 调用初始化工具;当前任务组收尾完成后初始化顺序中的下一个任务组,直至全部任务组完成后编排结束。用户明确指定任务组或要求 full 模式时按用户指定执行,不适用本约定。
32
+ - 用户仅给出 change 名(未指定任务组)时的约定语义:表示以简单模式串行实施该 change 的全部任务组。先读取 `openspec/changes/<change 名>/tasks.md` 获取任务组清单与顺序(该文件是编排规划清单,非被编排项目的业务源码,也是初始化前唯一允许读取的项目文件):未初始化时从清单第一个任务组开始,以显式 `mode="simple"` 调用初始化工具;当前任务组收尾完成后初始化顺序中的下一个任务组,直至全部任务组完成后按状态视图指引完成部署注意事项汇报,编排结束。用户明确指定任务组或要求 full 模式时按用户指定执行,不适用本约定。
33
33
  - 子代理工具命名随 harness 不同:
34
34
  - DSH 形态:`openspec_developer` / `openspec_reviewer` 两个物理子代理(`openspec_<role>`,物理 agent 已收敛,9 种逻辑身份经 `_agent` 参数承载),分派时直接调用这些专用工具,不要使用通用 `subagent` 代替;
35
35
  - 其他 harness 按各自原生子代理机制分派(Claude Code / Codex / ZCode 插件已注入对应子代理)。
@@ -52,12 +52,13 @@ capabilities: ["orchestrator"]
52
52
  - 断点续传:子代理因步骤限制中断后重新分派即可继续,无需保存已完成子任务列表
53
53
  - 工具列出多个子代理时并排分派(单条消息中同时发送),不串行等待
54
54
  - 分派前校验分派 prompt:按「禁止事项」中禁止转述的动态内容清单逐项检查,校验通过后再分派
55
- - 先解锁、后 status、再探索;禁止在首次 status 前手动探查项目文件/状态文件(唯一例外:用户未指定任务组时,按启动编排约定语义读取 tasks.md 确定任务组清单与顺序)
55
+ - 先解锁、后 status、再探索;禁止在首次 status 前手动探查项目文件/状态文件(例外一:用户未指定任务组时,按启动编排约定语义读取 tasks.md 确定任务组清单与顺序;例外二:change 全部任务组完成后的部署要点读取,以状态视图指引为准)
56
+ - change 全部任务组完成后,按状态视图「部署注意事项」区块指引向用户做一次收尾汇报:读主仓库 `openspec/changes/<changeId>/` 规划文档提取部署要点,并原样转述视图列出的实施补充点;不读业务源码、不自行解读或扩写实施补充点,无规划文档时跳过文档提取
56
57
 
57
58
  ## 禁止事项
58
59
 
59
60
  - 禁止修改代码/文档(edit/write 权限已强制禁止)
60
- - **禁止自行阅读/探查被编排项目的业务源码**——收到子代理的 issue 反馈时不得用 read 或其他工具理解 issue 技术内容。issue 的理解与修复是子代理职责,编排者只负责按状态流转分派,不解读 issue 内容;read 仅限状态文件做状态交叉验证
61
+ - **禁止自行阅读/探查被编排项目的业务源码**——收到子代理的 issue 反馈时不得用 read 或其他工具理解 issue 技术内容。issue 的理解与修复是子代理职责,编排者只负责按状态流转分派,不解读 issue 内容;read 仅限状态文件做状态交叉验证,以及 change 全部任务组完成后按状态视图指引读取 `openspec/changes/<changeId>/` 下的规划文档(仅提取部署要点,不读业务源码、不解读实现)
61
62
  - 禁止代子代理提交 step 裁决——step 提交必须由对应子代理独立调用提交工具完成;仅检查点决策可由编排者执行推进
62
63
  - **禁止居中代问/代记 blocker 的用户答复**——需用户拍板的问题由对应子代理直接向用户确认(有人值守)或自行裁决(无人值守),编排者不转述答复、不代记
63
64
  - 禁止使用通用子代理代替专用审查子代理——各子代理角色按工具返回的分派指令确定
@@ -43,6 +43,7 @@ phases:
43
43
  - "提交前完成自检并经 self_check_results 汇总:每项检查须含执行命令与结果摘要(证据可核验,供审查者分级复验对照与核验申报),禁止仅报结论不附证据"
44
44
  - "提交前按已加载的维度规范类 skill(style/architecture/performance/security/maintainability 能力类别)对本次变更文件逐项自检(聚焦本轮变更文件,不扩散全量代码),自检结论与工具检查结果一并汇总进 self_check_results;修复被驳回 issue 后再次提交前同样须按维度规范类 skill 对变更文件自检后再提交"
45
45
  - "自检须覆盖已加载的维度规范类 skill 的全部检查点,self_check_results 按维度逐项列结论(通过/遗留及理由),禁止仅汇总工具检查结果"
46
+ - "实施产出的部署注意点(数据/结构变更脚本执行、消息队列/缓存等中间件配置调整、新增配置项或功能开关、外部接口契约变化)凡属规划文档(design.md/tasks.md/proposal.md/specs/)未记载的,随提交在 self_check_results 中以 `部署注意点:` 开头的独立行逐条申报(每个注意点独占一行);规划文档已记载的不重复申报;修复轮重新提交时保留既有部署注意点行"
46
47
  - "修复完成的 issue 经 fixed_issue_ids 上报后进入待复核(review)状态,终态由报源 reviewer 复核裁定;不可修 issue 经 exempt_issue_ids 申请豁免(申请后进入待裁定(review)状态,由报源 reviewer 在后续审查轮次裁定,被驳回的 issue 回待修复);Info 级 issue 不阻塞提交,无需申请豁免;全部 blocking issue 均已上报修复或申请豁免后才可 passed 提交推进"
47
48
  - "确定性质量工具检查出的问题不得以压制手段(压制注解/注释抑制/规则排除配置/平台状态标注/构建跳过开关等)静默规避:确需压制时,必须在压制处代码注释说明清楚理由(commit message 可补充),并经 exempt_issue_ids 申请豁免由报源 reviewer 裁定;不得以 fixed_issue_ids 冒充修复;被驳回后须移除压制并真实修复"
48
49
  - "遇外部依赖、凭证、真实输入或必须 stub/降级/跳过验收才能继续时,以 verdict=failed 提交 blocker(含 source_role、task_id、category、description、evidence、attempted_actions、options)"
@@ -74,6 +74,7 @@ phases:
74
74
  - "提交前完成自检并经 self_check_results 汇总"
75
75
  - "提交前按已加载的维度规范类 skill(style/architecture/performance/security/maintainability 能力类别)对本次变更文件逐项自检(聚焦本轮变更文件,不扩散全量代码),自检结论与工具检查结果一并汇总进 self_check_results;修复被驳回 issue 后再次提交前同样须按维度规范类 skill 对变更文件自检后再提交"
76
76
  - "自检须覆盖已加载的维度规范类 skill 的全部检查点,self_check_results 按维度逐项列结论(通过/遗留及理由),禁止仅汇总工具检查结果"
77
+ - "实施产出的部署注意点(数据/结构变更脚本执行、消息队列/缓存等中间件配置调整、新增配置项或功能开关、外部接口契约变化)凡属规划文档(design.md/tasks.md/proposal.md/specs/)未记载的,随提交在 self_check_results 中以 `部署注意点:` 开头的独立行逐条申报(每个注意点独占一行);规划文档已记载的不重复申报;修复轮重新提交时保留既有部署注意点行"
77
78
  - "修复完成的 issue 经 fixed_issue_ids 上报后进入待复核(review)状态,终态由报源 reviewer 复核裁定;不可修 issue 经 exempt_issue_ids 申请豁免(申请后进入待裁定(review)状态,由报源 reviewer 在后续审查轮次裁定,被驳回的 issue 回待修复);Info 级 issue 不阻塞提交,无需申请豁免;全部 blocking issue 均已上报修复或申请豁免后才可 passed 提交推进"
78
79
  - "确定性质量工具检查出的问题不得以压制手段(压制注解/注释抑制/规则排除配置/平台状态标注/构建跳过开关等)静默规避:确需压制时,必须在压制处代码注释说明清楚理由(commit message 可补充),并经 exempt_issue_ids 申请豁免由报源 reviewer 裁定;不得以 fixed_issue_ids 冒充修复;被驳回后须移除压制并真实修复"
79
80
  - "遇外部依赖、凭证、真实输入或必须 stub/降级/跳过验收才能继续时,以 verdict=failed 提交 blocker(含 source_role、task_id、category、description、evidence、attempted_actions、options)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifrankwang/openspec-agents",
3
- "version": "0.133.2",
3
+ "version": "0.135.0",
4
4
  "description": "OpenSpec change orchestration plugin for AI coding agents (Claude Code, Codex, ZCode, DeepSeek Harness, OpenCode)",
5
5
  "author": "ifrankwang",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@ const TOOL_SPECS: Record<string, ToolSpec> = {
55
55
  },
56
56
  opx_orch_complete_task_group: {
57
57
  description:
58
- "完成任务组收尾:合并 task-group 分支到 baseBranch → 清理 worktree 与分支。须在收尾验证(verify_cleanup)通过后调用。合并冲突时中止并返回 blocked(保留 worktree/分支)。",
58
+ "完成任务组收尾:合并 task-group 分支到 baseBranch → 清理 worktree 与分支。须在收尾验证(verify_cleanup)通过后调用。合并冲突、主仓库本地改动文件与合并写入文件重合、或存在部分暂存文件时中止并返回 blocked(保留 worktree/分支);主仓库无关脏文件不阻塞合并。",
59
59
  schema: completeTaskGroupSchema,
60
60
  execute: (args, ctx) => completeTaskGroupExecute(args as any, ctx),
61
61
  },
package/src/core/git.ts CHANGED
@@ -216,19 +216,86 @@ export async function markTaskGroupCheckboxesComplete(
216
216
  }
217
217
  }
218
218
 
219
+ /** 单条 `git status --porcelain` 条目:XY 状态码 + 涉及路径(重命名条目含 old/new 双路径)。 */
220
+ interface StatusEntry {
221
+ x: string
222
+ y: string
223
+ paths: string[]
224
+ }
225
+
226
+ /** 去除 porcelain/diff 输出中路径的包裹引号(特殊字符路径会被 git 以双引号包裹)。 */
227
+ function unquotePath(p: string): string {
228
+ const t = p.trim()
229
+ return t.startsWith('"') && t.endsWith('"') && t.length >= 2 ? t.slice(1, -1) : t
230
+ }
231
+
232
+ /**
233
+ * 解析 `git status --porcelain` 为结构化条目(独立于 parsePorcelainPaths,后者仅取新路径且被
234
+ * detectChanges / reconcileMainPollution 依赖,语义不可原地变更)。
235
+ * 重命名条目(`R old -> new`)双向路径都计入——两侧路径都可能与合并写入清单重合。
236
+ *
237
+ * 注意:GitRunner 的 run/runChecked 都对 stdout 整体 trim(),porcelain 首行若以空格开头(X=' ' 的
238
+ * 未暂存条目)会被吃掉前导空格、列位整体左移一格。porcelain v1 的位置 2 恒为分隔空格,据此识别
239
+ * 被破坏的首行并补正。
240
+ */
241
+ function parseStatusEntries(out: string): StatusEntry[] {
242
+ const entries: StatusEntry[] = []
243
+ for (const raw of out.split("\n")) {
244
+ if (raw.length < 4) continue
245
+ let line = raw
246
+ if (line[2] !== " ") {
247
+ // 位置 2 非分隔空格 = 首行被整体 trim 破坏(X=' ' 条目吃掉前导空格,列位左移一格)
248
+ if (line[1] !== " ") continue
249
+ line = ` ${line}`
250
+ }
251
+ if (line[0] === " " && line[1] === " ") continue
252
+ const pathPart = line.slice(3)
253
+ const arrow = pathPart.indexOf(" -> ")
254
+ const paths = arrow >= 0
255
+ ? [unquotePath(pathPart.slice(0, arrow)), unquotePath(pathPart.slice(arrow + 4))]
256
+ : [unquotePath(pathPart)]
257
+ if (paths.every((p) => p !== "")) entries.push({ x: line[0], y: line[1], paths })
258
+ }
259
+ return entries
260
+ }
261
+
262
+ /** 解析 `git diff --name-only` 输出为路径列表(含引号路径解包)。 */
263
+ function parseDiffNameOnly(out: string): string[] {
264
+ return out.split("\n").map(unquotePath).filter(Boolean)
265
+ }
266
+
267
+ /**
268
+ * 两条路径是否重合(脏文件 ↔ 合并将写入文件):尾斜杠归一后相等(未跟踪目录 `dir/` 与文件 `dir`
269
+ * 同名冲突),或互为路径段前缀(未跟踪目录 `dir/` 覆盖目录下任意写入路径,双向判定)。
270
+ */
271
+ function pathsOverlap(a: string, b: string): boolean {
272
+ const na = a.replace(/\/+$/, "")
273
+ const nb = b.replace(/\/+$/, "")
274
+ if (!na || !nb) return false
275
+ if (na === nb) return true
276
+ return na.startsWith(`${nb}/`) || nb.startsWith(`${na}/`)
277
+ }
278
+
219
279
  /**
220
280
  * 收尾合并:将任务组分支合并进基础分支。
221
281
  *
222
282
  * 先解析主仓库根(后续命令一律以主仓库根为 -C 目标,不沿用入参目录),再按目标分支的检出状态分流:
223
- * 1. `merge-base --is-ancestor` 幂等检查:源分支已是目标分支祖先(如人工解决冲突后重调)直接成功,
224
- * 不产生空合并提交;
283
+ * 1. `merge-base --is-ancestor` 幂等检查(重试幂等根基,禁止移除):源分支已是目标分支祖先
284
+ * (如人工解决冲突后重调)直接成功,不产生空合并提交;
225
285
  * 2. 检出检测(`worktree list --porcelain`,按目标分支匹配 `branch refs/heads/<X>` 行):
226
- * - 目标分支正被其它 linked worktree 检出,或正被主仓库检出且主仓库有未提交改动 →
227
- * 返回 blockedMessage(未执行任何 git 变更动作,由上层转 blocked 文案)——
228
- * 单靠 update-ref 推进分支指针会使检出方暂存区/工作区与分支引用失步(status 显示整套反向改动);
229
- * - 目标分支正被主仓库检出且干净:仍先 `merge-tree --write-tree` 内存试算(冲突零副作用返回),
230
- * 无冲突则执行真实 `git merge --no-ff`(由 git 维护检出方 index/工作区;失败时 `merge --abort`
231
- * 兜底恢复后按冲突形态返回,理论上仅竞态会走到此分支);
286
+ * - 目标分支正被其它 linked worktree 检出 → 返回 blockedMessage(未执行任何 git 变更动作,
287
+ * 由上层转 blocked 文案)——单靠 update-ref 推进分支指针会使检出方暂存区/工作区与分支引用失步
288
+ * (status 显示整套反向改动);
289
+ * - 目标分支正被主仓库检出(无论是否干净):先 `merge-tree --write-tree` 内存拟合并
290
+ * (零副作用;退出码 1 = 冲突,按冲突形态返回),无冲突时以拟合并产出树精确计算
291
+ * 「合并将写入的文件清单」,与主仓库脏文件(已暂存/未暂存/未跟踪;重命名双向路径;
292
+ * 未跟踪目录按路径段前缀双向匹配)求重合后分流:
293
+ * * 有重合 → blocked(列出重合文件;真实合并会覆盖本地改动,交由人工处理);
294
+ * * 无重合且存在已暂存改动 → 完整暂存(暂存内容与工作区一致)时走无损路径:
295
+ * `restore --staged` → 真实合并 → finally `git add` 还原暂存态(合并不触碰这些文件);
296
+ * 部分暂存(同一文件既有已暂存又有未暂存改动)→ blocked(自动操作会破坏暂存粒度);
297
+ * * 无重合且仅未暂存/未跟踪脏 → 直接真实 `git merge --no-ff`(git 对无关脏文件安全保留;
298
+ * 失败时 `merge --abort` 兜底恢复);
232
299
  * 3. 无人检出(worktreeless 底层命令,主仓库工作区状态不受影响;冲突时目标分支引用未动、磁盘无半成品):
233
300
  * `merge-tree --write-tree`(需 git ≥ 2.38)内存试算合并树:退出码 1 = 冲突,直接返回;
234
301
  * `commit-tree` 生成双父合并提交,`update-ref` 以试算前目标分支 oid 作旧值校验推进(CAS,防并发覆盖)。
@@ -261,17 +328,6 @@ export async function mergeBranchToTarget(
261
328
  ].join("\n"),
262
329
  }
263
330
  }
264
- const status = await runGit(mainRepo, ["status", "--porcelain"])
265
- if (status.trim().length > 0) {
266
- return {
267
- success: false,
268
- conflict: false,
269
- blockedMessage: [
270
- `- **原因**: 目标分支 \`${targetBranch}\` 正被主仓库检出,且主仓库存在未提交改动;为避免覆盖本地改动,本次未执行任何合并动作(分支引用未动、无半成品)。`,
271
- `- **处理**: 请先 commit 或 stash 主仓库改动后重试;或自行执行 \`git merge ${sourceBranch}\`(真实合并自带脏工作区保护)。`,
272
- ].join("\n"),
273
- }
274
- }
275
331
  const mergeTree = await runGitChecked(mainRepo, ["merge-tree", "--write-tree", "--messages", targetBranch, sourceBranch])
276
332
  if (!mergeTree.success) {
277
333
  // merge-tree 退出码 1 = 冲突;其余为分支缺失等真实错误
@@ -280,9 +336,76 @@ export async function mergeBranchToTarget(
280
336
  }
281
337
  return { success: false, conflict: true }
282
338
  }
339
+ const mergedTreeOid = mergeTree.stdout.split("\n")[0].trim()
340
+
341
+ const statusRes = await runGitChecked(mainRepo, ["status", "--porcelain"])
342
+ if (!statusRes.success) {
343
+ throw new Error(`无法读取主仓库工作区状态:${statusRes.stderr}`)
344
+ }
345
+ const entries = parseStatusEntries(statusRes.stdout)
346
+
347
+ if (entries.length > 0) {
348
+ const dirtyPaths = [...new Set(entries.flatMap((e) => e.paths))]
349
+ const written = parseDiffNameOnly(
350
+ await runGit(mainRepo, ["diff", "--name-only", "--no-renames", targetBranch, mergedTreeOid]),
351
+ )
352
+ const overlap = written.filter((w) => dirtyPaths.some((d) => pathsOverlap(d, w)))
353
+ if (overlap.length > 0) {
354
+ return {
355
+ success: false,
356
+ conflict: false,
357
+ blockedMessage: [
358
+ `- **原因**: 目标分支 \`${targetBranch}\` 正被主仓库检出,且以下本地改动文件与任务组合并将写入的文件重合;为避免覆盖本地改动,本次未执行任何合并动作(分支引用未动、无半成品):`,
359
+ ...overlap.map((f) => ` - \`${f}\``),
360
+ `- **处理**: 请先 commit/stash/移除上述文件的本地改动后重试;或自行执行 \`git merge ${sourceBranch}\`(真实合并自带脏工作区保护)。`,
361
+ ].join("\n"),
362
+ }
363
+ }
364
+
365
+ // 已暂存条目:X 列非空且非 ?(?? 为未跟踪);重命名双向路径都须纳入还原
366
+ const stagedPaths = [...new Set(entries.filter((e) => e.x !== " " && e.x !== "?").flatMap((e) => e.paths))]
367
+ if (stagedPaths.length > 0) {
368
+ // 完整暂存判定:暂存区与工作区无差异。git merge 对 index 相对 HEAD 的任何差异一律拒绝
369
+ // (与合并是否重合无关),需先取消暂存绕行;部分暂存场景自动还原会丢失暂存粒度,拒绝代劳。
370
+ const unstagedDelta = await runGit(mainRepo, ["diff", "--name-only", "--", ...stagedPaths])
371
+ if (parseDiffNameOnly(unstagedDelta).length > 0) {
372
+ return {
373
+ success: false,
374
+ conflict: false,
375
+ blockedMessage: [
376
+ `- **原因**: 主仓库存在部分暂存文件(同一文件既有已暂存又有未暂存改动),自动合并会改变暂存粒度;本次未执行任何合并动作(分支引用未动、无半成品)。`,
377
+ `- **处理**: 请先 commit 或 stash,或补全/取消暂存后重试;或自行执行 \`git merge ${sourceBranch}\`。`,
378
+ ].join("\n"),
379
+ }
380
+ }
381
+ // 无损路径:取消暂存 → 真实合并(不触碰这些文件)→ 兜底重新暂存还原
382
+ let unstagedByUs = false
383
+ try {
384
+ const restore = await runGitChecked(mainRepo, ["restore", "--staged", "--", ...stagedPaths])
385
+ if (!restore.success) throw new Error(`git restore --staged 失败(未执行合并):${restore.stderr}`)
386
+ unstagedByUs = true
387
+ const mergeResult = await runGitChecked(mainRepo, ["merge", "--no-ff", sourceBranch])
388
+ if (!mergeResult.success) {
389
+ // 理论上仅竞态会走到此分支:兜底中止,避免主仓库停留在合并中间态
390
+ await runGitChecked(mainRepo, ["merge", "--abort"])
391
+ return { success: false, conflict: true }
392
+ }
393
+ return { success: true, conflict: false }
394
+ } finally {
395
+ if (unstagedByUs) {
396
+ const readd = await runGitChecked(mainRepo, ["add", "--", ...stagedPaths])
397
+ if (!readd.success) {
398
+ // 暂存态还原失败不可静默:合并已发生,抛错让上层感知(重试时 is-ancestor 幂等短路)
399
+ throw new Error(`合并后还原暂存态失败(git add):${readd.stderr};请人工核对这些文件的暂存状态`)
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+
283
406
  const mergeResult = await runGitChecked(mainRepo, ["merge", "--no-ff", sourceBranch])
284
407
  if (!mergeResult.success) {
285
- // 理论上仅竞态会走到此分支:兜底中止,避免主仓库停留在合并中间态
408
+ // 兜底中止,避免主仓库停留在合并中间态(无重合脏文件时 git 自身保护仍会拒绝越界写入)
286
409
  await runGitChecked(mainRepo, ["merge", "--abort"])
287
410
  return { success: false, conflict: true }
288
411
  }
@@ -12,7 +12,7 @@ import type { ParsedTask } from "../tasks-md.ts"
12
12
  import { assertOrchestrator, findTaskGroup } from "../derive.ts"
13
13
  import { assertPathWithin } from "../paths.ts"
14
14
  import { loadWorkflowFile, resolveWorkflowPath, type LoadedWorkflow } from "../workflow/loader.ts"
15
- import { createInitialWorkItem, isBlockingSeverity, isTerminalPhase, recommendForItem, resetInternalRetryCount, adjudicateStep, clearStepTags, REVIEW_FIX_POLICY_KEY } from "../workflow/engine.ts"
15
+ import { createInitialWorkItem, isBlockingSeverity, isTaskGroupSettled, isTerminalPhase, recommendForItem, resetInternalRetryCount, adjudicateStep, clearStepTags, REVIEW_FIX_POLICY_KEY } from "../workflow/engine.ts"
16
16
  import { renderWorkflowStatusView } from "../workflow/status.ts"
17
17
  import { taskChildrenOf } from "../task-children.ts"
18
18
  import type { WorkItem, WorkItemPhase } from "../workflow/types.ts"
@@ -357,21 +357,12 @@ function assertValidResetStepValues(
357
357
 
358
358
  /**
359
359
  * 判断目标组以外的全部任务组(workItems 中 `task:` 前缀、非目标组)是否"终态或从未激活",
360
- * 即 mode 切换窗口(W1 切组 / W2 重制当前组)的共同前置条件:
361
- * - 终态:item.phase === "done" 或 metadata.completed_at 已设置;
362
- * - 从未激活:无执行痕迹——tags 为空且所有 task children 的 phase 均为 todo 或终态(isTerminalPhase)。
360
+ * 即 mode 切换窗口(W1 切组 / W2 重制当前组)的共同前置条件(判定单一事实源:isTaskGroupSettled)。
363
361
  */
364
362
  function otherTaskGroupsSettled(state: OrchestrateState, targetGroupId: string): boolean {
365
363
  return state.workItems
366
364
  .filter((w) => w.id.startsWith("task:") && w.id !== `task:${targetGroupId}`)
367
- .every((item) => {
368
- if (item.phase === "done" || item.metadata["completed_at"] !== undefined) return true
369
- const noTags = Object.keys(item.tags).length === 0
370
- const childrenSettled = item.children
371
- .filter((c) => c.type === "task")
372
- .every((c) => c.phase === "todo" || isTerminalPhase(c.phase))
373
- return noTags && childrenSettled
374
- })
365
+ .every(isTaskGroupSettled)
375
366
  }
376
367
 
377
368
  // ─── 独立审查会话(kind=review,不绑定 OpenSpec change)───
package/src/core/views.ts CHANGED
@@ -199,6 +199,25 @@ export function renderDevSelfCheckDeclaration(metadata: Record<string, unknown>)
199
199
  return lines
200
200
  }
201
201
 
202
+ // ─── 部署注意点提取 ───
203
+
204
+ /** self_check_results 申报中实施补充点的行前缀(与 workflow 配置 implement instructions 的申报格式约定一致)。 */
205
+ const DEPLOYMENT_NOTE_PREFIX = "部署注意点:"
206
+
207
+ /** 从任务组 WorkItem 的 self_check_results 申报中提取「部署注意点:」前缀行(实施补充点,
208
+ * 规划文档未记载、开发者实施时申报);无命中返回空数组。命中行去除行首尾空白后原样保留
209
+ * (含前缀本身,便于转述端直接输出)。 */
210
+ export function extractDeploymentNoteLines(metadata: Record<string, unknown>): string[] {
211
+ const raw = metadata["self_check_results"]
212
+ if (typeof raw !== "string" || raw.trim() === "") return []
213
+ const hits: string[] = []
214
+ for (const line of raw.split("\n")) {
215
+ const trimmed = line.trim()
216
+ if (trimmed.startsWith(DEPLOYMENT_NOTE_PREFIX)) hits.push(trimmed)
217
+ }
218
+ return hits
219
+ }
220
+
202
221
  // ─── 占位符插值层 ───
203
222
 
204
223
  /** 插值白名单:仅这些 key 可被 {{key}} 占位符引用,其余一律保留原文(防配置注入任意动态值)。 */
@@ -32,6 +32,18 @@ export function isTerminalPhase(phase: WorkItemPhase): boolean {
32
32
  return phase === "done" || phase === "cancelled"
33
33
  }
34
34
 
35
+ /** 任务组「终态或从未激活」(mode 切换窗口与终态视图共享的单一判定):
36
+ * done 或 completed_at 已设置即终态;否则须无执行痕迹(tags 为空)且全部 task children
37
+ * 处于 todo 或终态。 */
38
+ export function isTaskGroupSettled(item: WorkItem): boolean {
39
+ if (item.phase === "done" || item.metadata["completed_at"] !== undefined) return true
40
+ const noTags = Object.keys(item.tags).length === 0
41
+ const childrenSettled = item.children
42
+ .filter((c) => c.type === "task")
43
+ .every((c) => c.phase === "todo" || isTerminalPhase(c.phase))
44
+ return noTags && childrenSettled
45
+ }
46
+
35
47
  export function isBlockingSeverity(severity: string | undefined): boolean {
36
48
  return severity !== undefined && (BLOCKING_SEVERITIES as readonly string[]).includes(severity)
37
49
  }
@@ -6,7 +6,7 @@ import type { ExemptionRecord } from "../exemptions.ts"
6
6
  import type { LoadedWorkflow } from "./loader.ts"
7
7
  import type { EngineRecommendation } from "./engine.ts"
8
8
  import type { DetectChangesResult } from "../git.ts"
9
- import { getStepVerdict, isTerminalPhase, isBlockingSeverity, phaseStepMismatch, REVIEW_STEP_TO_LAYER, blockingStepChildren } from "./engine.ts"
9
+ import { getStepVerdict, isTerminalPhase, isBlockingSeverity, isTaskGroupSettled, phaseStepMismatch, REVIEW_STEP_TO_LAYER, blockingStepChildren } from "./engine.ts"
10
10
  import { agentToReviewDimension, agentToReviewLayer, readIssueSource } from "../constants.ts"
11
11
  import { resolveChildIssueFields } from "./reset.ts"
12
12
  import { taskListOf, issueChildrenOf } from "../task-children.ts"
@@ -14,7 +14,9 @@ import {
14
14
  renderSkillSuggestions, renderEfficiencySteps, renderWorktreeSection,
15
15
  renderAgentSummaries, renderDevSelfCheckDeclaration, renderTaskItem, formatFilePath, formatSeverity,
16
16
  isWorktreeReady, renderWorktreeNotReady, interpolateText, renderStateMismatchDiagnostic,
17
+ extractDeploymentNoteLines,
17
18
  } from "../views.ts"
19
+ import { taskGroupFromWorkItem } from "../derive.ts"
18
20
  import { resolveMustDoForCaps, SKIP_REASON_FORMAT } from "../tools/gate.ts"
19
21
 
20
22
  export interface WorkflowStatusViewOptions {
@@ -71,7 +73,7 @@ export function renderWorkflowStatusView(
71
73
  return renderSuspended(item)
72
74
  }
73
75
  if (item.phase === "done" || item.phase === "cancelled") {
74
- return renderTerminalPhase(item, options.state)
76
+ return renderTerminalPhase(item, options.state, caller)
75
77
  }
76
78
  if (rec.status === "blocked") {
77
79
  if (caller.orchestrator) {
@@ -158,8 +160,10 @@ function renderSuspended(item: WorkItem): string {
158
160
  /** 终态渲染:done 区分已完成(completed_at 已写)与待收尾;cancelled 呈现已取消。
159
161
  * 独立审查会话(kind=review)待收口时渲染审查结果报告视图(issue 按严重级别/维度/文件位置归并,
160
162
  * 只审模式的交付物)——fix=none 下 issue 停留 todo 态、step tag=failed、item=done 为合法组合,
161
- * 报告视图不渲染豁免/复核操作提示(该通道在只审模式下是死路,无修复者消费裁定结论)。 */
162
- function renderTerminalPhase(item: WorkItem, state?: OrchestrateState): string {
163
+ * 报告视图不渲染豁免/复核操作提示(该通道在只审模式下是死路,无修复者消费裁定结论)。
164
+ * change 会话两形态在编排视角下追加「部署注意事项」区块(见 renderDeploymentNotesSection),
165
+ * 子代理视角维持既有文案。 */
166
+ function renderTerminalPhase(item: WorkItem, state?: OrchestrateState, caller?: StatusViewCaller): string {
163
167
  if (item.phase === "cancelled") {
164
168
  return [
165
169
  "# 🚫 任务组已取消",
@@ -197,6 +201,7 @@ function renderTerminalPhase(item: WorkItem, state?: OrchestrateState): string {
197
201
  "",
198
202
  "编排已完成并收尾。",
199
203
  "",
204
+ ...renderDeploymentNotesSection(state, caller),
200
205
  ].join("\n")
201
206
  }
202
207
  return [
@@ -204,9 +209,41 @@ function renderTerminalPhase(item: WorkItem, state?: OrchestrateState): string {
204
209
  "",
205
210
  "全部审核层已通过。调用 `opx_orch_complete_task_group` 合并分支并完成收尾。",
206
211
  "",
212
+ ...renderDeploymentNotesSection(state, caller),
207
213
  ].join("\n")
208
214
  }
209
215
 
216
+ /** 终态视图的「部署注意事项」区块(仅编排视角、change 会话渲染):指引编排者读主仓库规划文档提取
217
+ * 部署要点,并聚合各任务组 self_check_results 申报的「部署注意点:」前缀行(实施补充点,编排者纯转述)。
218
+ * 渲染前提:change 全部任务组均终态或从未激活(isTaskGroupSettled,含当前组——渲染到终态时当前组必已
219
+ * 终态),存在未收口组时不渲染(两形态维持既有文案)。全部组无实施补充点时省略补充点段(不渲染空段)。 */
220
+ function renderDeploymentNotesSection(state: OrchestrateState | undefined, caller: StatusViewCaller | undefined): string[] {
221
+ if (!caller?.orchestrator || !state || state.kind === "review") return []
222
+ const taskGroups = state.workItems.filter((w) => w.id.startsWith("task:"))
223
+ if (!taskGroups.every(isTaskGroupSettled)) return []
224
+ const lines = [
225
+ "## 部署注意事项汇报",
226
+ "",
227
+ "向用户汇报本次变更的部署注意事项:",
228
+ "",
229
+ `- 阅读主仓库 \`openspec/changes/${state.changeId}/\` 下规划文档(design.md、tasks.md、proposal.md、specs/)提取部署要点:数据/结构变更脚本执行、消息队列/缓存等中间件配置调整、新增配置项或功能开关、外部接口契约变化`,
230
+ "- 无规划文档时跳过文档提取,仅转述下方实施补充点",
231
+ "",
232
+ ]
233
+ const supplementary = taskGroups.flatMap((g) => {
234
+ const notes = extractDeploymentNoteLines(g.metadata)
235
+ if (notes.length === 0) return []
236
+ const tg = taskGroupFromWorkItem(g)
237
+ return [`- ${tg.name}(${tg.id}):`, ...notes.map((n) => ` - ${n}`)]
238
+ })
239
+ if (supplementary.length > 0) {
240
+ lines.push("实施补充点(开发者实施时申报,原样转述):", "")
241
+ lines.push(...supplementary)
242
+ lines.push("")
243
+ }
244
+ return lines
245
+ }
246
+
210
247
  /** 独立审查报告视图:issue 按严重级别/维度/文件位置归并的最终报告形态(views 同构渲染共享)。 */
211
248
  function renderReviewReport(item: WorkItem): string {
212
249
  const issues = issueChildrenOf(item)