@pareto4/pp-core 1.12.0 → 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 (81) 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-activation.cjs +19 -9
  11. package/pp-core/bin/lib/capability-registry.cjs +200 -10
  12. package/pp-core/bin/lib/check-command-router.cjs +1242 -37
  13. package/pp-core/bin/lib/clusters.cjs +1 -0
  14. package/pp-core/bin/lib/command-aliases.cjs +7 -0
  15. package/pp-core/bin/lib/compose-bindings.cjs +41 -27
  16. package/pp-core/bin/lib/config-loader.cjs +151 -8
  17. package/pp-core/bin/lib/decisions.cjs +104 -27
  18. package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
  19. package/pp-core/bin/lib/frontmatter.cjs +37 -0
  20. package/pp-core/bin/lib/git-base-branch.cjs +136 -10
  21. package/pp-core/bin/lib/init-contract.cjs +180 -66
  22. package/pp-core/bin/lib/init-interactive.cjs +4 -20
  23. package/pp-core/bin/lib/loop-gates.cjs +213 -0
  24. package/pp-core/bin/lib/loop-resolver.cjs +111 -35
  25. package/pp-core/bin/lib/org-profile.cjs +218 -44
  26. package/pp-core/bin/lib/phase-command-router.cjs +7 -1
  27. package/pp-core/bin/lib/phase-manifest.cjs +527 -0
  28. package/pp-core/bin/lib/phase-tier.cjs +15 -2
  29. package/pp-core/bin/lib/phase.cjs +51 -1
  30. package/pp-core/bin/lib/vault-audit.cjs +2070 -0
  31. package/pp-core/bin/lib/vault-command-router.cjs +460 -1
  32. package/pp-core/bin/lib/vault-practices.cjs +1142 -104
  33. package/pp-core/bin/lib/vault-review.cjs +2359 -0
  34. package/pp-core/bin/lib/vault-trace.cjs +2831 -0
  35. package/pp-core/bin/lib/vault-update.cjs +266 -0
  36. package/pp-core/bin/lib/vault-write-back.cjs +19 -0
  37. package/pp-core/bin/lib/vault.cjs +313 -11
  38. package/pp-core/bin/pp-tools.cjs +30 -13
  39. package/pp-core/bin/shared/model-catalog.json +2 -1
  40. package/pp-core/references/executor-vault-trace.md +47 -0
  41. package/pp-core/references/loop-hook-dispatch.md +20 -2
  42. package/pp-core/references/planner-vault-practices.md +46 -1
  43. package/pp-core/references/planning-config.md +1 -1
  44. package/pp-core/templates/practices/README.md +140 -11
  45. package/pp-core/templates/project-charter.md +226 -0
  46. package/pp-core/workflows/add-tests.md +4 -0
  47. package/pp-core/workflows/audit-fix.md +2 -0
  48. package/pp-core/workflows/audit-milestone.md +1 -1
  49. package/pp-core/workflows/autonomous.md +3 -3
  50. package/pp-core/workflows/check-todos.md +1 -1
  51. package/pp-core/workflows/code-review-fix.md +3 -1
  52. package/pp-core/workflows/code-review.md +1 -1
  53. package/pp-core/workflows/debug.md +4 -0
  54. package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
  55. package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
  56. package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
  57. package/pp-core/workflows/discovery-sprint.md +1 -1
  58. package/pp-core/workflows/discovery-to-build.md +1 -1
  59. package/pp-core/workflows/discuss-phase.md +2 -2
  60. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
  61. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
  62. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
  63. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
  64. package/pp-core/workflows/execute-phase.md +29 -12
  65. package/pp-core/workflows/execute-plan.md +4 -0
  66. package/pp-core/workflows/fast.md +4 -0
  67. package/pp-core/workflows/help/modes/full.md +3 -2
  68. package/pp-core/workflows/init.md +2 -2
  69. package/pp-core/workflows/new-project.md +1 -1
  70. package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
  71. package/pp-core/workflows/plan-phase.md +13 -10
  72. package/pp-core/workflows/quick.md +5 -1
  73. package/pp-core/workflows/secure-phase.md +1 -1
  74. package/pp-core/workflows/ship.md +7 -3
  75. package/pp-core/workflows/undo.md +1 -1
  76. package/pp-core/workflows/validate-phase.md +5 -1
  77. package/pp-core/workflows/vault-review.md +65 -0
  78. package/pp-core/workflows/verify-work.md +6 -3
  79. package/scripts/gen-capability-registry.cjs +11 -0
  80. package/scripts/lint-test-file-count.allowlist.json +10 -1
  81. package/scripts/prompt-injection-scan.sh +2 -0
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ /**
3
+ * Loop gate runner — the one owner of the generic `check.query` gate contract.
4
+ *
5
+ * Command surface: pp-tools loop run-gates <point> [--phase <N>] [--tier light|standard|heavy] [--query <q>] [--raw]
6
+ *
7
+ * Resolves the active hooks at <point> through the loop resolver (the same
8
+ * owner `loop render-hooks` uses, so an unreadable config refuses here too),
9
+ * then runs every active gate in registry order and applies the two-step gate
10
+ * contract execute-phase states for `execute:wave:post` / `execute:post`:
11
+ *
12
+ * Step 1 — did the check COMMAND succeed? (`pp-tools check <query> [<phase>]
13
+ * [<check.args>…] --raw` exits 0 with a JSON object). If not: `onError: halt` halts,
14
+ * `onError: skip` (or absent) warns and moves on; `block` is never read.
15
+ * Step 2 — read `block`. A blocking gate (`blocking: true`) halts on
16
+ * `block: true`; that halt is never bypassed by `onError`. An advisory gate
17
+ * (`blocking` not true) reports `block: true` or a non-empty `message` as an
18
+ * advisory and never halts. `block: false` with no message passes silently;
19
+ * a blocking gate that passes WITH a message (a note) passes with an `ℹ` line.
20
+ *
21
+ * `check.args` (optional) are literal tokens the gate declaration appends after
22
+ * the phase — the gate's own policy, stated where the gate is declared (e.g.
23
+ * `--stage plan`). `--query <q>` runs only the gates whose `check.query` is `q`,
24
+ * so a host can run one gate in code beside a loop that handles the others.
25
+ *
26
+ * A gate this runner cannot run (no `check.query`, or a query that is not a
27
+ * check name) halts when it is blocking and is reported when it is advisory:
28
+ * a blocking gate is never passed having checked nothing.
29
+ *
30
+ * Output: the verdict JSON on stdout (`{ point, phase, halt, gates[], lines[] }`),
31
+ * every line of `lines` also on stderr so a host capturing `$(…)` still shows
32
+ * them, and exit 1 when `halt` is true. A host therefore writes
33
+ * `X=$(pp_run loop run-gates <point> …) || exit 1` and the halt stops it.
34
+ */
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ const node_path_1 = __importDefault(require("node:path"));
39
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const ioMod = require("./io.cjs");
42
+ const { output: coreOutput, error: coreError, ERROR_REASON } = ioMod;
43
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
44
+ const loopResolverModule = require("./loop-resolver.cjs");
45
+ const { resolveProjectLoopHooks } = loopResolverModule;
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const configLoaderModule = require("./config-loader.cjs");
48
+ const { unreadableConfigFiles } = configLoaderModule;
49
+ const phase_tier_cjs_1 = require("./phase-tier.cjs");
50
+ /** A check name as `routeCheckCommand` accepts it (dotted or hyphenated); never an option or a path. */
51
+ const CHECK_QUERY_RE = /^[a-z0-9][a-z0-9.-]*$/i;
52
+ /** A declared `check.args` token: a flag or a plain word — never a path, a quote or a shell metacharacter. */
53
+ const CHECK_ARG_RE = /^(?:--[a-z][a-z0-9-]*|[a-z0-9][a-z0-9._-]*)$/i;
54
+ const CHECK_ARGS_MAX = 8;
55
+ const CHECK_TIMEOUT_MS = 300_000;
56
+ /** A check's operator message is relayed whole (line breaks kept), up to this many characters. */
57
+ const MESSAGE_CAP = 16_000;
58
+ function boundMessage(v) {
59
+ if (typeof v !== 'string')
60
+ return null;
61
+ const flat = v.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F]+/g, ' ').trim();
62
+ if (flat === '')
63
+ return null;
64
+ return flat.length > MESSAGE_CAP ? `${flat.slice(0, MESSAGE_CAP)}…` : flat;
65
+ }
66
+ /** The declared `check.args`, or null when the declaration carries an argument this runner will not pass. */
67
+ function declaredCheckArgs(check) {
68
+ const args = check ? check['args'] : undefined;
69
+ if (args === undefined)
70
+ return [];
71
+ if (!Array.isArray(args) || args.length > CHECK_ARGS_MAX)
72
+ return null;
73
+ return args.every((a) => typeof a === 'string' && CHECK_ARG_RE.test(a)) ? args : null;
74
+ }
75
+ /** Run `pp-tools check <query> [<phase>] [<args>…] --raw` in a child process (check handlers exit the process). */
76
+ const defaultCheckRunner = (query, phase, cwd, checkArgs) => {
77
+ const tools = node_path_1.default.join(__dirname, '..', 'pp-tools.cjs');
78
+ const args = [tools, 'check', query, ...(phase ? [phase] : []), ...checkArgs, '--raw'];
79
+ const r = (0, shell_command_projection_cjs_1.execTool)(process.execPath, args, { cwd, timeout: CHECK_TIMEOUT_MS });
80
+ if (r.exitCode !== 0 || r.signal) {
81
+ const why = r.signal ? `killed by ${r.signal}` : `exit ${r.exitCode}`;
82
+ return { ok: false, result: null, error: `${why}${r.stderr ? `: ${r.stderr.split('\n')[0]}` : ''}` };
83
+ }
84
+ if (r.stdout === '')
85
+ return { ok: false, result: null, error: 'empty output' };
86
+ try {
87
+ const parsed = JSON.parse(r.stdout);
88
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
89
+ return { ok: false, result: null, error: 'output is not a JSON object' };
90
+ return { ok: true, result: parsed, error: null };
91
+ }
92
+ catch {
93
+ return { ok: false, result: null, error: 'output is not JSON' };
94
+ }
95
+ };
96
+ /**
97
+ * Run every active gate at `point` (already resolved) under the two-step
98
+ * contract. Pure apart from `runCheck`.
99
+ */
100
+ function runGates(point, phase, activeHooks, cwd, runCheck = defaultCheckRunner) {
101
+ const gates = [];
102
+ const lines = [];
103
+ let halt = false;
104
+ for (const h of activeHooks) {
105
+ if (h.kind !== 'gate')
106
+ continue;
107
+ const blocking = h.blocking === true;
108
+ const onError = typeof h.onError === 'string' ? h.onError : 'skip';
109
+ const check = h.check && typeof h.check === 'object' ? h.check : null;
110
+ const query = check && typeof check['query'] === 'string' ? check['query'] : null;
111
+ const checkArgs = declaredCheckArgs(check);
112
+ const run = { capId: h.capId, query, blocking, onError, outcome: 'pass', block: null, message: null };
113
+ if (query === null || !CHECK_QUERY_RE.test(query) || checkArgs === null) {
114
+ run.outcome = 'unrunnable';
115
+ run.message = query === null
116
+ ? 'the gate declares no check.query'
117
+ : checkArgs === null ? 'check.args is not a list of plain flags and words' : 'check.query is not a check name';
118
+ if (blocking) {
119
+ halt = true;
120
+ lines.push(`⛔ ${point} blocked: capability gate (${h.capId}) cannot be run (${run.message}). Fix the capability declaration before continuing.`);
121
+ }
122
+ else {
123
+ lines.push(`⚠ ${h.capId} advisory (${point}): gate not run — ${run.message}.`);
124
+ }
125
+ gates.push(run);
126
+ continue;
127
+ }
128
+ // Step 1 — did the check command itself succeed?
129
+ const r = runCheck(query, phase, cwd, checkArgs);
130
+ if (!r.ok || r.result === null) {
131
+ run.message = boundMessage(r.error) ?? 'command error';
132
+ if (onError === 'halt') {
133
+ run.outcome = 'error-halt';
134
+ halt = true;
135
+ lines.push(`⛔ ${point} blocked: gate check command failed (${h.capId}: check ${query}): ${run.message}. Resolve before continuing.`);
136
+ }
137
+ else {
138
+ run.outcome = 'error-skip';
139
+ lines.push(`⚠ ${point} gate check command failed (${h.capId}: check ${query}): ${run.message} — skipped (onError: ${onError}).`);
140
+ }
141
+ gates.push(run);
142
+ continue;
143
+ }
144
+ // Step 2 — read block (only reached when the command succeeded).
145
+ const block = r.result['block'] === true;
146
+ run.block = block;
147
+ run.message = boundMessage(r.result['message']) ?? boundMessage(r.result['reason']);
148
+ if (blocking && block) {
149
+ run.outcome = 'blocked';
150
+ halt = true;
151
+ lines.push(`⛔ ${point} blocked by capability gate (${h.capId}): ${run.message ?? `check ${query} reported block`}. Resolve before continuing.`);
152
+ }
153
+ else if (!blocking && (block || boundMessage(r.result['message']) !== null)) {
154
+ run.outcome = 'advisory';
155
+ lines.push(`⚠ ${h.capId} advisory (${point}): ${run.message ?? `check ${query} reported block`}`);
156
+ }
157
+ else {
158
+ run.outcome = 'pass';
159
+ const note = blocking ? boundMessage(r.result['message']) : null;
160
+ if (note !== null)
161
+ lines.push(`ℹ ${h.capId} (${point}): ${note}`);
162
+ }
163
+ gates.push(run);
164
+ }
165
+ return { point, phase, halt, gates, lines };
166
+ }
167
+ /**
168
+ * `loop run-gates <point> [--phase <N>] [--tier <t>]`. Exit 1 on a halt or a
169
+ * refusal; the verdict JSON is on stdout either way except on a refusal.
170
+ */
171
+ function cmdLoopRunGates(cwd, point, raw, options = {}) {
172
+ if (!point) {
173
+ coreError('loop run-gates requires a <point> argument (e.g. verify:post)', ERROR_REASON.USAGE);
174
+ return;
175
+ }
176
+ const phase = typeof options['phase'] === 'string' && options['phase'] !== '' ? options['phase'] : null;
177
+ const tierCli = typeof options['tier'] === 'string' && options['tier'] !== '' ? options['tier'] : null;
178
+ if (tierCli !== null && !['light', 'standard', 'heavy'].includes(tierCli)) {
179
+ coreError('--tier needs one of light, standard, heavy', ERROR_REASON.USAGE);
180
+ return;
181
+ }
182
+ const phaseTier = (phase !== null || tierCli !== null) && unreadableConfigFiles(cwd).length === 0
183
+ ? (0, phase_tier_cjs_1.resolvePhaseTierForProject)(cwd, phase, tierCli)
184
+ : null;
185
+ const outcome = resolveProjectLoopHooks(cwd, point, { phaseTier, what: `loop run-gates ${point}` });
186
+ if (!outcome.ok) {
187
+ coreError(outcome.message, outcome.reason === 'config_unreadable' ? ERROR_REASON.CONFIG_PARSE_FAILED : undefined);
188
+ return;
189
+ }
190
+ const onlyQuery = typeof options['query'] === 'string' && options['query'] !== '' ? options['query'] : null;
191
+ const hooks = onlyQuery === null
192
+ ? outcome.resolved.activeHooks
193
+ : outcome.resolved.activeHooks.filter((h) => {
194
+ const c = h.check && typeof h.check === 'object' ? h.check : null;
195
+ return c !== null && c['query'] === onlyQuery;
196
+ });
197
+ const verdict = runGates(point, phase, hooks, cwd);
198
+ if (verdict.lines.length > 0) {
199
+ try {
200
+ process.stderr.write(verdict.lines.join('\n') + '\n');
201
+ }
202
+ catch {
203
+ /* stderr closed */
204
+ }
205
+ }
206
+ coreOutput(verdict, raw);
207
+ if (verdict.halt)
208
+ process.exitCode = 1;
209
+ }
210
+ module.exports = {
211
+ runGates,
212
+ cmdLoopRunGates,
213
+ };
@@ -30,10 +30,10 @@
30
30
  */
31
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
32
  const ioMod = require("./io.cjs");
33
- const { output: coreOutput, error: coreError } = ioMod;
33
+ const { output: coreOutput, error: coreError, ERROR_REASON } = ioMod;
34
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
35
  const configLoaderModule = require("./config-loader.cjs");
36
- const { loadConfig } = configLoaderModule;
36
+ const { loadConfig, unreadableConfigFiles, describeUnreadableConfig } = configLoaderModule;
37
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
38
38
  const capabilityStateModule = require("./capability-state.cjs");
39
39
  const { resolveCapabilityRuntimeState } = capabilityStateModule;
@@ -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,23 +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
  }
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);
514
+ return;
515
+ }
425
516
  const runtimeConfigDir = typeof options['configDir'] === 'string'
426
517
  ? options['configDir']
427
518
  : undefined;
428
- // Load the config snapshot ONCE and share it with both the capability-state
429
- // resolver (via configOverride) and loop-hook resolution, so federated keys
430
- // present in loadConfig resolve identically for `active` and for hook when/
431
- // configValues — eliminating the previous double loadConfig() call. Note: keys
432
- // absent from loadConfig still fall through to raw .planning/config.json reads
433
- // (precedence levels 2-3) in each pass; that residual re-read window is
434
- // pre-existing (unchanged by this consolidation), not introduced here.
435
- let config;
436
- try {
437
- config = loadConfig(cwd);
438
- }
439
- catch {
440
- config = {};
441
- }
442
519
  const tierPhase = typeof options['phase'] === 'string' && options['phase'] !== '' ? options['phase'] : null;
443
520
  const tierCli = typeof options['tier'] === 'string' && options['tier'] !== '' ? options['tier'] : null;
444
521
  let phaseTier = null;
@@ -447,32 +524,29 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
447
524
  coreError('--tier needs one of light, standard, heavy');
448
525
  return;
449
526
  }
450
- phaseTier = (0, phase_tier_cjs_1.resolvePhaseTierForProject)(cwd, tierPhase, tierCli);
451
- 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);
452
530
  }
453
- const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config);
454
- // Registry is the static generated module — same object capability-state uses internally.
455
- // eslint-disable-next-line @typescript-eslint/no-require-imports
456
- const registry = require('./capability-registry.cjs');
457
- const capabilityStatesById = new Map();
458
- for (const cap of state.capabilities || []) {
459
- capabilityStatesById.set(cap.id, cap);
460
- }
461
- let resolved;
462
- try {
463
- resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
464
- }
465
- catch (err) {
466
- const msg = (err instanceof Error) ? err.message : String(err);
467
- 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);
468
534
  return;
469
535
  }
536
+ const resolved = outcome.resolved;
470
537
  // --active-cap mode: print exactly 'true' or 'false' with no envelope
471
538
  if (activeCapId !== undefined) {
472
539
  const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
473
540
  process.stdout.write(isActive ? 'true\n' : 'false\n');
474
541
  return;
475
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
+ }
476
550
  const rendered = renderLoopHooks(resolved);
477
551
  const envelope = {
478
552
  point: resolved.point,
@@ -482,8 +556,8 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
482
556
  if (phaseTier) {
483
557
  envelope.phase_tier = { phase: tierPhase, tier: phaseTier.tier, source: phaseTier.source, suppressed: phaseTier.suppressed };
484
558
  }
485
- if (state.warnings && state.warnings.length > 0) {
486
- envelope.warnings = state.warnings;
559
+ if (outcome.warnings.length > 0) {
560
+ envelope.warnings = outcome.warnings;
487
561
  }
488
562
  // No rawValue third argument, so --raw emits the JSON envelope rather than
489
563
  // plain text. This is deliberate and load-bearing: 23 workflow call sites do
@@ -497,6 +571,8 @@ module.exports = {
497
571
  resolveLoopHooks,
498
572
  renderLoopHooks,
499
573
  cmdLoopRenderHooks,
574
+ resolveProjectLoopHooks,
575
+ contributionsInto,
500
576
  // Exported for tests
501
577
  _getNestedConfigValue,
502
578
  _resolveActivationValue,