@pareto4/pp-core 1.12.1 → 1.13.0

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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-reviewer.md +1 -1
  3. package/agents/pp-executor.md +2 -13
  4. package/agents/pp-planner.md +1 -1
  5. package/agents/pp-vault-reviewer.md +51 -0
  6. package/commands/pp-ns-review.md +2 -1
  7. package/commands/pp-vault-review.md +33 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +1 -1
  10. package/pp-core/bin/lib/capability-registry.cjs +200 -10
  11. package/pp-core/bin/lib/check-command-router.cjs +1242 -37
  12. package/pp-core/bin/lib/clusters.cjs +1 -0
  13. package/pp-core/bin/lib/command-aliases.cjs +7 -0
  14. package/pp-core/bin/lib/compose-bindings.cjs +41 -27
  15. package/pp-core/bin/lib/decisions.cjs +104 -27
  16. package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
  17. package/pp-core/bin/lib/frontmatter.cjs +37 -0
  18. package/pp-core/bin/lib/git-base-branch.cjs +136 -10
  19. package/pp-core/bin/lib/init-contract.cjs +180 -66
  20. package/pp-core/bin/lib/init-interactive.cjs +4 -20
  21. package/pp-core/bin/lib/loop-gates.cjs +213 -0
  22. package/pp-core/bin/lib/loop-resolver.cjs +107 -45
  23. package/pp-core/bin/lib/org-profile.cjs +218 -44
  24. package/pp-core/bin/lib/phase-command-router.cjs +7 -1
  25. package/pp-core/bin/lib/phase-manifest.cjs +527 -0
  26. package/pp-core/bin/lib/phase-tier.cjs +15 -2
  27. package/pp-core/bin/lib/phase.cjs +51 -1
  28. package/pp-core/bin/lib/vault-audit.cjs +2070 -0
  29. package/pp-core/bin/lib/vault-command-router.cjs +460 -1
  30. package/pp-core/bin/lib/vault-practices.cjs +1142 -104
  31. package/pp-core/bin/lib/vault-review.cjs +2359 -0
  32. package/pp-core/bin/lib/vault-trace.cjs +2831 -0
  33. package/pp-core/bin/lib/vault-update.cjs +266 -0
  34. package/pp-core/bin/lib/vault-write-back.cjs +19 -0
  35. package/pp-core/bin/lib/vault.cjs +277 -12
  36. package/pp-core/bin/pp-tools.cjs +30 -13
  37. package/pp-core/bin/shared/model-catalog.json +2 -1
  38. package/pp-core/references/executor-vault-trace.md +47 -0
  39. package/pp-core/references/loop-hook-dispatch.md +13 -2
  40. package/pp-core/references/planner-vault-practices.md +46 -1
  41. package/pp-core/references/planning-config.md +1 -1
  42. package/pp-core/templates/practices/README.md +140 -11
  43. package/pp-core/templates/project-charter.md +226 -0
  44. package/pp-core/workflows/add-tests.md +4 -0
  45. package/pp-core/workflows/audit-fix.md +2 -0
  46. package/pp-core/workflows/check-todos.md +1 -1
  47. package/pp-core/workflows/code-review-fix.md +2 -0
  48. package/pp-core/workflows/debug.md +4 -0
  49. package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
  50. package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
  51. package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
  52. package/pp-core/workflows/discovery-sprint.md +1 -1
  53. package/pp-core/workflows/discovery-to-build.md +1 -1
  54. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
  55. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
  56. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
  57. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
  58. package/pp-core/workflows/execute-phase.md +22 -5
  59. package/pp-core/workflows/execute-plan.md +4 -0
  60. package/pp-core/workflows/fast.md +4 -0
  61. package/pp-core/workflows/help/modes/full.md +3 -2
  62. package/pp-core/workflows/init.md +2 -2
  63. package/pp-core/workflows/new-project.md +1 -1
  64. package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
  65. package/pp-core/workflows/plan-phase.md +7 -4
  66. package/pp-core/workflows/quick.md +4 -0
  67. package/pp-core/workflows/ship.md +5 -1
  68. package/pp-core/workflows/undo.md +1 -1
  69. package/pp-core/workflows/validate-phase.md +4 -0
  70. package/pp-core/workflows/vault-review.md +65 -0
  71. package/pp-core/workflows/verify-work.md +3 -0
  72. package/scripts/gen-capability-registry.cjs +11 -0
  73. package/scripts/lint-test-file-count.allowlist.json +10 -1
  74. package/scripts/prompt-injection-scan.sh +2 -0
@@ -382,6 +382,80 @@ function renderLoopHooks(resolved) {
382
382
  return lines.join('\n');
383
383
  }
384
384
  // ─── I/O command handler ───────────────────────────────────────────────────────
385
+ /**
386
+ * The refusal text for a caller that resolves hooks over an unreadable config.
387
+ * One wording for every such caller (`loop render-hooks`, `phase.dispatch-plan`).
388
+ */
389
+ function unreadableConfigRefusal(what, unreadable) {
390
+ return (`${what} refused: the project config cannot be read, so no hook can be resolved ` +
391
+ `(rendering anyway would silently drop every hook the config switches on, a required client vault among them). ` +
392
+ `${describeUnreadableConfig(unreadable)}. Repair or remove the file, then re-run. Stop this workflow here.`);
393
+ }
394
+ /**
395
+ * Resolve the active hooks at `point` for the project at `cwd`, exactly as
396
+ * `loop render-hooks` does: refuse an unreadable config, load the config
397
+ * snapshot once, overlay the phase tier's gate booleans when one is given,
398
+ * resolve capability state over the same snapshot, then resolve the hooks.
399
+ * The ONE owner of "which hooks are active here" for every caller that needs
400
+ * the answer in code (`loop render-hooks`, `phase.dispatch-plan`'s executor
401
+ * contributions). Never exits; the caller decides how to report a refusal.
402
+ */
403
+ function resolveProjectLoopHooks(cwd, point, opts = {}) {
404
+ // Fail closed on a config that exists but cannot be read. Every hook's `when`
405
+ // and activation key is read from it, and both readers below swallow a parse
406
+ // failure into the defaults, so rendering anyway drops exactly the hooks the
407
+ // project switched on — a required client vault's fetch and blocking gate
408
+ // among them. A MISSING config is not refused: that is a fresh project, and
409
+ // the defaults are its real settings.
410
+ const unreadable = unreadableConfigFiles(cwd);
411
+ if (unreadable.length > 0) {
412
+ return { ok: false, reason: 'config_unreadable', message: unreadableConfigRefusal(opts.what ?? `loop render-hooks ${point}`, unreadable) };
413
+ }
414
+ // Load the config snapshot ONCE and share it with both the capability-state
415
+ // resolver (via configOverride) and loop-hook resolution, so federated keys
416
+ // present in loadConfig resolve identically for `active` and for hook when/
417
+ // configValues. Keys absent from loadConfig still fall through to raw
418
+ // .planning/config.json reads (precedence levels 2-3) in each pass.
419
+ let config;
420
+ try {
421
+ config = loadConfig(cwd);
422
+ }
423
+ catch {
424
+ config = {};
425
+ }
426
+ if (opts.phaseTier)
427
+ config = (0, phase_tier_cjs_1.overlayTierGates)(config, opts.phaseTier);
428
+ const state = resolveCapabilityRuntimeState(cwd, opts.configDir, config);
429
+ // Registry is the static generated module — same object capability-state uses internally.
430
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
431
+ const registry = require('./capability-registry.cjs');
432
+ const capabilityStatesById = new Map();
433
+ for (const cap of state.capabilities || []) {
434
+ capabilityStatesById.set(cap.id, cap);
435
+ }
436
+ try {
437
+ const resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
438
+ return { ok: true, resolved, warnings: state.warnings ?? [] };
439
+ }
440
+ catch (err) {
441
+ return { ok: false, reason: 'invalid_point', message: (err instanceof Error) ? err.message : String(err) };
442
+ }
443
+ }
444
+ /**
445
+ * The `fragment.inline` text of every active contribution into `role`, in
446
+ * resolved order, with the capability that contributed it.
447
+ */
448
+ function contributionsInto(resolved, role) {
449
+ const out = [];
450
+ for (const h of resolved.activeHooks) {
451
+ if (h.kind !== 'contribution' || h.into !== role)
452
+ continue;
453
+ if (typeof h.fragment?.inline !== 'string')
454
+ continue;
455
+ out.push({ capId: h.capId, fragment: h.fragment.inline });
456
+ }
457
+ return out;
458
+ }
385
459
  /**
386
460
  * Command entry point: load registry + config, resolve + render, emit envelope.
387
461
  *
@@ -396,6 +470,10 @@ function renderLoopHooks(resolved) {
396
470
  * pre-cutover keys like `workflow.ui_phase` that live in config.json but are not
397
471
  * yet exposed through loadConfig's whitelist.
398
472
  *
473
+ * Unreadable config: when any project config file exists but cannot be parsed
474
+ * (`unreadableConfigFiles`), every mode refuses before rendering — exit 1,
475
+ * empty stdout, one stderr line naming each file and its parse error.
476
+ *
399
477
  * --active-cap <capId>: when present, resolves hooks for <point> exactly as the
400
478
  * normal path does, then prints exactly `true` (if any resolved activeHook has
401
479
  * capId === <capId>) or `false` followed by a single newline, and exits 0.
@@ -403,6 +481,13 @@ function renderLoopHooks(resolved) {
403
481
  * Missing <capId> value → coreError + non-zero exit.
404
482
  * Unknown/inactive capId → `false` (not an error).
405
483
  *
484
+ * --into <role>: resolves exactly as the normal path does, then prints the
485
+ * `fragment.inline` text of each active contribution whose `into` equals
486
+ * <role>, in resolved order, separated by one blank line, with a trailing
487
+ * newline when anything printed. Nothing at all (empty stdout, exit 0) when no
488
+ * contribution matches. No JSON envelope; `--raw` is ignored. `--active-cap`
489
+ * takes precedence: given both, `--into` is ignored.
490
+ *
406
491
  * --phase <N> [--tier light|standard|heavy] (pack 9): resolve the phase's
407
492
  * ceremony tier (src/phase-tier.cts) and overlay its gate booleans
408
493
  * (`workflow.research`, `workflow.pattern_mapper`, …) on the config snapshot
@@ -422,37 +507,15 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
422
507
  coreError('--active-cap requires a <capId> value (e.g. --active-cap tdd)');
423
508
  return;
424
509
  }
425
- // Fail closed on a config that exists but cannot be read. Every hook's `when`
426
- // and activation key is read from it, and both readers below swallow a parse
427
- // failure into the defaults, so rendering anyway drops exactly the hooks the
428
- // project switched on — a required client vault's fetch and blocking gate
429
- // among them — and exits 0. A MISSING config is not refused: that is a fresh
430
- // project, and the defaults are its real settings. stdout stays empty so a
431
- // host capturing `$(…)` never reads a refusal as "no hooks".
432
- const unreadable = unreadableConfigFiles(cwd);
433
- if (unreadable.length > 0) {
434
- coreError(`loop render-hooks ${point} refused: the project config cannot be read, so no hook can be resolved ` +
435
- `(rendering anyway would silently drop every hook the config switches on, a required client vault among them). ` +
436
- `${describeUnreadableConfig(unreadable)}. Repair or remove the file, then re-run. Stop this workflow here.`, ERROR_REASON.CONFIG_PARSE_FAILED);
510
+ // --into <role> mode: emit matching contribution fragment text only
511
+ const intoRole = typeof options['into'] === 'string' ? options['into'] : undefined;
512
+ if (activeCapId === undefined && intoRole !== undefined && intoRole.trim() === '') {
513
+ coreError('Missing value for --into (e.g. --into executor)', ERROR_REASON.USAGE);
437
514
  return;
438
515
  }
439
516
  const runtimeConfigDir = typeof options['configDir'] === 'string'
440
517
  ? options['configDir']
441
518
  : undefined;
442
- // Load the config snapshot ONCE and share it with both the capability-state
443
- // resolver (via configOverride) and loop-hook resolution, so federated keys
444
- // present in loadConfig resolve identically for `active` and for hook when/
445
- // configValues — eliminating the previous double loadConfig() call. Note: keys
446
- // absent from loadConfig still fall through to raw .planning/config.json reads
447
- // (precedence levels 2-3) in each pass; that residual re-read window is
448
- // pre-existing (unchanged by this consolidation), not introduced here.
449
- let config;
450
- try {
451
- config = loadConfig(cwd);
452
- }
453
- catch {
454
- config = {};
455
- }
456
519
  const tierPhase = typeof options['phase'] === 'string' && options['phase'] !== '' ? options['phase'] : null;
457
520
  const tierCli = typeof options['tier'] === 'string' && options['tier'] !== '' ? options['tier'] : null;
458
521
  let phaseTier = null;
@@ -461,32 +524,29 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
461
524
  coreError('--tier needs one of light, standard, heavy');
462
525
  return;
463
526
  }
464
- phaseTier = (0, phase_tier_cjs_1.resolvePhaseTierForProject)(cwd, tierPhase, tierCli);
465
- config = (0, phase_tier_cjs_1.overlayTierGates)(config, phaseTier);
527
+ // The refusal below must come first: never resolve a tier from a config it cannot read.
528
+ if (unreadableConfigFiles(cwd).length === 0)
529
+ phaseTier = (0, phase_tier_cjs_1.resolvePhaseTierForProject)(cwd, tierPhase, tierCli);
466
530
  }
467
- const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config);
468
- // Registry is the static generated module — same object capability-state uses internally.
469
- // eslint-disable-next-line @typescript-eslint/no-require-imports
470
- const registry = require('./capability-registry.cjs');
471
- const capabilityStatesById = new Map();
472
- for (const cap of state.capabilities || []) {
473
- capabilityStatesById.set(cap.id, cap);
474
- }
475
- let resolved;
476
- try {
477
- resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
478
- }
479
- catch (err) {
480
- const msg = (err instanceof Error) ? err.message : String(err);
481
- coreError(msg);
531
+ const outcome = resolveProjectLoopHooks(cwd, point, { configDir: runtimeConfigDir, phaseTier });
532
+ if (!outcome.ok) {
533
+ coreError(outcome.message, outcome.reason === 'config_unreadable' ? ERROR_REASON.CONFIG_PARSE_FAILED : undefined);
482
534
  return;
483
535
  }
536
+ const resolved = outcome.resolved;
484
537
  // --active-cap mode: print exactly 'true' or 'false' with no envelope
485
538
  if (activeCapId !== undefined) {
486
539
  const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
487
540
  process.stdout.write(isActive ? 'true\n' : 'false\n');
488
541
  return;
489
542
  }
543
+ // --into mode: fragment text of the active contributions targeting <role>
544
+ if (intoRole !== undefined) {
545
+ const texts = contributionsInto(resolved, intoRole).map((c) => c.fragment);
546
+ if (texts.length > 0)
547
+ process.stdout.write(texts.join('\n\n') + '\n');
548
+ return;
549
+ }
490
550
  const rendered = renderLoopHooks(resolved);
491
551
  const envelope = {
492
552
  point: resolved.point,
@@ -496,8 +556,8 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
496
556
  if (phaseTier) {
497
557
  envelope.phase_tier = { phase: tierPhase, tier: phaseTier.tier, source: phaseTier.source, suppressed: phaseTier.suppressed };
498
558
  }
499
- if (state.warnings && state.warnings.length > 0) {
500
- envelope.warnings = state.warnings;
559
+ if (outcome.warnings.length > 0) {
560
+ envelope.warnings = outcome.warnings;
501
561
  }
502
562
  // No rawValue third argument, so --raw emits the JSON envelope rather than
503
563
  // plain text. This is deliberate and load-bearing: 23 workflow call sites do
@@ -511,6 +571,8 @@ module.exports = {
511
571
  resolveLoopHooks,
512
572
  renderLoopHooks,
513
573
  cmdLoopRenderHooks,
574
+ resolveProjectLoopHooks,
575
+ contributionsInto,
514
576
  // Exported for tests
515
577
  _getNestedConfigValue,
516
578
  _resolveActivationValue,
@@ -48,10 +48,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
48
48
  return (mod && mod.__esModule) ? mod : { "default": mod };
49
49
  };
50
50
  Object.defineProperty(exports, "__esModule", { value: true });
51
- exports.HOST_FORBIDDEN_CHARS = exports.PROFILE_REJECTED_CLONE_TARGET_PATTERNS = exports.ORG_PROFILE_SEED_KEYS = exports.ORG_PROFILE_SEED_SECTION = exports.ORG_PROFILE_ALLOWLIST = exports.ORG_PROFILE_DATA_FILE = exports.ORG_PROFILE_CONFIG_KEY = void 0;
51
+ exports.HOST_FORBIDDEN_CHARS = exports.PROFILE_REJECTED_CLONE_TARGET_PATTERNS = exports.ORG_PROFILE_SEED_KEYS = exports.ORG_PROFILE_SEED_SECTION = exports.ORG_PROFILE_ALLOWLIST = exports.TRACE_ACCEPTED_PREDICATE_MAX = exports.TRACE_ACCEPTED_PREDICATES_CAP = exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY = exports.VAULT_REQUIRED_CONFIG_KEY = exports.ORG_PROFILE_DATA_FILE = exports.ORG_PROFILE_CONFIG_KEY = void 0;
52
+ exports.resolveGlobalPackageRootCandidates = resolveGlobalPackageRootCandidates;
53
+ exports.globalRootCandidatesFor = globalRootCandidatesFor;
52
54
  exports.resolveOrgProfileDocument = resolveOrgProfileDocument;
53
55
  exports.resolveOrgProfile = resolveOrgProfile;
54
56
  exports.readProfileValue = readProfileValue;
57
+ exports.resolveVaultRequired = resolveVaultRequired;
58
+ exports.resolveAcceptedTracePredicates = resolveAcceptedTracePredicates;
55
59
  const node_fs_1 = __importDefault(require("node:fs"));
56
60
  const node_path_1 = __importDefault(require("node:path"));
57
61
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -62,10 +66,41 @@ exports.ORG_PROFILE_CONFIG_KEY = 'org.profile';
62
66
  /** The fixed filename read from the resolved package's own directory — never the
63
67
  * package's `main`/`exports` entry, and never loaded via `require` (D-08). */
64
68
  exports.ORG_PROFILE_DATA_FILE = 'org-profile.json';
69
+ /** The dotted key naming the vault load policy, declared boolean (default
70
+ * false) by `capabilities/vault/capability.json`. Named once here so neither
71
+ * the allowlist, the value guard nor `resolveVaultRequired` restates it. */
72
+ exports.VAULT_REQUIRED_CONFIG_KEY = 'vault.required';
65
73
  /**
66
- * D-06's fixed allowlist: a profile may contribute exactly these five dotted
74
+ * The dotted key naming the practice-trace predicate types a READER accepts
75
+ * besides pp-core's own (`TRACE_PREDICATE_TYPE` in src/vault-trace.cts). It
76
+ * exists so a project that switches engine mid-engagement can keep reading
77
+ * the traces its earlier engine wrote: the org profile of that engagement
78
+ * lists the earlier engine's predicate URI here. Writers never use it — pp-core
79
+ * always writes its own URI — and pp-core itself names no such URI.
80
+ */
81
+ exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY = 'vault.trace.accepted_predicates';
82
+ /** At most this many accepted predicate URIs, each at most this long. */
83
+ exports.TRACE_ACCEPTED_PREDICATES_CAP = 8;
84
+ exports.TRACE_ACCEPTED_PREDICATE_MAX = 200;
85
+ /**
86
+ * D-06's fixed allowlist: a profile may contribute exactly these seven dotted
67
87
  * config keys. Entry order is not contractual; membership is. Anything else
68
88
  * present in a profile document is dropped with a bounded warning.
89
+ *
90
+ * FIVE COORDINATES AND ONE POLICY. The first five answer where to fetch from.
91
+ * `vault.required` answers whether the vault load is mandatory, so one
92
+ * org-profile package can turn the load gate on across a fleet without a
93
+ * per-repository edit. That is this list's sharpest widening: an `npm update`
94
+ * of the profile package can move a build gate without a review in the
95
+ * consuming repository. It is bounded three ways: project config outranks the
96
+ * profile, so any project can opt out explicitly; the profile can only raise
97
+ * the gate from the built-in default `false` (a gate turning on fails
98
+ * closed); and the value guard accepts only the two boolean literals.
99
+ * `vault.enabled` (arming the capability) stays off the list.
100
+ *
101
+ * `vault.trace.accepted_predicates` is READ-ONLY widening: it lets the trace
102
+ * readers accept another engine's predicate URI (a list of https URIs). It
103
+ * changes no writer and no gate polarity.
69
104
  */
70
105
  exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
71
106
  'vault.provider',
@@ -73,6 +108,8 @@ exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
73
108
  'vault.fallback_ref',
74
109
  'vault.fallback_host',
75
110
  'platform.host',
111
+ exports.VAULT_REQUIRED_CONFIG_KEY,
112
+ exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY,
76
113
  ]);
77
114
  /**
78
115
  * The single top-level key naming the Phase 34 init-only seed section.
@@ -220,62 +257,108 @@ function describeUnknown(value) {
220
257
  return Object.prototype.toString.call(value);
221
258
  }
222
259
  }
223
- // ─── resolveGlobalPackageRoot ────────────────────────────────────────────────
260
+ // ─── Global package roots ────────────────────────────────────────────────────
224
261
  /**
225
- * D-03's "global" half of the two-path resolution ladder, as a pure
226
- * heuristic with NO subprocess spawn (this repo avoids spawning a
227
- * subprocess for a computation that does not strictly require one):
262
+ * D-03's "global" half of the two-path resolution ladder, as a pure heuristic
263
+ * with NO subprocess spawn. Returns an ORDERED list of existing candidate
264
+ * roots (a single root silently missed Homebrew layouts):
228
265
  *
229
266
  * 1. `NPM_CONFIG_PREFIX` / `npm_config_prefix`, when set to a non-blank
230
267
  * string: `<prefix>/node_modules` on `win32`, `<prefix>/lib/node_modules`
231
268
  * otherwise.
232
- * 2. Otherwise, derived from `process.execPath`: with
233
- * `execDir = path.dirname(process.execPath)`, `path.join(execDir,
234
- * 'node_modules')` on `win32`, `path.resolve(execDir, '..', 'lib',
235
- * 'node_modules')` otherwise.
269
+ * 2. Every PATH directory containing a `node` entry, mapped through the same
270
+ * `<bin>` → `<global tree>` rule — WITHOUT resolving symlinks. This is what
271
+ * finds Homebrew: `process.execPath` resolves `/opt/homebrew/bin/node` into
272
+ * the Cellar, whose `lib/node_modules` exists but holds only npm, while the
273
+ * real global tree lives at `/opt/homebrew/lib/node_modules` beside the
274
+ * PATH symlink.
275
+ * 3. Derived from `process.execPath` — still correct for nvm/Volta layouts.
236
276
  *
237
- * The candidate is returned only when `fs.existsSync(candidate)`;
238
- * otherwise `null`.
277
+ * Candidates are deduped and filtered to `fs.existsSync`; resolution tries
278
+ * each in order (first hit wins), so an existing-but-empty earlier root cannot
279
+ * mask a later root that holds the package.
239
280
  *
240
- * Accepted residual: an nvm/Volta-style layout that honours neither
241
- * `NPM_CONFIG_PREFIX` nor places the global tree beside the node binary
242
- * will simply not resolve a globally-installed profile — this degrades
243
- * per D-09 (falls through to the no-profile behaviour with a warning)
244
- * rather than failing.
281
+ * Detection (the `/pp-init` org-profile hint and its invariant check) passes
282
+ * `explicitPrefixOnly: true`: with `NPM_CONFIG_PREFIX` set, only the prefix
283
+ * root is scanned, because a hint must never come from a tree the operator did
284
+ * not point at. Detection roots are therefore a subset of resolution roots.
245
285
  *
246
- * pp-core's own install directory is deliberately NOT a candidate here:
247
- * the AI-runtime install is a bare directory copy with no `package.json`
248
- * and no `node_modules` of its own, so resolving relative to it would
249
- * never find a sibling package.
286
+ * pp-core's own install directory is deliberately NOT a candidate: the
287
+ * runtime install is a bare directory copy with no `node_modules` of its own.
250
288
  */
251
- function resolveGlobalPackageRoot() {
289
+ const GLOBAL_ROOT_PATH_SCAN_CAP = 64;
290
+ /** npm's own `<bin dir>` → `<global node_modules>` mapping. */
291
+ function globalRootFromBinDir(binDir) {
292
+ return process.platform === 'win32' ? node_path_1.default.join(binDir, 'node_modules') : node_path_1.default.resolve(binDir, '..', 'lib', 'node_modules');
293
+ }
294
+ function resolveGlobalPackageRootCandidates(opts = {}) {
295
+ const candidates = [];
296
+ const push = (c) => {
297
+ if (!candidates.includes(c))
298
+ candidates.push(c);
299
+ };
300
+ const existing = () => candidates.filter((c) => {
301
+ try {
302
+ return node_fs_1.default.existsSync(c);
303
+ }
304
+ catch {
305
+ return false;
306
+ }
307
+ });
252
308
  try {
253
309
  const prefixEnv = process.env.NPM_CONFIG_PREFIX ?? process.env.npm_config_prefix;
254
- let candidate;
255
310
  if (typeof prefixEnv === 'string' && prefixEnv.trim() !== '') {
256
311
  const prefix = prefixEnv.trim();
257
- candidate =
258
- process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules');
312
+ push(process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules'));
313
+ // An explicit prefix is authoritative for detection; one that points
314
+ // nowhere yields [] — never a fall-through to PATH.
315
+ if (opts.explicitPrefixOnly === true)
316
+ return existing();
259
317
  }
260
- else {
261
- const execDir = node_path_1.default.dirname(process.execPath);
262
- candidate =
263
- process.platform === 'win32'
264
- ? node_path_1.default.join(execDir, 'node_modules')
265
- : node_path_1.default.resolve(execDir, '..', 'lib', 'node_modules');
318
+ const pathDirs = (process.env.PATH ?? '')
319
+ .split(node_path_1.default.delimiter)
320
+ .filter((d) => d.trim() !== '')
321
+ .slice(0, GLOBAL_ROOT_PATH_SCAN_CAP);
322
+ const nodeNames = process.platform === 'win32' ? ['node.exe', 'node.cmd', 'node'] : ['node'];
323
+ for (const dir of pathDirs) {
324
+ for (const name of nodeNames) {
325
+ let found = false;
326
+ try {
327
+ found = node_fs_1.default.existsSync(node_path_1.default.join(dir, name));
328
+ }
329
+ catch {
330
+ // unreadable PATH entry — skip it
331
+ }
332
+ if (found) {
333
+ push(globalRootFromBinDir(dir));
334
+ break;
335
+ }
336
+ }
266
337
  }
267
- return node_fs_1.default.existsSync(candidate) ? candidate : null;
338
+ push(globalRootFromBinDir(node_path_1.default.dirname(process.execPath)));
268
339
  }
269
340
  catch {
270
- return null;
341
+ // degrade to whatever was collected before the fault
271
342
  }
343
+ return existing();
344
+ }
345
+ /**
346
+ * Map the `_globalRoot` test seam (a single root, or `null` for "no global
347
+ * root found") onto the candidate-list shape; when the seam is absent, run the
348
+ * production heuristic. The seam wins over `explicitPrefixOnly`.
349
+ */
350
+ function globalRootCandidatesFor(opts) {
351
+ if (opts && opts._globalRoot !== undefined) {
352
+ return opts._globalRoot === null ? [] : [opts._globalRoot];
353
+ }
354
+ return resolveGlobalPackageRootCandidates({ explicitPrefixOnly: opts?.explicitPrefixOnly === true });
272
355
  }
273
356
  // ─── resolveProfilePackage ───────────────────────────────────────────────────
274
357
  /**
275
358
  * D-03's two-path resolution ladder. Attempt `require.resolve(pkgName +
276
359
  * '/package.json', { paths: [cwd] })` first and report `source: 'project'`;
277
- * on failure attempt the same with `paths: [globalRoot]` when a global root
278
- * was found and report `source: 'global'`; otherwise `null`. The
360
+ * on failure attempt the same with `paths: [root]` for each global candidate
361
+ * root in order and report `source: 'global'`; otherwise `null`. The
279
362
  * project-local hit always wins when both would resolve — the global
280
363
  * attempt is only ever reached when the project attempt threw.
281
364
  *
@@ -283,19 +366,19 @@ function resolveGlobalPackageRoot() {
283
366
  * 220-228): the same try/catch-returns-null shape, one level removed
284
367
  * (resolving a data file's sibling `package.json`, not a CLI entry point).
285
368
  */
286
- function resolveProfilePackage(pkgName, cwd, globalRoot) {
369
+ function resolveProfilePackage(pkgName, cwd, globalRoots) {
287
370
  try {
288
371
  return { pkgJsonPath: require.resolve(`${pkgName}/package.json`, { paths: [cwd] }), source: 'project' };
289
372
  }
290
373
  catch {
291
- // fall through to the global attempt below
374
+ // fall through to the global attempts below
292
375
  }
293
- if (globalRoot) {
376
+ for (const globalRoot of globalRoots) {
294
377
  try {
295
378
  return { pkgJsonPath: require.resolve(`${pkgName}/package.json`, { paths: [globalRoot] }), source: 'global' };
296
379
  }
297
380
  catch {
298
- // fall through to null below
381
+ // try the next candidate root
299
382
  }
300
383
  }
301
384
  return null;
@@ -388,6 +471,45 @@ function flattenProfileDocument(doc) {
388
471
  */
389
472
  function profileValueDisposition(key, value) {
390
473
  try {
474
+ // The one boolean-typed member, disposed of before the string-only scalar
475
+ // guard below would reject its natural JSON form. Carried as the canonical
476
+ // string "true" / "false".
477
+ if (key === exports.VAULT_REQUIRED_CONFIG_KEY) {
478
+ if (value === true || value === false)
479
+ return { ok: true, value: String(value) };
480
+ if (typeof value === 'string') {
481
+ const t = value.trim();
482
+ if (t === 'true' || t === 'false')
483
+ return { ok: true, value: t };
484
+ }
485
+ return { ok: false, reason: 'value must be the boolean true or false' };
486
+ }
487
+ // The one list-typed member. Carried forward as a canonical JSON array
488
+ // string, so `contributes` stays a string map.
489
+ if (key === exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY) {
490
+ const list = Array.isArray(value) ? value : null;
491
+ if (list === null || list.length === 0) {
492
+ return { ok: false, reason: 'value must be a non-empty list of https URIs' };
493
+ }
494
+ if (list.length > exports.TRACE_ACCEPTED_PREDICATES_CAP) {
495
+ return { ok: false, reason: `value lists more than ${exports.TRACE_ACCEPTED_PREDICATES_CAP} URIs` };
496
+ }
497
+ const kept = [];
498
+ for (const item of list) {
499
+ if (typeof item !== 'string')
500
+ return { ok: false, reason: 'every entry must be a string' };
501
+ const t = item.trim();
502
+ if (t.length > exports.TRACE_ACCEPTED_PREDICATE_MAX) {
503
+ return { ok: false, reason: `an entry is longer than ${exports.TRACE_ACCEPTED_PREDICATE_MAX} characters` };
504
+ }
505
+ if (!/^https:\/\/[^\s\x00-\x1f\x7f"'<>`\\]+$/.test(t)) {
506
+ return { ok: false, reason: 'every entry must be an https URI with no whitespace or quotes' };
507
+ }
508
+ if (!kept.includes(t))
509
+ kept.push(t);
510
+ }
511
+ return { ok: true, value: JSON.stringify(kept) };
512
+ }
391
513
  if (typeof value !== 'string') {
392
514
  return { ok: false, reason: 'value must be a string' };
393
515
  }
@@ -458,7 +580,7 @@ function profileValueDisposition(key, value) {
458
580
  * The `_globalRoot` option is the repo's `_`-prefixed test seam convention
459
581
  * (as used by `_vault` / `_exec` / `_writeBack` in
460
582
  * `src/vault-command-router.cts`); production callers omit it and the
461
- * module calls `resolveGlobalPackageRoot()`. Passing `_globalRoot`
583
+ * module walks `resolveGlobalPackageRootCandidates()`. Passing `_globalRoot`
462
584
  * explicitly (including `null`, to simulate "no global root found")
463
585
  * overrides the production heuristic for that one call.
464
586
  *
@@ -498,16 +620,19 @@ function resolveOrgProfileDocument(cwd, opts) {
498
620
  if (pkgName === '') {
499
621
  return absentDocShape();
500
622
  }
501
- // Step 2: resolve the package (project, then global).
502
- const globalRoot = opts && opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalPackageRoot();
503
- const hit = resolveProfilePackage(pkgName, cwd, globalRoot);
623
+ // Step 2: resolve the package (project, then every global candidate).
624
+ const globalRoots = globalRootCandidatesFor(opts);
625
+ const hit = resolveProfilePackage(pkgName, cwd, globalRoots);
504
626
  if (!hit) {
505
627
  const echoed = sanitizeForWarning(pkgName);
628
+ const probed = globalRoots.length > 0
629
+ ? `the global npm root(s) ${globalRoots.map((r) => sanitizeForWarning(r)).join(', ')}`
630
+ : 'no locatable global npm root';
506
631
  return {
507
632
  ...absentDocShape(),
508
633
  configured: pkgName,
509
634
  warnings: [
510
- bound(`org profile "${echoed}" was not found — resolution was attempted from the project and the global npm root and found nothing. Falling back to built-in defaults.`),
635
+ bound(`org profile "${echoed}" was not found — resolution was attempted from the project and ${probed} and found nothing. Falling back to built-in defaults.`),
511
636
  ],
512
637
  };
513
638
  }
@@ -694,3 +819,52 @@ function readProfileValue(cwd, key, opts) {
694
819
  source: resolution.source,
695
820
  };
696
821
  }
822
+ /**
823
+ * The ONE ladder for `vault.required`, which `computeVaultVerdict` (the
824
+ * verdict owner) and `vault org-profile` both read:
825
+ *
826
+ * 1. The project's `.planning/config.json`. A present value wins, whatever
827
+ * it is: a JSON boolean is used as is; anything else is treated as false
828
+ * and reported as invalid (the gate card names it), never passed on to the
829
+ * profile — an explicit project value is the project's decision.
830
+ * 2. The org profile, already narrowed to "true" / "false" by the value guard.
831
+ * 3. The registered default, false.
832
+ *
833
+ * Never throws.
834
+ */
835
+ function resolveVaultRequired(cwd, opts) {
836
+ try {
837
+ const raw = cfgGet(cwd, exports.VAULT_REQUIRED_CONFIG_KEY, undefined);
838
+ if (raw !== undefined) {
839
+ return { required: raw === true, source: 'config', valid: typeof raw === 'boolean', raw, warnings: [] };
840
+ }
841
+ const profile = readProfileValue(cwd, exports.VAULT_REQUIRED_CONFIG_KEY, opts);
842
+ if (profile.value === 'true' || profile.value === 'false') {
843
+ return { required: profile.value === 'true', source: 'profile', valid: true, raw: undefined, warnings: profile.warnings };
844
+ }
845
+ return { required: false, source: 'default', valid: true, raw: undefined, warnings: profile.warnings };
846
+ }
847
+ catch {
848
+ return { required: false, source: 'default', valid: true, raw: undefined, warnings: [] };
849
+ }
850
+ }
851
+ // ─── resolveAcceptedTracePredicates ──────────────────────────────────────────
852
+ /**
853
+ * The extra practice-trace predicate types the org profile lists under
854
+ * `vault.trace.accepted_predicates` — `[]` when there is no profile, the key is
855
+ * absent, or its value was refused by the value guard. Org profile only: a
856
+ * project config value is not read (the key is not a project setting). Never
857
+ * throws.
858
+ */
859
+ function resolveAcceptedTracePredicates(cwd, opts) {
860
+ try {
861
+ const profile = readProfileValue(cwd, exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY, opts);
862
+ if (typeof profile.value !== 'string')
863
+ return [];
864
+ const parsed = JSON.parse(profile.value);
865
+ return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
866
+ }
867
+ catch {
868
+ return [];
869
+ }
870
+ }
@@ -6,7 +6,7 @@
6
6
  * Unsupported in this router:
7
7
  * - scaffold: routed through top-level scaffold command.
8
8
  *
9
- * CJS-only subcommands: mvp-mode, dispatch-plan, install-run, tier, tier-propose (dispatched directly, before hub).
9
+ * CJS-only subcommands: mvp-mode, dispatch-plan, install-run, tier, tier-propose, manifest (dispatched directly, before hub).
10
10
  *
11
11
  * #3788: dispatch is mediated by CommandRoutingHub. The public entry point
12
12
  * and observable CLI behaviour are unchanged.
@@ -73,6 +73,12 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
73
73
  phase.cmdPhaseTierPropose(cwd, args.slice(2), raw);
74
74
  return;
75
75
  }
76
+ // `manifest start|record` (pack 10): write `.phase-manifest.json`, which
77
+ // `/pp-undo --phase` reads (src/phase-manifest.cts).
78
+ if (subcommand === 'manifest') {
79
+ phase.cmdPhaseManifest(cwd, args.slice(2), raw);
80
+ return;
81
+ }
76
82
  // ── Build the CJS registry ──────────────────────────────────────────────────
77
83
  // Each handler receives a ctx object from the hub and must return a HubResult.
78
84
  const cjsRegistry = {