@opengsd/gsd-core 1.20.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 (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/bin/install.js +36 -40
  4. package/commands/gsd/new-milestone.md +2 -2
  5. package/commands/gsd/new-project.md +2 -2
  6. package/gsd-core/bin/gsd-tools.cjs +104 -70
  7. package/gsd-core/bin/lib/capability-registry.cjs +64 -64
  8. package/gsd-core/bin/lib/commands.cjs +19 -0
  9. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  10. package/gsd-core/bin/lib/init.cjs +7 -7
  11. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  12. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  13. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  14. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  15. package/gsd-core/bin/lib/phase.cjs +64 -15
  16. package/gsd-core/bin/lib/planning-snapshot.cjs +2 -2
  17. package/gsd-core/bin/lib/roadmap-parser.cjs +29 -0
  18. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  19. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  20. package/gsd-core/bin/lib/state-transition.cjs +12 -5
  21. package/gsd-core/bin/lib/state.cjs +2 -2
  22. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  23. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  24. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  25. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  26. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  27. package/hooks/gsd-agent-isolation-guard.js +1 -1
  28. package/hooks/gsd-cursor-subagent-start.js +1 -1
  29. package/hooks/lib/isolation-sentinel.js +77 -2
  30. package/package.json +1 -1
  31. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  32. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  33. package/scripts/changeset/lint.cjs +28 -3
  34. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  35. package/scripts/lib/platform-conformance-tier.generated.cjs +4 -0
  36. package/scripts/merge-commit-pr.cjs +186 -0
  37. package/scripts/release-ancestry.cjs +86 -0
  38. package/scripts/release-pipeline-pr.cjs +92 -0
  39. package/scripts/require-issue-link-policy.cjs +18 -9
  40. package/scripts/sync-next-version.cjs +11 -2
  41. package/skills/gsd-new-milestone/SKILL.md +1 -1
  42. package/skills/gsd-new-project/SKILL.md +1 -1
  43. package/vscode/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "gsd-core",
11
11
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
12
- "version": "1.20.0",
12
+ "version": "1.20.1",
13
13
  "source": "./",
14
14
  "author": {
15
15
  "name": "open-gsd",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gsd-core",
3
3
  "displayName": "GSD Core",
4
- "version": "1.20.0",
4
+ "version": "1.20.1",
5
5
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
6
6
  "author": {
7
7
  "name": "open-gsd",
package/bin/install.js CHANGED
@@ -2659,7 +2659,15 @@ function convertSlashCommandsToTraeSkillMentions(content) {
2659
2659
  });
2660
2660
  }
2661
2661
 
2662
- function convertClaudeToTraeMarkdown(content) {
2662
+ function convertClaudeToTraeMarkdown(content, pathPrefix) {
2663
+ return runtimeArtifactConversion._shieldEmittedPrefix(
2664
+ runtimeArtifactConversion._resolveTraeInstructionFileAtPrefix(content, pathPrefix),
2665
+ pathPrefix,
2666
+ convertClaudeToTraeMarkdownBody,
2667
+ );
2668
+ }
2669
+
2670
+ function convertClaudeToTraeMarkdownBody(content) {
2663
2671
  let converted = convertSlashCommandsToTraeSkillMentions(filterRuntimeNotesForTarget(content, 'trae'));
2664
2672
  converted = converted.replace(/\bBash\(/g, 'Shell(');
2665
2673
  converted = converted.replace(/\bEdit\(/g, 'StrReplace(');
@@ -2719,9 +2727,10 @@ function convertClaudeToTraeMarkdown(content) {
2719
2727
  converted = converted.replace(/\.\/\.claude\//g, './.trae/');
2720
2728
  converted = converted.replace(/\.claude\//g, '.trae/');
2721
2729
  // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
2722
- // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
2723
- converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.trae');
2724
- converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.trae');
2730
+ // Use negative lookahead (?![\w-]|\.\w) to preserve .claude-plugin, .claudeignore and .claude.json.
2731
+ // #5715: an emitted prefix is shielded by convertClaudeToTraeMarkdown; the skills path runs this before the prefix pass, and the profile maps the own-dir forms.
2732
+ converted = converted.replace(/~\/\.claude(?![\w-]|\.\w)/g, '~/.trae');
2733
+ converted = converted.replace(/\$HOME\/\.claude(?![\w-]|\.\w)/g, () => '$HOME/.trae');
2725
2734
  // Environment variable name rewrite
2726
2735
  converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'TRAE_CONFIG_DIR');
2727
2736
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
@@ -2765,8 +2774,8 @@ function convertClaudeCommandToTraeSkill(content, skillName) {
2765
2774
  return `${fm}\n${body}`;
2766
2775
  }
2767
2776
 
2768
- function convertClaudeAgentToTraeAgent(content) {
2769
- let converted = convertClaudeToTraeMarkdown(content);
2777
+ function convertClaudeAgentToTraeAgent(content, _isGlobal, meta) {
2778
+ let converted = convertClaudeToTraeMarkdown(content, meta && meta.pathPrefix);
2770
2779
 
2771
2780
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
2772
2781
  if (!frontmatter) return converted;
@@ -7279,14 +7288,16 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
7279
7288
  content = composeWorkflow(content, { sourcePath: agentTomlSourcePath });
7280
7289
  // Replace full .claude/gsd-core prefix so path resolves to the Codex
7281
7290
  // GSD install before generic .claude → .codex conversion rewrites it.
7282
- content = content.replace(/~\/\.claude\/gsd-core\//g, codexGsdPath);
7283
- content = content.replace(/\$HOME\/\.claude\/gsd-core\//g, codexGsdPath);
7291
+ // #5715: one pass, function replacement: a string replacement would expand `$&` in a
7292
+ // config dir, and a second pass would rescan the path the first emitted.
7293
+ content = content.replace(/(?:~|\$HOME)\/\.claude\/gsd-core\//g, () => codexGsdPath);
7284
7294
  // Route TOML emit through the same full Claude→Codex conversion pipeline
7285
7295
  // used on the `.md` emit path (#2639). Covers: slash-command rewrites,
7286
7296
  // $ARGUMENTS → {{GSD_ARGS}}, /clear removal, anchored and bare .claude/
7287
7297
  // paths, .claudeignore → .codexignore, and standalone "Claude" /
7288
7298
  // CLAUDE.md neutralization via neutralizeAgentReferences(..., 'AGENTS.md').
7289
- content = convertClaudeToCodexMarkdown(content);
7299
+ // #5715: the converter runs after the path above was emitted; shield it from the converter.
7300
+ content = runtimeArtifactConversion._shieldEmittedPrefix(content, codexGsdPath, convertClaudeToCodexMarkdown);
7290
7301
  const { frontmatter } = extractFrontmatterAndBody(content);
7291
7302
  // #3897 security review F1 (blocker, post-merge): this loop used to key
7292
7303
  // the sandbox/hold decision off ONLY the filename stem while the emitted
@@ -7949,7 +7960,9 @@ const RUNTIME_CONTENT_DISPATCH = {
7949
7960
  },
7950
7961
  },
7951
7962
  trae: {
7952
- md: (content) => convertClaudeToTraeMarkdown(content),
7963
+ // #5715: the Trae converter shields (and resolves) the emitted prefix itself.
7964
+ mdShieldsPrefix: true,
7965
+ md: (content, ctx) => convertClaudeToTraeMarkdown(content, ctx && ctx.pathPrefix),
7953
7966
  js: (content) => {
7954
7967
  content = content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
7955
7968
  return `/gsd-${commandName}`;
@@ -8152,33 +8165,8 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
8152
8165
  // balanced masking grammar used by this path and the rewrite engine.
8153
8166
  const rewriteGenericPaths = (body) => {
8154
8167
  content = body;
8155
- const globalClaudeRegex = /~\/\.claude\//g;
8156
- const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
8157
- const localClaudeRegex = /\.\/\.claude\//g;
8158
- content = content.replace(globalClaudeRegex, pathPrefix);
8159
- content = content.replace(globalClaudeHomeRegex, pathPrefix);
8160
- content = content.replace(localClaudeRegex, `./${dirName}/`);
8161
- // #3544 review (Finding 1 fallout): guarded with the SAME
8162
- // negative-lookahead convention already used at ~:2859-2860 below
8163
- // ("preserve .claude-plugin and .claudeignore"). A naive `\b` here
8164
- // is satisfied by ANY non-word character, including '-' — so for a
8165
- // --config-dir whose name EXTENDS '.claude' (e.g. '.claude-work',
8166
- // pathPrefix '$HOME/.claude-work/'), this pass re-matched the
8167
- // '$HOME/.claude' PREFIX of its own slash-form output (lines above)
8168
- // and re-appended the full prefix, corrupting every emitted path to
8169
- // '$HOME/.claude-work-work/...'. Harmless no-op for the literal
8170
- // default '.claude' (self-replace with an identical string), which
8171
- // is why this went undetected until a non-default config-dir name
8172
- // was exercised.
8173
- content = content.replace(/~\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
8174
- content = content.replace(/\$HOME\/\.claude(?![\w-])/g, pathPrefix.replace(/\/$/, ''));
8175
- content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
8176
- content = content.replace(/~\/\.qwen\//g, pathPrefix);
8177
- content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
8178
- content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`);
8179
- content = content.replace(/~\/\.hermes\//g, pathPrefix);
8180
- content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix);
8181
- content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`);
8168
+ // #5715: every generic source form in ONE pass (no rescan of the emitted prefix).
8169
+ content = runtimeArtifactConversion._rewriteSpecTreeConfigDirForms(content, pathPrefix, dirName);
8182
8170
  // #3544: restore @-file-reference lines to the tilde form Claude Code
8183
8171
  // actually expands — the SAME correction #3133 already applies to
8184
8172
  // skill/command bodies via _applyRuntimeRewrites's 'claude' case (see
@@ -8215,7 +8203,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
8215
8203
  content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
8216
8204
 
8217
8205
  // Apply per-runtime .md converter (if any)
8218
- if (dispatch.md) content = dispatch.md(content, ctx);
8206
+ // #5715: the generic pass above emitted `pathPrefix`; shield it from the converter
8207
+ // (a converter that skips the generic pass emitted nothing to shield).
8208
+ if (dispatch.md) {
8209
+ content = runtimeArtifactConversion._shieldEmittedPrefix(
8210
+ content,
8211
+ dispatch.mdSkipGenericRewrite || dispatch.mdShieldsPrefix ? undefined : pathPrefix,
8212
+ (shielded) => dispatch.md(shielded, ctx),
8213
+ );
8214
+ }
8219
8215
 
8220
8216
  // Re-run attribution after converter for runtimes that need it (copilot, antigravity)
8221
8217
  if (dispatch.mdReattributeAfter) content = processAttribution(content, getCommitAttribution(runtime));
@@ -10087,8 +10083,8 @@ const MANIFEST_NAME = 'gsd-file-manifest.json';
10087
10083
  * Compute SHA256 hash of file contents
10088
10084
  */
10089
10085
  function fileHash(filePath) {
10090
- const content = fs.readFileSync(filePath);
10091
- return crypto.createHash('sha256').update(content).digest('hex');
10086
+ // #5683: single owner of the per-file hash, shared with `effort sync`.
10087
+ return require('../gsd-core/bin/lib/install-manifest-record.cjs').fileHash(filePath);
10092
10088
  }
10093
10089
 
10094
10090
  /**
@@ -9,7 +9,7 @@ allowed-tools:
9
9
  - Grep
10
10
  - Agent
11
11
  - AskUserQuestion
12
- requires: [new-project, phase, plan-phase]
12
+ requires: [new-project, phase, plan-phase, discuss-phase, execute-phase]
13
13
  ---
14
14
 
15
15
  <arguments>$ARGUMENTS</arguments>
@@ -28,7 +28,7 @@ Brownfield equivalent of new-project. Project exists, PROJECT.md has history. Ga
28
28
  - `.planning/ROADMAP.md` — phase structure (continues numbering)
29
29
  - `.planning/STATE.md` — reset for new milestone
30
30
 
31
- **After:** `/gsd:plan-phase [N]` to start execution.
31
+ **After:** Run `/gsd:discuss-phase [N]` to clarify the approach (or `/gsd:plan-phase [N]` to plan directly); then `/gsd:execute-phase [N]` to execute.
32
32
  </objective>
33
33
 
34
34
  <execution_context>
@@ -9,7 +9,7 @@ allowed-tools:
9
9
  - Write
10
10
  - Agent
11
11
  - AskUserQuestion
12
- requires: [config, phase, plan-phase]
12
+ requires: [config, phase, plan-phase, discuss-phase, execute-phase]
13
13
  ---
14
14
 
15
15
  <arguments>$ARGUMENTS</arguments>
@@ -36,7 +36,7 @@ Initialize a new project through unified flow: questioning → research (optiona
36
36
  - `.planning/ROADMAP.md` — phase structure
37
37
  - `.planning/STATE.md` — project memory
38
38
 
39
- **After this command:** Run `/gsd:plan-phase 1` to start execution.
39
+ **After this command:** Run `/gsd:discuss-phase 1` to clarify the approach (or `/gsd:plan-phase 1` to plan directly); then `/gsd:execute-phase 1` to execute.
40
40
  </objective>
41
41
 
42
42
  <execution_context>
@@ -2908,6 +2908,54 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2908
2908
  }
2909
2909
  }
2910
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
+
2911
2959
  /**
2912
2960
  * Atomically persist the resolved dispatch-isolation decision to the
2913
2961
  * run-scoped sentinel both isolation guard hooks read
@@ -2918,7 +2966,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2918
2966
  * per-plan degrade call site and back-compat/tests) share exactly one
2919
2967
  * write implementation. Never throws — returns `{ recorded, path, error? }`.
2920
2968
  */
2921
- 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 } = {}) {
2922
2970
  const nodePath = require('path');
2923
2971
  const nodeFs = require('fs');
2924
2972
  const sentinelDir = nodePath.join(cwd, '.gsd');
@@ -2928,77 +2976,61 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2928
2976
  harness_flag: harnessFlag || null,
2929
2977
  phase: phase || null,
2930
2978
  plan: plan || null,
2931
- written_at: Date.now(),
2979
+ written_at: clock.now(),
2932
2980
  };
2981
+ let tmpPath = null;
2933
2982
  try {
2934
- nodeFs.mkdirSync(sentinelDir, { recursive: true });
2935
- // Never write through a project-supplied .gsd symlink. Resolve the
2936
- // directory as well so an ancestor redirect cannot escape this project.
2937
- if (nodeFs.lstatSync(sentinelDir).isSymbolicLink() ||
2938
- nodePath.relative(nodeFs.realpathSync(cwd), nodeFs.realpathSync(sentinelDir)) !== '.gsd') {
2939
- throw new Error('sentinel directory is a symlink or resolves outside the project');
2940
- }
2941
- // User projects do not inherit gsd-core's root .gitignore. Ignore this
2942
- // tool-owned directory locally before writing the first sentinel. An
2943
- // ordinary ignore-file IO failure must not prevent recording isolation.
2944
- 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');
2945
2988
  try {
2946
- nodeFs.writeFileSync(ignorePath, '*\n', { flag: 'wx' });
2947
- } catch (ignoreError) {
2948
- if (ignoreError.code === 'EEXIST') {
2949
- // Keep a pre-existing ignore file. A bare '*' already ignores the
2950
- // whole directory, including this file and the sentinel temp file.
2951
- // Read through an fd opened without following links where supported;
2952
- // replace by rename rather than append through the path, so a link
2953
- // swapped in after the check cannot redirect a write elsewhere.
2954
- let fd;
2955
- let ignoreTmpPath;
2956
- try {
2957
- if (nodeFs.lstatSync(ignorePath).isSymbolicLink()) {
2958
- throw new Error('sentinel ignore file is a symlink');
2959
- }
2960
- fd = nodeFs.openSync(ignorePath, nodeFs.constants.O_RDONLY | (nodeFs.constants.O_NOFOLLOW || 0));
2961
- if (!nodeFs.fstatSync(fd).isFile()) throw new Error('sentinel ignore file is not a regular file');
2962
- const current = nodeFs.readFileSync(fd, 'utf8');
2963
- nodeFs.closeSync(fd);
2964
- fd = undefined;
2965
- if (!current.includes('# gsd-core dispatch sentinel') && !/^\*\r?$/m.test(current)) {
2966
- const additionalRules = `${current.endsWith('\n') ? '' : '\n'}# gsd-core dispatch sentinel\n` +
2967
- '/dispatch-isolation-sentinel.json\n' +
2968
- '/dispatch-isolation-sentinel.json.tmp-*\n' +
2969
- '/.gitignore\n';
2970
- ignoreTmpPath = `${ignorePath}.tmp-${process.pid}-${require('crypto').randomBytes(6).toString('hex')}`;
2971
- nodeFs.writeFileSync(ignoreTmpPath, current + additionalRules, { flag: 'wx' });
2972
- if (nodeFs.lstatSync(ignorePath).isSymbolicLink()) {
2973
- throw new Error('sentinel ignore file is a symlink');
2974
- }
2975
- nodeFs.renameSync(ignoreTmpPath, ignorePath);
2976
- ignoreTmpPath = null;
2977
- }
2978
- } catch (existingIgnoreError) {
2979
- if (existingIgnoreError.code === 'ELOOP' || existingIgnoreError.message === 'sentinel ignore file is a symlink') {
2980
- throw new Error('sentinel ignore file is a symlink');
2981
- }
2982
- // Ignore seeding is best-effort; the sentinel is still useful if
2983
- // this path cannot be read or updated.
2984
- } finally {
2985
- if (fd !== undefined) nodeFs.closeSync(fd);
2986
- if (ignoreTmpPath) {
2987
- try { nodeFs.unlinkSync(ignoreTmpPath); } catch (_) { /* best-effort cleanup */ }
2988
- }
2989
- }
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');
3003
+ }
3004
+ if (ownerError && ownerError.code === toolDir.GSD_IGNORE_SYMLINK) {
3005
+ throw new Error('sentinel ignore file is a symlink');
2990
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);
2991
3023
  }
2992
- // Atomic write: unique temp file + rename, so a concurrent reader (a
2993
- // guard hook firing mid-write) never observes a partially-written
2994
- // sentinel. Unique per-process+time so concurrent orchestrator-worktree
2995
- // invocations sharing the same sentinelDir never collide on the temp name.
2996
- const tmpPath = `${sentinelPath}.tmp-${process.pid}-${Date.now()}`;
2997
- nodeFs.writeFileSync(tmpPath, JSON.stringify(payload));
2998
- nodeFs.renameSync(tmpPath, sentinelPath);
3024
+ require('./lib/shell-command-projection.cjs').retryRenameSync(tmpPath, sentinelPath);
3025
+ tmpPath = null;
3026
+ writeKeyedDispatchIsolationRecord(cwd, sentinelDir, payload, clock);
2999
3027
  return { recorded: true, path: '.gsd/dispatch-isolation-sentinel.json' };
3000
3028
  } catch (err) {
3001
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
+ }
3002
3034
  }
3003
3035
  }
3004
3036
 
@@ -4652,7 +4684,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4652
4684
 
4653
4685
  if (subcommand === 'phase-status') {
4654
4686
  // #1956: deterministic STATE.md-vs-ROADMAP.md phase-status drift.
4655
- const { planningDir } = require('./lib/planning-workspace.cjs');
4687
+ const { planningDir, resolvePhaseIdConvention } = require('./lib/planning-workspace.cjs');
4656
4688
  const { stateExtractField, stateCurrentPositionSlice } = require('./lib/state-document.cjs');
4657
4689
  const { locateRoadmapProgress } = require('./lib/roadmap-progress.cjs');
4658
4690
  const { phaseKeyFromProse } = require('./lib/phase-id.cjs');
@@ -4670,6 +4702,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4670
4702
  : undefined;
4671
4703
 
4672
4704
  const dir = planningDir(cwd);
4705
+ const phaseConvention = resolvePhaseIdConvention(cwd);
4706
+ const proseKey = (value) => phaseKeyFromProse(value, undefined, phaseConvention);
4673
4707
  const statePath = path.join(dir, 'STATE.md');
4674
4708
  const roadmapPath = path.join(dir, 'ROADMAP.md');
4675
4709
 
@@ -4680,7 +4714,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4680
4714
  // missing_state below
4681
4715
  }
4682
4716
  if (stateContent === null) {
4683
- const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
4717
+ const phase = phaseArg !== undefined ? proseKey(phaseArg) : null;
4684
4718
  output({
4685
4719
  verdict: 'uncheckable',
4686
4720
  reason: 'missing_state',
@@ -4720,7 +4754,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4720
4754
  // document.
4721
4755
  const currentPositionBody = stateCurrentPositionSlice(stateBody);
4722
4756
  if (currentPositionBody === null) {
4723
- const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
4757
+ const phase = phaseArg !== undefined ? proseKey(phaseArg) : null;
4724
4758
  output({
4725
4759
  verdict: 'uncheckable',
4726
4760
  reason: 'no_current_position',
@@ -4735,8 +4769,8 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4735
4769
  // Resolve the target phase: --phase if given, else whatever
4736
4770
  // STATE.md's Current Position reports as current.
4737
4771
  const phase = phaseArg !== undefined
4738
- ? phaseKeyFromProse(phaseArg)
4739
- : phaseKeyFromProse(stateExtractField(currentPositionBody, 'Phase'));
4772
+ ? proseKey(phaseArg)
4773
+ : proseKey(stateExtractField(currentPositionBody, 'Phase'));
4740
4774
 
4741
4775
  if (roadmapContent === null) {
4742
4776
  output({
@@ -4756,7 +4790,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
4756
4790
  // Progress owner's rule (roadmap-progress.cts), shared with every reader and writer (#5762).
4757
4791
  const { table } = locateRoadmapProgress(roadmapContent);
4758
4792
  const matchedRow = table
4759
- ? table.rows.find((row) => phaseKeyFromProse(row.Phase) === phase && phase !== null)
4793
+ ? table.rows.find((row) => proseKey(row.Phase) === phase && phase !== null)
4760
4794
  : undefined;
4761
4795
 
4762
4796
  if (!matchedRow) {