@ifrankwang/openspec-agents 0.137.0 → 0.138.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.
@@ -21671,6 +21671,12 @@ async function isAncestor(worktree, a, b) {
21671
21671
  return false;
21672
21672
  throw new Error(`无法判断 "${a}" 是否为 "${b}" 的祖先:${r.stderr}`);
21673
21673
  }
21674
+ async function listBranchDriftFiles(worktree, sourceBranch, targetBranch) {
21675
+ const r = await runGitChecked(worktree, ["diff", "--name-only", "--no-renames", `${sourceBranch}...${targetBranch}`]);
21676
+ if (!r.success)
21677
+ return null;
21678
+ return parseDiffNameOnly(r.stdout);
21679
+ }
21674
21680
  async function isLocalBranch(worktree, branch) {
21675
21681
  const r = await runGitChecked(worktree, ["rev-parse", "--verify", `refs/heads/${branch}`]);
21676
21682
  return r.success;
@@ -29081,6 +29087,12 @@ function renderReviewIssueSummary(item) {
29081
29087
  return lines.join(`
29082
29088
  `);
29083
29089
  }
29090
+ var DOCUMENTATION_EXTENSIONS = [".md", ".mdx", ".markdown", ".rst", ".adoc"];
29091
+ function isDocumentationOnly(files) {
29092
+ if (files === null)
29093
+ return false;
29094
+ return files.every((f) => DOCUMENTATION_EXTENSIONS.some((ext) => f.endsWith(ext)));
29095
+ }
29084
29096
  function rollbackToCleanupStep(item, workflow) {
29085
29097
  if (!workflow.stepMap.has("verify_cleanup"))
29086
29098
  return false;
@@ -29175,18 +29187,25 @@ async function completeTaskGroupLocked(params, ctx) {
29175
29187
  ${checkboxWarning}` : doneMessage;
29176
29188
  }
29177
29189
  const mergeTarget = state.baseBranch;
29190
+ let docDriftNote = "";
29178
29191
  if (branchName) {
29179
29192
  await ensureChangeBranch(ctx.worktree, branchName, mergeTarget);
29180
29193
  if (!await isAncestor(ctx.worktree, mergeTarget, branchName)) {
29181
- const workflow = loadWorkflowFile(resolveWorkflowPath(state));
29182
- const rolledBack = rollbackToCleanupStep(item, workflow);
29183
- if (rolledBack)
29184
- await writeState(ctx.worktree, state);
29185
- return renderFinalizeBlocked([
29186
- `基准分支 \`${mergeTarget}\` 已推进(与变更分支 \`${branchName}\` 存在漂移):基准分支最新提交未包含在变更分支历史中,直接合并会遗漏基准分支新内容。`
29187
- ], [
29188
- `在 worktree 内执行 \`git merge ${mergeTarget}\` 合入基准分支最新代码并解决冲突,完成回归验证后重新提交收尾验证(opx_agent_submit,step_id="verify_cleanup"),通过后再调用 opx_orch_complete_task_group 重新收口。`
29189
- ], { branchName, mergeTarget, rolledBack, checkboxWarning });
29194
+ const driftFiles = await listBranchDriftFiles(ctx.worktree, branchName, mergeTarget);
29195
+ if (!isDocumentationOnly(driftFiles)) {
29196
+ const workflow = loadWorkflowFile(resolveWorkflowPath(state));
29197
+ const rolledBack = rollbackToCleanupStep(item, workflow);
29198
+ if (rolledBack)
29199
+ await writeState(ctx.worktree, state);
29200
+ return renderFinalizeBlocked([
29201
+ `基准分支 \`${mergeTarget}\` 已推进(与变更分支 \`${branchName}\` 存在漂移):基准分支最新提交未包含在变更分支历史中,直接合并会遗漏基准分支新内容。`
29202
+ ], [
29203
+ `在 worktree 内执行 \`git merge ${mergeTarget}\` 合入基准分支最新代码并解决冲突,完成回归验证后重新提交收尾验证(opx_agent_submit,step_id="verify_cleanup"),通过后再调用 opx_orch_complete_task_group 重新收口。`
29204
+ ], { branchName, mergeTarget, rolledBack, checkboxWarning });
29205
+ }
29206
+ const files = driftFiles ?? [];
29207
+ const driftList = files.length > 10 ? `${files.slice(0, 10).join(", ")} 等 ${files.length} 个` : files.join(", ");
29208
+ docDriftNote = `- **基准漂移(纯文档)**: 基准分支 \`${mergeTarget}\` 相对变更分支切出点净变化 ${files.length} 个文件` + (files.length > 0 ? `(${driftList})` : "") + ",均为文档,不影响代码语义,未回退收尾验证直接合并。";
29190
29209
  }
29191
29210
  const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget);
29192
29211
  if (mergeResult.blockedMessage) {
@@ -29236,7 +29255,7 @@ ${checkboxWarning}` : doneMessage;
29236
29255
  }
29237
29256
  item.metadata["completed_at"] = new Date().toISOString();
29238
29257
  await writeState(ctx.worktree, state);
29239
- const notes = [cleanupNote, checkboxWarning, cleanupResidualWarning].filter(Boolean);
29258
+ const notes = [docDriftNote, cleanupNote, checkboxWarning, cleanupResidualWarning].filter(Boolean);
29240
29259
  const doneMessage = branchName ? `任务组已完成并合并到 "${mergeTarget}"。` : "任务组已完成(无变更分支引用,跳过合并)。";
29241
29260
  return notes.length > 0 ? `${doneMessage}
29242
29261
  ${notes.join(`
@@ -30360,7 +30379,7 @@ var TOOL_SPECS = {
30360
30379
  execute: (args, ctx) => statusExecute({ change_id: args.change_id }, ctx)
30361
30380
  },
30362
30381
  opx_orch_complete_task_group: {
30363
- description: "完成任务组收尾。非最后任务组仅做门禁与范围标记(不合并、不销毁);最后一个任务组收口时把 change 分支(change/{changeId})一次性合并回 baseBranch(基准分支漂移或文本冲突时回退到收尾验证 verify_cleanup 并返回 blocked),成功后销毁 worktree 并删分支。须在收尾验证(verify_cleanup)通过后调用。主仓库本地改动文件与合并写入文件重合、或存在部分暂存文件时中止并返回 blocked(保留 worktree/分支);主仓库无关脏文件不阻塞合并。",
30382
+ description: "完成任务组收尾。非最后任务组仅做门禁与范围标记(不合并、不销毁);最后一个任务组收口时把 change 分支(change/{changeId})一次性合并回 baseBranch(漂移含任一非文档文件或文本冲突时回退到收尾验证 verify_cleanup 并返回 blocked,纯文档漂移直接合并收口),成功后销毁 worktree 并删分支。须在收尾验证(verify_cleanup)通过后调用。主仓库本地改动文件与合并写入文件重合、或存在部分暂存文件时中止并返回 blocked(保留 worktree/分支);主仓库无关脏文件不阻塞合并。",
30364
30383
  schema: completeTaskGroupSchema,
30365
30384
  execute: (args, ctx) => completeTaskGroupExecute(args, ctx)
30366
30385
  },
@@ -30405,7 +30424,7 @@ async function ensureDefaultUnattended(args, ctx) {
30405
30424
  }
30406
30425
  } catch {}
30407
30426
  }
30408
- var PKG_VERSION = "0.137.0";
30427
+ var PKG_VERSION = "0.138.0";
30409
30428
  function buildMcpServer(worktree, opts = {}) {
30410
30429
  const mcp = new McpServer({ name: "openspec-agents", version: PKG_VERSION });
30411
30430
  for (const [name, spec] of Object.entries(TOOL_SPECS)) {
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 分支。合并前若基准分支已推进,先做基线漂移检查,发现漂移或冲突则把该任务组回退到收尾验证环节(合并基准分支最新代码并回归复验)后再收口——验收仅在漂移时动态发生,正常路径零额外环节。
95
+ 4. **收尾合并**:任务组完成只做门禁校验并标记变更范围,不合并、不销毁 worktree;最后一个任务组收口时把 change 分支一次性合并回基准分支,随后销毁 worktree 并删除 change 分支。合并前若基准分支已推进,先做基线漂移检查并比对基准侧净变化内容:纯文档变化(md / mdx / markdown / rst / adoc 五类后缀)不影响代码语义,直接合并收口;含任一非文档文件或文本冲突才把该任务组回退到收尾验证环节(合并基准分支最新代码并回归复验)后再收口——验收仅在实质漂移时动态发生,正常路径零额外环节。
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.137.0",
3
+ "version": "0.138.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(漂移含任一非文档文件或文本冲突时回退到收尾验证 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
@@ -169,6 +169,16 @@ export async function isAncestor(worktree: string, a: string, b: string): Promis
169
169
  throw new Error(`无法判断 "${a}" 是否为 "${b}" 的祖先:${r.stderr}`)
170
170
  }
171
171
 
172
+ /** 查询基准侧漂移文件清单:`git diff --name-only --no-renames <sourceBranch>...<targetBranch>`
173
+ * 三点区间 = merge-base(source, target) 到 target 的内容差异(基准侧自分支切出点以来的净变化,非 commit 口径)。
174
+ * 参数方向固定:source 传变更分支,target 传基准分支(切出点在 source 一侧)。
175
+ * 返回 null 表示 git 查询失败,调用方须按非文档保守回退。 */
176
+ export async function listBranchDriftFiles(worktree: string, sourceBranch: string, targetBranch: string): Promise<string[] | null> {
177
+ const r = await runGitChecked(worktree, ["diff", "--name-only", "--no-renames", `${sourceBranch}...${targetBranch}`])
178
+ if (!r.success) return null
179
+ return parseDiffNameOnly(r.stdout)
180
+ }
181
+
172
182
  /** 校验本地分支存在(refs/heads/<name> 严格本地命名空间,不含远端跟踪 ref)。 */
173
183
  export async function isLocalBranch(worktree: string, branch: string): Promise<boolean> {
174
184
  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, markTaskGroupCheckboxesComplete, mergeBranchToTarget, discoverDiskWorktrees, detectMainRepoPollution, detectChanges, removeTaskGroupWorktree, isLocalBranch, listLocalBranches, pathExists, type DetectChangesResult } from "../git.ts"
6
+ import { runGit, runGitChecked, getCurrentBranch, getMergeBase, isAncestor, isWorktreeClean, listBranchDriftFiles, 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,6 +1193,14 @@ function renderReviewIssueSummary(item: WorkItem): string {
1193
1193
  return lines.join("\n")
1194
1194
  }
1195
1195
 
1196
+ /** 漂移内容纯文档判定:null(查询失败)按非文档保守回退;空清单 = 内容无净变化(如基准上改了又回滚)视为纯文档;
1197
+ * 全部文件后缀属文档格式才放行。.txt 刻意排除(requirements.txt 等依赖清单具代码语义)。 */
1198
+ const DOCUMENTATION_EXTENSIONS = [".md", ".mdx", ".markdown", ".rst", ".adoc"]
1199
+ function isDocumentationOnly(files: string[] | null): boolean {
1200
+ if (files === null) return false
1201
+ return files.every((f) => DOCUMENTATION_EXTENSIONS.some((ext) => f.endsWith(ext)))
1202
+ }
1203
+
1196
1204
  /**
1197
1205
  * 最后任务组收口被基准分支漂移/文本冲突拦截时的回退:
1198
1206
  * 该任务组回退到收尾验证(verify_cleanup)等待重新收口——phase 回退 + clearStepTags + 按 recovery
@@ -1321,24 +1329,38 @@ async function completeTaskGroupLocked(params: { change_id: string }, ctx: ToolC
1321
1329
 
1322
1330
  // 最后任务组 → change 级收口:合并 change 分支回基准分支(一次性),成功后销毁 worktree 并删分支
1323
1331
  const mergeTarget = state.baseBranch
1332
+ // 纯文档漂移直通时的事实说明:仅在本次收口实际发生直通时非空;合并 blocked/conflict 分支不带该说明
1333
+ let docDriftNote = ""
1324
1334
  if (branchName) {
1325
1335
  // create-or-reuse(合并目标解析前):在途旧模型会话升级时从当前基准 tip 创建,天然含已合入代码
1326
1336
  await ensureChangeBranch(ctx.worktree, branchName, mergeTarget)
1327
1337
 
1328
- // 前置漂移检查(独立封装):基准 tip 须为 change 分支祖先;不满足 → 回退收尾验证并 blocked
1338
+ // 前置漂移检查(独立封装):基准 tip 须为 change 分支祖先;不满足时先比对基准侧净变化内容——
1339
+ // 纯文档漂移(含空清单 = 无净变化)不回退、直接合并收口;含任一非文档文件或清单查询失败 → 回退收尾验证并 blocked
1329
1340
  if (!(await isAncestor(ctx.worktree, mergeTarget, branchName))) {
1330
- const workflow = loadWorkflowFile(resolveWorkflowPath(state))
1331
- const rolledBack = rollbackToCleanupStep(item, workflow)
1332
- if (rolledBack) await writeState(ctx.worktree, state)
1333
- return renderFinalizeBlocked(
1334
- [
1335
- `基准分支 \`${mergeTarget}\` 已推进(与变更分支 \`${branchName}\` 存在漂移):基准分支最新提交未包含在变更分支历史中,直接合并会遗漏基准分支新内容。`,
1336
- ],
1337
- [
1338
- `在 worktree 内执行 \`git merge ${mergeTarget}\` 合入基准分支最新代码并解决冲突,完成回归验证后重新提交收尾验证(opx_agent_submit,step_id="verify_cleanup"),通过后再调用 opx_orch_complete_task_group 重新收口。`,
1339
- ],
1340
- { branchName, mergeTarget, rolledBack, checkboxWarning },
1341
- )
1341
+ const driftFiles = await listBranchDriftFiles(ctx.worktree, branchName, mergeTarget)
1342
+ if (!isDocumentationOnly(driftFiles)) {
1343
+ const workflow = loadWorkflowFile(resolveWorkflowPath(state))
1344
+ const rolledBack = rollbackToCleanupStep(item, workflow)
1345
+ if (rolledBack) await writeState(ctx.worktree, state)
1346
+ return renderFinalizeBlocked(
1347
+ [
1348
+ `基准分支 \`${mergeTarget}\` 已推进(与变更分支 \`${branchName}\` 存在漂移):基准分支最新提交未包含在变更分支历史中,直接合并会遗漏基准分支新内容。`,
1349
+ ],
1350
+ [
1351
+ `在 worktree 内执行 \`git merge ${mergeTarget}\` 合入基准分支最新代码并解决冲突,完成回归验证后重新提交收尾验证(opx_agent_submit,step_id="verify_cleanup"),通过后再调用 opx_orch_complete_task_group 重新收口。`,
1352
+ ],
1353
+ { branchName, mergeTarget, rolledBack, checkboxWarning },
1354
+ )
1355
+ }
1356
+ const files = driftFiles ?? []
1357
+ const driftList = files.length > 10
1358
+ ? `${files.slice(0, 10).join(", ")} 等 ${files.length} 个`
1359
+ : files.join(", ")
1360
+ docDriftNote =
1361
+ `- **基准漂移(纯文档)**: 基准分支 \`${mergeTarget}\` 相对变更分支切出点净变化 ${files.length} 个文件` +
1362
+ (files.length > 0 ? `(${driftList})` : "") +
1363
+ ",均为文档,不影响代码语义,未回退收尾验证直接合并。"
1342
1364
  }
1343
1365
 
1344
1366
  const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget)
@@ -1394,7 +1416,7 @@ async function completeTaskGroupLocked(params: { change_id: string }, ctx: ToolC
1394
1416
  }
1395
1417
  item.metadata["completed_at"] = new Date().toISOString()
1396
1418
  await writeState(ctx.worktree, state)
1397
- const notes = [cleanupNote, checkboxWarning, cleanupResidualWarning].filter(Boolean)
1419
+ const notes = [docDriftNote, cleanupNote, checkboxWarning, cleanupResidualWarning].filter(Boolean)
1398
1420
  const doneMessage = branchName
1399
1421
  ? `任务组已完成并合并到 "${mergeTarget}"。`
1400
1422
  : "任务组已完成(无变更分支引用,跳过合并)。"