@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +46 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +181 -68
- 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/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +627 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3039 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- 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/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- 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/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- 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 +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- 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,
|
|
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
|
|
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).
|
|
1921
|
-
//
|
|
1922
|
-
const
|
|
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.
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
:
|
|
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
|
|
2182
|
-
// channel this lane actually reads from (stdin, argv, an argv-embedded file
|
|
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:
|
|
2979
|
+
written_at: clock.now(),
|
|
2882
2980
|
};
|
|
2981
|
+
let tmpPath = null;
|
|
2883
2982
|
try {
|
|
2884
|
-
|
|
2885
|
-
//
|
|
2886
|
-
//
|
|
2887
|
-
|
|
2888
|
-
|
|
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
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
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
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
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 {
|
|
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 ?
|
|
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 ?
|
|
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
|
-
?
|
|
4689
|
-
:
|
|
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
|
-
//
|
|
4707
|
-
const table =
|
|
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) =>
|
|
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
|