@ifrankwang/openspec-agents 0.139.0 → 0.141.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.
package/README.md CHANGED
@@ -92,7 +92,7 @@ npm install -D @ifrankwang/openspec-agents
92
92
  1. **输入**:项目中的 OpenSpec 变更规范(变更说明、设计、任务清单、验收规格)。
93
93
  2. **分派**:主智能体把任务交给专职智能体——实现者负责写代码,审查者负责检查,质量把关者负责多维验证。
94
94
  3. **门禁**:每一步的结论通过检查后才进入下一步;不通过的结论自动回到实现环节修复。
95
- 4. **收尾合并**:任务组完成只做门禁校验并标记变更范围,不合并、不销毁 worktree;最后一个任务组收口时把 change 分支一次性合并回基准分支,随后销毁 worktree 并删除 change 分支。合并前若基准分支已推进,先做基线漂移检查并比对基准侧净变化内容:纯文档变化(md / mdx / markdown / rst / adoc 五类后缀)不影响代码语义,直接合并收口;含任一非文档文件或文本冲突才把该任务组回退到收尾验证环节(合并基准分支最新代码并回归复验)后再收口——验收仅在实质漂移时动态发生,正常路径零额外环节。
95
+ 4. **收尾合并**:任务组完成只做门禁校验并标记变更范围,不合并、不销毁 worktree;最后一个任务组收口时把 change 分支一次性合并回基准分支,随后销毁 worktree 并删除 change 分支。合并前若基准分支已推进,先做基线漂移检查并比对基准侧净变化内容:无害漂移不影响代码语义,直接合并收口——无害指三类之一:文档后缀变化(md / mdx / markdown / rst / adoc)、openspec/ 规划路径下的文档(提案/设计/任务/spec,不限后缀)、package.json 且变更内容仅版本号行;含任一其他文件或文本冲突才把该任务组回退到收尾验证环节(合并基准分支最新代码并回归复验)后再收口——验收仅在实质漂移时动态发生,正常路径零额外环节。
96
96
  5. **部署注意事项**:change 的全部任务组完成后,主智能体按状态视图指引汇总部署注意事项(规划文档提取 + 开发者实施补充点,含依赖清单/构建配置变更等合并后需在主仓库重装依赖、重新构建的事项)向用户汇报。全程留痕可审计。
97
97
 
98
98
  ## 独立审查会话
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifrankwang/openspec-agents",
3
- "version": "0.139.0",
3
+ "version": "0.141.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
- "完成任务组收尾。非最后任务组仅做门禁与范围标记(不合并、不销毁);最后一个任务组收口时把 change 分支(change/{changeId})一次性合并回 baseBranch(漂移含任一非文档文件或文本冲突时回退到收尾验证 verify_cleanup 并返回 blocked,纯文档漂移直接合并收口),成功后销毁 worktree 并删分支。须在收尾验证(verify_cleanup)通过后调用。主仓库本地改动文件与合并写入文件重合、或存在部分暂存文件时中止并返回 blocked(保留 worktree/分支);主仓库无关脏文件不阻塞合并。",
58
+ "完成任务组收尾。非最后任务组仅做门禁与范围标记(不合并、不销毁);最后一个任务组收口时把 change 分支(change/{changeId})一次性合并回 baseBranch(漂移无害——文档、openspec 规划路径或仅版本号变更的 package.json——时直接合并收口;漂移含任一其他文件或文本冲突时回退到收尾验证 verify_cleanup 并返回 blocked),成功后销毁 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
@@ -172,13 +172,36 @@ export async function isAncestor(worktree: string, a: string, b: string): Promis
172
172
  /** 查询基准侧漂移文件清单:`git diff --name-only --no-renames <sourceBranch>...<targetBranch>`
173
173
  * 三点区间 = merge-base(source, target) 到 target 的内容差异(基准侧自分支切出点以来的净变化,非 commit 口径)。
174
174
  * 参数方向固定:source 传变更分支,target 传基准分支(切出点在 source 一侧)。
175
- * 返回 null 表示 git 查询失败,调用方须按非文档保守回退。 */
175
+ * 返回 null 表示 git 查询失败,调用方须按非无害保守回退。 */
176
176
  export async function listBranchDriftFiles(worktree: string, sourceBranch: string, targetBranch: string): Promise<string[] | null> {
177
177
  const r = await runGitChecked(worktree, ["diff", "--name-only", "--no-renames", `${sourceBranch}...${targetBranch}`])
178
178
  if (!r.success) return null
179
179
  return parseDiffNameOnly(r.stdout)
180
180
  }
181
181
 
182
+ /**
183
+ * 判定单文件在三点区间(merge-base(source, target) → target)内的变更内容是否仅含 `"version"` 字段行:
184
+ * package.json 的版本号推进属发布元数据,无代码语义,收口漂移直通按无害漂移放行。
185
+ * 逐行解析 `git diff --no-renames <source>...<target> -- <filePath>` 的变更行(`+`/`-` 开头,跳过
186
+ * `+++`/`---` 文件头行):每一行都须匹配 `"version"` 字段行才算 version-only;存在任一其他变更行
187
+ * (依赖、脚本等,具代码语义)→ false;无任何变更行(空 diff,内容无净变化)→ true。
188
+ * 返回 null 表示 git 查询失败,调用方须按非无害保守回退。
189
+ */
190
+ export async function isVersionOnlyDrift(
191
+ worktree: string,
192
+ sourceBranch: string,
193
+ targetBranch: string,
194
+ filePath: string
195
+ ): Promise<boolean | null> {
196
+ const r = await runGitChecked(worktree, ["diff", "--no-renames", `${sourceBranch}...${targetBranch}`, "--", filePath])
197
+ if (!r.success) return null
198
+ for (const line of r.stdout.split("\n")) {
199
+ if (line.startsWith("+++") || line.startsWith("---")) continue
200
+ if ((line.startsWith("+") || line.startsWith("-")) && !/^[+-]\s*"version"\s*:/.test(line)) return false
201
+ }
202
+ return true
203
+ }
204
+
182
205
  /** 校验本地分支存在(refs/heads/<name> 严格本地命名空间,不含远端跟踪 ref)。 */
183
206
  export async function isLocalBranch(worktree: string, branch: string): Promise<boolean> {
184
207
  const r = await runGitChecked(worktree, ["rev-parse", "--verify", `refs/heads/${branch}`])
@@ -3,7 +3,7 @@ import { rmdir } from "node:fs/promises"
3
3
  import type { OrchestrateState, TaskItem, TaskStatus, WorkflowMode, ReviewScope } from "../types.ts"
4
4
  import { BUILD_PHASE_TARGETS, REVIEW_LAYERS, REVIEW_VERIFY_STEPS, SIMPLE_REVIEW_STEPS, REVIEW_TASK_GROUP_ID } from "../types.ts"
5
5
  import { agentToReviewLayer } from "../constants.ts"
6
- import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isAncestor, isWorktreeClean, listBranchDriftFiles, markTaskGroupCheckboxesComplete, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, removeTaskGroupWorktree, isLocalBranch, listLocalBranches, pathExists, type DetectChangesResult } from "../git.ts"
6
+ import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isAncestor, isWorktreeClean, listBranchDriftFiles, isVersionOnlyDrift, markTaskGroupCheckboxesComplete, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, removeTaskGroupWorktree, isLocalBranch, listLocalBranches, pathExists, type DetectChangesResult } from "../git.ts"
7
7
  import { readStateByWorktree, readStateByChangeId, writeState, writeContextToWorktree, getLockPath, acquireLock, releaseLock } from "../state.ts"
8
8
  import { generateIsolationNamespace } from "../namespace.ts"
9
9
  import { readExemptions } from "../exemptions.ts"
@@ -1193,12 +1193,37 @@ function renderReviewIssueSummary(item: WorkItem): string {
1193
1193
  return lines.join("\n")
1194
1194
  }
1195
1195
 
1196
- /** 漂移内容纯文档判定:null(查询失败)按非文档保守回退;空清单 = 内容无净变化(如基准上改了又回滚)视为纯文档;
1197
- * 全部文件后缀属文档格式才放行。.txt 刻意排除(requirements.txt 等依赖清单具代码语义)。 */
1196
+ /** 漂移无害判定之文档后缀白名单:.txt 刻意排除(requirements.txt 等依赖清单具代码语义)。 */
1198
1197
  const DOCUMENTATION_EXTENSIONS = [".md", ".mdx", ".markdown", ".rst", ".adoc"]
1199
- function isDocumentationOnly(files: string[] | null): boolean {
1198
+ /** 漂移无害判定之路径前缀白名单:openspec/ 为编排规划文档空间(提案/设计/任务/spec 等),不承载运行时代码。 */
1199
+ const DRIFT_NEUTRAL_PATH_PREFIXES = ["openspec/"]
1200
+ /**
1201
+ * 漂移内容无害判定,满足其一即无害:文档后缀 / openspec 规划路径前缀 / package.json 且三点区间内容
1202
+ * 仅含 `"version"` 字段行变化(版本号语义,无代码语义)。
1203
+ * null(清单查询失败)按非无害保守回退;空清单 = 内容无净变化(如基准上改了又回滚)视为无害;
1204
+ * version-only 内容查询失败(null)同样按非无害保守回退。
1205
+ */
1206
+ async function isHarmlessDrift(
1207
+ worktree: string,
1208
+ sourceBranch: string,
1209
+ targetBranch: string,
1210
+ files: string[] | null
1211
+ ): Promise<boolean> {
1200
1212
  if (files === null) return false
1201
- return files.every((f) => DOCUMENTATION_EXTENSIONS.some((ext) => f.endsWith(ext)))
1213
+ const versionCandidates: string[] = []
1214
+ for (const f of files) {
1215
+ if (DOCUMENTATION_EXTENSIONS.some((ext) => f.endsWith(ext))) continue
1216
+ if (DRIFT_NEUTRAL_PATH_PREFIXES.some((p) => f.startsWith(p))) continue
1217
+ if (f === "package.json" || f.endsWith("/package.json")) {
1218
+ versionCandidates.push(f)
1219
+ continue
1220
+ }
1221
+ return false
1222
+ }
1223
+ for (const f of versionCandidates) {
1224
+ if ((await isVersionOnlyDrift(worktree, sourceBranch, targetBranch, f)) !== true) return false
1225
+ }
1226
+ return true
1202
1227
  }
1203
1228
 
1204
1229
  /**
@@ -1322,24 +1347,26 @@ async function completeTaskGroupLocked(params: { change_id: string }, ctx: ToolC
1322
1347
  // 非最后任务组:门禁 → 勾选 → scope_end → completed_at;无合并、无销毁
1323
1348
  item.metadata["completed_at"] = new Date().toISOString()
1324
1349
  await writeState(ctx.worktree, state)
1350
+ // 取消限制事实说明与终态视图非最后组文案同措辞(cancelTaskGroupNote)
1325
1351
  const doneMessage =
1326
- "任务组已完成。本任务组变更保留在 change 分支上,待全部任务组完成后统一收口合并(本次不合并、不销毁 worktree)。"
1352
+ `任务组已完成。本任务组变更保留在 change 分支上,待全部任务组完成后统一收口合并(本次不合并、不销毁 worktree)。若某任务组已确认不再执行,当前版本不支持取消任务组,须人工编辑编排 state 文件(openspec/states/${state.changeId}.json)将该任务组的 phase 改为 "cancelled",收口合并才会触发。`
1327
1353
  return checkboxWarning ? `${doneMessage}\n${checkboxWarning}` : doneMessage
1328
1354
  }
1329
1355
 
1330
1356
  // 最后任务组 → change 级收口:合并 change 分支回基准分支(一次性),成功后销毁 worktree 并删分支
1331
1357
  const mergeTarget = state.baseBranch
1332
- // 纯文档漂移直通时的事实说明:仅在本次收口实际发生直通时非空;合并 blocked/conflict 分支不带该说明
1358
+ // 无害漂移直通时的事实说明:仅在本次收口实际发生直通时非空;合并 blocked/conflict 分支不带该说明
1333
1359
  let docDriftNote = ""
1334
1360
  if (branchName) {
1335
1361
  // create-or-reuse(合并目标解析前):在途旧模型会话升级时从当前基准 tip 创建,天然含已合入代码
1336
1362
  await ensureChangeBranch(ctx.worktree, branchName, mergeTarget)
1337
1363
 
1338
1364
  // 前置漂移检查(独立封装):基准 tip 须为 change 分支祖先;不满足时先比对基准侧净变化内容——
1339
- // 纯文档漂移(含空清单 = 无净变化)不回退、直接合并收口;含任一非文档文件或清单查询失败 → 回退收尾验证并 blocked
1365
+ // 无害漂移(文档 / openspec 规划路径 / 仅版本号变更的 package.json,含空清单 = 无净变化)不回退、
1366
+ // 直接合并收口;含任一其他文件或清单/内容查询失败 → 回退收尾验证并 blocked
1340
1367
  if (!(await isAncestor(ctx.worktree, mergeTarget, branchName))) {
1341
1368
  const driftFiles = await listBranchDriftFiles(ctx.worktree, branchName, mergeTarget)
1342
- if (!isDocumentationOnly(driftFiles)) {
1369
+ if (!(await isHarmlessDrift(ctx.worktree, branchName, mergeTarget, driftFiles))) {
1343
1370
  const workflow = loadWorkflowFile(resolveWorkflowPath(state))
1344
1371
  const rolledBack = rollbackToCleanupStep(item, workflow)
1345
1372
  if (rolledBack) await writeState(ctx.worktree, state)
@@ -1358,9 +1385,9 @@ async function completeTaskGroupLocked(params: { change_id: string }, ctx: ToolC
1358
1385
  ? `${files.slice(0, 10).join(", ")} 等 ${files.length} 个`
1359
1386
  : files.join(", ")
1360
1387
  docDriftNote =
1361
- `- **基准漂移(纯文档)**: 基准分支 \`${mergeTarget}\` 相对变更分支切出点净变化 ${files.length} 个文件` +
1388
+ `- **基准漂移(无代码语义)**: 基准分支 \`${mergeTarget}\` 相对变更分支切出点净变化 ${files.length} 个文件` +
1362
1389
  (files.length > 0 ? `(${driftList})` : "") +
1363
- ",均为文档,不影响代码语义,未回退收尾验证直接合并。"
1390
+ ",均为文档、openspec 规划路径或仅版本号变更,不影响代码语义,未回退收尾验证直接合并。"
1364
1391
  }
1365
1392
 
1366
1393
  const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget)
@@ -32,9 +32,10 @@ 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 或终态。 */
35
+ /** 任务组「终态或从未激活」(mode 切换窗口专用判定):done 或 completed_at 已设置即终态;
36
+ * 否则须无执行痕迹(tags 为空)且全部 task children 处于 todo 或终态。
37
+ * 「从未激活」计入收口仅对窗口场景正确(未开工组无进度,切走不丢东西);收口合并判定
38
+ * 不得复用此口径(见 isFinalTaskGroup)。 */
38
39
  export function isTaskGroupSettled(item: WorkItem): boolean {
39
40
  if (item.phase === "done" || item.metadata["completed_at"] !== undefined) return true
40
41
  const noTags = Object.keys(item.tags).length === 0
@@ -44,12 +45,14 @@ export function isTaskGroupSettled(item: WorkItem): boolean {
44
45
  return noTags && childrenSettled
45
46
  }
46
47
 
47
- /** 判断 item 是否为其 change 的最后一个收口任务组:其余全部任务组均「终态或从未激活」。
48
- * complete 的 change 级收口判定与终态视图文案分流共用此判定(单一事实源)。 */
48
+ /** 判断 item 是否为其 change 的最后一个收口任务组:其余全部任务组均终态(done 或
49
+ * completed_at 已设置)。「从未激活」不计入收口——串行编排下未开工组无任何进度痕迹,
50
+ * 若计入会令首个完成组被误判为最后组而提前合并销毁。complete 的 change 级收口判定与
51
+ * 终态视图文案分流/部署注意区块门禁共用此判定(单一事实源)。 */
49
52
  export function isFinalTaskGroup(state: { workItems: WorkItem[] }, item: WorkItem): boolean {
50
53
  return state.workItems
51
54
  .filter((w) => w.id.startsWith("task:") && w.id !== item.id)
52
- .every(isTaskGroupSettled)
55
+ .every((w) => isTerminalPhase(w.phase) || w.metadata["completed_at"] !== undefined)
53
56
  }
54
57
 
55
58
  export function isBlockingSeverity(severity: string | undefined): boolean {
@@ -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, isTaskGroupSettled, isFinalTaskGroup, phaseStepMismatch, REVIEW_STEP_TO_LAYER, blockingStepChildren } from "./engine.ts"
9
+ import { getStepVerdict, isTerminalPhase, isBlockingSeverity, isFinalTaskGroup, 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"
@@ -204,9 +204,9 @@ function renderTerminalPhase(item: WorkItem, state?: OrchestrateState, caller?:
204
204
  "",
205
205
  finalGroup
206
206
  ? "本任务组为最后一个收口任务组,change 收口已完成:变更分支已合并回基准分支,编排已完成并收尾。"
207
- : "本任务组已完成,变更保留在 change 分支上,待全部任务组完成后统一收口合并。",
207
+ : `本任务组已完成,变更保留在 change 分支上,待全部任务组完成后统一收口合并。${cancelTaskGroupNote(state)}`,
208
208
  "",
209
- ...renderDeploymentNotesSection(state, caller),
209
+ ...renderDeploymentNotesSection(state, item, caller),
210
210
  ].join("\n")
211
211
  }
212
212
  return [
@@ -217,18 +217,25 @@ function renderTerminalPhase(item: WorkItem, state?: OrchestrateState, caller?:
217
217
  ? "调用 `opx_orch_complete_task_group` 完成 change 收口(把变更分支合并回基准分支并清理)。"
218
218
  : "调用 `opx_orch_complete_task_group` 完成任务组收尾(变更保留在 change 分支,统一收口时合并)。",
219
219
  "",
220
- ...renderDeploymentNotesSection(state, caller),
220
+ ...renderDeploymentNotesSection(state, item, caller),
221
221
  ].join("\n")
222
222
  }
223
223
 
224
+ /** 非最后组收口文案的取消限制事实说明(与 complete 非最后组返回体同措辞):当前版本无取消任务组
225
+ * 的工具入口,弃做组须人工置 cancelled 后收口合并才会触发。无 state(理论不可达)返回空串。 */
226
+ function cancelTaskGroupNote(state: OrchestrateState | undefined): string {
227
+ if (!state) return ""
228
+ return `若某任务组已确认不再执行,当前版本不支持取消任务组,须人工编辑编排 state 文件(openspec/states/${state.changeId}.json)将该任务组的 phase 改为 "cancelled",收口合并才会触发。`
229
+ }
230
+
224
231
  /** 终态视图的「部署注意事项」区块(仅编排视角、change 会话渲染):指引编排者读主仓库规划文档提取
225
232
  * 部署要点,并聚合各任务组 self_check_results 申报的「部署注意点:」前缀行(实施补充点,编排者纯转述)。
226
- * 渲染前提:change 全部任务组均终态或从未激活(isTaskGroupSettled,含当前组——渲染到终态时当前组必已
227
- * 终态),存在未收口组时不渲染(两形态维持既有文案)。全部组无实施补充点时省略补充点段(不渲染空段)。 */
228
- function renderDeploymentNotesSection(state: OrchestrateState | undefined, caller: StatusViewCaller | undefined): string[] {
233
+ * 渲染前提:当前组为最后一个收口任务组(isFinalTaskGroup,其余组均终态且渲染到终态时当前组必已
234
+ * 终态),尚有未收口组时不渲染(两形态维持既有文案)。全部组无实施补充点时省略补充点段(不渲染空段)。 */
235
+ function renderDeploymentNotesSection(state: OrchestrateState | undefined, item: WorkItem, caller: StatusViewCaller | undefined): string[] {
229
236
  if (!caller?.orchestrator || !state || state.kind === "review") return []
237
+ if (!isFinalTaskGroup(state, item)) return []
230
238
  const taskGroups = state.workItems.filter((w) => w.id.startsWith("task:"))
231
- if (!taskGroups.every(isTaskGroupSettled)) return []
232
239
  const lines = [
233
240
  "## 部署注意事项汇报",
234
241
  "",