@ifrankwang/openspec-agents 0.140.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.
@@ -21677,6 +21677,19 @@ async function listBranchDriftFiles(worktree, sourceBranch, targetBranch) {
21677
21677
  return null;
21678
21678
  return parseDiffNameOnly(r.stdout);
21679
21679
  }
21680
+ async function isVersionOnlyDrift(worktree, sourceBranch, targetBranch, filePath) {
21681
+ const r = await runGitChecked(worktree, ["diff", "--no-renames", `${sourceBranch}...${targetBranch}`, "--", filePath]);
21682
+ if (!r.success)
21683
+ return null;
21684
+ for (const line of r.stdout.split(`
21685
+ `)) {
21686
+ if (line.startsWith("+++") || line.startsWith("---"))
21687
+ continue;
21688
+ if ((line.startsWith("+") || line.startsWith("-")) && !/^[+-]\s*"version"\s*:/.test(line))
21689
+ return false;
21690
+ }
21691
+ return true;
21692
+ }
21680
21693
  async function isLocalBranch(worktree, branch) {
21681
21694
  const r = await runGitChecked(worktree, ["rev-parse", "--verify", `refs/heads/${branch}`]);
21682
21695
  return r.success;
@@ -29093,10 +29106,27 @@ function renderReviewIssueSummary(item) {
29093
29106
  `);
29094
29107
  }
29095
29108
  var DOCUMENTATION_EXTENSIONS = [".md", ".mdx", ".markdown", ".rst", ".adoc"];
29096
- function isDocumentationOnly(files) {
29109
+ var DRIFT_NEUTRAL_PATH_PREFIXES = ["openspec/"];
29110
+ async function isHarmlessDrift(worktree, sourceBranch, targetBranch, files) {
29097
29111
  if (files === null)
29098
29112
  return false;
29099
- return files.every((f) => DOCUMENTATION_EXTENSIONS.some((ext) => f.endsWith(ext)));
29113
+ const versionCandidates = [];
29114
+ for (const f of files) {
29115
+ if (DOCUMENTATION_EXTENSIONS.some((ext) => f.endsWith(ext)))
29116
+ continue;
29117
+ if (DRIFT_NEUTRAL_PATH_PREFIXES.some((p) => f.startsWith(p)))
29118
+ continue;
29119
+ if (f === "package.json" || f.endsWith("/package.json")) {
29120
+ versionCandidates.push(f);
29121
+ continue;
29122
+ }
29123
+ return false;
29124
+ }
29125
+ for (const f of versionCandidates) {
29126
+ if (await isVersionOnlyDrift(worktree, sourceBranch, targetBranch, f) !== true)
29127
+ return false;
29128
+ }
29129
+ return true;
29100
29130
  }
29101
29131
  function rollbackToCleanupStep(item, workflow) {
29102
29132
  if (!workflow.stepMap.has("verify_cleanup"))
@@ -29197,7 +29227,7 @@ ${checkboxWarning}` : doneMessage;
29197
29227
  await ensureChangeBranch(ctx.worktree, branchName, mergeTarget);
29198
29228
  if (!await isAncestor(ctx.worktree, mergeTarget, branchName)) {
29199
29229
  const driftFiles = await listBranchDriftFiles(ctx.worktree, branchName, mergeTarget);
29200
- if (!isDocumentationOnly(driftFiles)) {
29230
+ if (!await isHarmlessDrift(ctx.worktree, branchName, mergeTarget, driftFiles)) {
29201
29231
  const workflow = loadWorkflowFile(resolveWorkflowPath(state));
29202
29232
  const rolledBack = rollbackToCleanupStep(item, workflow);
29203
29233
  if (rolledBack)
@@ -29210,7 +29240,7 @@ ${checkboxWarning}` : doneMessage;
29210
29240
  }
29211
29241
  const files = driftFiles ?? [];
29212
29242
  const driftList = files.length > 10 ? `${files.slice(0, 10).join(", ")} 等 ${files.length} 个` : files.join(", ");
29213
- docDriftNote = `- **基准漂移(纯文档)**: 基准分支 \`${mergeTarget}\` 相对变更分支切出点净变化 ${files.length} 个文件` + (files.length > 0 ? `(${driftList})` : "") + ",均为文档,不影响代码语义,未回退收尾验证直接合并。";
29243
+ docDriftNote = `- **基准漂移(无代码语义)**: 基准分支 \`${mergeTarget}\` 相对变更分支切出点净变化 ${files.length} 个文件` + (files.length > 0 ? `(${driftList})` : "") + ",均为文档、openspec 规划路径或仅版本号变更,不影响代码语义,未回退收尾验证直接合并。";
29214
29244
  }
29215
29245
  const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget);
29216
29246
  if (mergeResult.blockedMessage) {
@@ -30384,7 +30414,7 @@ var TOOL_SPECS = {
30384
30414
  execute: (args, ctx) => statusExecute({ change_id: args.change_id }, ctx)
30385
30415
  },
30386
30416
  opx_orch_complete_task_group: {
30387
- description: "完成任务组收尾。非最后任务组仅做门禁与范围标记(不合并、不销毁);最后一个任务组收口时把 change 分支(change/{changeId})一次性合并回 baseBranch(漂移含任一非文档文件或文本冲突时回退到收尾验证 verify_cleanup 并返回 blocked,纯文档漂移直接合并收口),成功后销毁 worktree 并删分支。须在收尾验证(verify_cleanup)通过后调用。主仓库本地改动文件与合并写入文件重合、或存在部分暂存文件时中止并返回 blocked(保留 worktree/分支);主仓库无关脏文件不阻塞合并。",
30417
+ description: "完成任务组收尾。非最后任务组仅做门禁与范围标记(不合并、不销毁);最后一个任务组收口时把 change 分支(change/{changeId})一次性合并回 baseBranch(漂移无害——文档、openspec 规划路径或仅版本号变更的 package.json——时直接合并收口;漂移含任一其他文件或文本冲突时回退到收尾验证 verify_cleanup 并返回 blocked),成功后销毁 worktree 并删分支。须在收尾验证(verify_cleanup)通过后调用。主仓库本地改动文件与合并写入文件重合、或存在部分暂存文件时中止并返回 blocked(保留 worktree/分支);主仓库无关脏文件不阻塞合并。",
30388
30418
  schema: completeTaskGroupSchema,
30389
30419
  execute: (args, ctx) => completeTaskGroupExecute(args, ctx)
30390
30420
  },
@@ -30429,7 +30459,7 @@ async function ensureDefaultUnattended(args, ctx) {
30429
30459
  }
30430
30460
  } catch {}
30431
30461
  }
30432
- var PKG_VERSION = "0.140.0";
30462
+ var PKG_VERSION = "0.141.0";
30433
30463
  function buildMcpServer(worktree, opts = {}) {
30434
30464
  const mcp = new McpServer({ name: "openspec-agents", version: PKG_VERSION });
30435
30465
  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 分支。合并前若基准分支已推进,先做基线漂移检查并比对基准侧净变化内容:纯文档变化(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.140.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
  /**
@@ -1330,17 +1355,18 @@ async function completeTaskGroupLocked(params: { change_id: string }, ctx: ToolC
1330
1355
 
1331
1356
  // 最后任务组 → change 级收口:合并 change 分支回基准分支(一次性),成功后销毁 worktree 并删分支
1332
1357
  const mergeTarget = state.baseBranch
1333
- // 纯文档漂移直通时的事实说明:仅在本次收口实际发生直通时非空;合并 blocked/conflict 分支不带该说明
1358
+ // 无害漂移直通时的事实说明:仅在本次收口实际发生直通时非空;合并 blocked/conflict 分支不带该说明
1334
1359
  let docDriftNote = ""
1335
1360
  if (branchName) {
1336
1361
  // create-or-reuse(合并目标解析前):在途旧模型会话升级时从当前基准 tip 创建,天然含已合入代码
1337
1362
  await ensureChangeBranch(ctx.worktree, branchName, mergeTarget)
1338
1363
 
1339
1364
  // 前置漂移检查(独立封装):基准 tip 须为 change 分支祖先;不满足时先比对基准侧净变化内容——
1340
- // 纯文档漂移(含空清单 = 无净变化)不回退、直接合并收口;含任一非文档文件或清单查询失败 → 回退收尾验证并 blocked
1365
+ // 无害漂移(文档 / openspec 规划路径 / 仅版本号变更的 package.json,含空清单 = 无净变化)不回退、
1366
+ // 直接合并收口;含任一其他文件或清单/内容查询失败 → 回退收尾验证并 blocked
1341
1367
  if (!(await isAncestor(ctx.worktree, mergeTarget, branchName))) {
1342
1368
  const driftFiles = await listBranchDriftFiles(ctx.worktree, branchName, mergeTarget)
1343
- if (!isDocumentationOnly(driftFiles)) {
1369
+ if (!(await isHarmlessDrift(ctx.worktree, branchName, mergeTarget, driftFiles))) {
1344
1370
  const workflow = loadWorkflowFile(resolveWorkflowPath(state))
1345
1371
  const rolledBack = rollbackToCleanupStep(item, workflow)
1346
1372
  if (rolledBack) await writeState(ctx.worktree, state)
@@ -1359,9 +1385,9 @@ async function completeTaskGroupLocked(params: { change_id: string }, ctx: ToolC
1359
1385
  ? `${files.slice(0, 10).join(", ")} 等 ${files.length} 个`
1360
1386
  : files.join(", ")
1361
1387
  docDriftNote =
1362
- `- **基准漂移(纯文档)**: 基准分支 \`${mergeTarget}\` 相对变更分支切出点净变化 ${files.length} 个文件` +
1388
+ `- **基准漂移(无代码语义)**: 基准分支 \`${mergeTarget}\` 相对变更分支切出点净变化 ${files.length} 个文件` +
1363
1389
  (files.length > 0 ? `(${driftList})` : "") +
1364
- ",均为文档,不影响代码语义,未回退收尾验证直接合并。"
1390
+ ",均为文档、openspec 规划路径或仅版本号变更,不影响代码语义,未回退收尾验证直接合并。"
1365
1391
  }
1366
1392
 
1367
1393
  const mergeResult = await mergeBranchToTarget(ctx.worktree, branchName, mergeTarget)