@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
|
@@ -42,7 +42,7 @@ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
|
|
|
42
42
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
43
43
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
44
44
|
const phaseIdMod = require("./phase-id.cjs");
|
|
45
|
-
const { comparePhaseNum } = phaseIdMod;
|
|
45
|
+
const { comparePhaseNum, parsePhaseFromProse } = phaseIdMod;
|
|
46
46
|
// #4129: the completion-ratio kernel for the resync-arm ratchet's percent
|
|
47
47
|
// (planning-scope's SCOPE — state-document's own dependency, no cycle here:
|
|
48
48
|
// state-document never imports this module).
|
|
@@ -1724,8 +1724,10 @@ function nextPhaseWording(disk) {
|
|
|
1724
1724
|
* position; the caller combines this with "the phase is already complete in
|
|
1725
1725
|
* ROADMAP.md" so a first-time out-of-order completion (#2028) still
|
|
1726
1726
|
* transitions. An unparseable or absent current phase is "not past".
|
|
1727
|
+
* #5676: under `milestone-prefixed` the id is the whole `M-NN` (`3-02`, not `3`); that read goes
|
|
1728
|
+
* through `parsePhaseFromProse` (the #5626 grammar owner), so the two cannot drift.
|
|
1727
1729
|
*/
|
|
1728
|
-
function isStatePastPhase(content, phaseNum, sourcePath) {
|
|
1730
|
+
function isStatePastPhase(content, phaseNum, sourcePath, convention) {
|
|
1729
1731
|
const fm = extractFrontmatter(content, sourcePath);
|
|
1730
1732
|
const fromFm = fm['current_phase'];
|
|
1731
1733
|
let raw = typeof fromFm === 'string' || typeof fromFm === 'number' ? String(fromFm) : null;
|
|
@@ -1734,10 +1736,15 @@ function isStatePastPhase(content, phaseNum, sourcePath) {
|
|
|
1734
1736
|
raw = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') || (0, state_document_cjs_1.stateExtractField)(body, 'Phase') || null;
|
|
1735
1737
|
}
|
|
1736
1738
|
// `Phase 4` / `phase 4 of 9` name phase 4 as surely as `4 of 9` does.
|
|
1737
|
-
|
|
1738
|
-
if (
|
|
1739
|
+
let id = null;
|
|
1740
|
+
if (raw !== null) {
|
|
1741
|
+
id = convention === 'milestone-prefixed'
|
|
1742
|
+
? parsePhaseFromProse(raw, 'milestone-prefixed').phase
|
|
1743
|
+
: (/^\s*(?:phase\s+)?(\d+(?:\.\d+)*)/i.exec(raw)?.[1] ?? null);
|
|
1744
|
+
}
|
|
1745
|
+
if (id === null)
|
|
1739
1746
|
return false;
|
|
1740
|
-
return comparePhaseNum(
|
|
1747
|
+
return comparePhaseNum(id, String(phaseNum)) > 0;
|
|
1741
1748
|
}
|
|
1742
1749
|
/**
|
|
1743
1750
|
* Apply a `completePhase` transition to STATE.md content.
|
|
@@ -29,7 +29,7 @@ const configLoaderMod = require("./config-loader.cjs");
|
|
|
29
29
|
const { loadConfig } = configLoaderMod;
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
31
|
const phaseIdMod = require("./phase-id.cjs");
|
|
32
|
-
const { parsePhaseFromProse,
|
|
32
|
+
const { parsePhaseFromProse, phaseNumberTokenSource, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
|
|
33
33
|
// #2761 M3: owns the bracket milestone intro and canonical pad2 spelling.
|
|
34
34
|
bracketMilestoneIntroSrcFor, } = phaseIdMod;
|
|
35
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -6386,7 +6386,7 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
6386
6386
|
if (completion.value.complete)
|
|
6387
6387
|
diskCompletedPhases++;
|
|
6388
6388
|
// Track the highest phase with incomplete plans (or any plans)
|
|
6389
|
-
const phaseMatch = dir.match(new RegExp(`^(${
|
|
6389
|
+
const phaseMatch = dir.match(new RegExp(`^(${phaseNumberTokenSource(syncConvention)})`, 'i'));
|
|
6390
6390
|
if (phaseMatch && plans > 0) {
|
|
6391
6391
|
if (summaries < plans) {
|
|
6392
6392
|
// Incomplete phase — this is likely the current one
|
|
@@ -25,7 +25,7 @@ const { readSubdirectories } = coreUtilsMod;
|
|
|
25
25
|
const planScan = require("./plan-scan.cjs");
|
|
26
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
27
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
28
|
-
const { planningPaths, planningRoot, getActiveWorkstream } = planningWorkspace;
|
|
28
|
+
const { planningPaths, planningRoot, getActiveWorkstream, resolvePhaseIdConvention } = planningWorkspace;
|
|
29
29
|
const state_document_cjs_1 = require("./state-document.cjs");
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
31
31
|
const frontmatterMod = require("./frontmatter.cjs");
|
|
@@ -101,7 +101,7 @@ function countRoadmapPhases(roadmapPath, fallbackCount, cwd, ws, versionOverride
|
|
|
101
101
|
* `phaseKeyFromDir` uses for directories, so a `| 01. … |` row and a `1-slug`
|
|
102
102
|
* directory cannot land in different key spaces (the padding-asymmetry defect).
|
|
103
103
|
*/
|
|
104
|
-
function parseRoadmapProgressRows(content, directoryContext) {
|
|
104
|
+
function parseRoadmapProgressRows(content, directoryContext, convention) {
|
|
105
105
|
// Milestone-ATTRIBUTING shape first. Both shapes carry `Plans Complete`, so
|
|
106
106
|
// probing that column first would pick a flat table appearing earlier in the
|
|
107
107
|
// document over a milestone-grouped one later — every row would come back
|
|
@@ -116,7 +116,7 @@ function parseRoadmapProgressRows(content, directoryContext) {
|
|
|
116
116
|
});
|
|
117
117
|
const rows = [];
|
|
118
118
|
for (const row of tableRows) {
|
|
119
|
-
const key = phaseKeyFromProse(row['Phase'], directoryContext);
|
|
119
|
+
const key = phaseKeyFromProse(row['Phase'], directoryContext, convention);
|
|
120
120
|
if (key === null)
|
|
121
121
|
continue;
|
|
122
122
|
const cell = (row['Milestone'] ?? '').trim();
|
|
@@ -439,7 +439,7 @@ function inspectWorkstream(cwd, name, options = {}) {
|
|
|
439
439
|
// `phaseKeyFromProse`, both from the phase-id owner module) so the two sides
|
|
440
440
|
// of the rollup cannot disagree.
|
|
441
441
|
const currentVersion = readCurrentMilestoneVersion(p.state, p.roadmap);
|
|
442
|
-
const progressRows = parseRoadmapProgressRows(roadmap, directoryContext);
|
|
442
|
+
const progressRows = parseRoadmapProgressRows(roadmap, directoryContext, resolvePhaseIdConvention(cwd, name));
|
|
443
443
|
// Phase keys the ROADMAP attributes to the current milestone. A row whose
|
|
444
444
|
// Milestone cell is blank or malformed is INCLUDED rather than dropped: a
|
|
445
445
|
// phase we cannot attribute must still be visible to the rollup. Dropping it
|
|
@@ -24,8 +24,10 @@ exports.cmdWorktreeSetBaseRef = cmdWorktreeSetBaseRef;
|
|
|
24
24
|
exports.classifyGitHead = classifyGitHead;
|
|
25
25
|
exports.observeHarnessForkFromHead = observeHarnessForkFromHead;
|
|
26
26
|
exports.evaluateWorktreeBaseDegrade = evaluateWorktreeBaseDegrade;
|
|
27
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
27
28
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
28
29
|
const node_path_1 = __importDefault(require("node:path"));
|
|
30
|
+
const gsd_tool_dir_cjs_1 = require("./gsd-tool-dir.cjs");
|
|
29
31
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
30
32
|
const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
|
|
31
33
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
@@ -572,13 +574,41 @@ function observeHarnessForkFromHead(deps) {
|
|
|
572
574
|
}
|
|
573
575
|
});
|
|
574
576
|
const stateWrite = deps.stateWrite ?? ((file, content) => {
|
|
577
|
+
let tmpPath = null;
|
|
575
578
|
try {
|
|
576
|
-
|
|
577
|
-
|
|
579
|
+
// #5757: the shared owner refuses a symlinked/escaping .gsd and keeps the
|
|
580
|
+
// directory git-ignored; the write is an exclusive random tmp + rename so
|
|
581
|
+
// a planted cache-file symlink is replaced, never followed.
|
|
582
|
+
const dir = (0, gsd_tool_dir_cjs_1.ensureToolOwnedGsdDir)(cwd, {
|
|
583
|
+
marker: '# gsd-core harness fork probe',
|
|
584
|
+
rules: ['/harness-fork-probe.json', '/harness-fork-probe.json.tmp-*', '/.gitignore'],
|
|
585
|
+
});
|
|
586
|
+
const target = node_path_1.default.join(dir, node_path_1.default.basename(file));
|
|
587
|
+
// `tmpPath` is assigned only once the exclusive create succeeded, so an
|
|
588
|
+
// EEXIST never unlinks an entry this process did not create.
|
|
589
|
+
const candidateTmp = `${target}.tmp-${process.pid}-${node_crypto_1.default.randomBytes(6).toString('hex')}`;
|
|
590
|
+
const tmpFd = node_fs_1.default.openSync(candidateTmp, 'wx');
|
|
591
|
+
tmpPath = candidateTmp;
|
|
592
|
+
try {
|
|
593
|
+
node_fs_1.default.writeFileSync(tmpFd, content, 'utf8');
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
node_fs_1.default.closeSync(tmpFd);
|
|
597
|
+
}
|
|
598
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, target);
|
|
599
|
+
tmpPath = null;
|
|
578
600
|
}
|
|
579
601
|
catch {
|
|
580
602
|
// A cache write must never break the check that produced the verdict.
|
|
581
603
|
}
|
|
604
|
+
finally {
|
|
605
|
+
if (tmpPath) {
|
|
606
|
+
try {
|
|
607
|
+
node_fs_1.default.unlinkSync(tmpPath);
|
|
608
|
+
}
|
|
609
|
+
catch { /* best-effort cleanup */ }
|
|
610
|
+
}
|
|
611
|
+
}
|
|
582
612
|
});
|
|
583
613
|
// 1. Cache — trusted only while the orchestrator HEAD is unchanged.
|
|
584
614
|
try {
|
|
@@ -422,7 +422,7 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
|
|
|
422
422
|
return { gsdProject: false, isolation: null, harnessFlag: null, error: null, sentinelDiscarded: null };
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
-
const sentinel = readSentinel(cwd, { clock });
|
|
425
|
+
const sentinel = readSentinel(cwd, { clock, dispatchIds });
|
|
426
426
|
// Hoisted so the "sentinel was present/fresh but did not apply" case below
|
|
427
427
|
// (#4594 row 15 — Postel's-Law finding) can distinguish itself from
|
|
428
428
|
// "absent"/"stale" without re-deriving applicability.
|
|
@@ -512,7 +512,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
|
|
|
512
512
|
// Hoisted (readSentinel never throws) so the "present, fresh, but did not
|
|
513
513
|
// apply" case (#4594 row 15) can be reported on every deny path below
|
|
514
514
|
// instead of silently discarded.
|
|
515
|
-
const sentinel = readSentinel(root, { clock });
|
|
515
|
+
const sentinel = readSentinel(root, { clock, dispatchIds });
|
|
516
516
|
const applies = sentinelAppliesToDispatch(sentinel, dispatchIds);
|
|
517
517
|
const sentinelDiscarded = buildSentinelDiscard(sentinel, dispatchIds);
|
|
518
518
|
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
// of the same repo are separate directories on disk, so each gets its own
|
|
30
30
|
// sentinel with no cross-worktree collision, no lock file needed.
|
|
31
31
|
//
|
|
32
|
+
// #5682: the single slot above is last-writer-wins, so two orchestrators sharing one checkout
|
|
33
|
+
// overwrote each other's decision and the guard discarded the foreign record. The writer now ALSO
|
|
34
|
+
// records each plan-scoped decision at `<cwd>/.gsd/dispatch-isolation/<phase>--<plan>.json`
|
|
35
|
+
// (same payload, same cwd-derived location and forgery posture), and `readSentinel` consults the
|
|
36
|
+
// record keyed by the dispatch's own phase/plan first, falling back to the single slot.
|
|
37
|
+
//
|
|
32
38
|
// #3045 SECURITY F3 (LOW, accepted risk — documented, not redesigned for):
|
|
33
39
|
// the sentinel lives at a fixed, predictable, gitignored path inside the
|
|
34
40
|
// PRIMARY checkout. An agent already running inside a legitimately isolated
|
|
@@ -64,6 +70,31 @@ const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'n
|
|
|
64
70
|
|
|
65
71
|
const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.json');
|
|
66
72
|
|
|
73
|
+
// #5682: per-dispatch records, one file per `<phase>--<plan>` under this directory.
|
|
74
|
+
const KEYED_SENTINEL_DIR = path.join('.gsd', 'dispatch-isolation');
|
|
75
|
+
const SAFE_SENTINEL_KEY_RE = /^[A-Za-z0-9._-]{1,100}$/;
|
|
76
|
+
|
|
77
|
+
/** #5682: strict allow-list for a phase/plan string that becomes a path segment. */
|
|
78
|
+
function isSafeSentinelKey(s) {
|
|
79
|
+
return typeof s === 'string' && SAFE_SENTINEL_KEY_RE.test(s) && s !== '.' && s !== '..';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** #5682: keyed record path for a dispatch, or null unless BOTH phase and plan are safe keys. */
|
|
83
|
+
function keyedSentinelPath(root, phase, plan) {
|
|
84
|
+
if (!isSafeSentinelKey(phase) || !isSafeSentinelKey(plan)) return null;
|
|
85
|
+
return path.join(root, KEYED_SENTINEL_DIR, `${phase}--${plan}.json`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* #5682: phase-level keyed record path (`<phase>.json`, written when a decision carries no plan),
|
|
90
|
+
* or null unless the phase is a safe key. Two orchestrators recording plan-less decisions for
|
|
91
|
+
* different phases each keep their own file.
|
|
92
|
+
*/
|
|
93
|
+
function phaseSentinelPath(root, phase) {
|
|
94
|
+
if (!isSafeSentinelKey(phase)) return null;
|
|
95
|
+
return path.join(root, KEYED_SENTINEL_DIR, `${phase}.json`);
|
|
96
|
+
}
|
|
97
|
+
|
|
67
98
|
// #3045 SECURITY F2 fix: how long a written sentinel is trusted as "this
|
|
68
99
|
// dispatch's decision" before a reader falls back to the conservative
|
|
69
100
|
// registry+config check.
|
|
@@ -174,11 +205,51 @@ function resolveSentinelRoot(cwd) {
|
|
|
174
205
|
* per the repo's clock-seam convention, so staleness is testable without
|
|
175
206
|
* asserting on wall-clock time.
|
|
176
207
|
*/
|
|
177
|
-
function readSentinel(cwd, { clock = Date } = {}) {
|
|
208
|
+
function readSentinel(cwd, { clock = Date, dispatchIds = null } = {}) {
|
|
178
209
|
const root = resolveSentinelRoot(cwd);
|
|
210
|
+
|
|
211
|
+
// #5682: candidates are the record keyed by this dispatch's own phase/plan, the phase-level
|
|
212
|
+
// record for its phase, and the single legacy slot. A keyed candidate counts only when it is
|
|
213
|
+
// well-formed and its contents agree with its own filename key; the legacy slot only when it
|
|
214
|
+
// applies to this dispatch. Among FRESH candidates the latest `writtenAt` wins, so a newer
|
|
215
|
+
// phase-level decision is not shadowed by an older plan-scoped one (and vice versa).
|
|
216
|
+
const keyedCandidates = [];
|
|
217
|
+
if (dispatchIds) {
|
|
218
|
+
const planFile = keyedSentinelPath(root, dispatchIds.phase, dispatchIds.plan);
|
|
219
|
+
if (planFile) {
|
|
220
|
+
const c = parseSentinelFile(planFile, clock);
|
|
221
|
+
if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === dispatchIds.plan) keyedCandidates.push(c);
|
|
222
|
+
}
|
|
223
|
+
const phaseFile = phaseSentinelPath(root, dispatchIds.phase);
|
|
224
|
+
if (phaseFile) {
|
|
225
|
+
const c = parseSentinelFile(phaseFile, clock);
|
|
226
|
+
if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === null) keyedCandidates.push(c);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const legacy = parseSentinelFile(sentinelPath(root), clock);
|
|
231
|
+
const legacyApplies = legacy.present && !legacy.malformed && sentinelAppliesToDispatch(legacy, dispatchIds);
|
|
232
|
+
|
|
233
|
+
const fresh = keyedCandidates.filter((c) => !c.stale);
|
|
234
|
+
if (legacyApplies && !legacy.stale) fresh.push(legacy);
|
|
235
|
+
if (fresh.length > 0) return latestWritten(fresh);
|
|
236
|
+
|
|
237
|
+
// Nothing fresh: a stale keyed record still feeds the aged-none path; otherwise the legacy
|
|
238
|
+
// slot is returned exactly as before.
|
|
239
|
+
if (keyedCandidates.length > 0) return latestWritten(keyedCandidates);
|
|
240
|
+
return legacy;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The candidate with the greatest `writtenAt` (ties keep the earlier-listed, i.e. most specific). */
|
|
244
|
+
function latestWritten(candidates) {
|
|
245
|
+
return candidates.reduce((best, c) => (c.writtenAt > best.writtenAt ? c : best));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Parse and validate one sentinel file. Never throws. Shape: see `readSentinel`. */
|
|
249
|
+
function parseSentinelFile(file, clock) {
|
|
179
250
|
let raw;
|
|
180
251
|
try {
|
|
181
|
-
raw = fs.readFileSync(
|
|
252
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
182
253
|
} catch {
|
|
183
254
|
return { present: false };
|
|
184
255
|
}
|
|
@@ -348,6 +419,10 @@ function buildSentinelDiscard(sentinel, dispatchIds) {
|
|
|
348
419
|
module.exports = {
|
|
349
420
|
VALID_ISOLATION,
|
|
350
421
|
SENTINEL_RELATIVE_PATH,
|
|
422
|
+
KEYED_SENTINEL_DIR,
|
|
423
|
+
isSafeSentinelKey,
|
|
424
|
+
keyedSentinelPath,
|
|
425
|
+
phaseSentinelPath,
|
|
351
426
|
SENTINEL_STALE_MS,
|
|
352
427
|
AGED_NONE_MAX_MS,
|
|
353
428
|
sentinelPath,
|
|
@@ -422,7 +422,7 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
|
|
|
422
422
|
return { gsdProject: false, isolation: null, harnessFlag: null, error: null, sentinelDiscarded: null };
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
-
const sentinel = readSentinel(cwd, { clock });
|
|
425
|
+
const sentinel = readSentinel(cwd, { clock, dispatchIds });
|
|
426
426
|
// Hoisted so the "sentinel was present/fresh but did not apply" case below
|
|
427
427
|
// (#4594 row 15 — Postel's-Law finding) can distinguish itself from
|
|
428
428
|
// "absent"/"stale" without re-deriving applicability.
|
|
@@ -512,7 +512,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
|
|
|
512
512
|
// Hoisted (readSentinel never throws) so the "present, fresh, but did not
|
|
513
513
|
// apply" case (#4594 row 15) can be reported on every deny path below
|
|
514
514
|
// instead of silently discarded.
|
|
515
|
-
const sentinel = readSentinel(root, { clock });
|
|
515
|
+
const sentinel = readSentinel(root, { clock, dispatchIds });
|
|
516
516
|
const applies = sentinelAppliesToDispatch(sentinel, dispatchIds);
|
|
517
517
|
const sentinelDiscarded = buildSentinelDiscard(sentinel, dispatchIds);
|
|
518
518
|
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
// of the same repo are separate directories on disk, so each gets its own
|
|
30
30
|
// sentinel with no cross-worktree collision, no lock file needed.
|
|
31
31
|
//
|
|
32
|
+
// #5682: the single slot above is last-writer-wins, so two orchestrators sharing one checkout
|
|
33
|
+
// overwrote each other's decision and the guard discarded the foreign record. The writer now ALSO
|
|
34
|
+
// records each plan-scoped decision at `<cwd>/.gsd/dispatch-isolation/<phase>--<plan>.json`
|
|
35
|
+
// (same payload, same cwd-derived location and forgery posture), and `readSentinel` consults the
|
|
36
|
+
// record keyed by the dispatch's own phase/plan first, falling back to the single slot.
|
|
37
|
+
//
|
|
32
38
|
// #3045 SECURITY F3 (LOW, accepted risk — documented, not redesigned for):
|
|
33
39
|
// the sentinel lives at a fixed, predictable, gitignored path inside the
|
|
34
40
|
// PRIMARY checkout. An agent already running inside a legitimately isolated
|
|
@@ -64,6 +70,31 @@ const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'n
|
|
|
64
70
|
|
|
65
71
|
const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.json');
|
|
66
72
|
|
|
73
|
+
// #5682: per-dispatch records, one file per `<phase>--<plan>` under this directory.
|
|
74
|
+
const KEYED_SENTINEL_DIR = path.join('.gsd', 'dispatch-isolation');
|
|
75
|
+
const SAFE_SENTINEL_KEY_RE = /^[A-Za-z0-9._-]{1,100}$/;
|
|
76
|
+
|
|
77
|
+
/** #5682: strict allow-list for a phase/plan string that becomes a path segment. */
|
|
78
|
+
function isSafeSentinelKey(s) {
|
|
79
|
+
return typeof s === 'string' && SAFE_SENTINEL_KEY_RE.test(s) && s !== '.' && s !== '..';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** #5682: keyed record path for a dispatch, or null unless BOTH phase and plan are safe keys. */
|
|
83
|
+
function keyedSentinelPath(root, phase, plan) {
|
|
84
|
+
if (!isSafeSentinelKey(phase) || !isSafeSentinelKey(plan)) return null;
|
|
85
|
+
return path.join(root, KEYED_SENTINEL_DIR, `${phase}--${plan}.json`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* #5682: phase-level keyed record path (`<phase>.json`, written when a decision carries no plan),
|
|
90
|
+
* or null unless the phase is a safe key. Two orchestrators recording plan-less decisions for
|
|
91
|
+
* different phases each keep their own file.
|
|
92
|
+
*/
|
|
93
|
+
function phaseSentinelPath(root, phase) {
|
|
94
|
+
if (!isSafeSentinelKey(phase)) return null;
|
|
95
|
+
return path.join(root, KEYED_SENTINEL_DIR, `${phase}.json`);
|
|
96
|
+
}
|
|
97
|
+
|
|
67
98
|
// #3045 SECURITY F2 fix: how long a written sentinel is trusted as "this
|
|
68
99
|
// dispatch's decision" before a reader falls back to the conservative
|
|
69
100
|
// registry+config check.
|
|
@@ -174,11 +205,51 @@ function resolveSentinelRoot(cwd) {
|
|
|
174
205
|
* per the repo's clock-seam convention, so staleness is testable without
|
|
175
206
|
* asserting on wall-clock time.
|
|
176
207
|
*/
|
|
177
|
-
function readSentinel(cwd, { clock = Date } = {}) {
|
|
208
|
+
function readSentinel(cwd, { clock = Date, dispatchIds = null } = {}) {
|
|
178
209
|
const root = resolveSentinelRoot(cwd);
|
|
210
|
+
|
|
211
|
+
// #5682: candidates are the record keyed by this dispatch's own phase/plan, the phase-level
|
|
212
|
+
// record for its phase, and the single legacy slot. A keyed candidate counts only when it is
|
|
213
|
+
// well-formed and its contents agree with its own filename key; the legacy slot only when it
|
|
214
|
+
// applies to this dispatch. Among FRESH candidates the latest `writtenAt` wins, so a newer
|
|
215
|
+
// phase-level decision is not shadowed by an older plan-scoped one (and vice versa).
|
|
216
|
+
const keyedCandidates = [];
|
|
217
|
+
if (dispatchIds) {
|
|
218
|
+
const planFile = keyedSentinelPath(root, dispatchIds.phase, dispatchIds.plan);
|
|
219
|
+
if (planFile) {
|
|
220
|
+
const c = parseSentinelFile(planFile, clock);
|
|
221
|
+
if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === dispatchIds.plan) keyedCandidates.push(c);
|
|
222
|
+
}
|
|
223
|
+
const phaseFile = phaseSentinelPath(root, dispatchIds.phase);
|
|
224
|
+
if (phaseFile) {
|
|
225
|
+
const c = parseSentinelFile(phaseFile, clock);
|
|
226
|
+
if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === null) keyedCandidates.push(c);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const legacy = parseSentinelFile(sentinelPath(root), clock);
|
|
231
|
+
const legacyApplies = legacy.present && !legacy.malformed && sentinelAppliesToDispatch(legacy, dispatchIds);
|
|
232
|
+
|
|
233
|
+
const fresh = keyedCandidates.filter((c) => !c.stale);
|
|
234
|
+
if (legacyApplies && !legacy.stale) fresh.push(legacy);
|
|
235
|
+
if (fresh.length > 0) return latestWritten(fresh);
|
|
236
|
+
|
|
237
|
+
// Nothing fresh: a stale keyed record still feeds the aged-none path; otherwise the legacy
|
|
238
|
+
// slot is returned exactly as before.
|
|
239
|
+
if (keyedCandidates.length > 0) return latestWritten(keyedCandidates);
|
|
240
|
+
return legacy;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The candidate with the greatest `writtenAt` (ties keep the earlier-listed, i.e. most specific). */
|
|
244
|
+
function latestWritten(candidates) {
|
|
245
|
+
return candidates.reduce((best, c) => (c.writtenAt > best.writtenAt ? c : best));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Parse and validate one sentinel file. Never throws. Shape: see `readSentinel`. */
|
|
249
|
+
function parseSentinelFile(file, clock) {
|
|
179
250
|
let raw;
|
|
180
251
|
try {
|
|
181
|
-
raw = fs.readFileSync(
|
|
252
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
182
253
|
} catch {
|
|
183
254
|
return { present: false };
|
|
184
255
|
}
|
|
@@ -348,6 +419,10 @@ function buildSentinelDiscard(sentinel, dispatchIds) {
|
|
|
348
419
|
module.exports = {
|
|
349
420
|
VALID_ISOLATION,
|
|
350
421
|
SENTINEL_RELATIVE_PATH,
|
|
422
|
+
KEYED_SENTINEL_DIR,
|
|
423
|
+
isSafeSentinelKey,
|
|
424
|
+
keyedSentinelPath,
|
|
425
|
+
phaseSentinelPath,
|
|
351
426
|
SENTINEL_STALE_MS,
|
|
352
427
|
AGED_NONE_MAX_MS,
|
|
353
428
|
sentinelPath,
|
package/package.json
CHANGED
|
@@ -26,7 +26,7 @@ Brownfield equivalent of new-project. Project exists, PROJECT.md has history. Ga
|
|
|
26
26
|
- `.planning/ROADMAP.md` — phase structure (continues numbering)
|
|
27
27
|
- `.planning/STATE.md` — reset for new milestone
|
|
28
28
|
|
|
29
|
-
**After:** `/skill:gsd-plan-phase [N]` to
|
|
29
|
+
**After:** Run `/skill:gsd-discuss-phase [N]` to clarify the approach (or `/skill:gsd-plan-phase [N]` to plan directly); then `/skill:gsd-execute-phase [N]` to execute.
|
|
30
30
|
</objective>
|
|
31
31
|
|
|
32
32
|
<execution_context>
|
|
@@ -32,7 +32,7 @@ Initialize a new project through unified flow: questioning → research (optiona
|
|
|
32
32
|
- `.planning/ROADMAP.md` — phase structure
|
|
33
33
|
- `.planning/STATE.md` — project memory
|
|
34
34
|
|
|
35
|
-
**After this command:** Run `/skill:gsd-plan-phase 1` to
|
|
35
|
+
**After this command:** Run `/skill:gsd-discuss-phase 1` to clarify the approach (or `/skill:gsd-plan-phase 1` to plan directly); then `/skill:gsd-execute-phase 1` to execute.
|
|
36
36
|
</objective>
|
|
37
37
|
|
|
38
38
|
<execution_context>
|
|
@@ -20,6 +20,9 @@ const LINT_REASON = Object.freeze({
|
|
|
20
20
|
OK_FRAGMENT_PRESENT: 'ok_fragment_present',
|
|
21
21
|
OK_OPT_OUT_LABEL: 'ok_opt_out_label',
|
|
22
22
|
OK_NO_USER_FACING_CHANGES: 'ok_no_user_facing_changes',
|
|
23
|
+
// #5903: the release pipeline's own PRs consume every fragment into
|
|
24
|
+
// CHANGELOG.md, so they carry none by design.
|
|
25
|
+
OK_RELEASE_PIPELINE: 'ok_release_pipeline',
|
|
23
26
|
FAIL_MISSING_FRAGMENT: 'fail_missing_fragment',
|
|
24
27
|
FAIL_INVALID_FRAGMENT: 'fail_invalid_fragment',
|
|
25
28
|
FAIL_PR_FIELD_DRIFT: 'fail_pr_field_drift',
|
|
@@ -86,13 +89,18 @@ function findPrFieldDrift(prEntries, realPrNumber) {
|
|
|
86
89
|
return drift;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
function evaluateLint({
|
|
92
|
+
function evaluateLint({
|
|
93
|
+
changedFiles, labels, fragmentFailures = [], prFieldDrift = [], releasePipelineKind = null,
|
|
94
|
+
}) {
|
|
90
95
|
if (fragmentFailures.length > 0) {
|
|
91
96
|
return { ok: false, reason: LINT_REASON.FAIL_INVALID_FRAGMENT, failures: fragmentFailures };
|
|
92
97
|
}
|
|
93
98
|
if (prFieldDrift.length > 0) {
|
|
94
99
|
return { ok: false, reason: LINT_REASON.FAIL_PR_FIELD_DRIFT, drift: prFieldDrift };
|
|
95
100
|
}
|
|
101
|
+
if (releasePipelineKind) {
|
|
102
|
+
return { ok: true, reason: LINT_REASON.OK_RELEASE_PIPELINE, releasePipelineKind };
|
|
103
|
+
}
|
|
96
104
|
if (changedFiles.some(isFragment)) {
|
|
97
105
|
return { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT };
|
|
98
106
|
}
|
|
@@ -107,6 +115,7 @@ function evaluateLint({ changedFiles, labels, fragmentFailures = [], prFieldDrif
|
|
|
107
115
|
|
|
108
116
|
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
|
|
109
117
|
const { parseFragment } = require('./parse.cjs');
|
|
118
|
+
const { releasePipelinePrKind } = require('../release-pipeline-pr.cjs');
|
|
110
119
|
|
|
111
120
|
function main() {
|
|
112
121
|
const fs = require('node:fs');
|
|
@@ -119,6 +128,12 @@ function main() {
|
|
|
119
128
|
// `pull_request` in the payload, or no payload at all) — the drift check
|
|
120
129
|
// below is a no-op in that case, it never fails a push run.
|
|
121
130
|
let realPrNumber = null;
|
|
131
|
+
// #5903: head/base refs and whether the head lives in this repository, for
|
|
132
|
+
// the release-pipeline exemption. sameRepo is false when anything is missing
|
|
133
|
+
// (a deleted fork has head.repo === null).
|
|
134
|
+
let headRef;
|
|
135
|
+
let baseRef;
|
|
136
|
+
let sameRepo = false;
|
|
122
137
|
if (eventPath && fs.existsSync(eventPath)) {
|
|
123
138
|
try {
|
|
124
139
|
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
|
@@ -126,6 +141,12 @@ function main() {
|
|
|
126
141
|
if (event.pull_request && Number.isInteger(event.pull_request.number)) {
|
|
127
142
|
realPrNumber = event.pull_request.number;
|
|
128
143
|
}
|
|
144
|
+
headRef = event.pull_request?.head?.ref;
|
|
145
|
+
baseRef = event.pull_request?.base?.ref;
|
|
146
|
+
const headRepo = event.pull_request?.head?.repo?.full_name;
|
|
147
|
+
const thisRepo = process.env.GITHUB_REPOSITORY;
|
|
148
|
+
sameRepo = typeof headRepo === 'string' && typeof thisRepo === 'string'
|
|
149
|
+
&& headRepo !== '' && headRepo.toLowerCase() === thisRepo.toLowerCase();
|
|
129
150
|
} catch { /* fall through */ }
|
|
130
151
|
}
|
|
131
152
|
// #2988: local fallback must match the repo's integration branch (`next`),
|
|
@@ -176,11 +197,15 @@ function main() {
|
|
|
176
197
|
}
|
|
177
198
|
|
|
178
199
|
const prFieldDrift = findPrFieldDrift(prEntries, realPrNumber);
|
|
179
|
-
const
|
|
200
|
+
const releasePipelineKind = releasePipelinePrKind({ headRef, baseRef, sameRepo });
|
|
201
|
+
const verdict = evaluateLint({
|
|
202
|
+
changedFiles, labels, fragmentFailures, prFieldDrift, releasePipelineKind,
|
|
203
|
+
});
|
|
180
204
|
if (process.argv.includes('--json')) {
|
|
181
205
|
process.stdout.write(JSON.stringify({ ...verdict, changedFiles, labels }, null, 2) + '\n');
|
|
182
206
|
} else if (verdict.ok) {
|
|
183
|
-
|
|
207
|
+
const kind = verdict.releasePipelineKind ? ` (${verdict.releasePipelineKind})` : '';
|
|
208
|
+
process.stdout.write(`ok changeset-lint: ${verdict.reason}${kind}\n`);
|
|
184
209
|
} else if (verdict.reason === LINT_REASON.FAIL_INVALID_FRAGMENT) {
|
|
185
210
|
process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`);
|
|
186
211
|
process.stderr.write(`The following .changeset fragment(s) failed content validation:\n`);
|
|
@@ -34,6 +34,7 @@ module.exports = {
|
|
|
34
34
|
"tests/capability-source.test.cjs",
|
|
35
35
|
"tests/capability-state.test.cjs",
|
|
36
36
|
"tests/capability-trust.test.cjs",
|
|
37
|
+
"tests/changeset-lint.test.cjs",
|
|
37
38
|
"tests/changeset-parse.test.cjs",
|
|
38
39
|
"tests/check-contract-drift.test.cjs",
|
|
39
40
|
"tests/check-predicate.test.cjs",
|
|
@@ -105,6 +106,7 @@ module.exports = {
|
|
|
105
106
|
"tests/gsd-secret-read-guard.test.cjs",
|
|
106
107
|
"tests/gsd-settings-advanced.test.cjs",
|
|
107
108
|
"tests/gsd-statusline.test.cjs",
|
|
109
|
+
"tests/gsd-tool-dir.platform.test.cjs",
|
|
108
110
|
"tests/gsd-tools-path-refs.test.cjs",
|
|
109
111
|
"tests/gsd-validate-commit-crash-policy.test.cjs",
|
|
110
112
|
"tests/gsd-validate-commit-sigpipe.test.cjs",
|
|
@@ -142,6 +144,7 @@ module.exports = {
|
|
|
142
144
|
"tests/markdown-sectionizer-xml-tags.property.test.cjs",
|
|
143
145
|
"tests/markdown-sectionizer.test.cjs",
|
|
144
146
|
"tests/mcp-catalog.test.cjs",
|
|
147
|
+
"tests/merge-commit-pr.test.cjs",
|
|
145
148
|
"tests/milestone-archive.test.cjs",
|
|
146
149
|
"tests/milestone-release-tags.test.cjs",
|
|
147
150
|
"tests/milestone-window-single-owner.test.cjs",
|
|
@@ -201,6 +204,7 @@ module.exports = {
|
|
|
201
204
|
"tests/quick-research.test.cjs",
|
|
202
205
|
"tests/reapply-verify-hunks.test.cjs",
|
|
203
206
|
"tests/refactor-1390-t3-characterization.test.cjs",
|
|
207
|
+
"tests/release-pipeline-pr.test.cjs",
|
|
204
208
|
"tests/require-issue-link-policy.test.cjs",
|
|
205
209
|
"tests/response-language-coverage.test.cjs",
|
|
206
210
|
"tests/retired-artifact-cleanup.test.cjs",
|
|
@@ -102,6 +102,7 @@ module.exports = {
|
|
|
102
102
|
"tests/effort-local-install.test.cjs",
|
|
103
103
|
"tests/effort-surface-axis.test.cjs",
|
|
104
104
|
"tests/effort-sync-installed-runtime.test.cjs",
|
|
105
|
+
"tests/effort-sync-manifest.test.cjs",
|
|
105
106
|
"tests/emitted-ack-trailer.test.cjs",
|
|
106
107
|
"tests/emitted-attribution.test.cjs",
|
|
107
108
|
"tests/emitted-provenance.test.cjs",
|
|
@@ -153,6 +154,7 @@ module.exports = {
|
|
|
153
154
|
"tests/gsd-mcp-server.test.cjs",
|
|
154
155
|
"tests/gsd-path-export-hook.test.cjs",
|
|
155
156
|
"tests/gsd-statusline.test.cjs",
|
|
157
|
+
"tests/gsd-tool-dir.platform.test.cjs",
|
|
156
158
|
"tests/gsd-validate-commit-crash-policy.test.cjs",
|
|
157
159
|
"tests/gsd-validate-commit-sigpipe.test.cjs",
|
|
158
160
|
"tests/gsd-write-guard.test.cjs",
|
|
@@ -287,6 +289,8 @@ module.exports = {
|
|
|
287
289
|
"tests/quick-review-scope-tip-bound.test.cjs",
|
|
288
290
|
"tests/read-guard.test.cjs",
|
|
289
291
|
"tests/reapply-verify-hunks.test.cjs",
|
|
292
|
+
"tests/release-ancestry.test.cjs",
|
|
293
|
+
"tests/release-backmerge-invariants.test.cjs",
|
|
290
294
|
"tests/repo-layout.test.cjs",
|
|
291
295
|
"tests/representative-corpus.test.cjs",
|
|
292
296
|
"tests/require-fs-op-fallback.rule.test.cjs",
|