@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/gsd-core/bin/gsd-tools.cjs +104 -70
- package/gsd-core/bin/lib/capability-registry.cjs +64 -64
- package/gsd-core/bin/lib/commands.cjs +19 -0
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/init.cjs +7 -7
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/phase-id.cjs +26 -9
- package/gsd-core/bin/lib/phase.cjs +64 -15
- package/gsd-core/bin/lib/planning-snapshot.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +29 -0
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/state-transition.cjs +12 -5
- package/gsd-core/bin/lib/state.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +1 -1
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +4 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +11 -2
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/vscode/package.json +1 -1
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 .
|
|
2723
|
-
|
|
2724
|
-
converted = converted.replace(
|
|
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
|
-
|
|
7283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10091
|
-
return
|
|
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
|
|
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
|
|
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:
|
|
2979
|
+
written_at: clock.now(),
|
|
2932
2980
|
};
|
|
2981
|
+
let tmpPath = null;
|
|
2933
2982
|
try {
|
|
2934
|
-
|
|
2935
|
-
//
|
|
2936
|
-
//
|
|
2937
|
-
|
|
2938
|
-
|
|
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
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
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
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
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 ?
|
|
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 ?
|
|
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
|
-
?
|
|
4739
|
-
:
|
|
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) =>
|
|
4793
|
+
? table.rows.find((row) => proseKey(row.Phase) === phase && phase !== null)
|
|
4760
4794
|
: undefined;
|
|
4761
4795
|
|
|
4762
4796
|
if (!matchedRow) {
|