@opengsd/gsd-core 1.19.0 → 1.20.1

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 (131) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/bin/install.js +36 -40
  9. package/commands/gsd/new-milestone.md +2 -2
  10. package/commands/gsd/new-project.md +2 -2
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +177 -93
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  14. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  15. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  16. package/gsd-core/bin/lib/commands.cjs +46 -17
  17. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  18. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  19. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  20. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  21. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  22. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  23. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  24. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  25. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  26. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  27. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  28. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  29. package/gsd-core/bin/lib/init.cjs +181 -68
  30. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  31. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  32. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  33. package/gsd-core/bin/lib/io.cjs +6 -0
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  35. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  36. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  37. package/gsd-core/bin/lib/milestone.cjs +18 -50
  38. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  39. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  40. package/gsd-core/bin/lib/phase-id.cjs +627 -36
  41. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  42. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  43. package/gsd-core/bin/lib/phase.cjs +3039 -666
  44. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  45. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  47. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  48. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  49. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  51. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  52. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
  54. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  55. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  56. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  57. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  58. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  59. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  60. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  61. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  62. package/gsd-core/bin/lib/state-document.cjs +332 -23
  63. package/gsd-core/bin/lib/state-transition.cjs +51 -15
  64. package/gsd-core/bin/lib/state.cjs +499 -180
  65. package/gsd-core/bin/lib/template.cjs +2 -2
  66. package/gsd-core/bin/lib/validate.cjs +3 -2
  67. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  68. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  69. package/gsd-core/bin/lib/verify.cjs +2 -2
  70. package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
  71. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  72. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  73. package/gsd-core/references/cicd-review-patterns.md +45 -0
  74. package/gsd-core/references/necessity-review.md +48 -0
  75. package/gsd-core/workflows/code-review-fix.md +46 -5
  76. package/gsd-core/workflows/code-review.md +3 -0
  77. package/gsd-core/workflows/execute-phase.md +1 -1
  78. package/gsd-core/workflows/execute-plan.md +1 -1
  79. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  80. package/gsd-core/workflows/plan-phase.md +1 -1
  81. package/gsd-core/workflows/pr-branch.md +2 -0
  82. package/gsd-core/workflows/reapply-patches.md +104 -0
  83. package/gsd-core/workflows/review.md +54 -141
  84. package/gsd-core/workflows/secure-phase.md +1 -1
  85. package/gsd-core/workflows/settings-advanced.md +57 -9
  86. package/gsd-core/workflows/ship.md +12 -0
  87. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  88. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  89. package/hooks/dist/gsd-graphify-update.sh +6 -0
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  92. package/hooks/dist/gsd-workflow-guard.js +2 -1
  93. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  94. package/hooks/dist/lib/git-probe.js +16 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  96. package/hooks/gsd-agent-isolation-guard.js +1 -1
  97. package/hooks/gsd-cursor-subagent-start.js +1 -1
  98. package/hooks/gsd-graphify-update.sh +6 -0
  99. package/hooks/gsd-statusline.js +3 -2
  100. package/hooks/gsd-windsurf-pre-write.js +4 -4
  101. package/hooks/gsd-workflow-guard.js +2 -1
  102. package/hooks/gsd-worktree-path-guard.js +4 -4
  103. package/hooks/lib/git-probe.js +16 -0
  104. package/hooks/lib/isolation-sentinel.js +77 -2
  105. package/package.json +3 -3
  106. package/pi/package-agents/gsd-code-fixer.md +9 -4
  107. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  108. package/pi/package-agents/gsd-executor.md +8 -4
  109. package/pi/package-agents/manifest.json +3 -3
  110. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  111. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  112. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  113. package/scripts/changeset/lint.cjs +28 -3
  114. package/scripts/ci-next-health.cjs +246 -23
  115. package/scripts/docs-guard-registry.cjs +2 -0
  116. package/scripts/gen-features.cjs +14 -2
  117. package/scripts/lib/drift-scan.cjs +27 -0
  118. package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
  119. package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
  120. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  121. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  122. package/scripts/merge-commit-pr.cjs +186 -0
  123. package/scripts/mutation-matrix.cjs +23 -1
  124. package/scripts/release-ancestry.cjs +86 -0
  125. package/scripts/release-pipeline-pr.cjs +92 -0
  126. package/scripts/require-issue-link-policy.cjs +18 -9
  127. package/scripts/sync-next-version.cjs +48 -7
  128. package/skills/gsd-new-milestone/SKILL.md +1 -1
  129. package/skills/gsd-new-project/SKILL.md +1 -1
  130. package/skills/gsd-update/SKILL.md +5 -2
  131. package/vscode/package.json +1 -1
@@ -1631,7 +1631,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1631
1631
  const fsx = require('node:fs');
1632
1632
  const os = require('node:os');
1633
1633
  const { REVIEWER_LANES, mergeReviewerLanes } = require('./lib/review-lane-descriptor.cjs');
1634
- const { resolveLanePlan: resolveLanePlanBase, resolveLaneEffort, resolveLaneBudget, applyPromptOverride } = require('./lib/review-lane-invocation.cjs');
1634
+ const { resolveLanePlan: resolveLanePlanBase, resolveLaneEffort, resolveLaneCap, refuseIfOverCap, renderPromptRefusalStub, artifactPaths, applyPromptOverride } = require('./lib/review-lane-invocation.cjs');
1635
+ const { estimateTokens } = require('./lib/prompt-budget.cjs');
1635
1636
  // OpenCode 2.x argv differs from 1.x (no --variant; effort rides on the model as `#variant`;
1636
1637
  // ADR-5331 D5/D8). The generation is probed lazily, only when the opencode lane is resolved.
1637
1638
  const resolveLanePlan = (laneInput) => {
@@ -1915,11 +1916,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1915
1916
  };
1916
1917
  const effortFor = (lane) => resolveLaneEffort(lane, configGet, renderLaneEffort);
1917
1918
 
1918
- // Per-lane prompt budget: `resolveLaneBudget` (review-lane-invocation.cjs) owns the #2797
1919
+ // Per-lane prompt cap: `resolveLaneCap` (review-lane-invocation.cjs) owns the #2797 + #4559
1919
1920
  // resolution semantics (shared with src/reviewer-step-dispatch.cts, #4209 R3 — was two
1920
- // verbatim copies). Only the budget VALUE is resolved here; assembly/trimming stay in
1921
- // `prompt-budget`, which the workflow calls, handing the trimmed file back via `--prompt-file`.
1922
- const budgetFor = (lane) => resolveLaneBudget(lane, configGet);
1921
+ // verbatim copies). `plan` reports it; `invoke` REFUSES a lane whose prompt is over it
1922
+ // (#4559) rather than trimming — the prompt is never edited on the way to a lane.
1923
+ const capFor = (lane) => resolveLaneCap(lane, configGet);
1923
1924
 
1924
1925
  // #4209 (ADR-2782 seam) — the ONE interpreter route for a step that declared
1925
1926
  // `supportsReviewerLanes: true`. Wires `dispatchReviewerLanes` (src/reviewer-step-dispatch.cts)
@@ -2059,17 +2060,19 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2059
2060
  } catch (e) {
2060
2061
  return { slug, ok: false, reason: 'malformed_lane', detail: `resolver threw: ${e && e.message ? e.message : String(e)}` };
2061
2062
  }
2062
- return r.ok
2063
- ? {
2064
- slug,
2065
- ok: true,
2066
- section: lane.reviewsSection,
2067
- transport: r.plan.transport,
2068
- promptBudget: budgetFor(lane),
2069
- promptPath: r.plan.transport === 'spawn' ? r.plan.stdin : r.plan.promptPath,
2070
- plan: r.plan,
2071
- }
2072
- : { slug, ok: false, reason: r.reason, detail: r.detail };
2063
+ if (!r.ok) return { slug, ok: false, reason: r.reason, detail: r.detail };
2064
+ const laneCap = capFor(lane);
2065
+ return {
2066
+ slug,
2067
+ ok: true,
2068
+ section: lane.reviewsSection,
2069
+ transport: r.plan.transport,
2070
+ promptBudget: laneCap.cap,
2071
+ promptCapSource: laneCap.source,
2072
+ contextWindowTokens: laneCap.contextWindowTokens,
2073
+ promptPath: r.plan.transport === 'spawn' ? r.plan.stdin : r.plan.promptPath,
2074
+ plan: r.plan,
2075
+ };
2073
2076
  });
2074
2077
 
2075
2078
  if (sub === 'plan') {
@@ -2178,14 +2181,61 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2178
2181
  ];
2179
2182
  }
2180
2183
 
2181
- // `--prompt-file` lets the caller substitute a budget-trimmed prompt. Applied to whichever
2182
- // channel this lane actually reads from (stdin, argv, an argv-embedded file reference, or the
2183
- // http promptPath), so one flag serves every lane (#5049).
2184
+ // `--prompt-file` lets the caller substitute a different prompt (a section subset, say). Applied
2185
+ // to whichever channel this lane actually reads from (stdin, argv, an argv-embedded file
2186
+ // reference, or the http promptPath), so one flag serves every lane (#5049). It is no longer a
2187
+ // trim hand-off: nothing in review.md trims a prompt to fit a lane since #4559.
2184
2188
  const promptOverride = flag('--prompt-file');
2185
2189
  if (promptOverride) {
2186
2190
  entry.plan = applyPromptOverride(entry.plan, promptOverride, repoRoot);
2187
2191
  }
2188
2192
 
2193
+ // #4559: refuse, never trim. Measure the prompt this lane would actually receive — the file
2194
+ // the RESOLVED plan reads (stdin file, or the plan's prompt path for argv / argv-file-ref /
2195
+ // http lanes; `--prompt-file` already retargeted the plan above) — against its cap BEFORE any
2196
+ // consent lookup, probe or spawn. An unreadable file is not a refusal: the runner reports its
2197
+ // own error for it. The decision itself lives in `refuseIfOverCap`; this only does the IO.
2198
+ {
2199
+ const plannedPrompt = entry.plan.transport === 'spawn' && entry.plan.stdin
2200
+ ? entry.plan.stdin
2201
+ : entry.plan.promptPath;
2202
+ let promptText = null;
2203
+ try {
2204
+ promptText = fs.readFileSync(plannedPrompt || artifactPaths(runDir, entry.slug).promptPath, 'utf8');
2205
+ } catch { promptText = null; }
2206
+ if (promptText !== null) {
2207
+ const refused = refuseIfOverCap({
2208
+ lane: laneBySlug.get(entry.slug),
2209
+ estimatedTokens: estimateTokens(promptText),
2210
+ configGet,
2211
+ });
2212
+ if (refused) {
2213
+ const reviewPath = entry.plan.reviewPath;
2214
+ let stubWritten = true;
2215
+ try {
2216
+ fs.mkdirSync(path.dirname(reviewPath), { recursive: true });
2217
+ fs.writeFileSync(reviewPath, renderPromptRefusalStub(entry.slug, refused.detail), 'utf8');
2218
+ } catch (e) {
2219
+ stubWritten = false;
2220
+ process.stderr.write(
2221
+ `review-lane invoke: could not write the refusal stub to ${reviewPath}: ` +
2222
+ `${e && e.message ? e.message : String(e)}\n`,
2223
+ );
2224
+ }
2225
+ output({
2226
+ slug: entry.slug,
2227
+ ok: false,
2228
+ reason: refused.reason,
2229
+ detail: refused.detail,
2230
+ reviewPath,
2231
+ stubWritten,
2232
+ refusal: refused.refusal,
2233
+ }, raw);
2234
+ return;
2235
+ }
2236
+ }
2237
+ }
2238
+
2189
2239
  // ADR-2782 D5 rule 4: re-resolve the consented destination at INVOCATION. `undefined` (no
2190
2240
  // consent record, or a record predating rule 1) means "nothing to compare" and ALLOWS — a
2191
2241
  // first-party lane ships inside the SHA-pinned distribution and is never consent-gated, so
@@ -2858,6 +2908,54 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2858
2908
  }
2859
2909
  }
2860
2910
 
2911
+ /**
2912
+ * #5682: ALSO record a plan-scoped decision at `.gsd/dispatch-isolation/<phase>--<plan>.json`, so a
2913
+ * second orchestrator's write to the single legacy slot cannot erase it. Same payload, same atomic
2914
+ * tmp+rename, same symlink/containment checks as the legacy directory. Best-effort: any failure is
2915
+ * swallowed so the legacy result is unchanged. The key rule lives in hooks/lib/isolation-sentinel.js
2916
+ * (the reader); if that module is unreachable from this install layout, only the legacy file is written.
2917
+ */
2918
+ function writeKeyedDispatchIsolationRecord(cwd, sentinelDir, payload, clock) {
2919
+ const nodePath = require('path');
2920
+ const nodeFs = require('fs');
2921
+ try {
2922
+ const {
2923
+ keyedSentinelPath, phaseSentinelPath, KEYED_SENTINEL_DIR, AGED_NONE_MAX_MS,
2924
+ } = require('../../hooks/lib/isolation-sentinel.js');
2925
+ // A plan-scoped decision lands at <phase>--<plan>.json; a plan-less one (the phase-level
2926
+ // degrade) at <phase>.json, so concurrent phase-level orchestrators keep separate records.
2927
+ const keyedFile = payload.plan
2928
+ ? keyedSentinelPath(cwd, payload.phase, payload.plan)
2929
+ : phaseSentinelPath(cwd, payload.phase);
2930
+ if (!keyedFile) return;
2931
+ const keyedDir = nodePath.join(cwd, KEYED_SENTINEL_DIR);
2932
+ nodeFs.mkdirSync(keyedDir, { recursive: true });
2933
+ if (nodeFs.lstatSync(keyedDir).isSymbolicLink() ||
2934
+ nodePath.relative(nodeFs.realpathSync(sentinelDir), nodeFs.realpathSync(keyedDir)) !== 'dispatch-isolation') {
2935
+ return;
2936
+ }
2937
+ const tmpPath = `${keyedFile}.tmp-${process.pid}-${require('crypto').randomBytes(6).toString('hex')}`;
2938
+ try {
2939
+ nodeFs.writeFileSync(tmpPath, JSON.stringify(payload), { flag: 'wx' });
2940
+ nodeFs.renameSync(tmpPath, keyedFile);
2941
+ } catch (writeError) {
2942
+ try { nodeFs.unlinkSync(tmpPath); } catch (_) { /* best-effort cleanup */ }
2943
+ throw writeError;
2944
+ }
2945
+ // Prune records and orphaned temp files older than the longest any record can still govern
2946
+ // (AGED_NONE_MAX_MS, #5479), measured on the injected clock.
2947
+ const cutoff = clock.now() - AGED_NONE_MAX_MS;
2948
+ const written = nodePath.basename(keyedFile);
2949
+ for (const name of nodeFs.readdirSync(keyedDir)) {
2950
+ if (name === written || !(name.endsWith('.json') || name.includes('.json.tmp-'))) continue;
2951
+ try {
2952
+ const file = nodePath.join(keyedDir, name);
2953
+ if (nodeFs.lstatSync(file).mtimeMs < cutoff) nodeFs.unlinkSync(file);
2954
+ } catch (_) { /* best-effort prune */ }
2955
+ }
2956
+ } catch (_) { /* keyed record is best-effort; the legacy record already landed */ }
2957
+ }
2958
+
2861
2959
  /**
2862
2960
  * Atomically persist the resolved dispatch-isolation decision to the
2863
2961
  * run-scoped sentinel both isolation guard hooks read
@@ -2868,7 +2966,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2868
2966
  * per-plan degrade call site and back-compat/tests) share exactly one
2869
2967
  * write implementation. Never throws — returns `{ recorded, path, error? }`.
2870
2968
  */
2871
- function writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag = null, phase = null, plan = null }) {
2969
+ function writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag = null, phase = null, plan = null }, { clock = require('./lib/clock.cjs').realClock } = {}) {
2872
2970
  const nodePath = require('path');
2873
2971
  const nodeFs = require('fs');
2874
2972
  const sentinelDir = nodePath.join(cwd, '.gsd');
@@ -2878,77 +2976,61 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2878
2976
  harness_flag: harnessFlag || null,
2879
2977
  phase: phase || null,
2880
2978
  plan: plan || null,
2881
- written_at: Date.now(),
2979
+ written_at: clock.now(),
2882
2980
  };
2981
+ let tmpPath = null;
2883
2982
  try {
2884
- nodeFs.mkdirSync(sentinelDir, { recursive: true });
2885
- // Never write through a project-supplied .gsd symlink. Resolve the
2886
- // directory as well so an ancestor redirect cannot escape this project.
2887
- if (nodeFs.lstatSync(sentinelDir).isSymbolicLink() ||
2888
- nodePath.relative(nodeFs.realpathSync(cwd), nodeFs.realpathSync(sentinelDir)) !== '.gsd') {
2889
- throw new Error('sentinel directory is a symlink or resolves outside the project');
2890
- }
2891
- // User projects do not inherit gsd-core's root .gitignore. Ignore this
2892
- // tool-owned directory locally before writing the first sentinel. An
2893
- // ordinary ignore-file IO failure must not prevent recording isolation.
2894
- const ignorePath = nodePath.join(sentinelDir, '.gitignore');
2983
+ // #5757: the shared tool-owned .gsd owner refuses a project-supplied
2984
+ // symlink (or ancestor redirect) and seeds/extends .gsd/.gitignore, so
2985
+ // this writer and the fork-probe cache apply one policy. An ordinary
2986
+ // ignore-file IO failure must not prevent recording isolation.
2987
+ const toolDir = require('./lib/gsd-tool-dir.cjs');
2895
2988
  try {
2896
- nodeFs.writeFileSync(ignorePath, '*\n', { flag: 'wx' });
2897
- } catch (ignoreError) {
2898
- if (ignoreError.code === 'EEXIST') {
2899
- // Keep a pre-existing ignore file. A bare '*' already ignores the
2900
- // whole directory, including this file and the sentinel temp file.
2901
- // Read through an fd opened without following links where supported;
2902
- // replace by rename rather than append through the path, so a link
2903
- // swapped in after the check cannot redirect a write elsewhere.
2904
- let fd;
2905
- let ignoreTmpPath;
2906
- try {
2907
- if (nodeFs.lstatSync(ignorePath).isSymbolicLink()) {
2908
- throw new Error('sentinel ignore file is a symlink');
2909
- }
2910
- fd = nodeFs.openSync(ignorePath, nodeFs.constants.O_RDONLY | (nodeFs.constants.O_NOFOLLOW || 0));
2911
- if (!nodeFs.fstatSync(fd).isFile()) throw new Error('sentinel ignore file is not a regular file');
2912
- const current = nodeFs.readFileSync(fd, 'utf8');
2913
- nodeFs.closeSync(fd);
2914
- fd = undefined;
2915
- if (!current.includes('# gsd-core dispatch sentinel') && !/^\*\r?$/m.test(current)) {
2916
- const additionalRules = `${current.endsWith('\n') ? '' : '\n'}# gsd-core dispatch sentinel\n` +
2917
- '/dispatch-isolation-sentinel.json\n' +
2918
- '/dispatch-isolation-sentinel.json.tmp-*\n' +
2919
- '/.gitignore\n';
2920
- ignoreTmpPath = `${ignorePath}.tmp-${process.pid}-${require('crypto').randomBytes(6).toString('hex')}`;
2921
- nodeFs.writeFileSync(ignoreTmpPath, current + additionalRules, { flag: 'wx' });
2922
- if (nodeFs.lstatSync(ignorePath).isSymbolicLink()) {
2923
- throw new Error('sentinel ignore file is a symlink');
2924
- }
2925
- nodeFs.renameSync(ignoreTmpPath, ignorePath);
2926
- ignoreTmpPath = null;
2927
- }
2928
- } catch (existingIgnoreError) {
2929
- if (existingIgnoreError.code === 'ELOOP' || existingIgnoreError.message === 'sentinel ignore file is a symlink') {
2930
- throw new Error('sentinel ignore file is a symlink');
2931
- }
2932
- // Ignore seeding is best-effort; the sentinel is still useful if
2933
- // this path cannot be read or updated.
2934
- } finally {
2935
- if (fd !== undefined) nodeFs.closeSync(fd);
2936
- if (ignoreTmpPath) {
2937
- try { nodeFs.unlinkSync(ignoreTmpPath); } catch (_) { /* best-effort cleanup */ }
2938
- }
2939
- }
2989
+ toolDir.ensureToolOwnedGsdDir(cwd, {
2990
+ marker: '# gsd-core dispatch sentinel',
2991
+ rules: [
2992
+ '/dispatch-isolation-sentinel.json',
2993
+ '/dispatch-isolation-sentinel.json.tmp-*',
2994
+ '/dispatch-isolation/',
2995
+ '/.gitignore',
2996
+ ],
2997
+ });
2998
+ } catch (ownerError) {
2999
+ // The recorded `error` text is observable JSON (hyrums-law): keep the
3000
+ // sentinel's historical wording, translating by the owner's stable code.
3001
+ if (ownerError && ownerError.code === toolDir.GSD_DIR_UNSAFE) {
3002
+ throw new Error('sentinel directory is a symlink or resolves outside the project');
2940
3003
  }
3004
+ if (ownerError && ownerError.code === toolDir.GSD_IGNORE_SYMLINK) {
3005
+ throw new Error('sentinel ignore file is a symlink');
3006
+ }
3007
+ throw ownerError;
3008
+ }
3009
+ // Atomic write: exclusive random temp file + rename, so a concurrent
3010
+ // reader (a guard hook firing mid-write) never observes a
3011
+ // partially-written sentinel, concurrent invocations sharing the same
3012
+ // sentinelDir never collide on the temp name, and a planted link at a
3013
+ // guessed temp name is never written through.
3014
+ // `tmpPath` is assigned only once the exclusive create succeeded, so an
3015
+ // EEXIST never unlinks an entry this process did not create.
3016
+ const candidateTmp = `${sentinelPath}.tmp-${process.pid}-${require('crypto').randomBytes(6).toString('hex')}`;
3017
+ const tmpFd = nodeFs.openSync(candidateTmp, 'wx');
3018
+ tmpPath = candidateTmp;
3019
+ try {
3020
+ nodeFs.writeFileSync(tmpFd, JSON.stringify(payload));
3021
+ } finally {
3022
+ nodeFs.closeSync(tmpFd);
2941
3023
  }
2942
- // Atomic write: unique temp file + rename, so a concurrent reader (a
2943
- // guard hook firing mid-write) never observes a partially-written
2944
- // sentinel. Unique per-process+time so concurrent orchestrator-worktree
2945
- // invocations sharing the same sentinelDir never collide on the temp name.
2946
- const tmpPath = `${sentinelPath}.tmp-${process.pid}-${Date.now()}`;
2947
- nodeFs.writeFileSync(tmpPath, JSON.stringify(payload));
2948
- nodeFs.renameSync(tmpPath, sentinelPath);
3024
+ require('./lib/shell-command-projection.cjs').retryRenameSync(tmpPath, sentinelPath);
3025
+ tmpPath = null;
3026
+ writeKeyedDispatchIsolationRecord(cwd, sentinelDir, payload, clock);
2949
3027
  return { recorded: true, path: '.gsd/dispatch-isolation-sentinel.json' };
2950
3028
  } catch (err) {
2951
3029
  return { recorded: false, path: '.gsd/dispatch-isolation-sentinel.json', error: err && err.message };
3030
+ } finally {
3031
+ if (tmpPath) {
3032
+ try { nodeFs.unlinkSync(tmpPath); } catch (_) { /* best-effort cleanup */ }
3033
+ }
2952
3034
  }
2953
3035
  }
2954
3036
 
@@ -4602,9 +4684,9 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4602
4684
 
4603
4685
  if (subcommand === 'phase-status') {
4604
4686
  // #1956: deterministic STATE.md-vs-ROADMAP.md phase-status drift.
4605
- const { planningDir } = require('./lib/planning-workspace.cjs');
4687
+ const { planningDir, resolvePhaseIdConvention } = require('./lib/planning-workspace.cjs');
4606
4688
  const { stateExtractField, stateCurrentPositionSlice } = require('./lib/state-document.cjs');
4607
- const { findRoadmapProgressTable } = require('./lib/roadmap-parser.cjs');
4689
+ const { locateRoadmapProgress } = require('./lib/roadmap-progress.cjs');
4608
4690
  const { phaseKeyFromProse } = require('./lib/phase-id.cjs');
4609
4691
  // STATE.md's YAML frontmatter carries its own lowercase `status:`
4610
4692
  // scalar ahead of the body's `## Current Position` prose "Status:"
@@ -4620,6 +4702,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4620
4702
  : undefined;
4621
4703
 
4622
4704
  const dir = planningDir(cwd);
4705
+ const phaseConvention = resolvePhaseIdConvention(cwd);
4706
+ const proseKey = (value) => phaseKeyFromProse(value, undefined, phaseConvention);
4623
4707
  const statePath = path.join(dir, 'STATE.md');
4624
4708
  const roadmapPath = path.join(dir, 'ROADMAP.md');
4625
4709
 
@@ -4630,7 +4714,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4630
4714
  // missing_state below
4631
4715
  }
4632
4716
  if (stateContent === null) {
4633
- const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
4717
+ const phase = phaseArg !== undefined ? proseKey(phaseArg) : null;
4634
4718
  output({
4635
4719
  verdict: 'uncheckable',
4636
4720
  reason: 'missing_state',
@@ -4670,7 +4754,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4670
4754
  // document.
4671
4755
  const currentPositionBody = stateCurrentPositionSlice(stateBody);
4672
4756
  if (currentPositionBody === null) {
4673
- const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
4757
+ const phase = phaseArg !== undefined ? proseKey(phaseArg) : null;
4674
4758
  output({
4675
4759
  verdict: 'uncheckable',
4676
4760
  reason: 'no_current_position',
@@ -4685,8 +4769,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4685
4769
  // Resolve the target phase: --phase if given, else whatever
4686
4770
  // STATE.md's Current Position reports as current.
4687
4771
  const phase = phaseArg !== undefined
4688
- ? phaseKeyFromProse(phaseArg)
4689
- : phaseKeyFromProse(stateExtractField(currentPositionBody, 'Phase'));
4772
+ ? proseKey(phaseArg)
4773
+ : proseKey(stateExtractField(currentPositionBody, 'Phase'));
4690
4774
 
4691
4775
  if (roadmapContent === null) {
4692
4776
  output({
@@ -4702,11 +4786,11 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4702
4786
 
4703
4787
  const stateStatus = stateExtractField(currentPositionBody, 'Status');
4704
4788
 
4705
- // #1956/#2012: scoped to `## Progress` first (decoy-avoidance) —
4706
- // see findRoadmapProgressTable's doc comment (roadmap-parser.cts).
4707
- const table = findRoadmapProgressTable(roadmapContent);
4789
+ // #1956/#2012: scoped to `## Progress` first (decoy-avoidance) — the
4790
+ // Progress owner's rule (roadmap-progress.cts), shared with every reader and writer (#5762).
4791
+ const { table } = locateRoadmapProgress(roadmapContent);
4708
4792
  const matchedRow = table
4709
- ? table.rows.find((row) => phaseKeyFromProse(row.Phase) === phase && phase !== null)
4793
+ ? table.rows.find((row) => proseKey(row.Phase) === phase && phase !== null)
4710
4794
  : undefined;
4711
4795
 
4712
4796
  if (!matchedRow) {
@@ -34,7 +34,7 @@ const consentMod = require('./capability-consent.cjs');
34
34
  const projectRootMod = require('./project-root.cjs');
35
35
  // #1459 finding 4: the SHARED hardened lock primitive (single source of truth for lifecycle + consent).
36
36
  const lockMod = require('./capability-lock.cjs');
37
- const { platformWriteSync, retryRenameSync } = require('./shell-command-projection.cjs');
37
+ const { platformWriteSync, retryRenameSync, shellSingleQuote } = require('./shell-command-projection.cjs');
38
38
  // #1463: numeric major.minor.patch comparison for the outdated check (the SAME compare the resolver
39
39
  // and capability list use). -1 (a<b), 0 (equal), 1 (a>b).
40
40
  const semverMod = require('./semver-compare.cjs');
@@ -308,17 +308,6 @@ function isSafeHookScriptPath(script) {
308
308
  return false;
309
309
  return true;
310
310
  }
311
- /**
312
- * #1460 (R) HIGH: POSIX single-quote an arbitrary string for safe inclusion in a shell command.
313
- * The emitted hook `command` is the ABSOLUTE confined script path, which begins with the
314
- * (non-manifest) install-prefix — commonly a home dir containing spaces/special chars (e.g.
315
- * "/Users/Bob Smith/.claude/..."). Written unquoted it would word-split (and, with a hostile
316
- * prefix, could inject). Wrapping in single quotes — with each embedded `'` escaped as `'\''` —
317
- * makes the whole path a single shell token that no metacharacter inside it can break.
318
- */
319
- function shellSingleQuote(value) {
320
- return "'" + value.replace(/'/g, "'\\''") + "'";
321
- }
322
311
  /**
323
312
  * #1634: build the emitted hook `command` for an ABSOLUTE confined script path. For `.js`-family
324
313
  * hooks (`.js`/`.cjs`/`.mjs`) prefix with `node` so the hook runs regardless of the source's