@opengsd/gsd-core 1.5.0 → 1.6.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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/agents/gsd-roadmapper.md +6 -0
  5. package/bin/install.js +199 -365
  6. package/commands/gsd/capture.md +5 -1
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +695 -5
  9. package/gsd-core/bin/lib/adr-parser.cjs +45 -23
  10. package/gsd-core/bin/lib/audit.cjs +2 -2
  11. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  12. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  14. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  15. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  16. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  17. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  18. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  19. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  20. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  22. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  23. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  24. package/gsd-core/bin/lib/commands.cjs +247 -0
  25. package/gsd-core/bin/lib/config-loader.cjs +98 -84
  26. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  27. package/gsd-core/bin/lib/config.cjs +7 -1
  28. package/gsd-core/bin/lib/decisions.cjs +149 -60
  29. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  30. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  31. package/gsd-core/bin/lib/init.cjs +91 -22
  32. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  35. package/gsd-core/bin/lib/milestone.cjs +41 -2
  36. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  37. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  38. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  39. package/gsd-core/bin/lib/phase.cjs +33 -4
  40. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  41. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  42. package/gsd-core/bin/lib/project-root.cjs +89 -2
  43. package/gsd-core/bin/lib/resolution.cjs +26 -0
  44. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  45. package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
  46. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  47. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  48. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
  49. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  50. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
  51. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  52. package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
  53. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  54. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  55. package/gsd-core/bin/lib/state-document.cjs +4 -2
  56. package/gsd-core/bin/lib/state.cjs +317 -161
  57. package/gsd-core/bin/lib/surface.cjs +12 -19
  58. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  59. package/gsd-core/bin/lib/uat.cjs +39 -26
  60. package/gsd-core/bin/lib/validate.cjs +5 -2
  61. package/gsd-core/bin/lib/verify.cjs +40 -15
  62. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  63. package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
  64. package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
  65. package/gsd-core/references/context-budget.md +8 -8
  66. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  67. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  68. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  69. package/gsd-core/references/planner-antipatterns.md +48 -0
  70. package/gsd-core/references/planning-config.md +4 -0
  71. package/gsd-core/references/prohibition-probe.md +15 -9
  72. package/gsd-core/references/scout-codebase.md +2 -2
  73. package/gsd-core/workflows/autonomous.md +33 -33
  74. package/gsd-core/workflows/diagnose-issues.md +6 -1
  75. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  76. package/gsd-core/workflows/discuss-phase.md +1 -2
  77. package/gsd-core/workflows/execute-phase.md +12 -12
  78. package/gsd-core/workflows/help/modes/full.md +10 -0
  79. package/gsd-core/workflows/list-seeds.md +63 -0
  80. package/gsd-core/workflows/manager.md +37 -37
  81. package/gsd-core/workflows/pr-branch.md +156 -0
  82. package/gsd-core/workflows/quick.md +6 -1
  83. package/gsd-core/workflows/review.md +10 -2
  84. package/gsd-core/workflows/spec-phase.md +8 -3
  85. package/gsd-core/workflows/verify-phase.md +2 -2
  86. package/package.json +6 -3
  87. package/scripts/gen-capability-matrix.cjs +284 -0
  88. package/scripts/gen-capability-registry.cjs +96 -1853
  89. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  90. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  91. package/scripts/lint-resolution-provenance.cjs +192 -0
  92. package/scripts/lint-test-file-count.allowlist.json +9 -0
  93. package/scripts/prompt-injection-scan.sh +1 -0
  94. package/scripts/run-tests.cjs +14 -0
  95. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -33,7 +33,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
33
33
  };
34
34
  const node_fs_1 = __importDefault(require("node:fs"));
35
35
  const node_path_1 = __importDefault(require("node:path"));
36
- const node_os_1 = __importDefault(require("node:os"));
37
36
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
38
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
38
  const installProfiles = require("./install-profiles.cjs");
@@ -41,7 +40,9 @@ const { readActiveProfile, resolveProfile, loadSkillsManifest, } = installProfil
41
40
  const clusters_cjs_1 = require("./clusters.cjs");
42
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
43
42
  const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
44
- const { findInstallSourceRoot, getInstallExports } = runtimeArtifactLayout;
43
+ const { findInstallSourceRoot } = runtimeArtifactLayout;
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
45
46
  const SURFACE_FILE_NAME = '.gsd-surface.json';
46
47
  /**
47
48
  * Read the surface state from a runtime config directory.
@@ -265,26 +266,18 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry)
265
266
  const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
266
267
  // Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
267
268
  // so SKILL.md bodies reference the install target (pathPrefix), not the
268
- // converter's default ~/.claude paths (#813). Computed lazily so command-only
269
- // runtimes do not trigger the install.js require.
270
- let pathPrefix = null;
269
+ // converter's default ~/.claude paths (#813). Delegated to the conversion
270
+ // module's deep seam (ADR-1508 / #1511 Phase 2) — no attribution resolver
271
+ // needed here (proven: Co-Authored-By never appears in staged content; see
272
+ // brief PROVEN KEY FACT). No getInstallExports() call required.
271
273
  for (const kind of layout.kinds) {
272
274
  const staged = kind.stage(resolved);
273
275
  if (kind.kind === 'skills') {
274
- const installExports = getInstallExports();
275
- if (pathPrefix === null) {
276
- const scope = layout.scope ?? 'global';
277
- const resolvedTarget = node_path_1.default.resolve(layout.configDir).replace(/\\/g, '/');
278
- const homeDir = node_os_1.default.homedir().replace(/\\/g, '/');
279
- pathPrefix = installExports.computePathPrefix({
280
- isGlobal: scope === 'global',
281
- isOpencode: layout.runtime === 'opencode',
282
- isWindowsHost: process.platform === 'win32',
283
- resolvedTarget,
284
- homeDir,
285
- });
286
- }
287
- installExports.applyRuntimeContentRewritesInPlace(staged, layout.runtime, pathPrefix);
276
+ runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
277
+ runtime: layout.runtime,
278
+ configDir: layout.configDir,
279
+ scope: layout.scope ?? 'global',
280
+ });
288
281
  }
289
282
  const dest = node_path_1.default.join(layout.configDir, kind.destSubpath);
290
283
  _syncGsdDir(staged, dest, kind, skillManifest);
@@ -19,6 +19,9 @@ const node_path_1 = __importDefault(require("node:path"));
19
19
  // eslint-disable-next-line @typescript-eslint/no-require-imports
20
20
  const frontmatter = require("./frontmatter.cjs");
21
21
  const { extractFrontmatter } = frontmatter;
22
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
23
+ const markdownSectionizer = require("./markdown-sectionizer.cjs");
24
+ const { stripFencedCode } = markdownSectionizer;
22
25
  // ─── Blocking state sets (documented for maintainability) ─────────────────────
23
26
  // UAT file frontmatter `status` values that indicate the file is not fully done
24
27
  const BLOCKING_UAT_FM_STATUSES = new Set([
@@ -53,8 +56,8 @@ function stripFalsePositiveContexts(content) {
53
56
  let stripped = content.replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(\r?\n|$)/, '');
54
57
  // Step (b): remove HTML comments anywhere; unterminated comment swallows to EOF
55
58
  stripped = stripped.replace(/<!--[\s\S]*?(?:-->|$)/g, '');
56
- // Step (c): remove fenced code blocks via CommonMark-style state machine (handles CRLF + indented fences)
57
- stripped = _stripFencedBlocks(stripped).text;
59
+ // Step (c): remove fenced code blocks via the canonical seam (ADR-1372 T5)
60
+ stripped = stripFencedCode(stripped).text;
58
61
  // Step (d): remove blockquote lines
59
62
  stripped = stripped
60
63
  .split('\n')
@@ -62,49 +65,6 @@ function stripFalsePositiveContexts(content) {
62
65
  .join('\n');
63
66
  return stripped;
64
67
  }
65
- /**
66
- * CommonMark-style fenced-code-block stripper.
67
- * Tracks the opening delimiter char and length so that a ~~~ line inside a
68
- * ``` fence is correctly treated as fence content, not a closing delimiter.
69
- *
70
- * Opening rule: first delimiter line with char+len sets openFence.
71
- * Closing rule: delimiter line with SAME char, run length >= openFence.len,
72
- * and NO trailing non-whitespace text closes the fence.
73
- * All delimiter and content lines are dropped; non-fence lines are kept.
74
- * Returns the kept text plus unterminatedFence:true if EOF inside a fence.
75
- */
76
- function _stripFencedBlocks(content) {
77
- const lines = content.split('\n');
78
- const kept = [];
79
- let openFence = null;
80
- const delimRe = /^(\s*)(`{3,}|~{3,})(.*)$/;
81
- for (const rawLine of lines) {
82
- // Tolerate CRLF: strip trailing \r for matching, but we work on split-by-\n lines
83
- // (the outer caller joined by \n already; we just handle a stray \r in the last char)
84
- const line = rawLine.replace(/\r$/, '');
85
- const m = delimRe.exec(line);
86
- if (m) {
87
- const char = m[2][0];
88
- const len = m[2].length;
89
- const trailing = m[3];
90
- if (openFence === null) {
91
- // Opening delimiter — drop this line and record the fence
92
- openFence = { char, len };
93
- }
94
- else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
95
- // Closing delimiter (same char, sufficient length, no trailing text) — drop and close
96
- openFence = null;
97
- }
98
- // else: mismatched delimiter inside fence (e.g. ~~~ inside ```) — drop as content
99
- continue; // delimiter lines are always dropped
100
- }
101
- if (openFence === null) {
102
- kept.push(rawLine);
103
- }
104
- // Lines inside fence are dropped
105
- }
106
- return { text: kept.join('\n'), unterminatedFence: openFence !== null };
107
- }
108
68
  /**
109
69
  * Analyse raw markdown for structural anomalies (unterminated fence / comment).
110
70
  * Exported for unit-testability and used by evaluateUatPassed for per-file malformed detection.
@@ -129,8 +89,8 @@ function analyzeMarkdown(raw) {
129
89
  }
130
90
  i = close + 3;
131
91
  }
132
- // Fence state machine gives the accurate unterminated-fence signal.
133
- const { unterminatedFence } = _stripFencedBlocks(raw);
92
+ // Fence state machine gives the accurate unterminated-fence signal (seam, ADR-1372 T5).
93
+ const { unterminatedFence } = stripFencedCode(raw);
134
94
  return { unterminatedFence, unterminatedComment };
135
95
  }
136
96
  // ─── parseUatResultItems ──────────────────────────────────────────────────────
@@ -18,6 +18,9 @@ const node_path_1 = __importDefault(require("node:path"));
18
18
  const io = require("./io.cjs");
19
19
  const { output, error } = io;
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const markdownSectionizer = require("./markdown-sectionizer.cjs");
22
+ const { collectSection, tokenizeHeadings } = markdownSectionizer;
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
24
  const roadmapParser = require("./roadmap-parser.cjs");
22
25
  const { getMilestonePhaseFilter } = roadmapParser;
23
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -128,11 +131,16 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
128
131
  }
129
132
  // ─── parseCurrentTest ─────────────────────────────────────────────────────────
130
133
  function parseCurrentTest(content) {
131
- const currentTestMatch = content.match(/##\s*Current Test\s*(?:\n<!--[\s\S]*?-->)?\n([\s\S]*?)(?=\n##\s|$)/i);
132
- if (!currentTestMatch) {
134
+ // Use the seam to locate the ## Current Test section (ADR-1372 T5).
135
+ // HTML-comment stripping within the section body is UAT-specific, so we keep
136
+ // the comment removal caller-side after extracting the body.
137
+ const currentTestSection = collectSection(content, (h) => /^current\s+test$/i.test(h.text) && h.level === 2, { levelBounded: true });
138
+ if (!currentTestSection) {
133
139
  error('UAT file is missing a Current Test section');
134
140
  }
135
- const section = currentTestMatch[1].trimEnd();
141
+ // Remove any leading HTML comment block (UAT-specific document structure)
142
+ const rawBody = currentTestSection.body.replace(/^<!--[\s\S]*?-->\s*\n?/, '');
143
+ const section = rawBody.trimEnd();
136
144
  if (!section.trim()) {
137
145
  error('Current Test section is empty');
138
146
  }
@@ -173,36 +181,41 @@ function parseCurrentTest(content) {
173
181
  };
174
182
  }
175
183
  function parseFirstPendingTest(content) {
176
- const testsMatch = content.match(/##\s*Tests\s*\n([\s\S]*?)(?=\n##\s|$)/i);
177
- if (!testsMatch) {
184
+ // Use the seam to locate the ## Tests section (ADR-1372 T5).
185
+ const testsSection = collectSection(content, (h) => /^tests$/i.test(h.text) && h.level === 2, { levelBounded: true });
186
+ if (!testsSection) {
178
187
  return null;
179
188
  }
180
- const testsSection = testsMatch[1];
181
- const headingPattern = /^###\s*(\d+)\.\s*([^\n]+)\s*$/gm;
182
- const headings = [];
183
- let headingMatch;
184
- while ((headingMatch = headingPattern.exec(testsSection)) !== null) {
185
- headings.push({
186
- index: headingMatch.index,
187
- number: parseInt(headingMatch[1], 10),
188
- name: headingMatch[2].trim(),
189
- });
190
- }
191
- for (let i = 0; i < headings.length; i += 1) {
192
- const current = headings[i];
193
- const next = headings[i + 1];
194
- const block = testsSection.slice(current.index, next ? next.index : undefined);
189
+ const sectionBody = testsSection.body;
190
+ // Within the Tests section body, find ### N. Name sub-headings.
191
+ // tokenizeHeadings operates on the section body as a standalone document,
192
+ // filtering to level-3 headings matching the UAT-specific "N. Name" pattern.
193
+ // The UAT-specific item parsing (number extraction, result parsing) stays caller-side.
194
+ const subHeadings = tokenizeHeadings(sectionBody).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
195
+ for (let i = 0; i < subHeadings.length; i += 1) {
196
+ const current = subHeadings[i];
197
+ const next = subHeadings[i + 1];
198
+ // Slice the block for this sub-test from the section body text
199
+ const block = next
200
+ ? sectionBody.slice(current.offset, next.offset)
201
+ : sectionBody.slice(current.offset);
195
202
  if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
196
203
  continue;
197
204
  }
205
+ // Extract the UAT-specific number and name from the heading text
206
+ const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
207
+ if (!headingParts)
208
+ continue;
209
+ const testNumber = parseInt(headingParts[1], 10);
210
+ const testName = headingParts[2].trim();
198
211
  const expected = parseExpectedFromTestBlock(block);
199
212
  if (!expected) {
200
- error(`Pending UAT test ${current.number} is missing an expected field`);
213
+ error(`Pending UAT test ${testNumber} is missing an expected field`);
201
214
  }
202
215
  return {
203
216
  complete: false,
204
- number: current.number,
205
- name: (0, security_cjs_1.sanitizeForDisplay)(current.name),
217
+ number: testNumber,
218
+ name: (0, security_cjs_1.sanitizeForDisplay)(testName),
206
219
  expected: (0, security_cjs_1.sanitizeForDisplay)(expected),
207
220
  };
208
221
  }
@@ -273,10 +286,10 @@ function parseUatItems(content) {
273
286
  function parseVerificationItems(content, status) {
274
287
  const items = [];
275
288
  if (status === 'human_needed') {
276
- // Extract from human_verification section — look for numbered items or table rows
277
- const hvSection = content.match(/##\s*Human Verification.*?\n([\s\S]*?)(?=\n##\s|\n---\s|$)/i);
289
+ // Use the seam to locate the ## Human Verification section (ADR-1372 T5).
290
+ const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
278
291
  if (hvSection) {
279
- const lines = hvSection[1].split('\n');
292
+ const lines = hvSection.body.split('\n');
280
293
  for (const line of lines) {
281
294
  // Match table rows: | N | description | ... |
282
295
  const tableMatch = line.match(/\|\s*(\d+)\s*\|\s*([^|]+)/);
@@ -37,14 +37,17 @@ exports.canonicalPlanStem = canonicalPlanStem;
37
37
  exports.phaseVariants = phaseVariants;
38
38
  exports.buildRoadmapPhaseVariants = buildRoadmapPhaseVariants;
39
39
  exports.buildNotStartedPhaseVariants = buildNotStartedPhaseVariants;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const phaseIdMod = require("./phase-id.cjs");
42
+ const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod;
40
43
  // ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
41
44
  // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
42
45
  // deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
43
- exports.phaseDirNameRe = /^(?:[A-Z]{1,6}-)?\d{2,}(?:-\d+)*(?:\.\d+)*-[\w-]+$/i;
46
+ exports.phaseDirNameRe = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`, 'i');
44
47
  // Extracts the full phase token from a directory name, including milestone-prefixed
45
48
  // multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
46
49
  // Greedily captures all leading all-digit segments before the first letter-start segment.
47
- exports.PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+(?:-\d+)*[A-Z]?(?:\.\d+)*)(?:-[a-z]|$)/i;
50
+ exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d+)*[A-Z]?(?:\\.\\d+)*)(?:-[a-z]|$)`, 'i');
48
51
  exports.MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
49
52
  // ── Issue #26: I001 canonicalization ────────────────────────────────────────
50
53
  function canonicalPlanStem(stem) {
@@ -50,7 +50,7 @@ const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = ro
50
50
  // eslint-disable-next-line @typescript-eslint/no-require-imports
51
51
  const worktreeSafetyMod = require("./worktree-safety.cjs");
52
52
  const { inspectWorktreeHealth } = worktreeSafetyMod;
53
- const { planningDir } = planningWorkspace;
53
+ const { planningDir, planningRoot } = planningWorkspace;
54
54
  const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
55
55
  const { writeStateMd } = stateMod;
56
56
  const { MODEL_PROFILES } = modelProfilesMod;
@@ -1112,12 +1112,18 @@ function cmdValidateHealth(cwd, options, raw) {
1112
1112
  }, raw);
1113
1113
  return;
1114
1114
  }
1115
- const planBase = planningDir(cwd);
1116
- const projectPath = node_path_1.default.join(planBase, 'PROJECT.md');
1117
- const roadmapPath = node_path_1.default.join(planBase, 'ROADMAP.md');
1118
- const statePath = node_path_1.default.join(planBase, 'STATE.md');
1119
- const configPath = node_path_1.default.join(planBase, 'config.json');
1120
- const phasesDir = node_path_1.default.join(planBase, 'phases');
1115
+ // rootBase always resolves to .planning/ (shared root — PROJECT.md, config.json live here)
1116
+ // wsBase resolves to .planning/workstreams/<ws>/ when GSD_WORKSTREAM is set (STATE.md, ROADMAP.md, phases/)
1117
+ const rootBase = planningRoot(cwd);
1118
+ const wsBase = planningDir(cwd);
1119
+ // planBase is kept as an alias for wsBase for all the internal helpers (collectDiskPhases, etc.)
1120
+ // that are already parameterised on the workstream-aware path.
1121
+ const planBase = wsBase;
1122
+ const projectPath = node_path_1.default.join(rootBase, 'PROJECT.md');
1123
+ const roadmapPath = node_path_1.default.join(wsBase, 'ROADMAP.md');
1124
+ const statePath = node_path_1.default.join(wsBase, 'STATE.md');
1125
+ const configPath = node_path_1.default.join(rootBase, 'config.json');
1126
+ const phasesDir = node_path_1.default.join(wsBase, 'phases');
1121
1127
  const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1122
1128
  const slash = (name) => (0, runtime_slash_cjs_1.formatGsdSlash)(name, _slashRuntime);
1123
1129
  const errors = [];
@@ -1133,7 +1139,7 @@ function cmdValidateHealth(cwd, options, raw) {
1133
1139
  else
1134
1140
  info.push(issue);
1135
1141
  };
1136
- if (!node_fs_1.default.existsSync(planBase)) {
1142
+ if (!node_fs_1.default.existsSync(rootBase)) {
1137
1143
  addIssue('error', 'E001', '.planning/ directory not found', `Run ${slash('new-project')} to initialize`);
1138
1144
  output({ status: 'broken', errors, warnings, info, repairable_count: 0 }, raw);
1139
1145
  return;
@@ -1404,7 +1410,17 @@ function cmdValidateHealth(cwd, options, raw) {
1404
1410
  continue;
1405
1411
  }
1406
1412
  if (finding['kind'] === 'stale') {
1407
- addIssue('warning', 'W017', `Stale git worktree: ${finding['path']} (last modified ${finding['ageMinutes']} minutes ago)`, `Run: git worktree remove ${finding['path']} --force`);
1413
+ // Do not flag the active session's worktree — removing it would be harmful.
1414
+ const worktreePath = finding['path'];
1415
+ const activeCwd = process.cwd();
1416
+ const normalizedWorktree = node_path_1.default.resolve(worktreePath);
1417
+ const normalizedCwd = node_path_1.default.resolve(activeCwd);
1418
+ // Skip if the worktree IS the cwd or is an ancestor of it.
1419
+ const isActiveWorktree = normalizedCwd === normalizedWorktree ||
1420
+ normalizedCwd.startsWith(normalizedWorktree + node_path_1.default.sep);
1421
+ if (isActiveWorktree)
1422
+ continue;
1423
+ addIssue('warning', 'W017', `Stale git worktree: ${worktreePath} (last modified ${finding['ageMinutes']} minutes ago)`, `Run: git worktree remove ${worktreePath} --force`);
1408
1424
  }
1409
1425
  }
1410
1426
  }
@@ -1440,8 +1456,8 @@ function cmdValidateHealth(cwd, options, raw) {
1440
1456
  catch {
1441
1457
  /* W021 check is advisory — skip on error */
1442
1458
  }
1443
- const milestonesPath = node_path_1.default.join(planBase, 'MILESTONES.md');
1444
- const milestonesArchiveDir = node_path_1.default.join(planBase, 'milestones');
1459
+ const milestonesPath = node_path_1.default.join(rootBase, 'MILESTONES.md');
1460
+ const milestonesArchiveDir = node_path_1.default.join(rootBase, 'milestones');
1445
1461
  const missingFromRegistry = [];
1446
1462
  try {
1447
1463
  if (node_fs_1.default.existsSync(milestonesArchiveDir)) {
@@ -1470,7 +1486,7 @@ function cmdValidateHealth(cwd, options, raw) {
1470
1486
  /* intentionally empty — milestone sync check is advisory */
1471
1487
  }
1472
1488
  try {
1473
- const entries = node_fs_1.default.readdirSync(planBase, { withFileTypes: true });
1489
+ const entries = node_fs_1.default.readdirSync(rootBase, { withFileTypes: true });
1474
1490
  for (const entry of entries) {
1475
1491
  if (!entry.isFile())
1476
1492
  continue;
@@ -1566,7 +1582,7 @@ function cmdValidateHealth(cwd, options, raw) {
1566
1582
  }
1567
1583
  const milestone = getMilestoneInfo(cwd);
1568
1584
  const projectRef = node_path_1.default
1569
- .relative(cwd, node_path_1.default.join(planningDir(cwd), 'PROJECT.md'))
1585
+ .relative(cwd, node_path_1.default.join(rootBase, 'PROJECT.md'))
1570
1586
  .split(node_path_1.default.sep)
1571
1587
  .join('/');
1572
1588
  let stateContent = `# Session State\n\n`;
@@ -1871,8 +1887,17 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1871
1887
  else if (status === 'D')
1872
1888
  deleted.push(file);
1873
1889
  }
1874
- const config = loadConfig(cwd);
1875
- const wf = config?.workflow;
1890
+ // loadConfig() returns a flattened object — there is no nested `workflow`
1891
+ // key. Read the raw config.json directly to access workflow-scoped keys,
1892
+ // matching the pattern used in check-command-router.cts:readWorkflowConfig.
1893
+ let wf;
1894
+ try {
1895
+ const rawCfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf-8'));
1896
+ wf = rawCfg['workflow'];
1897
+ }
1898
+ catch {
1899
+ wf = undefined;
1900
+ }
1876
1901
  const threshold = Number.isInteger(wf?.drift_threshold) && wf?.drift_threshold >= 1
1877
1902
  ? wf?.drift_threshold
1878
1903
  : 3;
@@ -693,6 +693,206 @@ function cmdWorktreeCleanupWave(cwd, args = []) {
693
693
  process.exitCode = 1;
694
694
  }
695
695
  }
696
+ /**
697
+ * Pure planner for the per-agent wave-manifest append.
698
+ *
699
+ * Validates the candidate entry at write time using the SAME rules the
700
+ * cleanup-wave reader enforces (via `normalizeCleanupManifestEntry`), so an
701
+ * entry that `record-agent` accepts is guaranteed to survive
702
+ * `normalizeCleanupManifest` on read — a field that would be silently dropped
703
+ * at cleanup time fails loudly here instead.
704
+ *
705
+ * `agent_id` is treated write-strict (required) even though the reader is
706
+ * lenient (nullable): the whole point of this verb is to catch an
707
+ * under-populated entry at write time, and an entry whose author cannot be
708
+ * identified defeats that. A duplicate `(worktree_path, branch)` is also
709
+ * rejected loudly — the reader dedups on that key, so a re-record would be
710
+ * silently dropped (the failure mode this verb exists to eliminate). The
711
+ * on-disk shape stays the existing 4-field entry (`agent_id`, `worktree_path`,
712
+ * `branch`, `expected_base`) — no schema change; the reader re-derives
713
+ * `allowed_bases`.
714
+ */
715
+ function planWorktreeRecordAgent(manifestRaw, fields) {
716
+ // 1. Write-strict required-field check (loud, with which flag is missing).
717
+ // Trim first so a whitespace-only value (" ") is rejected here rather
718
+ // than deferred to a guaranteed `git worktree remove` failure at cleanup.
719
+ const agentId = (fields.agentId || '').trim();
720
+ const worktreePath = (fields.worktreePath || '').trim();
721
+ const branch = (fields.branch || '').trim();
722
+ const base = (fields.base || '').trim();
723
+ const missing = [];
724
+ if (!agentId)
725
+ missing.push('--agent-id');
726
+ if (!worktreePath)
727
+ missing.push('--path');
728
+ if (!branch)
729
+ missing.push('--branch');
730
+ if (!base)
731
+ missing.push('--base');
732
+ if (missing.length > 0) {
733
+ return {
734
+ ok: false,
735
+ reason: 'missing_field',
736
+ hint: `record-agent requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
737
+ entry: null,
738
+ manifest: null,
739
+ };
740
+ }
741
+ // 2. Shared validation: run the candidate through the reader's normalizer.
742
+ // If it returns null the reader would drop this entry on read — reject now.
743
+ const candidate = {
744
+ agent_id: agentId,
745
+ worktree_path: worktreePath,
746
+ branch,
747
+ expected_base: base,
748
+ };
749
+ const entry = normalizeCleanupManifestEntry(candidate);
750
+ if (!entry) {
751
+ return {
752
+ ok: false,
753
+ reason: 'invalid_entry',
754
+ hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ^worktree-agent-[A-Za-z0-9._/-]+$ (got branch="${branch}"). Fix the field and re-run.`,
755
+ entry: null,
756
+ manifest: null,
757
+ };
758
+ }
759
+ // 3. Parse the existing manifest. The init shell ({orchestrator_root, worktrees: []})
760
+ // is written inline by the orchestrator before any agent spawns; a missing or
761
+ // malformed manifest is a loud failure here, not a silent under-populated write.
762
+ let parsed;
763
+ try {
764
+ parsed = JSON.parse(manifestRaw);
765
+ }
766
+ catch {
767
+ return {
768
+ ok: false,
769
+ reason: 'invalid_manifest_json',
770
+ hint: 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before recording agents.',
771
+ entry: null,
772
+ manifest: null,
773
+ };
774
+ }
775
+ // Accept the canonical {worktrees: []} shell or a bare top-level array (both
776
+ // are read by normalizeCleanupManifest); preserve any other top-level keys.
777
+ let worktrees;
778
+ let writeBack;
779
+ if (Array.isArray(parsed)) {
780
+ worktrees = parsed;
781
+ writeBack = worktrees;
782
+ }
783
+ else if (parsed && typeof parsed === 'object') {
784
+ const container = parsed;
785
+ if (container.worktrees === undefined)
786
+ container.worktrees = [];
787
+ if (!Array.isArray(container.worktrees)) {
788
+ return {
789
+ ok: false,
790
+ reason: 'manifest_shape_invalid',
791
+ hint: 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.',
792
+ entry: null,
793
+ manifest: null,
794
+ };
795
+ }
796
+ worktrees = container.worktrees;
797
+ writeBack = container;
798
+ }
799
+ else {
800
+ return {
801
+ ok: false,
802
+ reason: 'manifest_shape_invalid',
803
+ hint: 'Manifest must be a JSON object {"worktrees": []} or a top-level array.',
804
+ entry: null,
805
+ manifest: null,
806
+ };
807
+ }
808
+ // 4. Reject a duplicate (worktree_path, branch). The reader dedups on this
809
+ // exact key, but only over entries that NORMALIZE successfully — so an
810
+ // existing malformed same-key entry (which the reader would drop) must NOT
811
+ // block recording a valid one. Run each existing entry through the reader's
812
+ // own normalizer and compare only the entries the reader would keep; this
813
+ // matches its dedup behavior exactly. A real duplicate signals an upstream
814
+ // double-spawn — surface it loudly instead of silently dropping it.
815
+ const dupKey = `${entry.worktree_path}\0${entry.branch}`;
816
+ const isDuplicate = worktrees.some((existing) => {
817
+ const normalized = normalizeCleanupManifestEntry(existing);
818
+ return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dupKey;
819
+ });
820
+ if (isDuplicate) {
821
+ return {
822
+ ok: false,
823
+ reason: 'duplicate_entry',
824
+ hint: `The manifest already records worktree_path="${entry.worktree_path}" branch="${entry.branch}". The cleanup reader dedups on (worktree_path, branch), so re-recording would be silently dropped — this usually signals an upstream double-spawn. Investigate rather than re-record.`,
825
+ entry: null,
826
+ manifest: null,
827
+ };
828
+ }
829
+ // 5. Append the minimal 4-field entry, matching the existing on-disk format.
830
+ const recorded = {
831
+ agent_id: entry.agent_id,
832
+ worktree_path: entry.worktree_path,
833
+ branch: entry.branch,
834
+ expected_base: entry.expected_base,
835
+ };
836
+ worktrees.push(recorded);
837
+ return {
838
+ ok: true,
839
+ reason: 'ok',
840
+ entry: recorded,
841
+ manifest: `${JSON.stringify(writeBack, null, 2)}\n`,
842
+ };
843
+ }
844
+ /**
845
+ * CLI command: append a validated per-agent entry to a wave cleanup manifest.
846
+ *
847
+ * Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
848
+ *
849
+ * Fails loudly (non-zero exit + recovery hint on stderr) when a field is
850
+ * missing/garbled or the manifest is absent/malformed, rather than appending an
851
+ * under-populated entry that the cleanup reader would silently drop.
852
+ */
853
+ function cmdWorktreeRecordAgent(cwd, args = [], deps = {}) {
854
+ const flag = (name) => {
855
+ const i = args.indexOf(name);
856
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : '';
857
+ };
858
+ const write = deps.write || ((s) => process.stdout.write(s));
859
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
860
+ const manifestPath = flag('--manifest');
861
+ if (!manifestPath) {
862
+ writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
863
+ process.exitCode = 2;
864
+ return { ok: false, reason: 'usage', entry: null };
865
+ }
866
+ const resolved = node_path_1.default.resolve(cwd, manifestPath);
867
+ const readFile = deps.readFile || ((p) => node_fs_1.default.readFileSync(p, 'utf8'));
868
+ let manifestRaw;
869
+ try {
870
+ manifestRaw = readFile(resolved);
871
+ }
872
+ catch (err) {
873
+ const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before recording agents.`;
874
+ writeErr(`[gsd] worktree.record-agent: manifest_read_failed — ${hint}\n`);
875
+ write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: err.message }, null, 2)}\n`);
876
+ process.exitCode = 1;
877
+ return { ok: false, reason: 'manifest_read_failed', hint, entry: null };
878
+ }
879
+ const plan = planWorktreeRecordAgent(manifestRaw, {
880
+ agentId: flag('--agent-id'),
881
+ worktreePath: flag('--path'),
882
+ branch: flag('--branch'),
883
+ base: flag('--base'),
884
+ });
885
+ if (!plan.ok || plan.manifest === null) {
886
+ writeErr(`[gsd] worktree.record-agent: ${plan.reason} — ${plan.hint || ''}\n`);
887
+ write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
888
+ process.exitCode = 1;
889
+ return { ok: false, reason: plan.reason, hint: plan.hint, entry: null };
890
+ }
891
+ const writeFile = deps.writeFile || ((p, content) => node_fs_1.default.writeFileSync(p, content, 'utf8'));
892
+ writeFile(resolved, plan.manifest);
893
+ write(`${JSON.stringify({ ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }, null, 2)}\n`);
894
+ return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
895
+ }
696
896
  /**
697
897
  * Reap orphaned linked worktrees whose lock owner process is dead, whose
698
898
  * branch tip is fully merged into the default branch, and whose lock file
@@ -978,6 +1178,8 @@ module.exports = {
978
1178
  planWorktreeWaveCleanup,
979
1179
  executeWorktreeWaveCleanupPlan,
980
1180
  cmdWorktreeCleanupWave,
1181
+ planWorktreeRecordAgent,
1182
+ cmdWorktreeRecordAgent,
981
1183
  reapOrphanWorktrees,
982
1184
  cmdWorktreeReapOrphans,
983
1185
  resolveWorktreeRoot,
@@ -53,7 +53,8 @@
53
53
  "post_planning_gaps": true,
54
54
  "security_enforcement": true,
55
55
  "security_asvs_level": 1,
56
- "security_block_on": "high"
56
+ "security_block_on": "high",
57
+ "context_guard_mode": "warn"
57
58
  },
58
59
  "planning": {
59
60
  "commit_docs": true,
@@ -93,5 +94,9 @@
93
94
  "plan_review": {
94
95
  "source_grounding": true,
95
96
  "source_grounding_authority": "grep"
97
+ },
98
+ "capabilities": {
99
+ "strict_known_registries": null,
100
+ "auto_update": false
96
101
  }
97
102
  }
@@ -55,6 +55,8 @@
55
55
  "workflow.subagent_timeout",
56
56
  "workflow.test_command",
57
57
  "workflow.build_command",
58
+ "workflow.mvp_mode",
59
+ "workflow.context_guard_mode",
58
60
  "executor.stall_detect_interval_minutes",
59
61
  "executor.stall_threshold_minutes",
60
62
  "workflow.inline_plan_threshold",
@@ -91,7 +93,9 @@
91
93
  "model_policy.high",
92
94
  "model_policy.medium",
93
95
  "model_policy.low",
94
- "agent_skills_security.trusted_global_roots"
96
+ "agent_skills_security.trusted_global_roots",
97
+ "capabilities.strict_known_registries",
98
+ "capabilities.auto_update"
95
99
  ],
96
100
  "runtimeStateKeys": [
97
101
  "workflow._auto_chain_active"