@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
@@ -42,13 +42,22 @@ function deriveProgressFromRoadmap(roadmapContent) {
42
42
  completedPhases = completeMatches ? completeMatches.length : null;
43
43
  // Count total phase rows in the progress table.
44
44
  // Identify the table by looking for Phase|...|Status|...|Completed header.
45
- const progressTableMatch = roadmapContent.match(/\|\s*Phase\s*\|[^|]*\|[^|]*Status[^|]*\|[^|]*Completed[^|]*\|[\s\S]*?(?=\n\n|\n##|$)/i);
45
+ const progressTableMatch = roadmapContent.match(
46
+ // allow-adhoc-markdown: table-scoped regex with heading lookahead as stop; table parsing, out of seam scope; pending #1372
47
+ /\|\s*Phase\s*\|[^|]*\|[^|]*Status[^|]*\|[^|]*Completed[^|]*\|[\s\S]*?(?=\n\n|\n##|$)/i);
46
48
  if (progressTableMatch) {
47
49
  const tableText = progressTableMatch[0];
48
- // Count data rows (rows starting with pipe then a phase number)
49
- const dataRowPattern = /^\|\s*\d+/gm;
50
- const dataRows = tableText.match(dataRowPattern);
51
- totalPhases = dataRows ? dataRows.length : null;
50
+ // Count data rows (rows starting with pipe then a phase number),
51
+ // excluding 999.x backlog phases. Mirrors init.cts /^999(?:\.|$)/ filter.
52
+ const dataRowPattern = /^\|\s*(\d+[^|]*)\|/gm;
53
+ let dataRowCount = 0;
54
+ let drm;
55
+ while ((drm = dataRowPattern.exec(tableText)) !== null) {
56
+ if (/^999\b/.test(drm[1].trim()))
57
+ continue;
58
+ dataRowCount++;
59
+ }
60
+ totalPhases = dataRowCount > 0 ? dataRowCount : null;
52
61
  }
53
62
  // Sum plan counts from M/N columns in progress table
54
63
  let totalPlansSum = 0;
@@ -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(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`);
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(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) ||
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(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`);
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)
@@ -1430,6 +1430,34 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
1430
1430
  const report = evaluateUatPassed(phaseFullDir, { policy: opts.policy });
1431
1431
  output({ phase: phaseNum, ...report }, raw);
1432
1432
  }
1433
+ // #1437 — phase.list-plans: list plan files for a given phase number.
1434
+ // Returns the full scan result from scanPhasePlans so callers can read plan
1435
+ // paths without re-discovering the phase directory themselves.
1436
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
1437
+ const planScanMod = require("./plan-scan.cjs");
1438
+ const { scanPhasePlans } = planScanMod;
1439
+ function cmdPhaseListPlans(cwd, phaseNum, raw) {
1440
+ if (!phaseNum) {
1441
+ error('phase number required for phase list-plans');
1442
+ }
1443
+ const phaseInfo = findPhaseInternal(cwd, phaseNum);
1444
+ if (!phaseInfo) {
1445
+ output({ phase: phaseNum, plan_count: 0, has_plans: false, plans: [], phase_dir: null }, raw);
1446
+ return;
1447
+ }
1448
+ const phaseDir = node_path_1.default.join(cwd, phaseInfo['directory']);
1449
+ const scan = scanPhasePlans(phaseDir);
1450
+ const phaseRel = phaseInfo['directory'];
1451
+ // Build absolute-usable relative paths for each plan file.
1452
+ const plans = scan.planFiles.map((f) => toPosixPath(node_path_1.default.join(phaseRel, f)));
1453
+ output({
1454
+ phase: phaseNum,
1455
+ phase_dir: phaseRel,
1456
+ plan_count: scan.planCount,
1457
+ has_plans: scan.planCount > 0,
1458
+ plans,
1459
+ }, raw);
1460
+ }
1433
1461
  module.exports = {
1434
1462
  cmdPhasesList,
1435
1463
  cmdPhaseNextDecimal,
@@ -1442,5 +1470,6 @@ module.exports = {
1442
1470
  cmdPhaseRemove,
1443
1471
  cmdPhaseComplete,
1444
1472
  cmdPhaseUatPassed,
1473
+ cmdPhaseListPlans,
1445
1474
  computeDependencyLevels,
1446
1475
  };
@@ -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, and `violationFixture: check_violation_fixture`
83
- * ONLY when that scalar is a non-empty string (#1346 — composes #1278 locate with #1279 proof).
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
- // KNOWN RESIDUAL (documented, fail-open direction, tracked follow-up #1346): existence is
486
- // necessary but not sufficient — a deliberately deceptive negative test that reds merely BECAUSE
487
- // `GSD_PROHIB_SUBJECT` is set (rather than because the subject's CONTENT violates the must-NOT)
488
- // is still accepted. Proving "the red was CAUSED BY the violation" cannot be done generically for
489
- // an arbitrary author-supplied test, so it is recorded as a constraint, not silently implied-solved.
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
- let out = '';
496
- try {
497
- out = (0, node_child_process_1.execFileSync)(process.execPath, buildNodeTestArgs(check), {
498
- cwd,
499
- encoding: 'utf-8',
500
- stdio: ['ignore', 'pipe', 'pipe'],
501
- windowsHide: true,
502
- // CONVENTION (#1279): the negative test reads its subject-under-test from this env var.
503
- env: { ...childEnv(), GSD_PROHIB_SUBJECT: fixture },
504
- timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
505
- maxBuffer: CHECK_MAX_BUFFER,
506
- });
507
- }
508
- catch (e) {
509
- // A negative test that goes RED exits non-zero; the partial TAP (with the `# fail` summary)
510
- // is on stdout. Parse what we have: a real failure here is the PROOF the test is fail-first.
511
- const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
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: isNonVacuousNodeTestRed(out, check.target), method: 'violation-fixture' };
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 };
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Project-Root Resolution Module — resolves a project root from a starting
4
- * directory by walking the ancestor chain and applying four heuristics:
4
+ * directory by walking the ancestor chain and applying five heuristics:
5
5
  * (0) own .planning/ guard (#1362)
6
6
  * (1) parent .planning/config.json sub_repos
7
7
  * (2) legacy multiRepo: true + ancestor .git
8
8
  * (3) .git heuristic with parent .planning/
9
+ * (4) nearest ancestor .planning/ (#1414, Resolution Provenance P1)
9
10
  * Bounded by FIND_PROJECT_ROOT_MAX_DEPTH ancestors. Sync I/O.
10
11
  *
11
12
  * ADR-457 build-at-publish: the hand-written bin/lib/project-root.cjs
@@ -17,6 +18,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
18
  };
18
19
  Object.defineProperty(exports, "__esModule", { value: true });
19
20
  exports.findProjectRoot = findProjectRoot;
21
+ exports.consentProjectRoot = consentProjectRoot;
20
22
  const node_fs_1 = __importDefault(require("node:fs"));
21
23
  const node_path_1 = __importDefault(require("node:path"));
22
24
  const node_os_1 = __importDefault(require("node:os"));
@@ -107,13 +109,98 @@ function findProjectRoot(startDir) {
107
109
  }
108
110
  if (matched)
109
111
  return parent;
110
- // Heuristic: parent has .planning/ and we're inside a git repo.
112
+ // Heuristic (3): parent has .planning/ and we're inside a git repo.
113
+ // Before returning, check if any further ancestor has sub_repos that explicitly
114
+ // claims our startDir — explicit sub_repos config takes precedence over the
115
+ // implicit .git signal. (#1422)
111
116
  if (isInsideGitRepo(parent)) {
117
+ // Lookahead: walk ancestors above `parent` to find a sub_repos claim.
118
+ let ancestor = node_path_1.default.dirname(parent);
119
+ let ancestorDepth = 0;
120
+ while (ancestor !== fsRoot && ancestor !== home && ancestorDepth < FIND_PROJECT_ROOT_MAX_DEPTH) {
121
+ const ancestorPlanning = ancestor + node_path_1.default.sep + '.planning';
122
+ try {
123
+ if (node_fs_1.default.existsSync(ancestorPlanning) && node_fs_1.default.statSync(ancestorPlanning).isDirectory()) {
124
+ const ancestorConfig = ancestor + node_path_1.default.sep + '.planning' + node_path_1.default.sep + 'config.json';
125
+ const rawA = node_fs_1.default.readFileSync(ancestorConfig, 'utf-8');
126
+ const cfgA = JSON.parse(rawA);
127
+ const subReposValueA = cfgA['sub_repos'] ??
128
+ (cfgA['planning'] && typeof cfgA['planning'] === 'object'
129
+ ? cfgA['planning']['sub_repos']
130
+ : undefined);
131
+ const subReposA = Array.isArray(subReposValueA) ? subReposValueA : [];
132
+ if (subReposA.length > 0) {
133
+ const relPathA = node_path_1.default.relative(ancestor, resolvedStart);
134
+ const topSegmentA = relPathA.split(node_path_1.default.sep)[0];
135
+ if (subReposA.includes(topSegmentA)) {
136
+ return ancestor;
137
+ }
138
+ }
139
+ }
140
+ }
141
+ catch {
142
+ // ignore — config missing or unparseable, keep walking
143
+ }
144
+ const nextAncestor = node_path_1.default.dirname(ancestor);
145
+ if (nextAncestor === ancestor)
146
+ break;
147
+ ancestor = nextAncestor;
148
+ ancestorDepth += 1;
149
+ }
112
150
  return parent;
113
151
  }
114
152
  }
115
153
  dir = parent;
116
154
  depth += 1;
117
155
  }
156
+ // Heuristic (4): nearest ancestor .planning/ — last resort before fallback.
157
+ // Runs only after heuristics (1)–(3) have been exhausted without a match,
158
+ // ensuring sub_repos / multiRepo / .git-based resolution always wins when
159
+ // applicable. Walks upward again within the same FIND_PROJECT_ROOT_MAX_DEPTH
160
+ // bound; returns the nearest ancestor directory that contains a .planning/
161
+ // subdirectory so config resolves correctly when invoked from a plain
162
+ // descendant of a single-repo project. (#1414)
163
+ let dir2 = resolvedStart;
164
+ let depth2 = 0;
165
+ while (dir2 !== fsRoot && depth2 < FIND_PROJECT_ROOT_MAX_DEPTH) {
166
+ const parent2 = node_path_1.default.dirname(dir2);
167
+ if (parent2 === dir2)
168
+ break;
169
+ try {
170
+ const candidatePlanning = parent2 + node_path_1.default.sep + '.planning';
171
+ if (node_fs_1.default.existsSync(candidatePlanning) && node_fs_1.default.statSync(candidatePlanning).isDirectory()) {
172
+ return parent2;
173
+ }
174
+ }
175
+ catch {
176
+ // ignore fs errors and continue walking
177
+ }
178
+ if (parent2 === home)
179
+ break;
180
+ dir2 = parent2;
181
+ depth2 += 1;
182
+ }
118
183
  return startDir;
119
184
  }
185
+ /**
186
+ * #1459 (IC-01 / CB-4): THE single canonical derivation of the PROJECT ROOT used to bind/lookup a
187
+ * project-scope consent record. Install (the CLI/lifecycle RECORD site), the loader (the LOOKUP
188
+ * site), and `trust revoke` (CB-4) MUST all derive the consent root through this one helper so the
189
+ * recorded key always matches the looked-up key — otherwise installing from a SUBDIR records consent
190
+ * at `realpath(subdir)` while the loader looks it up at `realpath(findProjectRoot)` and the freshly
191
+ * installed cap is immediately INACTIVE (install-then-inactive).
192
+ *
193
+ * The rule: `realpath(findProjectRoot(cwd))` (findProjectRoot is total — it returns `cwd` itself when
194
+ * no project root is found, so there is no null branch), falling back to `path.resolve(cwd)` when the
195
+ * resolved root cannot be realpath'd (e.g. it does not exist yet). The consent store realpaths
196
+ * whatever it is given, so passing the SAME logical root from every site is what guarantees the match.
197
+ */
198
+ function consentProjectRoot(cwd) {
199
+ const root = findProjectRoot(cwd);
200
+ try {
201
+ return node_fs_1.default.realpathSync(root);
202
+ }
203
+ catch {
204
+ return node_path_1.default.resolve(root);
205
+ }
206
+ }
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ /**
3
+ * Resolution Convention — canonical shape for config-interpreting read verbs.
4
+ *
5
+ * Extracted as the anchor for ADR-1411 P3 (Resolution Provenance, #1416).
6
+ * Exports the `Resolution<T>` envelope used when a verb reads and interprets
7
+ * configuration (e.g. agent-skills). Not used by mutation verbs (see
8
+ * capability-writer's `SetCapabilityStateResult` for the mutation shape) or
9
+ * plain read verbs (see capability-state's `ResolveCapabilityRuntimeStateResult`).
10
+ *
11
+ * This is a pure types+builder leaf — no other src/ imports.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.makeResolution = makeResolution;
15
+ // ─── Builder ──────────────────────────────────────────────────────────────────
16
+ /**
17
+ * Construct a `Resolution<T>` envelope from a value and its provenance fields.
18
+ */
19
+ function makeResolution(value, opts) {
20
+ return {
21
+ value,
22
+ configured: opts.configured,
23
+ reason: opts.reason,
24
+ warnings: opts.warnings,
25
+ };
26
+ }
@@ -150,10 +150,23 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
150
150
  },
151
151
  'upgrade': () => {
152
152
  const dryRun = !args.includes('--apply');
153
- const convention = args.find((_a, i) => args[i - 1] === '--convention') || 'milestone-prefixed';
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
- process.stderr.write('Only --convention milestone-prefixed is supported\n');
156
- process.exit(1);
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 });