@opengsd/gsd-core 1.7.0-rc.1 → 1.7.0-rc.2

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 (74) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +4 -2
  27. package/bin/install.js +118 -1
  28. package/gemini-extension.json +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +18 -7
  30. package/gsd-core/bin/lib/capability-loader.cjs +27 -9
  31. package/gsd-core/bin/lib/capability-registry.cjs +51 -49
  32. package/gsd-core/bin/lib/capability-source.cjs +22 -7
  33. package/gsd-core/bin/lib/capability-validator.cjs +24 -2
  34. package/gsd-core/bin/lib/commands.cjs +2 -1
  35. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  36. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  37. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  38. package/gsd-core/bin/lib/host-integration.cjs +61 -0
  39. package/gsd-core/bin/lib/init.cjs +34 -6
  40. package/gsd-core/bin/lib/milestone.cjs +49 -10
  41. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  42. package/gsd-core/bin/lib/phase.cjs +37 -27
  43. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  44. package/gsd-core/bin/lib/probe-core.cjs +44 -4
  45. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  46. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  47. package/gsd-core/bin/lib/roadmap.cjs +28 -20
  48. package/gsd-core/bin/lib/state-transition.cjs +15 -0
  49. package/gsd-core/bin/lib/state.cjs +27 -8
  50. package/gsd-core/bin/lib/validate.cjs +2 -1
  51. package/gsd-core/bin/lib/verify.cjs +6 -4
  52. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  53. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  54. package/gsd-core/bin/shared/model-catalog.json +8 -8
  55. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  56. package/gsd-core/references/model-profiles.md +27 -0
  57. package/gsd-core/workflows/autonomous.md +22 -24
  58. package/gsd-core/workflows/complete-milestone.md +6 -10
  59. package/gsd-core/workflows/execute-phase.md +1 -1
  60. package/gsd-core/workflows/forensics.md +3 -3
  61. package/gsd-core/workflows/help/modes/full.md +1 -1
  62. package/gsd-core/workflows/milestone-summary.md +3 -3
  63. package/gsd-core/workflows/new-milestone.md +6 -0
  64. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  65. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  66. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  67. package/gsd-core/workflows/plan-phase.md +3 -158
  68. package/gsd-core/workflows/review.md +7 -2
  69. package/gsd-core/workflows/settings-advanced.md +10 -10
  70. package/gsd-core/workflows/verify-work.md +1 -2
  71. package/package.json +3 -1
  72. package/scripts/run-tests.cjs +51 -1
  73. package/scripts/sync-manifest-versions.cjs +66 -14
  74. package/skills/gsd-review/SKILL.md +6 -0
@@ -48,11 +48,40 @@ exports.VALID_STATUS = ['resolved', 'dismissed', 'unresolved'];
48
48
  function errMessage(e) {
49
49
  return e instanceof Error ? e.message : String(e);
50
50
  }
51
+ /**
52
+ * Structural guard for ONE item of the report `items[]` — enforces the `Item` contract (#1907).
53
+ * `analyzeCoverage` always emits fully-populated, category/status-validated Items, so this never
54
+ * rejects legitimate output; it catches an adapter that bypasses the merge and hands back per-item
55
+ * garbage (e.g. `items:[{}]`) inside a well-shaped envelope — which the container-only guard let
56
+ * sail through as green output despite the fail-closed docstring below.
57
+ */
58
+ function isValidItem(item) {
59
+ if (item == null || typeof item !== 'object')
60
+ return false;
61
+ const i = item;
62
+ if (typeof i.requirement_id !== 'string' || !i.requirement_id.trim())
63
+ return false;
64
+ if (typeof i.category !== 'string' || !i.category.trim())
65
+ return false;
66
+ if (!exports.VALID_STATUS.includes(i.status))
67
+ return false;
68
+ if (typeof i.probe !== 'string')
69
+ return false;
70
+ // The three nullable fields must be a string or null — never some other type.
71
+ if (i.verification !== null && typeof i.verification !== 'string')
72
+ return false;
73
+ if (i.resolution !== null && typeof i.resolution !== 'string')
74
+ return false;
75
+ if (i.reason !== null && typeof i.reason !== 'string')
76
+ return false;
77
+ return true;
78
+ }
51
79
  /**
52
80
  * Structural guard for the report an adapter's `analyze` returns. The scaffold types `analyze`
53
81
  * loosely (it runs over JSON-parsed input the adapter `as`-casts), so a future adapter (#644)
54
82
  * that forgets to validate inside its closure could hand back a malformed object. Rather than
55
- * stringify garbage as green output, `runProbeCli` checks the report shape and fails closed.
83
+ * stringify garbage as green output, `runProbeCli` checks the report shape — container AND every
84
+ * item — and fails closed.
56
85
  */
57
86
  function isValidReport(report) {
58
87
  if (report == null || typeof report !== 'object')
@@ -60,6 +89,8 @@ function isValidReport(report) {
60
89
  const r = report;
61
90
  if (!Array.isArray(r.items))
62
91
  return false;
92
+ if (!r.items.every(isValidItem))
93
+ return false;
63
94
  const c = r.coverage;
64
95
  if (c == null || typeof c !== 'object')
65
96
  return false;
@@ -373,13 +404,22 @@ function truthStatement(truth) {
373
404
  /**
374
405
  * Extract a truth's verification tier, or `null` when it carries none (a plain string, or an object
375
406
  * with no/garbled marker). Failing toward `null` is the Postel-safe direction: an unrecognized marker
376
- * grades NORMALLY (never a spurious abstention — the over-abstention guard, AC#3), and the marker is
377
- * machine-emitted from validated edge data so garbling is not a live input path.
407
+ * grades NORMALLY (never a spurious abstention — the over-abstention guard, AC#3).
408
+ *
409
+ * The marker is NOT only machine-emitted: `must_haves` markers can be authored BY HAND (#1820's
410
+ * spec-optional predicate rail), and the frontmatter continuation-KV parser preserves stray
411
+ * surrounding whitespace/quotes on a hand-authored value. So we normalize before comparison
412
+ * (Postel: be liberal in what you accept) — `'backstop '`, `' backstop'`, `'"backstop"'` all
413
+ * recognize as the tier. Without this, a hand-authored non-inferable `backstop` truth with a stray
414
+ * trailing space silently grades green instead of abstaining — the exact #1154 false-pass (#1905).
415
+ * An unrecoverably-corrupted marker (e.g. an embedded quote) stays unrecognized → null → graded
416
+ * normally (AC#3): we cannot know its intent, and abstaining on it would be a spurious abstention.
378
417
  */
379
418
  function truthVerification(truth) {
380
419
  if (truth == null || typeof truth !== 'object')
381
420
  return null;
382
- const v = truth.verification;
421
+ const raw = truth.verification;
422
+ const v = typeof raw === 'string' ? raw.trim().replace(/^["']|["']$/g, '').trim() : raw;
383
423
  return v === 'explicit' || v === 'backstop' ? v : null;
384
424
  }
385
425
  /**
@@ -46,9 +46,10 @@ function checkW021(content) {
46
46
  // Capture the major integer.
47
47
  const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
48
48
  // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
49
- const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*\s*:/i;
49
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
50
+ const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i;
50
51
  // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
51
- const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)\s*:/i;
52
+ const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i;
52
53
  let currentMilestoneMajor = null;
53
54
  const lines = content.split('\n');
54
55
  for (const line of lines) {
@@ -22,7 +22,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
22
22
  const node_path_1 = __importDefault(require("node:path"));
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const phaseIdModule = require("./phase-id.cjs");
25
- const { escapeRegex, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, } = phaseIdModule;
25
+ const { escapeRegex, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, } = phaseIdModule;
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const planningWorkspace = require("./planning-workspace.cjs");
28
28
  const { planningDir } = planningWorkspace;
@@ -82,7 +82,8 @@ function extractCurrentMilestone(content, cwd) {
82
82
  const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index : detailsOpenIdx;
83
83
  const preamble = content.slice(0, preambleCutoff)
84
84
  .replace(/<details>[\s\S]*?<\/details>/gi, '')
85
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
85
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
86
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
86
87
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
87
88
  return preamble + content.slice(detailsOpenIdx, detailsEnd);
88
89
  }
@@ -151,7 +152,8 @@ function extractCurrentMilestone(content, cwd) {
151
152
  }
152
153
  const preamble = beforeMilestones
153
154
  .replace(/<details>[\s\S]*?<\/details>/gi, '')
154
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
155
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
156
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
155
157
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
156
158
  return detailsSection
157
159
  ? preamble + currentSection + '\n' + detailsSection
@@ -171,16 +173,20 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
171
173
  return before + after.replace(pattern, replacement);
172
174
  }
173
175
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
174
- const phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i');
175
- const headerMatch = content.match(phasePattern);
176
+ // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
177
+ const headingPattern = new RegExp(`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
178
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
179
+ const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
180
+ if (headingIndex === -1)
181
+ return null;
182
+ const heading = headings[headingIndex];
183
+ const headerMatch = heading.text.match(headingPattern);
176
184
  if (!headerMatch)
177
185
  return null;
178
186
  const phaseName = headerMatch[1].trim();
179
- const headerIndex = headerMatch.index;
180
- const restOfContent = content.slice(headerIndex);
181
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
182
- const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
183
- const section = content.slice(headerIndex, sectionEnd).trim();
187
+ const nextHeading = headings.slice(headingIndex + 1).find((candidate) => candidate.level <= heading.level);
188
+ const sectionEnd = nextHeading ? nextHeading.offset : content.length;
189
+ const section = content.slice(heading.offset, sectionEnd).trim();
184
190
  const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
185
191
  const goal = goalMatch ? goalMatch[1].trim() : null;
186
192
  return {
@@ -209,6 +215,9 @@ function roadmapPhaseLookupSources(phaseNum) {
209
215
  function getRoadmapPhaseInternal(cwd, phaseNum) {
210
216
  if (!phaseNum)
211
217
  return null;
218
+ const normalizedPhase = stripProjectCodePrefix(phaseNum);
219
+ if (/^999(?:\.|$)/.test(normalizedPhase))
220
+ return null;
212
221
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
213
222
  if (!node_fs_1.default.existsSync(roadmapPath))
214
223
  return null;
@@ -369,7 +378,8 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
369
378
  }
370
379
  // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
371
380
  // T4 seam migration: phase headings inside fences are excluded automatically.
372
- const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/i;
381
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
382
+ const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/i;
373
383
  for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmap)) {
374
384
  if (h.level < 2 || h.level > 4)
375
385
  continue;
@@ -17,13 +17,14 @@ const ioMod = require("./io.cjs");
17
17
  const { output, error } = ioMod;
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const phaseIdMod = require("./phase-id.cjs");
20
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches } = phaseIdMod;
20
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
21
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
22
  const phaseLocatorMod = require("./phase-locator.cjs");
23
23
  const { findPhaseInternal } = phaseLocatorMod;
24
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
25
  const roadmapParserModule = require("./roadmap-parser.cjs");
26
26
  const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule;
27
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
27
28
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
28
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
30
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -94,12 +95,14 @@ function countPhasePlansAndSummaries(phaseDir) {
94
95
  * checklist-only match), or null if the phase is not present at all.
95
96
  */
96
97
  function searchPhaseInContent(content, escapedPhase, phaseNum) {
97
- // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
98
- const phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
99
- const headerMatch = content.match(phasePattern);
98
+ // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
99
+ const headingPattern = new RegExp(`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
100
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
101
+ const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
102
+ const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern);
100
103
  if (!headerMatch) {
101
104
  // Fallback: check if phase exists in summary list but missing detail section
102
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`, 'i');
105
+ const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`, 'i');
103
106
  const checklistMatch = content.match(checklistPattern);
104
107
  if (checklistMatch) {
105
108
  return {
@@ -113,14 +116,12 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
113
116
  return null;
114
117
  }
115
118
  const phaseName = headerMatch[1].trim();
116
- const headerIndex = headerMatch.index;
117
- // Find the end of this section (next ## or ### phase header, or end of file).
118
- // Also matches bracket-prefixed headings like ### [GSD] Phase 2-01:.
119
- const restOfContent = content.slice(headerIndex);
120
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w][\w.-]*/i);
121
- const sectionEnd = nextHeaderMatch
122
- ? headerIndex + nextHeaderMatch.index
123
- : content.length;
119
+ const headerIndex = headings[headingIndex].offset;
120
+ const currentHeading = headings[headingIndex];
121
+ const nextHeading = headings
122
+ .slice(headingIndex + 1)
123
+ .find((candidate) => candidate.level <= currentHeading.level);
124
+ const sectionEnd = nextHeading ? nextHeading.offset : content.length;
124
125
  const section = content.slice(headerIndex, sectionEnd).trim();
125
126
  // Extract goal if present (supports both **Goal:** and **Goal**: formats)
126
127
  const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
@@ -159,6 +160,8 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
159
160
  * phase resolution as `roadmap.get-phase` — not a milestone-only subset.
160
161
  */
161
162
  function getRoadmapPhaseWithFallback(cwd, phaseNum) {
163
+ if (/^999(?:\.|$)/.test(stripProjectCodePrefix(phaseNum)))
164
+ return null;
162
165
  const roadmapPath = planningPaths(cwd).roadmap;
163
166
  if (!node_fs_1.default.existsSync(roadmapPath))
164
167
  return null;
@@ -185,6 +188,10 @@ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
185
188
  }
186
189
  // ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
187
190
  function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
191
+ if (/^999(?:\.|$)/.test(stripProjectCodePrefix(phaseNum))) {
192
+ output({ found: false, phase_number: phaseNum }, raw, '');
193
+ return;
194
+ }
188
195
  const roadmapPath = planningPaths(cwd).roadmap;
189
196
  if (!node_fs_1.default.existsSync(roadmapPath)) {
190
197
  output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
@@ -249,7 +256,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
249
256
  const content = extractCurrentMilestone(rawContent, cwd);
250
257
  const phasesDir = planningPaths(cwd).phases;
251
258
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
252
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi;
259
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
260
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
253
261
  const phases = [];
254
262
  let match;
255
263
  // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
@@ -325,7 +333,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
325
333
  // #3537: padding-tolerant fragment — the heading discovered above may use
326
334
  // a different padding than the summary-bullet checkbox below it (mixed
327
335
  // padding inside one ROADMAP is legal and seen in real projects).
328
- const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}[:\\s]`, 'i');
336
+ const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
329
337
  const checkboxMatch = content.match(checkboxPattern);
330
338
  const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
331
339
  // If roadmap marks phase complete, trust that over disk file structure.
@@ -450,14 +458,14 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
450
458
  // `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
451
459
  // `**Plans:** N plans` — bold "Plans:" (colon inside bold)
452
460
  // `Plans: N plans` — plain text header
453
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
461
+ const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
454
462
  const planCountText = isComplete
455
463
  ? `${summaryCount}/${planCount} plans complete`
456
464
  : `${summaryCount}/${planCount} plans executed`;
457
465
  roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
458
466
  // If complete: check checkbox
459
467
  if (isComplete) {
460
- const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}[:\\s][^\\n]*)`, 'i');
468
+ const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
461
469
  roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
462
470
  }
463
471
  // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**")
@@ -507,8 +515,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
507
515
  //
508
516
  // Pattern A: anchor to bare `Plans:` header (preferred).
509
517
  // Pattern B: fallback to bold summary when no bare header exists.
510
- const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
511
- const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
518
+ const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
519
+ const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
512
520
  const sortedMissing = [...missingPlans].sort();
513
521
  const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
514
522
  const inserter = (match) => `${match}\n${newRows}`;
@@ -645,7 +653,7 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
645
653
  // #3537: padding-tolerant fragment so the caller's resolved padded id
646
654
  // matches un-padded ROADMAP headings.
647
655
  const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
648
- const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}:[^\\n]*)`, 'i');
656
+ const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*)`, 'i');
649
657
  const phaseMatch = content.match(phaseHeaderPattern);
650
658
  if (!phaseMatch)
651
659
  return;
@@ -19,6 +19,7 @@ exports.STATE_MD_SECTIONS = exports.FIELD_CLASSIFICATION = void 0;
19
19
  exports.getFieldClassification = getFieldClassification;
20
20
  exports.applyStatePreservation = applyStatePreservation;
21
21
  exports.transitionCore = transitionCore;
22
+ exports.sliceCurrentPositionSection = sliceCurrentPositionSection;
22
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
23
24
  const frontmatter = require("./frontmatter.cjs");
24
25
  const state_document_cjs_1 = require("./state-document.cjs");
@@ -319,6 +320,20 @@ function locateCurrentPosition(body) {
319
320
  }
320
321
  return { start, end };
321
322
  }
323
+ /**
324
+ * Return the body text of the `## Current Position` section, or `null` when it
325
+ * is absent. Reuses the fence-aware `locateCurrentPosition` locator (ADR-1372).
326
+ *
327
+ * Exposed so callers that must read a position field (e.g. `cmdStatePrune`,
328
+ * #1776) can scope extraction to the canonical section instead of the whole
329
+ * document — where `stateExtractField`'s pipe-table fallback could otherwise
330
+ * latch onto an unrelated `| Phase | N |` row elsewhere in STATE.md. This
331
+ * scopes the *caller*; the shared extractor is left broad for every other use.
332
+ */
333
+ function sliceCurrentPositionSection(body) {
334
+ const span = locateCurrentPosition(body);
335
+ return span === null ? null : body.slice(span.start, span.end);
336
+ }
322
337
  /**
323
338
  * First-time ## Current Position mutation: update Phase / Plan / Status /
324
339
  * Last activity lines. Mirrors state.cts:2261-2324 byte-for-behaviour
@@ -35,7 +35,7 @@ const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
35
35
  const scanPhasePlans = require("./plan-scan.cjs");
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
37
  const stateTransitionMod = require("./state-transition.cjs");
38
- const { transitionCore, applyStatePreservation } = stateTransitionMod;
38
+ const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
39
39
  const state_document_cjs_1 = require("./state-document.cjs");
40
40
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
41
41
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
@@ -1293,7 +1293,8 @@ function buildStateFrontmatter(bodyContent, cwd) {
1293
1293
  // truth for total_phases (#549).
1294
1294
  let roadmapPhaseCount = 0;
1295
1295
  if (roadmapScope !== null) {
1296
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1296
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1297
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
1297
1298
  let m;
1298
1299
  while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1299
1300
  // Only count tokens that contain at least one digit — excludes
@@ -2245,7 +2246,8 @@ function cmdStateSync(cwd, options, raw) {
2245
2246
  try {
2246
2247
  let roadmapPhaseCount = 0;
2247
2248
  if (syncRoadmapScope !== null) {
2248
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
2249
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
2250
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
2249
2251
  let m;
2250
2252
  while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
2251
2253
  // Only count tokens that contain at least one digit — excludes
@@ -2321,12 +2323,29 @@ function cmdStatePrune(cwd, options, raw) {
2321
2323
  }
2322
2324
  const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
2323
2325
  const dryRun = !!options.dryRun;
2324
- // #1760: the canonical STATE.md template emits `Phase: [X] of [Y]`, not
2325
- // `Current Phase:`. Read both (mirroring buildStateFrontmatter /
2326
- // resolvePhaseIdForCompletePhase) so prune engages on template-conformant
2327
- // STATE.md instead of bailing with "Only 0 phases — nothing to prune".
2326
+ // Resolve the current phase via the same canonical chain buildStateFrontmatter
2327
+ // uses (frontmatter `current_phase` → `Current Phase` field → prose `Phase: X
2328
+ // of Y`), so prune engages on template-conformant STATE.md instead of bailing
2329
+ // "Only 0 phases" (#1760).
2330
+ // #1776: scope ONLY the prose `Phase:` term to the canonical `## Current
2331
+ // Position` section. Over the whole body, `stateExtractField`'s pipe-table
2332
+ // fallback matches any `| Phase | N |` row (e.g. a historical verification
2333
+ // table), resolving a stale phase and computing a wrong cutoff. Frontmatter and
2334
+ // the explicit `Current Phase` field are unambiguous, so they stay document-wide;
2335
+ // the shared extractor is not narrowed for any other caller.
2328
2336
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
2329
- const currentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(rawState, 'Current Phase') || (0, state_document_cjs_1.stateExtractField)(rawState, 'Phase');
2337
+ const fm = extractFrontmatter(rawState);
2338
+ const body = stripFrontmatter(rawState);
2339
+ // Mirror buildStateFrontmatter's fmScalar: only string/number/boolean
2340
+ // frontmatter scalars are usable (an object/array `current_phase` is ignored,
2341
+ // which also avoids a base-to-string on a non-primitive).
2342
+ const fmRawPhase = fm.current_phase;
2343
+ const fmCurrentPhase = typeof fmRawPhase === 'string' ? (fmRawPhase.trim() || null)
2344
+ : typeof fmRawPhase === 'number' || typeof fmRawPhase === 'boolean' ? String(fmRawPhase)
2345
+ : null;
2346
+ const positionSection = sliceCurrentPositionSection(body);
2347
+ const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionSection, 'Phase')).phase : null;
2348
+ const currentPhaseRaw = fmCurrentPhase ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase;
2330
2349
  const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0;
2331
2350
  const cutoff = currentPhase - keepRecent;
2332
2351
  if (cutoff <= 0) {
@@ -93,7 +93,8 @@ function buildRoadmapPhaseVariants(roadmapContent) {
93
93
  const roadmapPhaseVariants = new Set();
94
94
  // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
95
95
  // and bracket-prefixed (### [GSD] Phase 2-01:) headings.
96
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
96
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
97
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
97
98
  let m;
98
99
  while ((m = phasePattern.exec(roadmapContent)) !== null) {
99
100
  roadmapPhases.add(m[1]);
@@ -40,7 +40,7 @@ const configLoaderMod = require("./config-loader.cjs");
40
40
  const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod;
41
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
42
  const phaseIdMod = require("./phase-id.cjs");
43
- const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId } = phaseIdMod;
43
+ const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const phaseLocatorMod = require("./phase-locator.cjs");
46
46
  const { findPhaseInternal } = phaseLocatorMod;
@@ -992,7 +992,8 @@ function checkMilestonePrefixMismatches(roadmapContent, { getMilestoneFromPhaseI
992
992
  }
993
993
  for (const section of sections) {
994
994
  const content = roadmapContent.slice(section.start, section.end);
995
- const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
995
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
996
+ const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
996
997
  let pm;
997
998
  while ((pm = phaseRx.exec(content)) !== null) {
998
999
  const phaseId = pm[1];
@@ -1351,7 +1352,7 @@ function cmdValidateHealth(cwd, options, raw) {
1351
1352
  stateContent.match(/Current Phase:\s*(\S+)/i);
1352
1353
  if (currentPhaseMatch) {
1353
1354
  const statePhase = currentPhaseMatch[1].replace(/^0+/, '');
1354
- const phaseCheckboxRe = new RegExp(`-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}[:\\s]`, 'i');
1355
+ const phaseCheckboxRe = new RegExp(`-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
1355
1356
  if (phaseCheckboxRe.test(roadmapContentFull)) {
1356
1357
  const stateStatus = stateContent.match(/\*\*Status:\*\*\s*(.+)/i);
1357
1358
  const statusVal = stateStatus ? stateStatus[1].trim().toLowerCase() : '';
@@ -1509,7 +1510,8 @@ function cmdValidateHealth(cwd, options, raw) {
1509
1510
  if (isMarkedComplete) {
1510
1511
  const roadmapRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1511
1512
  const scopedContent = extractCurrentMilestone(roadmapRaw, cwd);
1512
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1513
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1514
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
1513
1515
  const unstarted = [];
1514
1516
  let pm;
1515
1517
  // Non-hoisted: load-order matters (circular dep guard)
@@ -28,7 +28,7 @@ function isCompletedInventory(status) {
28
28
  return /\bmilestone\s+complete\b/.test(s) || /\barchived\b/.test(s);
29
29
  }
30
30
  function buildWorkstreamInventory(inputs) {
31
- const { name, projectDir, workstreamDir, phaseDirNames, activeWorkstreamName, phaseFilesCounts, roadmapPhaseCount, stateProjection, filesExist, } = inputs;
31
+ const { name, projectDir, workstreamDir, phaseDirNames, activeWorkstreamName, phaseFilesCounts, roadmapPhaseCount, stateProjection, filesExist, milestoneShipped, } = inputs;
32
32
  // Index counts by directory for O(1) lookup during sort/iteration
33
33
  const countsMap = new Map();
34
34
  for (const entry of phaseFilesCounts) {
@@ -56,6 +56,14 @@ function buildWorkstreamInventory(inputs) {
56
56
  summary_count: counts.summaryCount,
57
57
  });
58
58
  }
59
+ // #1913: derive status from authoritative shipped signals rather than trusting
60
+ // the mutable STATE.md `Status` field. When a shipped signal is present, the
61
+ // workstream is "milestone complete" regardless of a stale field value.
62
+ const fieldStatus = stateProjection.status;
63
+ const useDerived = milestoneShipped;
64
+ const status = useDerived ? 'milestone complete' : fieldStatus;
65
+ const status_source = useDerived ? 'derived' : 'field';
66
+ const status_conflict = useDerived && !isCompletedInventory(fieldStatus);
59
67
  return {
60
68
  name,
61
69
  path: toPosixPath(node_path_1.default.relative(projectDir, workstreamDir)),
@@ -65,7 +73,9 @@ function buildWorkstreamInventory(inputs) {
65
73
  state: filesExist.state,
66
74
  requirements: filesExist.requirements,
67
75
  },
68
- status: stateProjection.status,
76
+ status,
77
+ status_source,
78
+ status_conflict,
69
79
  current_phase: stateProjection.current_phase,
70
80
  last_activity: stateProjection.last_activity,
71
81
  phases,
@@ -63,6 +63,33 @@ function readStateProjection(statePath) {
63
63
  };
64
64
  }
65
65
  }
66
+ /**
67
+ * #1913: detect an authoritative shipped signal for a workstream so the
68
+ * inventory status is never trusted from the mutable STATE.md `Status` field
69
+ * alone. Returns true when EITHER an archived milestone snapshot is present
70
+ * under `<planningBase>/milestones/` OR the workstream ROADMAP carries a
71
+ * SHIPPED marker — both are hard to desync, unlike the hand-maintained field.
72
+ */
73
+ function workstreamMilestoneShipped(roadmapPath, planningBase) {
74
+ try {
75
+ const milestonesDir = node_path_1.default.join(planningBase, 'milestones');
76
+ for (const entry of node_fs_1.default.readdirSync(milestonesDir, { withFileTypes: true })) {
77
+ if (entry.isFile() && /-ROADMAP\.md$/i.test(entry.name))
78
+ return true;
79
+ }
80
+ }
81
+ catch {
82
+ /* no milestones archive dir */
83
+ }
84
+ try {
85
+ if (/SHIPPED/i.test(node_fs_1.default.readFileSync(roadmapPath, 'utf-8')))
86
+ return true;
87
+ }
88
+ catch {
89
+ /* no roadmap */
90
+ }
91
+ return false;
92
+ }
66
93
  function sortWorkstreamInventories(inventories, activeWorkstreamName) {
67
94
  return [...inventories].sort((a, b) => {
68
95
  const aActive = a.name === activeWorkstreamName ? 1 : 0;
@@ -99,6 +126,7 @@ function inspectWorkstream(cwd, name, options = {}) {
99
126
  state: node_fs_1.default.existsSync(p.state),
100
127
  requirements: node_fs_1.default.existsSync(p.requirements),
101
128
  },
129
+ milestoneShipped: workstreamMilestoneShipped(p.roadmap, p.planning),
102
130
  });
103
131
  }
104
132
  function listWorkstreamInventories(cwd) {
@@ -9,7 +9,7 @@
9
9
  "runtimeTierDefaults": {
10
10
  "claude": {
11
11
  "opus": { "model": "claude-opus-4-8" },
12
- "sonnet": { "model": "claude-sonnet-4-6" },
12
+ "sonnet": { "model": "claude-sonnet-5" },
13
13
  "haiku": { "model": "claude-haiku-4-5" }
14
14
  },
15
15
  "codex": {
@@ -29,17 +29,17 @@
29
29
  },
30
30
  "opencode": {
31
31
  "opus": { "model": "anthropic/claude-opus-4-8" },
32
- "sonnet": { "model": "anthropic/claude-sonnet-4-6" },
32
+ "sonnet": { "model": "anthropic/claude-sonnet-5" },
33
33
  "haiku": { "model": "anthropic/claude-haiku-4-5" }
34
34
  },
35
35
  "copilot": {
36
36
  "opus": { "model": "claude-opus-4-8" },
37
- "sonnet": { "model": "claude-sonnet-4-6" },
37
+ "sonnet": { "model": "claude-sonnet-5" },
38
38
  "haiku": { "model": "claude-haiku-4-5" }
39
39
  },
40
40
  "hermes": {
41
41
  "opus": { "model": "anthropic/claude-opus-4-8" },
42
- "sonnet": { "model": "anthropic/claude-sonnet-4-6" },
42
+ "sonnet": { "model": "anthropic/claude-sonnet-5" },
43
43
  "haiku": { "model": "anthropic/claude-haiku-4-5" }
44
44
  },
45
45
  "kilo": {
@@ -91,13 +91,13 @@
91
91
  "providerPresets": {
92
92
  "anthropic": {
93
93
  "opus": { "low": { "model": "claude-opus-4-5" }, "medium": { "model": "claude-opus-4-8" }, "high": { "model": "claude-opus-4-8" } },
94
- "sonnet": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-sonnet-4-6" }, "high": { "model": "claude-opus-4-8" } },
95
- "haiku": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-haiku-4-5" }, "high": { "model": "claude-sonnet-4-6" } }
94
+ "sonnet": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-sonnet-5" }, "high": { "model": "claude-opus-4-8" } },
95
+ "haiku": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-haiku-4-5" }, "high": { "model": "claude-sonnet-5" } }
96
96
  },
97
97
  "anthropic-fable": {
98
98
  "opus": { "low": { "model": "claude-opus-4-5" }, "medium": { "model": "claude-opus-4-8" }, "high": { "model": "claude-fable-5" } },
99
- "sonnet": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-sonnet-4-6" }, "high": { "model": "claude-fable-5" } },
100
- "haiku": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-haiku-4-5" }, "high": { "model": "claude-sonnet-4-6" } }
99
+ "sonnet": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-sonnet-5" }, "high": { "model": "claude-fable-5" } },
100
+ "haiku": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-haiku-4-5" }, "high": { "model": "claude-sonnet-5" } }
101
101
  },
102
102
  "openai": {
103
103
  "opus": { "low": { "model": "gpt-5.4", "reasoning_effort": "medium" }, "medium": { "model": "gpt-5.5", "reasoning_effort": "high" }, "high": { "model": "gpt-5.5", "reasoning_effort": "xhigh" } },
@@ -0,0 +1,60 @@
1
+ # Agent Skills Self-Load (Bootstrap)
2
+
3
+ > **Shared contract.** Every `agent_skills` consumer agent self-loads its configured
4
+ > skills in its mandatory init step, so a project's `.planning/config.json`
5
+ > `agent_skills.<agent-type>` mapping reaches the agent that actually does the work —
6
+ > even when the orchestrator did not run bash init (e.g. a runtime whose `Skill()`
7
+ > delegation does not reliably execute the delegated workflow's bash, such as Cursor;
8
+ > see open-gsd/gsd-core#1600 / #1601). This is the durable counterpart to the
9
+ > orchestrator-side injection documented under
10
+ > [Agent Skills Injection](../../docs/CONFIGURATION.md#agent-skills-injection).
11
+
12
+ ## When to run
13
+
14
+ In your mandatory init step — right after `mandatory-initial-read.md` / the
15
+ `Project skills` discovery, before any other work.
16
+
17
+ ## Steps
18
+
19
+ 1. **Dedup guard (MANDATORY).** Look at your own prompt. If it already contains an
20
+ `<agent_skills>` block, the orchestrator already injected one — **skip self-load
21
+ entirely.** Loading a second copy wastes context on runtimes where orchestrator-side
22
+ injection also runs (e.g. Claude Code). The guard is what keeps the two seams from
23
+ doubling the block.
24
+
25
+ 2. **Query your configured skills.** Use **your own agent type** — the `name:` value in
26
+ your frontmatter (e.g. an agent whose frontmatter says `name: gsd-executor` queries
27
+ `gsd-executor`). The query is read-only and idempotent — it exits 0 with an empty
28
+ block when nothing is configured for your type:
29
+
30
+ ```bash
31
+ _AGENT_SKILLS=$(gsd_run query agent-skills <YOUR-FRONTMATTER-NAME> 2>/dev/null || true)
32
+ ```
33
+
34
+ The runtime `gsd_run` resolver is the standard one from
35
+ `_runtime-launcher.snippet.sh`; your own init already defines it.
36
+
37
+ 3. **Read every listed skill.** The block emits entries as `@<path>/SKILL.md`
38
+ includes — `Read` each one before starting work. If the block is empty, there is
39
+ nothing to do (zero overhead).
40
+
41
+ ## What self-load does and does not cover
42
+
43
+ | Skill form | Self-loads? | Notes |
44
+ |---|---|---|
45
+ | Project-relative path (`skills/my-skill`) | ✅ everywhere | `Read` the `@`-include |
46
+ | Global personal (`global:<name>`) | ✅ everywhere | resolves to the runtime global skills dir, then `Read` |
47
+ | Plugin-provided (`global:<plugin>:<skill>`) | Claude only | emitted as a Skill-tool directive on Claude; **skipped with a warning on all other runtimes** — the plugin/Skill-tool model has no equivalent elsewhere (#1601, #1258). Not closeable on Cursor. |
48
+
49
+ ## Notes
50
+
51
+ - **Idempotent and read-only.** `query agent-skills` never mutates state; calling it
52
+ twice (once by the orchestrator, once by the agent) is harmless because the dedup
53
+ guard suppresses the second load.
54
+ - **No new config keys.** This reuses the existing `agent_skills` map and the existing
55
+ `buildAgentSkillsBlock` / `cmdAgentSkills` machinery (`src/init.cts`). The 22 consumer
56
+ agent types are mirrored in `tests/agent-skills.test.cjs` (`CONSUMER_AGENTS`) and
57
+ guarded against drift by `tests/agent-skills-bootstrap.test.cjs`.
58
+ - **Checkers / read-only agents.** Bash is universal across consumer agents, so
59
+ self-load works for plan-checkers, verifiers, and auditors too; the bootstrap assumes
60
+ no tool an agent lacks.