@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
@@ -24,7 +24,13 @@
24
24
  */
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
26
  const ioMod = require("./io.cjs");
27
- const { output: coreOutput, error: coreError } = ioMod;
27
+ const { output: coreOutput } = ioMod;
28
+ // ExitError (NOT process.exit) is how every gsd-tools command signals a non-zero exit: runMain
29
+ // translates it to process.exitCode so buffered stdout flushes first. Calling process.exit() here
30
+ // truncates a just-written --raw JSON payload before the reader sees it (a real silent-output bug).
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ const cliExitMod = require("./cli-exit.cjs");
33
+ const { ExitError } = cliExitMod;
28
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
35
  const capabilityStateMod = require("./capability-state.cjs");
30
36
  const { resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir } = capabilityStateMod;
@@ -322,7 +328,8 @@ function cmdCapabilitySet(cwd, runtimeConfigDir, capId, options, raw) {
322
328
  // Do NOT print human stderr lines — raw consumers parse the JSON.
323
329
  coreOutput({ capabilities: result.capabilities, warnings: result.warnings, errors: result.errors }, true);
324
330
  if (result.errors.length > 0) {
325
- process.exit(1);
331
+ // Throw (don't process.exit) so the JSON written just above flushes before the process ends.
332
+ throw new ExitError(1);
326
333
  }
327
334
  return;
328
335
  }
@@ -333,10 +340,12 @@ function cmdCapabilitySet(cwd, runtimeConfigDir, capId, options, raw) {
333
340
  for (const e of result.errors) {
334
341
  process.stderr.write(`capability set: error: ${e}\n`);
335
342
  }
336
- // Exit non-zero if any errors (hard failures — requested action was not realized).
343
+ // Exit non-zero if any errors (hard failures — requested action was not realized). The per-error
344
+ // lines were already written to stderr above; signal the exit code via ExitError (not process.exit)
345
+ // so any pending stdout/stderr flushes — runMain maps it to process.exitCode.
337
346
  if (result.errors.length > 0) {
338
- coreError(`capability set: ${String(result.errors.length)} error(s) — see above`);
339
- return; // unreachable — coreError calls process.exit(1)
347
+ process.stderr.write(`Error: capability set: ${String(result.errors.length)} error(s) — see above\n`);
348
+ throw new ExitError(1);
340
349
  }
341
350
  // Human-readable summary: focus on the target capability
342
351
  const cap = result.capabilities.find((c) => c.id === capId);
@@ -22,6 +22,7 @@ const { planningDir } = planningWorkspaceMod;
22
22
  const phaseLocatorMod = require("./phase-locator.cjs");
23
23
  const { findPhaseInternal } = phaseLocatorMod;
24
24
  const decisions_cjs_1 = require("./decisions.cjs");
25
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
25
26
  const ui_safety_gate_cjs_1 = require("./ui-safety-gate.cjs");
26
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
28
  const verifyModule = require("./verify.cjs");
@@ -128,10 +129,11 @@ function loadPlanContents(phaseDir) {
128
129
  const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
129
130
  const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
130
131
  function stripCommentsAndFences(text) {
131
- return text
132
- .replace(/<!--[\s\S]*?-->/g, ' ')
133
- .replace(/```[\s\S]*?```/g, ' ')
134
- .replace(/~~~[\s\S]*?~~~/g, ' ');
132
+ // HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
133
+ const htmlStripped = text.replace(/<!--[\s\S]*?-->/g, ' ');
134
+ // Fenced-code stripping: delegate to the canonical CommonMark-correct seam.
135
+ // replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`).
136
+ return (0, markdown_sectionizer_cjs_1.stripFencedCode)(htmlStripped).text;
135
137
  }
136
138
  function extractYamlBlock(frontmatter, key) {
137
139
  const match = frontmatter.match(new RegExp(`^${key}\\s*:(.*)$`, 'm'));
@@ -169,18 +171,19 @@ function extractPlanDesignatedSections(planContent) {
169
171
  if (block)
170
172
  parts.push(block);
171
173
  }
174
+ // Replace hand-rolled split(/\r?\n/) + heading walk with the seam's collectSections.
175
+ // stopPredicate fires on EVERY heading (collectSections needs to start a section at
176
+ // each heading), then we filter to designated ones — same semantics as the prior
177
+ // inDesignated flag: emit the heading line + body only when DESIGNATED_HEADINGS_RE matches.
178
+ const sections = (0, markdown_sectionizer_cjs_1.collectSections)(body, () => true);
172
179
  const bodyParts = [];
173
- let inDesignated = false;
174
- for (const line of body.split(/\r?\n/)) {
175
- const heading = /^#{1,6}\s+/.test(line);
176
- if (heading) {
177
- inDesignated = DESIGNATED_HEADINGS_RE.test(line);
178
- if (inDesignated)
179
- bodyParts.push(line);
180
- continue;
180
+ for (const section of sections) {
181
+ const headingLine = '#'.repeat(section.heading.level) + ' ' + section.heading.text;
182
+ if (DESIGNATED_HEADINGS_RE.test(headingLine)) {
183
+ bodyParts.push(headingLine);
184
+ if (section.body)
185
+ bodyParts.push(section.body);
181
186
  }
182
- if (inDesignated)
183
- bodyParts.push(line);
184
187
  }
185
188
  parts.push(bodyParts.join('\n'));
186
189
  parts.push(extractXmlTagBodies(cleaned));
@@ -213,8 +216,12 @@ function buildVerifyMessage(notHonored) {
213
216
  'This is a soft warning - verification status is unchanged.',
214
217
  ].join('\n');
215
218
  }
216
- function loadTrackableDecisions(contextPath) {
217
- return (0, decisions_cjs_1.parseDecisions)(readIfExists(contextPath)).filter((decision) => decision.trackable);
219
+ function loadDecisionExtraction(contextPath) {
220
+ const extraction = (0, decisions_cjs_1.extractDecisions)(readIfExists(contextPath));
221
+ return {
222
+ trackable: extraction.decisions.filter((d) => d.trackable),
223
+ outcome: extraction.outcome,
224
+ };
218
225
  }
219
226
  function cmdDecisionCoveragePlan(projectDir, args, raw) {
220
227
  const phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
@@ -227,7 +234,31 @@ function cmdDecisionCoveragePlan(projectDir, args, raw) {
227
234
  output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
228
235
  return;
229
236
  }
230
- const decisions = loadTrackableDecisions(contextPath);
237
+ const { trackable: decisions, outcome } = loadDecisionExtraction(contextPath);
238
+ // #1365 fail-loud gate: any could-not-parse outcome must NOT silently pass —
239
+ // even when some decisions were extracted (e.g. D-01 valid but D-02 malformed).
240
+ // A parse-miss on ANY bullet means the gate cannot certify full coverage.
241
+ // Fire independent of decisions.length so a partial-parse still blocks.
242
+ if (outcome === 'could-not-parse') {
243
+ const partialParse = decisions.length > 0;
244
+ output({
245
+ passed: false,
246
+ skipped: false,
247
+ reason: 'could-not-parse',
248
+ total: decisions.length,
249
+ covered: 0,
250
+ uncovered: [],
251
+ message: partialParse
252
+ ? 'Decision coverage gate: decisions could not be fully parsed — one or more ' +
253
+ '`- **D-NN ...**` bullets appear malformed (missing `:` or ` — ` separator). ' +
254
+ 'Fix the bullet format so all D-NN decisions can be read before re-running the gate.'
255
+ : 'Decision coverage gate: could not parse decisions — possible format mismatch. ' +
256
+ 'The CONTEXT.md appears to be decision-shaped (has a <decisions> block, a decisions heading, ' +
257
+ 'or D- tokens) but no D-NN bullets could be extracted. Check the formatting of the decisions ' +
258
+ 'block and ensure bullets follow the `- **D-NN:** text` or `- **D-NN — title** body` form.',
259
+ }, raw, undefined);
260
+ return;
261
+ }
231
262
  if (decisions.length === 0) {
232
263
  output({ passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
233
264
  return;
@@ -305,7 +336,27 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
305
336
  output({ skipped: true, blocking: false, reason: 'CONTEXT.md missing', total: 0, honored: 0, not_honored: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
306
337
  return;
307
338
  }
308
- const decisions = loadTrackableDecisions(contextPath);
339
+ const { trackable: decisions, outcome: decisionOutcome } = loadDecisionExtraction(contextPath);
340
+ // Mirror could-not-parse surface for verify (non-blocking advisory WARN).
341
+ // Fire independent of decisions.length — a parse-miss on any bullet must surface,
342
+ // even when some decisions were partially extracted (#1365 fix-parity with plan gate).
343
+ if (decisionOutcome === 'could-not-parse') {
344
+ const partialParse = decisions.length > 0;
345
+ output({
346
+ skipped: false,
347
+ blocking: false,
348
+ reason: 'could-not-parse',
349
+ total: decisions.length,
350
+ honored: 0,
351
+ not_honored: [],
352
+ message: partialParse
353
+ ? 'Decision coverage verify (warning): decisions could not be fully parsed — one or more ' +
354
+ '`- **D-NN ...**` bullets appear malformed. Fix the bullet format in the CONTEXT.md decisions block.'
355
+ : 'Decision coverage verify (warning): could not parse decisions — possible format mismatch. ' +
356
+ 'Check the formatting of the CONTEXT.md decisions block.',
357
+ }, raw, undefined);
358
+ return;
359
+ }
309
360
  if (decisions.length === 0) {
310
361
  output({ skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
311
362
  return;
@@ -444,6 +444,14 @@ exports.PHASE_COMMAND_ALIASES = [
444
444
  ],
445
445
  "subcommand": "scaffold",
446
446
  "mutation": true
447
+ },
448
+ {
449
+ "canonical": "phase.list-plans",
450
+ "aliases": [
451
+ "phase list-plans"
452
+ ],
453
+ "subcommand": "list-plans",
454
+ "mutation": false
447
455
  }
448
456
  ];
449
457
  exports.PHASES_COMMAND_ALIASES = [
@@ -12,6 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
15
+ const security_cjs_1 = require("./security.cjs");
15
16
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
17
  const ioMod = require("./io.cjs");
17
18
  const { output, error } = ioMod;
@@ -144,6 +145,110 @@ function cmdListTodos(cwd, area, raw) {
144
145
  const result = { count, todos };
145
146
  output(result, raw, count.toString());
146
147
  }
148
+ /**
149
+ * List captured seeds from .planning/seeds/SEED-*.md for browsing/audit (#441).
150
+ *
151
+ * Unlike audit.scanSeeds (which returns only *unimplemented* seeds for the
152
+ * milestone surface), this lists seeds of every status with the richer fields a
153
+ * human audit needs (scope, trigger, planted date). An optional case-insensitive
154
+ * status filter narrows the set. Seed content is user-controlled, so every
155
+ * displayed field is passed through sanitizeForDisplay and each file path is
156
+ * validated with requireSafePath before reading. Read-only — never mutates.
157
+ */
158
+ /**
159
+ * Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
160
+ * frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
161
+ *
162
+ * seed_id: frontmatter `id:` when it matches `SEED-NNN`, else the numeric prefix
163
+ * of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
164
+ * remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
165
+ * `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
166
+ */
167
+ function deriveSeedIdentity(stem, rawFmId) {
168
+ const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
169
+ let seedId;
170
+ if (/^SEED-\d+$/i.test(fmId)) {
171
+ seedId = fmId;
172
+ }
173
+ else {
174
+ const numMatch = stem.match(/^(SEED-\d+)/i);
175
+ seedId = numMatch ? numMatch[1] : stem;
176
+ }
177
+ const slugMatch = stem.match(/^SEED-\d+-(.+)$/i);
178
+ const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
179
+ return { seed_id: seedId, slug };
180
+ }
181
+ function cmdListSeeds(cwd, statusFilter, raw) {
182
+ const planDir = planningDir(cwd);
183
+ const seedsDir = node_path_1.default.join(planDir, 'seeds');
184
+ const wantStatus = statusFilter ? statusFilter.trim().toLowerCase() : null;
185
+ const seeds = [];
186
+ const summary = {};
187
+ // Frontmatter values are not guaranteed to be scalars: extractFrontmatter
188
+ // yields {} for a bare `key:` line and an array for `key: [a, b]`. Coerce every
189
+ // read to a string so one malformed seed cannot crash the whole audit list
190
+ // (`.toLowerCase()` on a non-string throws) or leak a raw object/array into the
191
+ // JSON contract. Mirrors the existing `typeof fm.id === 'string'` guard below.
192
+ const fmStr = (v) => (typeof v === 'string' ? v : '');
193
+ let files;
194
+ try {
195
+ files = node_fs_1.default.readdirSync(seedsDir, { withFileTypes: true });
196
+ }
197
+ catch {
198
+ // No seeds dir (or unreadable) — an empty, non-error result. The seed dir is
199
+ // created lazily by the first plant-seed, so absence is the normal zero case.
200
+ output({ count: 0, seeds: [], summary: {} }, raw, '0');
201
+ return;
202
+ }
203
+ for (const entry of files) {
204
+ if (!entry.isFile())
205
+ continue;
206
+ if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md'))
207
+ continue;
208
+ let safeFilePath;
209
+ try {
210
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file', { allowAbsolute: true });
211
+ }
212
+ catch {
213
+ continue;
214
+ }
215
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
216
+ if (content === null)
217
+ continue;
218
+ const fm = extractFrontmatter(content);
219
+ const status = (fmStr(fm.status) || 'dormant').toLowerCase().trim() || 'dormant';
220
+ // Match on the raw lowercased status (both sides already normalized);
221
+ // sanitizeForDisplay is for output, not comparison.
222
+ if (wantStatus && status !== wantStatus)
223
+ continue;
224
+ // Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
225
+ // back to the numeric prefix of the filename, then to the whole stem. The
226
+ // descriptive remainder of the filename (`SEED-NNN-<slug>.md`) is the slug.
227
+ const stem = node_path_1.default.basename(entry.name, '.md');
228
+ const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
229
+ let title = (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.title).slice(0, 100));
230
+ if (!title) {
231
+ const headingMatch = content.match(/^#\s*(.+)$/m);
232
+ if (headingMatch)
233
+ title = (0, security_cjs_1.sanitizeForDisplay)(headingMatch[1].trim().slice(0, 100));
234
+ }
235
+ const safeStatus = (0, security_cjs_1.sanitizeForDisplay)(status);
236
+ summary[safeStatus] = (summary[safeStatus] || 0) + 1;
237
+ seeds.push({
238
+ seed_id: (0, security_cjs_1.sanitizeForDisplay)(seedId),
239
+ slug: (0, security_cjs_1.sanitizeForDisplay)(slug),
240
+ status: safeStatus,
241
+ scope: (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.scope) || 'unknown'),
242
+ trigger_when: (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.trigger_when)),
243
+ planted: (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.planted)),
244
+ title,
245
+ path: toPosixPath(node_path_1.default.relative(cwd, safeFilePath)),
246
+ });
247
+ }
248
+ // Stable order: by seed_id so output is deterministic across filesystems.
249
+ seeds.sort((a, b) => a.seed_id.localeCompare(b.seed_id));
250
+ output({ count: seeds.length, seeds, summary }, raw, seeds.length.toString());
251
+ }
147
252
  function cmdVerifyPathExists(cwd, targetPath, raw) {
148
253
  if (!targetPath) {
149
254
  error('path required for verification');
@@ -629,6 +734,145 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
629
734
  };
630
735
  output(result, raw, Object.entries(repos).map(([r, v]) => `${r}:${v.hash || 'skip'}`).join(' '));
631
736
  }
737
+ /**
738
+ * Prepare a sub-repo for a companion PR branch.
739
+ *
740
+ * Detects uncommitted changes, creates a new branch, stages every changed
741
+ * file explicitly (never git add -A per universal-anti-patterns.md:44), commits,
742
+ * and pushes with --set-upstream. Returns a structured result the workflow uses
743
+ * to call `gh pr create`.
744
+ *
745
+ * On a stage/commit failure (nothing committed yet), the branch is deleted and
746
+ * the caller is returned to the original HEAD so the repo is left clean. On a
747
+ * push failure, the commit already exists — the branch is left in place instead
748
+ * so the user's work is not lost; the error includes a retry instruction.
749
+ */
750
+ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
751
+ if (!repo) {
752
+ error('--repo required');
753
+ }
754
+ if (!branch) {
755
+ error('--branch required');
756
+ }
757
+ if (!commitMessage || commitMessage.startsWith('--')) {
758
+ error('commit message required');
759
+ }
760
+ if (branch.startsWith('-')) {
761
+ error(`Branch name must not start with '-': ${branch}`);
762
+ }
763
+ // 0. Security: validate repo path is contained within the workspace root.
764
+ // Uses security.cjs validatePath (symlink-safe realpathSync + startsWith guard)
765
+ // to reject ../escape, absolute paths, and symlink traversal.
766
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
767
+ const { validatePath } = require('./security.cjs');
768
+ const pathCheck = validatePath(repo, cwd);
769
+ if (!pathCheck.safe) {
770
+ error(`Sub-repo path is unsafe: ${pathCheck.error}`);
771
+ }
772
+ const repoCwd = pathCheck.resolved;
773
+ if (!node_fs_1.default.existsSync(repoCwd)) {
774
+ error(`Sub-repo not found: ${repoCwd}`);
775
+ }
776
+ // 1. Collect changed files via porcelain status — explicit, never git add -A.
777
+ // ?? (untracked) lines are excluded — only stage tracked modifications.
778
+ const statusResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotePath=false', 'status', '--porcelain'], { cwd: repoCwd });
779
+ if (statusResult.exitCode !== 0) {
780
+ error(`git status failed in ${repo}: ${statusResult.stderr}`);
781
+ }
782
+ // Parse porcelain output into two lists:
783
+ // changedFiles — all affected paths (old + new for renames) → goes into result.files
784
+ // filesToStage — paths to pass to git add (rename old-paths are already staged by
785
+ // the rename op and no longer exist in the worktree; only add new paths)
786
+ const changedFiles = [];
787
+ const filesToStage = [];
788
+ for (const line of statusResult.stdout.split('\n').filter(Boolean).filter(l => !l.startsWith('??'))) {
789
+ // execGit trims the entire stdout string, which may strip the leading X-status
790
+ // space from the first output line. Normalize before slicing.
791
+ const normalized = line.trimStart();
792
+ const file = normalized.slice(2).trim();
793
+ const arrowIdx = file.indexOf(' -> ');
794
+ if (arrowIdx !== -1) {
795
+ const oldPath = file.slice(0, arrowIdx).trim();
796
+ const newPath = file.slice(arrowIdx + 4).trim();
797
+ changedFiles.push(oldPath, newPath);
798
+ filesToStage.push(newPath); // old path already staged; worktree no longer has it
799
+ }
800
+ else {
801
+ changedFiles.push(file);
802
+ filesToStage.push(file);
803
+ }
804
+ }
805
+ if (changedFiles.length === 0) {
806
+ output({ ok: true, repo, branch, committed: false, reason: 'nothing_to_commit', files: [] }, raw, 'nothing_to_commit');
807
+ return;
808
+ }
809
+ // 2. Guard: refuse if branch already exists — checkout -b is non-idempotent
810
+ const branchCheck = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', branch], { cwd: repoCwd });
811
+ if (branchCheck.exitCode === 0) {
812
+ error(`Branch already exists in ${repo}: ${branch}. Delete it first or choose a unique name.`);
813
+ }
814
+ // Capture current HEAD before switching so rollback can return explicitly.
815
+ // git checkout - fails on a fresh single-branch repo with no prior HEAD.
816
+ const prevBranchResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoCwd });
817
+ const prevBranchName = prevBranchResult.exitCode === 0 ? prevBranchResult.stdout.trim() : null;
818
+ // 3. Create branch
819
+ const checkoutResult = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branch], { cwd: repoCwd });
820
+ if (checkoutResult.exitCode !== 0) {
821
+ error(`Failed to create branch ${branch} in ${repo}: ${checkoutResult.stderr}`);
822
+ }
823
+ // Helper: rollback the created branch and return to the previous HEAD.
824
+ const rollback = () => {
825
+ if (prevBranchName) {
826
+ (0, shell_command_projection_cjs_1.execGit)(['checkout', prevBranchName], { cwd: repoCwd });
827
+ }
828
+ (0, shell_command_projection_cjs_1.execGit)(['branch', '-D', branch], { cwd: repoCwd });
829
+ };
830
+ // 4. Stage explicit files (never git add -A per universal-anti-patterns.md:44)
831
+ for (const file of filesToStage) {
832
+ const addResult = (0, shell_command_projection_cjs_1.execGit)(['add', '--', file], { cwd: repoCwd });
833
+ if (addResult.exitCode !== 0) {
834
+ rollback();
835
+ error(`Failed to stage ${file} in ${repo}: ${addResult.stderr}`);
836
+ }
837
+ }
838
+ // 5. Commit
839
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(['commit', '-m', commitMessage], { cwd: repoCwd });
840
+ if (commitResult.exitCode !== 0) {
841
+ rollback();
842
+ error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
843
+ }
844
+ // 6. Capture commit hash
845
+ const hashResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--short', 'HEAD'], { cwd: repoCwd });
846
+ const commitHash = hashResult.exitCode === 0 ? hashResult.stdout.trim() : null;
847
+ // 7. Capture remote URL and derive GitHub owner/repo slug for gh pr create
848
+ const remoteResult = (0, shell_command_projection_cjs_1.execGit)(['remote', 'get-url', 'origin'], { cwd: repoCwd });
849
+ const remoteUrl = remoteResult.exitCode === 0 ? remoteResult.stdout.trim() : null;
850
+ let remoteSlug = null;
851
+ if (remoteUrl) {
852
+ const m = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/);
853
+ remoteSlug = m ? m[1] : null;
854
+ }
855
+ // 8. Push with --set-upstream so gh pr create can find the branch.
856
+ // Network operation — use a longer timeout than the default 10 s.
857
+ // Do NOT rollback on push failure — the commit already exists on the local branch.
858
+ // Deleting the branch here would destroy the only ref holding the user's work.
859
+ // Leave the branch in place so the user can retry the push.
860
+ const pushResult = (0, shell_command_projection_cjs_1.execGit)(['push', '--set-upstream', 'origin', branch], { cwd: repoCwd, timeout: 60_000 });
861
+ if (pushResult.exitCode !== 0) {
862
+ error(`Failed to push ${branch} in ${repo}: ${pushResult.stderr}\nBranch ${branch} was created locally — retry with: git -C ${repo} push --set-upstream origin ${branch}`);
863
+ }
864
+ const result = {
865
+ ok: true,
866
+ repo,
867
+ branch,
868
+ committed: true,
869
+ files: changedFiles,
870
+ commit_hash: commitHash,
871
+ remote_url: remoteUrl,
872
+ remote_slug: remoteSlug,
873
+ };
874
+ output(result, raw, `${repo}@${commitHash ?? 'unknown'}`);
875
+ }
632
876
  function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
633
877
  if (!summaryPath) {
634
878
  error('summary-path required for summary-extract');
@@ -1224,6 +1468,8 @@ module.exports = {
1224
1468
  cmdGenerateSlug,
1225
1469
  cmdCurrentTimestamp,
1226
1470
  cmdListTodos,
1471
+ cmdListSeeds,
1472
+ deriveSeedIdentity,
1227
1473
  cmdVerifyPathExists,
1228
1474
  cmdHistoryDigest,
1229
1475
  cmdResolveModel,
@@ -1232,6 +1478,7 @@ module.exports = {
1232
1478
  cmdEffortSync,
1233
1479
  cmdCommit,
1234
1480
  cmdCommitToSubrepo,
1481
+ cmdPrSubrepo,
1235
1482
  cmdSummaryExtract,
1236
1483
  cmdWebsearch,
1237
1484
  cmdProgressRender,