coding-agent-harness 1.0.4 → 1.0.5

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +661 -21
  3. package/LICENSE-EXCEPTION.md +37 -0
  4. package/README.md +33 -1
  5. package/README.zh-CN.md +23 -1
  6. package/SKILL.md +9 -8
  7. package/docs-release/architecture/overview.md +1 -1
  8. package/docs-release/architecture/overview.zh-CN.md +1 -1
  9. package/docs-release/architecture/system-explainer/01-system-overview.md +217 -0
  10. package/docs-release/architecture/system-explainer/02-module-dependency.md +257 -0
  11. package/docs-release/architecture/system-explainer/03-task-lifecycle.md +304 -0
  12. package/docs-release/architecture/system-explainer/04-check-and-governance.md +239 -0
  13. package/docs-release/architecture/system-explainer/05-data-flow.md +276 -0
  14. package/docs-release/architecture/system-explainer/06-preset-and-migration.md +303 -0
  15. package/docs-release/architecture/system-explainer/README.md +67 -0
  16. package/docs-release/architecture/system-explainer/en-US/01-system-overview.md +226 -0
  17. package/docs-release/architecture/system-explainer/en-US/02-module-dependency.md +263 -0
  18. package/docs-release/architecture/system-explainer/en-US/03-task-lifecycle.md +319 -0
  19. package/docs-release/architecture/system-explainer/en-US/04-check-and-governance.md +250 -0
  20. package/docs-release/architecture/system-explainer/en-US/05-data-flow.md +290 -0
  21. package/docs-release/architecture/system-explainer/en-US/06-preset-and-migration.md +323 -0
  22. package/docs-release/architecture/system-explainer/en-US/README.md +70 -0
  23. package/docs-release/guides/agent-installation.en-US.md +8 -7
  24. package/docs-release/guides/agent-installation.md +9 -7
  25. package/docs-release/guides/preset-development.md +26 -2
  26. package/docs-release/guides/task-state-machine.en-US.md +30 -13
  27. package/docs-release/guides/task-state-machine.md +30 -13
  28. package/examples/minimal-project/docs/09-PLANNING/TASKS/demo-task/INDEX.md +60 -0
  29. package/package.json +3 -2
  30. package/references/harness-ledger.md +1 -1
  31. package/scripts/commands/migration-command.mjs +30 -0
  32. package/scripts/commands/task-command.mjs +26 -25
  33. package/scripts/harness.mjs +7 -3
  34. package/scripts/lib/capability-registry.mjs +17 -21
  35. package/scripts/lib/check-module-parallel.mjs +9 -16
  36. package/scripts/lib/check-profiles.mjs +35 -81
  37. package/scripts/lib/check-task-contracts.mjs +13 -5
  38. package/scripts/lib/core-shared.mjs +55 -2
  39. package/scripts/lib/dashboard-data.mjs +126 -18
  40. package/scripts/lib/dashboard-workbench.mjs +80 -1
  41. package/scripts/lib/dashboard-writer.mjs +6 -2
  42. package/scripts/lib/git-status-summary.mjs +1 -1
  43. package/scripts/lib/governance-sync.mjs +180 -83
  44. package/scripts/lib/harness-core.mjs +1 -0
  45. package/scripts/lib/markdown-utils.mjs +33 -0
  46. package/scripts/lib/migration-planner.mjs +4 -6
  47. package/scripts/lib/phase-kind.mjs +50 -0
  48. package/scripts/lib/preset-engine.mjs +5 -8
  49. package/scripts/lib/preset-registry.mjs +188 -39
  50. package/scripts/lib/review-confirm-git-gate.mjs +1 -1
  51. package/scripts/lib/status-builder.mjs +88 -0
  52. package/scripts/lib/status-dashboard-renderer.mjs +7 -4
  53. package/scripts/lib/task-audit-metadata.mjs +385 -0
  54. package/scripts/lib/task-audit-migration.mjs +350 -0
  55. package/scripts/lib/task-completion-consistency.mjs +11 -1
  56. package/scripts/lib/task-lifecycle/create-task-helpers.mjs +67 -0
  57. package/scripts/lib/task-lifecycle/phase-sync.mjs +88 -0
  58. package/scripts/lib/task-lifecycle/review-confirm.mjs +40 -29
  59. package/scripts/lib/task-lifecycle/review-gates.mjs +13 -10
  60. package/scripts/lib/task-lifecycle/review-submission.mjs +63 -0
  61. package/scripts/lib/task-lifecycle/scaffold-provenance.mjs +49 -0
  62. package/scripts/lib/task-lifecycle/template-files.mjs +53 -0
  63. package/scripts/lib/task-lifecycle.mjs +114 -147
  64. package/scripts/lib/task-metadata.mjs +118 -0
  65. package/scripts/lib/task-review-model.mjs +54 -68
  66. package/scripts/lib/task-scanner.mjs +70 -143
  67. package/skills/preset-creator/references/complex-task-skeleton/brief.md +11 -0
  68. package/templates/AGENTS.md.template +7 -5
  69. package/templates/dashboard/assets/app-src/00-state.js +12 -0
  70. package/templates/dashboard/assets/app-src/10-router.js +3 -0
  71. package/templates/dashboard/assets/app-src/20-overview.js +7 -3
  72. package/templates/dashboard/assets/app-src/35-task-detail.js +46 -6
  73. package/templates/dashboard/assets/app-src/55-presets.js +375 -0
  74. package/templates/dashboard/assets/app-src/60-shared.js +3 -1
  75. package/templates/dashboard/assets/app-src/90-bindings.js +131 -0
  76. package/templates/dashboard/assets/app.css +583 -0
  77. package/templates/dashboard/assets/app.css.manifest.json +1 -0
  78. package/templates/dashboard/assets/app.js +578 -10
  79. package/templates/dashboard/assets/app.manifest.json +1 -0
  80. package/templates/dashboard/assets/css-src/00-foundation.css +4 -0
  81. package/templates/dashboard/assets/css-src/40-detail-modules-migration.css +62 -0
  82. package/templates/dashboard/assets/css-src/45-presets.css +516 -0
  83. package/templates/dashboard/assets/i18n.js +140 -2
  84. package/templates/planning/INDEX.md +87 -0
  85. package/templates/planning/brief.md +1 -1
  86. package/templates/planning/module_session_prompt.md +1 -0
  87. package/templates/planning/review.md +0 -18
  88. package/templates/planning/task_plan.md +4 -43
  89. package/templates/planning/visual_map.md +13 -9
  90. package/templates/planning/visual_map.simple.md +52 -0
  91. package/templates/reference/execution-workflow-standard.md +29 -2
  92. package/templates-zh-CN/AGENTS.md.template +7 -5
  93. package/templates-zh-CN/planning/INDEX.md +87 -0
  94. package/templates-zh-CN/planning/brief.md +1 -1
  95. package/templates-zh-CN/planning/module_session_prompt.md +1 -0
  96. package/templates-zh-CN/planning/review.md +0 -18
  97. package/templates-zh-CN/planning/task_plan.md +3 -63
  98. package/templates-zh-CN/planning/visual_map.md +14 -7
  99. package/templates-zh-CN/planning/visual_map.simple.md +48 -0
  100. package/templates-zh-CN/reference/execution-workflow-standard.md +31 -6
@@ -1,10 +1,11 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
4
+ import crypto from "node:crypto";
3
5
  import { spawnSync } from "node:child_process";
4
- import { readBundledTemplate, readFileSafe, repoRoot, todayDate, toPosix, visualMapFile } from "./core-shared.mjs";
6
+ import { readBundledTemplate, readFileSafe, readJsonSafe, repoRoot, todayDate, toPosix, visualMapFile } from "./core-shared.mjs";
5
7
  import { collectTasks } from "./task-scanner.mjs";
6
- import { firstColumn, splitMarkdownRow, updateMarkdownTableRow } from "./markdown-utils.mjs";
7
- import { markdownCell } from "./task-lifecycle/text-utils.mjs";
8
+ import { appendMarkdownTableRow, firstColumn, fitMarkdownTableRow, splitMarkdownRow, upsertMarkdownTableRow } from "./markdown-utils.mjs";
8
9
 
9
10
  export class GovernanceSyncError extends Error {
10
11
  constructor(message, { code = "governance-sync-failed", details = {}, recovery = [] } = {}) {
@@ -16,38 +17,14 @@ export class GovernanceSyncError extends Error {
16
17
  }
17
18
  }
18
19
 
19
- export function beginGovernanceSync(target, { operation = "governance-sync", dryRun = false } = {}) {
20
+ export function beginGovernanceSync(target, { operation = "governance-sync", dryRun = false, allowDirtyWorktree = false, allowedRelativePaths = [] } = {}) {
20
21
  if (dryRun) return { target, dryRun, operation, git: inspectGit(target.projectRoot), lockPath: "", active: false };
21
22
  const lockPath = path.join(target.projectRoot, ".harness/locks/governance-sync.lock");
22
23
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
23
- let fd = null;
24
- try {
25
- fd = fs.openSync(lockPath, "wx");
26
- fs.writeFileSync(
27
- fd,
28
- `${JSON.stringify({
29
- operation,
30
- pid: process.pid,
31
- host: process.env.HOSTNAME || "",
32
- branch: currentBranch(target.projectRoot),
33
- targetRoot: target.projectRoot,
34
- startedAt: new Date().toISOString(),
35
- }, null, 2)}\n`,
36
- );
37
- } catch (error) {
38
- if (fd !== null) fs.closeSync(fd);
39
- throw new GovernanceSyncError("Governance sync lock already exists; refusing concurrent registry writes.", {
40
- code: "governance-lock-exists",
41
- details: { lockPath, error: error.message },
42
- recovery: [
43
- `Inspect ${lockPath}.`,
44
- "If no process owns the lock, remove it manually and retry.",
45
- ],
46
- });
47
- }
48
- if (fd !== null) fs.closeSync(fd);
24
+ acquireGovernanceSyncLock(lockPath, target, { operation });
49
25
 
50
26
  const gitState = inspectGit(target.projectRoot);
27
+ const allowed = [...new Set((allowedRelativePaths || []).filter(Boolean).map(toPosix))].sort();
51
28
  if (gitState.inGit) {
52
29
  if (real(gitState.gitRoot) !== real(target.projectRoot)) {
53
30
  releaseGovernanceSync({ lockPath, active: true });
@@ -57,7 +34,7 @@ export function beginGovernanceSync(target, { operation = "governance-sync", dry
57
34
  recovery: ["Run the harness command against the target repository root."],
58
35
  });
59
36
  }
60
- if (gitState.entries.length > 0) {
37
+ if (gitState.entries.length > 0 && !allowDirtyWorktree) {
61
38
  releaseGovernanceSync({ lockPath, active: true });
62
39
  throw new GovernanceSyncError("Governance sync requires a clean Git working tree before CLI-owned writes.", {
63
40
  code: "governance-git-dirty",
@@ -65,9 +42,79 @@ export function beginGovernanceSync(target, { operation = "governance-sync", dry
65
42
  recovery: ["Commit or otherwise resolve unrelated changes before running this lifecycle command."],
66
43
  });
67
44
  }
45
+ if (gitState.entries.length > 0 && allowDirtyWorktree) {
46
+ try {
47
+ assertDirtyCompatibleWithWriteScope(gitState.entries, allowed);
48
+ } catch (error) {
49
+ releaseGovernanceSync({ lockPath, active: true });
50
+ throw error;
51
+ }
52
+ }
68
53
  assertCommitIdentity(target.projectRoot);
69
54
  }
70
- return { target, dryRun, operation, git: gitState, lockPath, active: true };
55
+ const initialDirtyEntries = gitState.inGit ? gitState.entries.map((entry) => ({
56
+ ...entry,
57
+ fingerprint: fingerprintEntry(target.projectRoot, entry),
58
+ })) : [];
59
+ return { target, dryRun, operation, git: gitState, initialDirtyEntries, lockPath, active: true };
60
+ }
61
+
62
+ function acquireGovernanceSyncLock(lockPath, target, { operation }) {
63
+ for (let attempt = 0; attempt < 2; attempt += 1) {
64
+ let fd = null;
65
+ try {
66
+ fd = fs.openSync(lockPath, "wx");
67
+ fs.writeFileSync(
68
+ fd,
69
+ `${JSON.stringify({
70
+ operation,
71
+ pid: process.pid,
72
+ host: governanceLockHost(),
73
+ branch: currentBranch(target.projectRoot),
74
+ targetRoot: target.projectRoot,
75
+ startedAt: new Date().toISOString(),
76
+ }, null, 2)}\n`,
77
+ );
78
+ fs.closeSync(fd);
79
+ return;
80
+ } catch (error) {
81
+ if (fd !== null) fs.closeSync(fd);
82
+ if (error?.code === "EEXIST" && attempt === 0 && removeStaleGovernanceSyncLock(lockPath)) continue;
83
+ throw governanceLockExistsError(lockPath, error);
84
+ }
85
+ }
86
+ }
87
+
88
+ function removeStaleGovernanceSyncLock(lockPath) {
89
+ const lockContent = readFileSafe(lockPath);
90
+ const lock = readJsonSafe(lockPath, null);
91
+ if (!lock) return false;
92
+ if (lock.host !== governanceLockHost()) return false;
93
+ if (!Number.isInteger(lock?.pid) || lock.pid <= 0) return false;
94
+ try {
95
+ process.kill(lock.pid, 0);
96
+ return false;
97
+ } catch (error) {
98
+ if (error?.code !== "ESRCH") return false;
99
+ }
100
+ if (readFileSafe(lockPath) !== lockContent) return false;
101
+ fs.rmSync(lockPath);
102
+ return true;
103
+ }
104
+
105
+ function governanceLockHost() {
106
+ return process.env.HOSTNAME || os.hostname() || "";
107
+ }
108
+
109
+ function governanceLockExistsError(lockPath, error) {
110
+ return new GovernanceSyncError("Governance sync lock already exists; refusing concurrent registry writes.", {
111
+ code: "governance-lock-exists",
112
+ details: { lockPath, error: error?.message || String(error) },
113
+ recovery: [
114
+ `Inspect ${lockPath}.`,
115
+ "If no process owns the lock, remove it manually and retry.",
116
+ ],
117
+ });
71
118
  }
72
119
 
73
120
  export function releaseGovernanceSync(context) {
@@ -82,24 +129,32 @@ export function releaseGovernanceSync(context) {
82
129
  export function commitGovernanceSync(context, allowedRelativePaths, { message = "chore(harness): sync governance state" } = {}) {
83
130
  const allowed = [...new Set((allowedRelativePaths || []).filter(Boolean).map(toPosix))].sort();
84
131
  if (context?.dryRun || !context?.git?.inGit) return { committed: false, reason: context?.git?.inGit ? "dry-run" : "not-git", allowedPaths: allowed };
85
- assertOnlyAllowedChanged(context.target.projectRoot, allowed);
86
132
  if (allowed.length === 0) return { committed: false, reason: "no-allowed-paths", allowedPaths: allowed };
133
+ assertNoUnexpectedOutsideChanges(context.target.projectRoot, allowed, context.initialDirtyEntries || []);
87
134
  git(context.target.projectRoot, ["add", "--", ...allowed]);
88
135
  assertOnlyAllowedStaged(context.target.projectRoot, allowed);
89
136
  const staged = git(context.target.projectRoot, ["diff", "--cached", "--name-only", "-z"]).stdout.split("\0").filter(Boolean);
90
137
  if (staged.length === 0) return { committed: false, reason: "no-changes", allowedPaths: allowed };
91
- const commitResult = git(context.target.projectRoot, ["commit", "-m", message], { allowFailure: true });
138
+ const commitResult = git(context.target.projectRoot, ["-c", "core.hooksPath=/dev/null", "commit", "--no-verify", "-m", message], { allowFailure: true });
92
139
  if (commitResult.status !== 0) {
140
+ let outsideChanges = null;
141
+ try {
142
+ assertNoUnexpectedOutsideChanges(context.target.projectRoot, allowed, context.initialDirtyEntries || []);
143
+ } catch (error) {
144
+ outsideChanges = error.details || null;
145
+ }
93
146
  throw new GovernanceSyncError("Governance sync wrote files but Git commit failed.", {
94
147
  code: "governance-git-commit-failed",
95
- details: { stdout: commitResult.stdout.trim(), stderr: commitResult.stderr.trim(), allowedPaths: allowed },
148
+ details: { stdout: commitResult.stdout.trim(), stderr: commitResult.stderr.trim(), allowedPaths: allowed, outsideChanges },
96
149
  recovery: [
97
150
  `Inspect files: ${allowed.join(", ")}`,
98
- `Then run: git add -- ${allowed.join(" ")} && git commit -m ${JSON.stringify(message)}`,
151
+ `Then run: git add -- ${allowed.join(" ")} && git -c core.hooksPath=/dev/null commit --no-verify -m ${JSON.stringify(message)}`,
99
152
  ],
100
153
  });
101
154
  }
102
- assertClean(context.target.projectRoot);
155
+ assertLastCommitOnlyAllowed(context.target.projectRoot, allowed);
156
+ assertNoUnexpectedOutsideChanges(context.target.projectRoot, allowed, context.initialDirtyEntries || []);
157
+ assertWriteScopeClean(context.target.projectRoot, allowed);
103
158
  return { committed: true, commitSha: git(context.target.projectRoot, ["rev-parse", "HEAD"]).stdout.trim(), allowedPaths: allowed };
104
159
  }
105
160
 
@@ -138,7 +193,7 @@ export function syncModuleStepGovernance(target, { moduleKey, stepId, state, dry
138
193
  "module-step",
139
194
  todayDate(),
140
195
  ];
141
- fs.writeFileSync(ledgerPath, appendRow(content, /^ID$/i, row));
196
+ fs.writeFileSync(ledgerPath, appendMarkdownTableRow(content, /^ID$/i, row));
142
197
  }
143
198
  changes.push({ destination: ledgerRelative, action: dryRun ? "would-sync-governance" : "sync-governance", surface: "harness-ledger" });
144
199
  return { changes };
@@ -168,7 +223,7 @@ function syncLedgerRow(target, task, { event, state, message, planPath, reviewPa
168
223
  message || "none",
169
224
  todayDate(),
170
225
  ];
171
- fs.writeFileSync(ledgerPath, upsertRow(content, /^ID$/i, (header, existing) => rowMatchesPlan(header, existing, planPath), row));
226
+ fs.writeFileSync(ledgerPath, upsertMarkdownTableRow(content, /^ID$/i, (header, existing) => rowMatchesPlan(header, existing, planPath), row));
172
227
  }
173
228
  return { destination: relative, action: dryRun ? "would-sync-governance" : "sync-governance", surface: "harness-ledger" };
174
229
  }
@@ -195,7 +250,7 @@ function syncModuleRegistryRow(target, task, { state, planPath, dryRun }) {
195
250
  "none",
196
251
  todayDate(),
197
252
  ];
198
- fs.writeFileSync(registryPath, upsertRow(content, /^ID$/i, (header, existing) => rowMatchesModule(header, existing, moduleKey, modulePlan), row));
253
+ fs.writeFileSync(registryPath, upsertMarkdownTableRow(content, /^ID$/i, (header, existing) => rowMatchesModule(header, existing, moduleKey, modulePlan), row));
199
254
  }
200
255
  return { destination: relative, action: dryRun ? "would-sync-governance" : "sync-governance", surface: "module-registry" };
201
256
  }
@@ -285,7 +340,7 @@ function renderModuleVisualMap(moduleKey, tasks) {
285
340
  });
286
341
  const graphLines = tasks.map((task, index) => {
287
342
  const stepId = moduleStepId(task);
288
- const label = markdownCell(task.title || task.shortId || task.id).replace(/"/g, "'");
343
+ const label = fitMarkdownTableRow([task.title || task.shortId || task.id], 1)[0].replace(/"/g, "'");
289
344
  if (index === 0) return ` ${stepId}["${label}"]`;
290
345
  const previous = moduleStepId(tasks[index - 1]);
291
346
  return ` ${previous} --> ${stepId}["${label}"]`;
@@ -313,7 +368,7 @@ ${graphLines.length ? graphLines.join("\n") : " EMPTY[\"No module tasks\"]"}
313
368
 
314
369
  | Phase ID | Depends On | State | Completion | Output | Required Evidence | Evidence Status | Blocking Risk | Owner / Handoff |
315
370
  | --- | --- | --- | ---: | --- | --- | --- | --- | --- |
316
- ${rows.map((row) => `| ${fitRow(row, 9).join(" | ")} |`).join("\n")}
371
+ ${rows.map((row) => `| ${fitMarkdownTableRow(row, 9).join(" | ")} |`).join("\n")}
317
372
 
318
373
  Allowed Evidence Status: missing, partial, present, waived.
319
374
  `;
@@ -329,10 +384,10 @@ function replaceTableRows(content, headerPattern, rows) {
329
384
  if (!separator.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
330
385
  let end = index + 2;
331
386
  while (end < lines.length && lines[end].trim().startsWith("|")) end += 1;
332
- lines.splice(index + 2, end - index - 2, ...rows.map((row) => `| ${fitRow(row, header.length).join(" | ")} |`));
387
+ lines.splice(index + 2, end - index - 2, ...rows.map((row) => `| ${fitMarkdownTableRow(row, header.length).join(" | ")} |`));
333
388
  return `${lines.join("\n").trimEnd()}\n`;
334
389
  }
335
- return `${String(content || "").trimEnd()}\n\n${rows.map((row) => `| ${fitRow(row, row.length).join(" | ")} |`).join("\n")}\n`;
390
+ return `${String(content || "").trimEnd()}\n\n${rows.map((row) => `| ${fitMarkdownTableRow(row, row.length).join(" | ")} |`).join("\n")}\n`;
336
391
  }
337
392
 
338
393
  function existingOrTemplate(filePath, templateSource) {
@@ -361,32 +416,6 @@ function ensureFileFromTemplate(destinationPath, templateSource, { dryRun = fals
361
416
  fs.writeFileSync(destinationPath, readBundledTemplate(templateSource));
362
417
  }
363
418
 
364
- function upsertRow(content, headerPattern, matcher, row) {
365
- const updated = updateMarkdownTableRow(content, headerPattern, (header, existing) => (matcher(header, existing) ? fitRow(row, header.length) : null));
366
- if (updated.matched) return updated.content;
367
- return appendRow(content, headerPattern, row);
368
- }
369
-
370
- function appendRow(content, headerPattern, row) {
371
- const lines = String(content || "").split(/\r?\n/);
372
- for (let index = 0; index < lines.length; index += 1) {
373
- if (!lines[index].trim().startsWith("|")) continue;
374
- const header = splitMarkdownRow(lines[index]);
375
- if (!header.some((cell) => headerPattern.test(cell))) continue;
376
- let insertAt = index + 2;
377
- while (insertAt < lines.length && lines[insertAt].trim().startsWith("|")) insertAt += 1;
378
- lines.splice(insertAt, 0, `| ${fitRow(row, header.length).join(" | ")} |`);
379
- return lines.join("\n");
380
- }
381
- return `${String(content || "").trimEnd()}\n\n| ${row.join(" | ")} |\n`;
382
- }
383
-
384
- function fitRow(row, length) {
385
- const next = row.map((cell) => markdownCell(cell));
386
- while (next.length < length) next.push("");
387
- return next.slice(0, length);
388
- }
389
-
390
419
  function rowMatchesPlan(header, row, planPath) {
391
420
  const planIndex = firstColumn(header, ["Task Plan", "Plan", "当前产物"]);
392
421
  return planIndex >= 0 && String(row[planIndex] || "").includes(planPath);
@@ -446,19 +475,28 @@ function assertCommitIdentity(root) {
446
475
  }
447
476
  }
448
477
 
449
- function assertOnlyAllowedChanged(root, allowedPaths) {
450
- const outside = statusEntries(root).filter((entry) => !allowedPaths.includes(entry.path));
451
- if (outside.length > 0) {
452
- throw new GovernanceSyncError("Governance sync produced changes outside the allowlist.", {
453
- code: "governance-allowlist-violation",
454
- details: { disallowed: outside, allowedPaths },
455
- recovery: ["Inspect the extra paths; the CLI will not stage or commit unrelated files."],
478
+ function assertDirtyCompatibleWithWriteScope(entries, allowedPaths) {
479
+ const allowed = new Set(allowedPaths);
480
+ const overlapping = entries.filter((entry) => allowed.has(entry.path));
481
+ if (overlapping.length > 0) {
482
+ throw new GovernanceSyncError("Governance sync write scope overlaps existing dirty files; refusing to overwrite user-owned changes.", {
483
+ code: "governance-write-scope-dirty",
484
+ details: { overlapping, allowedPaths },
485
+ recovery: ["Commit, move, or remove the overlapping files before retrying this lifecycle command."],
486
+ });
487
+ }
488
+ const outsideStaged = entries.filter((entry) => entry.index !== " " && entry.index !== "?" && !allowed.has(entry.path));
489
+ if (outsideStaged.length > 0) {
490
+ throw new GovernanceSyncError("Git index contains staged files outside the governance sync write scope.", {
491
+ code: "governance-index-outside-write-scope",
492
+ details: { disallowed: outsideStaged, allowedPaths },
493
+ recovery: ["Unstage unrelated files before retrying the lifecycle command."],
456
494
  });
457
495
  }
458
496
  }
459
497
 
460
498
  function assertOnlyAllowedStaged(root, allowedPaths) {
461
- const outside = statusEntries(root).filter((entry) => entry.index !== " " && !allowedPaths.includes(entry.path));
499
+ const outside = statusEntries(root).filter((entry) => entry.index !== " " && entry.index !== "?" && !allowedPaths.includes(entry.path));
462
500
  if (outside.length > 0) {
463
501
  throw new GovernanceSyncError("Git index contains staged files outside the governance sync allowlist.", {
464
502
  code: "governance-index-allowlist-violation",
@@ -468,17 +506,76 @@ function assertOnlyAllowedStaged(root, allowedPaths) {
468
506
  }
469
507
  }
470
508
 
471
- function assertClean(root) {
509
+ function assertNoUnexpectedOutsideChanges(root, allowedPaths, initialDirtyEntries) {
510
+ const allowed = new Set(allowedPaths);
511
+ const initialByPath = new Map(
512
+ (initialDirtyEntries || [])
513
+ .filter((entry) => !allowed.has(entry.path))
514
+ .map((entry) => [entry.path, entry]),
515
+ );
516
+ const unexpected = [];
517
+ const changed = [];
518
+ for (const entry of statusEntries(root)) {
519
+ if (allowed.has(entry.path)) continue;
520
+ const current = { ...entry, fingerprint: fingerprintEntry(root, entry) };
521
+ const initial = initialByPath.get(entry.path);
522
+ if (!initial) {
523
+ unexpected.push(current);
524
+ } else if (initial.raw !== current.raw || initial.fingerprint !== current.fingerprint) {
525
+ changed.push({ before: initial, after: current });
526
+ }
527
+ }
528
+ if (unexpected.length > 0 || changed.length > 0) {
529
+ throw new GovernanceSyncError("Governance sync produced changes outside its write scope.", {
530
+ code: "governance-allowlist-violation",
531
+ details: { unexpected, changed, allowedPaths },
532
+ recovery: ["Inspect the extra paths; the CLI will not stage or commit unrelated files."],
533
+ });
534
+ }
535
+ }
536
+
537
+ function assertLastCommitOnlyAllowed(root, allowedPaths) {
538
+ const committed = git(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", "HEAD"]).stdout
539
+ .split("\0")
540
+ .filter(Boolean)
541
+ .map(toPosix);
542
+ const outside = committed.filter((file) => !allowedPaths.includes(file));
543
+ if (outside.length > 0) {
544
+ throw new GovernanceSyncError("Governance sync commit contains files outside its write scope.", {
545
+ code: "governance-commit-allowlist-violation",
546
+ details: { disallowed: outside, committed, allowedPaths },
547
+ recovery: ["Inspect the last commit and remove any files that are not owned by the lifecycle command."],
548
+ });
549
+ }
550
+ }
551
+
552
+ function assertWriteScopeClean(root, allowedPaths) {
472
553
  const entries = statusEntries(root);
473
- if (entries.length > 0) {
474
- throw new GovernanceSyncError("Governance sync commit completed but working tree is not clean.", {
554
+ const remaining = entries.filter((entry) => allowedPaths.includes(entry.path));
555
+ if (remaining.length > 0) {
556
+ throw new GovernanceSyncError("Governance sync commit completed but write scope is not clean.", {
475
557
  code: "governance-post-commit-dirty",
476
- details: { entries },
477
- recovery: ["Inspect remaining files before continuing."],
558
+ details: { entries: remaining, allowedPaths },
559
+ recovery: ["Inspect remaining write-scope files before continuing."],
478
560
  });
479
561
  }
480
562
  }
481
563
 
564
+ function fingerprintEntry(root, entry) {
565
+ const absolute = path.join(root, entry.path);
566
+ try {
567
+ const stat = fs.lstatSync(absolute);
568
+ if (stat.isSymbolicLink()) return `symlink:${fs.readlinkSync(absolute)}`;
569
+ if (stat.isFile()) {
570
+ return `file:${stat.size}:${crypto.createHash("sha256").update(fs.readFileSync(absolute)).digest("hex")}`;
571
+ }
572
+ if (stat.isDirectory()) return "directory";
573
+ return `${stat.mode}:${stat.size}`;
574
+ } catch {
575
+ return "missing";
576
+ }
577
+ }
578
+
482
579
  function statusEntries(root) {
483
580
  return git(root, ["status", "--porcelain=v1", "--untracked-files=all"]).stdout
484
581
  .split(/\r?\n/)
@@ -2,6 +2,7 @@ export * from "./core-shared.mjs";
2
2
  export * from "./markdown-utils.mjs";
3
3
  export * from "./capability-registry.mjs";
4
4
  export * from "./task-scanner.mjs";
5
+ export * from "./status-builder.mjs";
5
6
  export * from "./check-profiles.mjs";
6
7
  export * from "./dashboard-data.mjs";
7
8
  export * from "./dashboard-workbench.mjs";
@@ -156,3 +156,36 @@ export function updateMarkdownTableRow(content, headerPattern, updater) {
156
156
  }
157
157
  return { content, matched: false };
158
158
  }
159
+
160
+ export function upsertMarkdownTableRow(content, headerPattern, matcher, row) {
161
+ const updated = updateMarkdownTableRow(content, headerPattern, (header, existing) => (matcher(header, existing) ? fitMarkdownTableRow(row, header.length) : null));
162
+ if (updated.matched) return updated.content;
163
+ return appendMarkdownTableRow(content, headerPattern, row);
164
+ }
165
+
166
+ export function appendMarkdownTableRow(content, headerPattern, row) {
167
+ const lines = String(content || "").split(/\r?\n/);
168
+ for (let index = 0; index < lines.length; index += 1) {
169
+ if (!lines[index].trim().startsWith("|")) continue;
170
+ const header = splitMarkdownRow(lines[index]);
171
+ if (!header.some((cell) => headerPattern.test(cell))) continue;
172
+ let insertAt = index + 2;
173
+ while (insertAt < lines.length && lines[insertAt].trim().startsWith("|")) insertAt += 1;
174
+ lines.splice(insertAt, 0, `| ${fitMarkdownTableRow(row, header.length).join(" | ")} |`);
175
+ return lines.join("\n");
176
+ }
177
+ return `${String(content || "").trimEnd()}\n\n| ${row.map(markdownTableCell).join(" | ")} |\n`;
178
+ }
179
+
180
+ export function fitMarkdownTableRow(row, length) {
181
+ const next = row.map(markdownTableCell);
182
+ while (next.length < length) next.push("");
183
+ return next.slice(0, length);
184
+ }
185
+
186
+ function markdownTableCell(value) {
187
+ return String(value || "")
188
+ .replace(/\r?\n/g, " ")
189
+ .replaceAll("|", "\\|")
190
+ .trim();
191
+ }
@@ -6,6 +6,7 @@ import {
6
6
  normalizeTarget,
7
7
  normalizeLocale,
8
8
  readFileSafe,
9
+ readJsonSafe,
9
10
  existsInDocs,
10
11
  walkFiles,
11
12
  toPosix,
@@ -363,12 +364,9 @@ export function verifyMigrationSession(sessionPathInput, { fullCutover = false }
363
364
  }
364
365
  const failures = [];
365
366
  const warnings = [];
366
- let session;
367
- try {
368
- session = JSON.parse(fs.readFileSync(sessionPath, "utf8"));
369
- } catch (error) {
370
- return { operation: "migrate-verify", status: "fail", failures: [`invalid session json: ${error.message}`], warnings };
371
- }
367
+ let readError = null;
368
+ const session = readJsonSafe(sessionPath, null, { onError: (error) => { readError = error; } });
369
+ if (!session) return { operation: "migrate-verify", status: "fail", failures: [`invalid session json: ${readError?.message || "unknown parse error"}`], warnings };
372
370
  if (session.operation !== "migrate-run") failures.push("session operation is not migrate-run");
373
371
  if (session.schemaVersion !== 1 && session.version !== 1) failures.push("session missing schema version");
374
372
  if (session.planOnly) failures.push("plan-only session is not completed migration evidence; rerun migrate-run without --plan-only");
@@ -0,0 +1,50 @@
1
+ export const allowedPhaseKinds = new Set(["init", "execution", "gate"]);
2
+ export const allowedPhaseActors = new Set(["agent", "human", "coordinator"]);
3
+
4
+ export function normalizePhaseKind(value) {
5
+ const normalized = String(value || "")
6
+ .replace(/`/g, "")
7
+ .trim()
8
+ .toLowerCase()
9
+ .replaceAll("_", "-");
10
+ if (!normalized) return "execution";
11
+ if (normalized === "exec" || normalized === "implementation") return "execution";
12
+ if (normalized === "prep" || normalized === "discussion") return "init";
13
+ if (normalized === "review" || normalized === "closeout") return "gate";
14
+ return normalized;
15
+ }
16
+
17
+ export function normalizePhaseActor(value) {
18
+ const normalized = String(value || "")
19
+ .replace(/`/g, "")
20
+ .trim()
21
+ .toLowerCase()
22
+ .replaceAll("_", "-");
23
+ return normalized || "agent";
24
+ }
25
+
26
+ export function isExecutionPhase(phase) {
27
+ return normalizePhaseKind(phase?.kind) === "execution";
28
+ }
29
+
30
+ export function nonSkippedPhases(phases = []) {
31
+ return phases.filter((phase) => phase.state !== "skipped");
32
+ }
33
+
34
+ export function implementationPhases(phases = []) {
35
+ return nonSkippedPhases(phases).filter(isExecutionPhase);
36
+ }
37
+
38
+ export function phaseCompletionAverage(phases = []) {
39
+ const scored = implementationPhases(phases);
40
+ if (scored.length === 0) return 0;
41
+ return Math.round(scored.reduce((sum, phase) => sum + phase.completion, 0) / scored.length);
42
+ }
43
+
44
+ export function phaseHasRecordedProgress(phase) {
45
+ return (
46
+ phase.completion > 0 ||
47
+ ["in_progress", "review", "blocked", "done"].includes(String(phase.state || "").toLowerCase()) ||
48
+ ["partial", "present", "waived"].includes(String(phase.evidenceStatus || "").toLowerCase())
49
+ );
50
+ }
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { spawnSync } from "node:child_process";
5
- import { repoRoot, taskContractMarker, toPosix, visualMapFile } from "./core-shared.mjs";
5
+ import { readJsonSafe, repoRoot, taskContractMarker, toPosix, visualMapFile } from "./core-shared.mjs";
6
6
  import { verifyMigrationSession } from "./migration-planner.mjs";
7
7
  import { buildPresetAudit, renderPresetTemplate } from "./preset-registry.mjs";
8
8
 
@@ -25,12 +25,9 @@ export function resolvePresetInputs(preset, { cliArgs = [], fromSession = "", ta
25
25
  }
26
26
  const filePath = path.resolve(String(rawValue));
27
27
  if (!fs.existsSync(filePath)) throw new Error(`Preset input file not found for ${declaration.flag || name}: ${rawValue}`);
28
- let value;
29
- try {
30
- value = JSON.parse(fs.readFileSync(filePath, "utf8"));
31
- } catch (error) {
32
- throw new Error(`Invalid preset JSON input ${declaration.flag || name}: ${error.message}`);
33
- }
28
+ let readError = null;
29
+ const value = readJsonSafe(filePath, null, { onError: (error) => { readError = error; } });
30
+ if (value === null) throw new Error(`Invalid preset JSON input ${declaration.flag || name}: ${readError?.message || "unknown parse error"}`);
34
31
  if (declaration.validateOperation && value.operation !== declaration.validateOperation) {
35
32
  throw new Error(`${preset.id} preset requires ${declaration.flag || name} operation ${declaration.validateOperation}`);
36
33
  }
@@ -463,7 +460,7 @@ function targetCommit(projectRoot) {
463
460
 
464
461
  function packageVersion() {
465
462
  try {
466
- return JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version || "unknown";
463
+ return readJsonSafe(path.join(repoRoot, "package.json"), {}).version || "unknown";
467
464
  } catch {
468
465
  return "unknown";
469
466
  }