@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-roadmapper.md +6 -0
- package/bin/install.js +199 -365
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +695 -5
- package/gsd-core/bin/lib/adr-parser.cjs +45 -23
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +98 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +7 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-id.cjs +25 -11
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +33 -4
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
- package/gsd-core/bin/lib/roadmap.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +40 -15
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-context-guard.md +16 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +4 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +12 -12
- package/gsd-core/workflows/help/modes/full.md +10 -0
- package/gsd-core/workflows/list-seeds.md +63 -0
- package/gsd-core/workflows/manager.md +37 -37
- package/gsd-core/workflows/pr-branch.md +156 -0
- package/gsd-core/workflows/quick.md +6 -1
- package/gsd-core/workflows/review.md +10 -2
- package/gsd-core/workflows/spec-phase.md +8 -3
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/package.json +6 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/prompt-injection-scan.sh +1 -0
- package/scripts/run-tests.cjs +14 -0
- 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
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
217
|
-
|
|
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 =
|
|
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 =
|
|
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,
|