@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
@@ -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
- const m = raw === null ? null : /^\s*(?:phase\s+)?(\d+(?:\.\d+)*)/i.exec(raw);
1738
- if (!m)
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(m[1], String(phaseNum)) > 0;
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, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
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(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
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
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
577
- node_fs_1.default.writeFileSync(file, content, 'utf8');
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(sentinelPath(root), 'utf-8');
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(sentinelPath(root), 'utf-8');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.20.0",
3
+ "version": "1.20.1",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "main": ".opencode/plugins/gsd-core.js",
6
6
  "bin": {
@@ -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 start execution.
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 start execution.
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({ changedFiles, labels, fragmentFailures = [], prFieldDrift = [] }) {
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 verdict = evaluateLint({ changedFiles, labels, fragmentFailures, prFieldDrift });
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
- process.stdout.write(`ok changeset-lint: ${verdict.reason}\n`);
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",