@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/pp-code-reviewer.md +1 -1
- package/agents/pp-executor.md +2 -13
- package/agents/pp-planner.md +1 -1
- package/agents/pp-vault-reviewer.md +51 -0
- package/commands/pp-ns-review.md +2 -1
- package/commands/pp-vault-review.md +33 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +200 -10
- package/pp-core/bin/lib/check-command-router.cjs +1242 -37
- package/pp-core/bin/lib/clusters.cjs +1 -0
- package/pp-core/bin/lib/command-aliases.cjs +7 -0
- package/pp-core/bin/lib/compose-bindings.cjs +41 -27
- package/pp-core/bin/lib/decisions.cjs +104 -27
- package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
- package/pp-core/bin/lib/frontmatter.cjs +37 -0
- package/pp-core/bin/lib/git-base-branch.cjs +136 -10
- package/pp-core/bin/lib/init-contract.cjs +180 -66
- package/pp-core/bin/lib/init-interactive.cjs +4 -20
- package/pp-core/bin/lib/loop-gates.cjs +213 -0
- package/pp-core/bin/lib/loop-resolver.cjs +107 -45
- package/pp-core/bin/lib/org-profile.cjs +218 -44
- package/pp-core/bin/lib/phase-command-router.cjs +7 -1
- package/pp-core/bin/lib/phase-manifest.cjs +527 -0
- package/pp-core/bin/lib/phase-tier.cjs +15 -2
- package/pp-core/bin/lib/phase.cjs +51 -1
- package/pp-core/bin/lib/vault-audit.cjs +2070 -0
- package/pp-core/bin/lib/vault-command-router.cjs +460 -1
- package/pp-core/bin/lib/vault-practices.cjs +1142 -104
- package/pp-core/bin/lib/vault-review.cjs +2359 -0
- package/pp-core/bin/lib/vault-trace.cjs +2831 -0
- package/pp-core/bin/lib/vault-update.cjs +266 -0
- package/pp-core/bin/lib/vault-write-back.cjs +19 -0
- package/pp-core/bin/lib/vault.cjs +277 -12
- package/pp-core/bin/pp-tools.cjs +30 -13
- package/pp-core/bin/shared/model-catalog.json +2 -1
- package/pp-core/references/executor-vault-trace.md +47 -0
- package/pp-core/references/loop-hook-dispatch.md +13 -2
- package/pp-core/references/planner-vault-practices.md +46 -1
- package/pp-core/references/planning-config.md +1 -1
- package/pp-core/templates/practices/README.md +140 -11
- package/pp-core/templates/project-charter.md +226 -0
- package/pp-core/workflows/add-tests.md +4 -0
- package/pp-core/workflows/audit-fix.md +2 -0
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/code-review-fix.md +2 -0
- package/pp-core/workflows/debug.md +4 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
- package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build.md +1 -1
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
- package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
- package/pp-core/workflows/execute-phase.md +22 -5
- package/pp-core/workflows/execute-plan.md +4 -0
- package/pp-core/workflows/fast.md +4 -0
- package/pp-core/workflows/help/modes/full.md +3 -2
- package/pp-core/workflows/init.md +2 -2
- package/pp-core/workflows/new-project.md +1 -1
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
- package/pp-core/workflows/plan-phase.md +7 -4
- package/pp-core/workflows/quick.md +4 -0
- package/pp-core/workflows/ship.md +5 -1
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/validate-phase.md +4 -0
- package/pp-core/workflows/vault-review.md +65 -0
- package/pp-core/workflows/verify-work.md +3 -0
- package/scripts/gen-capability-registry.cjs +11 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
- 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
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
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 (
|
|
500
|
-
envelope.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
|
-
*
|
|
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
|
-
// ───
|
|
260
|
+
// ─── Global package roots ────────────────────────────────────────────────────
|
|
224
261
|
/**
|
|
225
|
-
* D-03's "global" half of the two-path resolution ladder, as a pure
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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.
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
-
*
|
|
238
|
-
*
|
|
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
|
-
*
|
|
241
|
-
* `
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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
|
|
247
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
338
|
+
push(globalRootFromBinDir(node_path_1.default.dirname(process.execPath)));
|
|
268
339
|
}
|
|
269
340
|
catch {
|
|
270
|
-
|
|
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: [
|
|
278
|
-
*
|
|
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,
|
|
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
|
|
374
|
+
// fall through to the global attempts below
|
|
292
375
|
}
|
|
293
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
503
|
-
const hit = resolveProfilePackage(pkgName, cwd,
|
|
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
|
|
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 = {
|