@opengsd/gsd-core 1.6.0-rc.1 → 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-roadmapper.md +6 -0
- package/bin/install.js +92 -332
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +18 -3
- package/gsd-core/bin/lib/adr-parser.cjs +21 -6
- package/gsd-core/bin/lib/capability-registry.cjs +48 -48
- package/gsd-core/bin/lib/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +6 -0
- package/gsd-core/bin/lib/config.cjs +6 -0
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- package/gsd-core/bin/lib/phase-id.cjs +25 -11
- package/gsd-core/bin/lib/phase.cjs +4 -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/roadmap-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +29 -8
- 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-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +11 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +2 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-context-guard.md +16 -0
- package/gsd-core/references/planning-config.md +1 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- package/gsd-core/workflows/execute-phase.md +8 -6
- 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 +4 -1
- package/scripts/prompt-injection-scan.sh +1 -0
|
@@ -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,
|
|
@@ -135,6 +135,12 @@ function _deepMergeConfig(base, overlay) {
|
|
|
135
135
|
return overlay;
|
|
136
136
|
const result = { ...base };
|
|
137
137
|
for (const key of Object.keys(overlay)) {
|
|
138
|
+
// Prototype-pollution guard — mirrors the four sibling guards in this file
|
|
139
|
+
// (lines ~315/319/331/341/549). Without it a workstream/root config.json with
|
|
140
|
+
// {"__proto__": {...}} pollutes this merged object's prototype chain and can
|
|
141
|
+
// spoof unset config flags. (Per-object pollution, not global Object.prototype.)
|
|
142
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype')
|
|
143
|
+
continue;
|
|
138
144
|
if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) {
|
|
139
145
|
result[key] = _deepMergeConfig((base[key] ?? {}), overlay[key]);
|
|
140
146
|
}
|
|
@@ -210,6 +210,7 @@ function buildNewProjectConfig(userChoices) {
|
|
|
210
210
|
ui_safety_gate: true,
|
|
211
211
|
ai_integration_phase: true,
|
|
212
212
|
human_verify_mode: 'end-of-phase',
|
|
213
|
+
context_guard_mode: 'warn',
|
|
213
214
|
text_mode: false,
|
|
214
215
|
research_before_questions: false,
|
|
215
216
|
discuss_mode: 'discuss',
|
|
@@ -556,6 +557,11 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
|
|
556
557
|
if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
|
|
557
558
|
error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
|
|
558
559
|
}
|
|
560
|
+
// Context exhaustion guard mode (#1452)
|
|
561
|
+
const VALID_CONTEXT_GUARD_MODES = ['auto', 'warn', 'off'];
|
|
562
|
+
if (kp === 'workflow.context_guard_mode' && !VALID_CONTEXT_GUARD_MODES.includes(String(parsedValue))) {
|
|
563
|
+
error(`Invalid workflow.context_guard_mode '${val}'. Valid values: ${VALID_CONTEXT_GUARD_MODES.join(', ')}`);
|
|
564
|
+
}
|
|
559
565
|
// Context position enum validation (#2937)
|
|
560
566
|
const VALID_CONTEXT_POSITIONS = ['front', 'end'];
|
|
561
567
|
if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
|
|
@@ -56,10 +56,14 @@ function extractFrontmatter(content) {
|
|
|
56
56
|
const frontmatter = {};
|
|
57
57
|
// Match frontmatter only at byte 0 — a `---` block later in the document
|
|
58
58
|
// body (YAML examples, horizontal rules) must never be treated as frontmatter.
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
59
|
+
const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
|
|
60
|
+
if (headerEnd === -1)
|
|
61
61
|
return frontmatter;
|
|
62
|
-
const
|
|
62
|
+
const closingLineStart = content.indexOf('\n---', headerEnd);
|
|
63
|
+
if (closingLineStart === -1)
|
|
64
|
+
return frontmatter;
|
|
65
|
+
const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
|
|
66
|
+
const yaml = content.slice(headerEnd, yamlEnd);
|
|
63
67
|
const lines = yaml.split(/\r?\n/);
|
|
64
68
|
const stack = [{ obj: frontmatter, key: null, indent: -1 }];
|
|
65
69
|
for (const line of lines) {
|
|
@@ -14,10 +14,24 @@
|
|
|
14
14
|
function escapeRegex(value) {
|
|
15
15
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
16
16
|
}
|
|
17
|
+
// project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
|
|
18
|
+
// leading underscores are not valid project codes per .planning/config.json.
|
|
19
|
+
const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
|
|
20
|
+
const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
|
|
21
|
+
const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
|
|
22
|
+
const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
|
|
23
|
+
function stripProjectCodePrefix(value, caseInsensitive = true) {
|
|
24
|
+
const input = String(value);
|
|
25
|
+
const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
|
|
26
|
+
return input.replace(re, '');
|
|
27
|
+
}
|
|
28
|
+
function hasProjectCodePrefix(value) {
|
|
29
|
+
return PROJECT_CODE_PREFIX_STRIP_RE_I.test(String(value));
|
|
30
|
+
}
|
|
17
31
|
function normalizePhaseName(phase) {
|
|
18
32
|
const str = String(phase);
|
|
19
33
|
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
|
20
|
-
const stripped = str
|
|
34
|
+
const stripped = stripProjectCodePrefix(str, false);
|
|
21
35
|
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
|
|
22
36
|
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
23
37
|
if (milestoneMatch) {
|
|
@@ -39,8 +53,7 @@ function normalizePhaseName(phase) {
|
|
|
39
53
|
return str;
|
|
40
54
|
}
|
|
41
55
|
function getMilestoneFromPhaseId(phaseId) {
|
|
42
|
-
const
|
|
43
|
-
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
56
|
+
const stripped = stripProjectCodePrefix(phaseId);
|
|
44
57
|
const m = stripped.match(/^0*(\d+)-\d/);
|
|
45
58
|
if (!m)
|
|
46
59
|
return null;
|
|
@@ -50,8 +63,7 @@ function getMilestoneFromPhaseId(phaseId) {
|
|
|
50
63
|
return `v${major}.0`;
|
|
51
64
|
}
|
|
52
65
|
function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
|
|
53
|
-
const
|
|
54
|
-
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
66
|
+
const stripped = stripProjectCodePrefix(phaseId);
|
|
55
67
|
const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
|
|
56
68
|
if (!m)
|
|
57
69
|
return null;
|
|
@@ -70,7 +82,7 @@ function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
|
|
|
70
82
|
* prose regardless of zero-padding on either side.
|
|
71
83
|
*/
|
|
72
84
|
function phaseMarkdownRegexSource(phaseNum) {
|
|
73
|
-
const stripped =
|
|
85
|
+
const stripped = stripProjectCodePrefix(phaseNum);
|
|
74
86
|
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
|
|
75
87
|
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
|
|
76
88
|
if (milestoneSegments && milestoneSegments[2]) {
|
|
@@ -99,14 +111,14 @@ function phaseMarkdownRegexSource(phaseNum) {
|
|
|
99
111
|
*/
|
|
100
112
|
function phaseMarkdownRegexSourceExact(phaseNum) {
|
|
101
113
|
const raw = String(phaseNum);
|
|
102
|
-
if (
|
|
114
|
+
if (!hasProjectCodePrefix(raw))
|
|
103
115
|
return null;
|
|
104
116
|
return escapeRegex(raw);
|
|
105
117
|
}
|
|
106
118
|
function comparePhaseNum(a, b) {
|
|
107
119
|
// Strip optional project_code prefix before comparing
|
|
108
|
-
const sa =
|
|
109
|
-
const sb =
|
|
120
|
+
const sa = stripProjectCodePrefix(a);
|
|
121
|
+
const sb = stripProjectCodePrefix(b);
|
|
110
122
|
const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
111
123
|
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
112
124
|
if (milestoneA && milestoneB) {
|
|
@@ -162,7 +174,7 @@ function comparePhaseNum(a, b) {
|
|
|
162
174
|
* Extract the phase token from a directory name.
|
|
163
175
|
*/
|
|
164
176
|
function extractPhaseToken(dirName) {
|
|
165
|
-
const codePrefixMatch = dirName.match(
|
|
177
|
+
const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
|
166
178
|
let prefix = '';
|
|
167
179
|
let rest = dirName;
|
|
168
180
|
if (codePrefixMatch) {
|
|
@@ -192,7 +204,7 @@ function phaseTokenMatches(dirName, normalized) {
|
|
|
192
204
|
const token = extractPhaseToken(dirName);
|
|
193
205
|
if (token.toUpperCase() === normalized.toUpperCase())
|
|
194
206
|
return true;
|
|
195
|
-
const stripped = dirName
|
|
207
|
+
const stripped = stripProjectCodePrefix(dirName);
|
|
196
208
|
if (stripped !== dirName) {
|
|
197
209
|
const strippedToken = extractPhaseToken(stripped);
|
|
198
210
|
if (strippedToken.toUpperCase() === normalized.toUpperCase())
|
|
@@ -202,6 +214,8 @@ function phaseTokenMatches(dirName, normalized) {
|
|
|
202
214
|
}
|
|
203
215
|
module.exports = {
|
|
204
216
|
escapeRegex,
|
|
217
|
+
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
|
218
|
+
stripProjectCodePrefix,
|
|
205
219
|
normalizePhaseName,
|
|
206
220
|
getMilestoneFromPhaseId,
|
|
207
221
|
getPhaseDirFromPhaseId,
|
|
@@ -32,7 +32,7 @@ const coreUtilsMod = require("./core-utils.cjs");
|
|
|
32
32
|
const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
|
|
33
33
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
34
34
|
const phaseIdMod = require("./phase-id.cjs");
|
|
35
|
-
const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches } = phaseIdMod;
|
|
35
|
+
const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, } = phaseIdMod;
|
|
36
36
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
|
37
37
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
38
38
|
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
|
|
@@ -167,7 +167,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
|
167
167
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
168
168
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
169
169
|
baseExists = dirs.some((d) => phaseTokenMatches(d, normalized));
|
|
170
|
-
const dirPattern = new RegExp(
|
|
170
|
+
const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
|
|
171
171
|
for (const dir of dirs) {
|
|
172
172
|
const match = dir.match(dirPattern);
|
|
173
173
|
if (match)
|
|
@@ -310,7 +310,7 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
310
310
|
const match = dirs.find((d) => phaseTokenMatches(d, normalized));
|
|
311
311
|
if (!match)
|
|
312
312
|
continue;
|
|
313
|
-
const dirMatch = match.match(
|
|
313
|
+
const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) ||
|
|
314
314
|
match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
|
315
315
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
316
316
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
@@ -756,7 +756,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
756
756
|
try {
|
|
757
757
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
758
758
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
759
|
-
const decimalPattern = new RegExp(
|
|
759
|
+
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
|
|
760
760
|
for (const dir of dirs) {
|
|
761
761
|
const dm = dir.match(decimalPattern);
|
|
762
762
|
if (dm)
|
|
@@ -288,6 +288,13 @@ function projectProhibitions(items) {
|
|
|
288
288
|
if (typeof p.check_violation_fixture === 'string' && p.check_violation_fixture.trim() !== '') {
|
|
289
289
|
entry.check_violation_fixture = String(p.check_violation_fixture);
|
|
290
290
|
}
|
|
291
|
+
// `check_clean_fixture` (#1346) rides BOTH kinds — the KNOWN-CLEAN control subject the prover
|
|
292
|
+
// requires to stay GREEN (content-dependence proof). Emit ONLY a non-empty fixture (blank ->
|
|
293
|
+
// absent so no control runs; the documented residual remains). Like the violation fixture it is
|
|
294
|
+
// meaningless without the descriptor, so it lives inside this well-formed-descriptor branch.
|
|
295
|
+
if (typeof p.check_clean_fixture === 'string' && p.check_clean_fixture.trim() !== '') {
|
|
296
|
+
entry.check_clean_fixture = String(p.check_clean_fixture);
|
|
297
|
+
}
|
|
291
298
|
}
|
|
292
299
|
out.push(entry);
|
|
293
300
|
}
|
|
@@ -79,8 +79,9 @@ const probe_core_cjs_1 = require("./probe-core.cjs");
|
|
|
79
79
|
* - `null`/`undefined`/non-object input -> `null`.
|
|
80
80
|
* - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed).
|
|
81
81
|
* - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule`
|
|
82
|
-
* ONLY when `check_rule` is a non-empty string,
|
|
83
|
-
* ONLY when that scalar is a non-empty string (
|
|
82
|
+
* ONLY when `check_rule` is a non-empty string, `violationFixture: check_violation_fixture`
|
|
83
|
+
* ONLY when that scalar is a non-empty string (composes #1278 locate with #1279 proof), and
|
|
84
|
+
* `cleanFixture: check_clean_fixture` ONLY when that scalar is non-empty (#1346 causation control).
|
|
84
85
|
* - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation
|
|
85
86
|
* (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`.
|
|
86
87
|
* - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever
|
|
@@ -118,6 +119,13 @@ function descriptorFromProjection(projected) {
|
|
|
118
119
|
const fixture = scalar(projected.check_violation_fixture);
|
|
119
120
|
if (fixture.trim().length > 0)
|
|
120
121
|
descriptor.violationFixture = fixture;
|
|
122
|
+
// `cleanFixture` (#1346) rides BOTH kinds — reconstruct it from `check_clean_fixture` so the
|
|
123
|
+
// causation control runs end-to-end: when present the prover also requires the check to stay GREEN
|
|
124
|
+
// against this known-clean subject (proving the violation RED is content-dependent). Absent/blank ->
|
|
125
|
+
// no control (the documented residual remains; backward-compatible with the #1314 compose path).
|
|
126
|
+
const clean = scalar(projected.check_clean_fixture);
|
|
127
|
+
if (clean.trim().length > 0)
|
|
128
|
+
descriptor.cleanFixture = clean;
|
|
121
129
|
return descriptor;
|
|
122
130
|
}
|
|
123
131
|
/** node --test argv. Forces the TAP reporter so the summary counts are parseable + version-stable;
|
|
@@ -360,6 +368,31 @@ const CHECK_MAX_BUFFER = 16 * 1024 * 1024;
|
|
|
360
368
|
function posTimeout(timeoutMs, def) {
|
|
361
369
|
return typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : def;
|
|
362
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Spawn the negative `node --test` against a single subject (set via the `GSD_PROHIB_SUBJECT`
|
|
373
|
+
* convention, #1279) and return its TAP output. Reuses the bounded-subprocess machinery
|
|
374
|
+
* (`process.execPath`, arg arrays → no shell, `childEnv`, bounded `timeout`/`maxBuffer`) and NEVER
|
|
375
|
+
* throws — a RED run exits non-zero, so the partial TAP (with the `# fail` summary) is recovered from
|
|
376
|
+
* the thrown error's `stdout`. The prover calls this once per subject: the KNOWN-BAD violation fixture
|
|
377
|
+
* (expect RED) and, for the #1346 causation control, the KNOWN-CLEAN control subject (expect GREEN).
|
|
378
|
+
*/
|
|
379
|
+
function runNodeTestWithSubject(check, cwd, subject, timeoutMs) {
|
|
380
|
+
try {
|
|
381
|
+
return (0, node_child_process_1.execFileSync)(process.execPath, buildNodeTestArgs(check), {
|
|
382
|
+
cwd,
|
|
383
|
+
encoding: 'utf-8',
|
|
384
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
385
|
+
windowsHide: true,
|
|
386
|
+
env: { ...childEnv(), GSD_PROHIB_SUBJECT: subject },
|
|
387
|
+
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
|
|
388
|
+
maxBuffer: CHECK_MAX_BUFFER,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
|
|
393
|
+
return typeof stdout === 'string' ? stdout : '';
|
|
394
|
+
}
|
|
395
|
+
}
|
|
363
396
|
function defaultRunCheck(check, cwd, timeoutMs) {
|
|
364
397
|
try {
|
|
365
398
|
if (check.kind === 'node-test') {
|
|
@@ -482,36 +515,36 @@ function defaultProveFailFirst(check, cwd, timeoutMs) {
|
|
|
482
515
|
// a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning
|
|
483
516
|
// closes the realistic typo/stale-path case (#1279 review, Major 1).
|
|
484
517
|
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
518
|
+
// CAUSATION (#1346): existence + a non-vacuous red is necessary but not sufficient — a deceptive
|
|
519
|
+
// negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set (rather than because the
|
|
520
|
+
// subject's CONTENT violates the must-NOT) would otherwise be accepted. The OPTIONAL `cleanFixture`
|
|
521
|
+
// control below proves content-dependence when supplied (red on bad AND green on clean). When NO
|
|
522
|
+
// clean fixture is authored the control cannot run, so the residual remains a documented constraint
|
|
523
|
+
// for that case (an author opts into the stronger proof by supplying a known-clean control subject).
|
|
490
524
|
// Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads
|
|
491
525
|
// `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must
|
|
492
526
|
// use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole).
|
|
493
527
|
if (!fixture || !node_fs_1.default.existsSync(node_path_1.default.resolve(cwd, fixture)))
|
|
494
528
|
return { provenFailFirst: false };
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
out = typeof stdout === 'string' ? stdout : '';
|
|
529
|
+
// Run the negative test against the KNOWN-BAD subject and require a NON-VACUOUS red.
|
|
530
|
+
const redOut = runNodeTestWithSubject(check, cwd, fixture, timeoutMs);
|
|
531
|
+
if (!isNonVacuousNodeTestRed(redOut, check.target))
|
|
532
|
+
return { provenFailFirst: false, method: 'violation-fixture' };
|
|
533
|
+
// #1346 CAUSATION CONTROL (optional): if a clean control subject is supplied, run the SAME test
|
|
534
|
+
// against it and require it to stay GREEN. This proves the red above was caused by the subject's
|
|
535
|
+
// CONTENT — a deceptive test that reds merely because GSD_PROHIB_SUBJECT is SET reds here too →
|
|
536
|
+
// not content-dependent → not proven. Absent → no control (documented residual; backward-compat).
|
|
537
|
+
const clean = check.cleanFixture;
|
|
538
|
+
if (clean) {
|
|
539
|
+
// A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric
|
|
540
|
+
// with the violation-fixture existence guard (resolve against the SAME `cwd` as the child).
|
|
541
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.resolve(cwd, clean)))
|
|
542
|
+
return { provenFailFirst: false, method: 'violation-fixture' };
|
|
543
|
+
const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs);
|
|
544
|
+
if (!isNonVacuousNodeTestPass(cleanOut, check.target))
|
|
545
|
+
return { provenFailFirst: false, method: 'violation-fixture' };
|
|
513
546
|
}
|
|
514
|
-
return { provenFailFirst:
|
|
547
|
+
return { provenFailFirst: true, method: 'violation-fixture' };
|
|
515
548
|
}
|
|
516
549
|
// Unknown kind — defensive; the LOCATE guard already rejects it.
|
|
517
550
|
return { provenFailFirst: false };
|
|
@@ -150,10 +150,23 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
|
|
|
150
150
|
},
|
|
151
151
|
'upgrade': () => {
|
|
152
152
|
const dryRun = !args.includes('--apply');
|
|
153
|
-
|
|
153
|
+
// Parse `--convention <value>` and `--convention=<value>`. When the flag is
|
|
154
|
+
// absent entirely, default to the only supported convention; when present
|
|
155
|
+
// with a missing/unsupported value, fall through to the rejection below
|
|
156
|
+
// (fail-closed — never silently run a migration the user did not request).
|
|
157
|
+
let convention = 'milestone-prefixed';
|
|
158
|
+
const conventionFlagIdx = args.findIndex((a) => a === '--convention' || a.startsWith('--convention='));
|
|
159
|
+
if (conventionFlagIdx !== -1) {
|
|
160
|
+
const token = args[conventionFlagIdx];
|
|
161
|
+
convention = token.includes('=')
|
|
162
|
+
? token.slice(token.indexOf('=') + 1)
|
|
163
|
+
: (args[conventionFlagIdx + 1] ?? '');
|
|
164
|
+
}
|
|
154
165
|
if (convention !== 'milestone-prefixed') {
|
|
155
|
-
|
|
156
|
-
process.exit
|
|
166
|
+
// No-throw hub contract (ADR-0012): a hub-dispatched handler must not call
|
|
167
|
+
// process.exit. Throw instead — the hub converts this to HandlerFailure and
|
|
168
|
+
// the adapter routes it through the injected error() boundary.
|
|
169
|
+
throw new Error('Only --convention milestone-prefixed is supported');
|
|
157
170
|
}
|
|
158
171
|
const plan = roadmapUpgrade.computeMigrationPlan(cwd);
|
|
159
172
|
roadmapUpgrade.applyMigration(cwd, plan, { dryRun });
|