@pareto4/pp-core 1.12.0 → 1.12.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pp-core",
3
3
  "displayName": "PP Core",
4
- "version": "1.12.0",
4
+ "version": "1.12.1",
5
5
  "description": "PP Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
6
6
  "author": {
7
7
  "name": "pareto4",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pp-core",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "PP Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads pp-core's operating context into every Gemini CLI session.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pareto4/pp-core",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "Pareto pp-core — The Pareto Project's AI-assisted engineering framework: meta-prompting, context engineering, and spec-driven development for AI coding agents.",
5
5
  "bin": {
6
6
  "pp-core": "bin/install.js",
@@ -55,6 +55,20 @@ function _readRawConfigKey(filePath, dotKey) {
55
55
  return { found: false, value: undefined };
56
56
  }
57
57
  }
58
+ /**
59
+ * The raw config files levels 2 and 3 read, in read order: the active
60
+ * `planningDir(cwd)/config.json`, then the root `planningRoot(cwd)/config.json`
61
+ * when it is a different file. Exported so `unreadableConfigFiles`
62
+ * (src/config-loader.cts) parses every file the hook resolver reads — this list
63
+ * beside `configFilesFor`, the loader's — without restating either. Both levels
64
+ * skip an unparseable file with a stderr warning and read on, which is the
65
+ * swallow a gate-governing caller must refuse.
66
+ */
67
+ function activationConfigFilesFor(cwd) {
68
+ const wsConfigPath = node_path_1.default.join(planningDir(cwd), 'config.json');
69
+ const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
70
+ return wsConfigPath !== rootConfigPath ? [wsConfigPath, rootConfigPath] : [wsConfigPath];
71
+ }
58
72
  /**
59
73
  * Resolve the raw value for a dotted config key using the four-level precedence
60
74
  * walk. Returns { found, value } with the RAW value (not coerced to boolean),
@@ -76,15 +90,10 @@ function resolveConfigKey(dotKey, opts) {
76
90
  return { found: true, value: fromConfig.value };
77
91
  // Level 2 + 3: raw config.json files (only when cwd is available)
78
92
  if (cwd) {
79
- const wsConfigPath = node_path_1.default.join(planningDir(cwd), 'config.json');
80
- const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
81
- const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
82
- if (fromWs.found)
83
- return { found: true, value: fromWs.value };
84
- if (wsConfigPath !== rootConfigPath) {
85
- const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
86
- if (fromRoot.found)
87
- return { found: true, value: fromRoot.value };
93
+ for (const filePath of activationConfigFilesFor(cwd)) {
94
+ const fromFile = _readRawConfigKey(filePath, dotKey);
95
+ if (fromFile.found)
96
+ return { found: true, value: fromFile.value };
88
97
  }
89
98
  }
90
99
  // Level 4: registry configSchema default
@@ -110,4 +119,5 @@ module.exports = {
110
119
  _readRawConfigKey,
111
120
  _resolveActivationValue,
112
121
  resolveConfigKey,
122
+ activationConfigFilesFor,
113
123
  };
@@ -43,6 +43,9 @@ const model_catalog_cjs_1 = require("./model-catalog.cjs");
43
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports
44
44
  const federatedConfigModule = require("./federated-config.cjs");
45
45
  const { mergeFederatedConfig } = federatedConfigModule;
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const capabilityActivationModule = require("./capability-activation.cjs");
48
+ const { activationConfigFilesFor } = capabilityActivationModule;
46
49
  // The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
47
50
  // Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
48
51
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
@@ -341,16 +344,154 @@ function _applyFederatedOverlay(baseConfig, userConfig) {
341
344
  _applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
342
345
  return cloned;
343
346
  }
344
- function loadConfig(cwd, options = {}) {
347
+ /**
348
+ * The workstream `loadConfig` reads for `options`: an explicit `workstream`
349
+ * option, then `workstreamContext.ws`, then `GSD_WORKSTREAM`. Shared with
350
+ * `configFilesFor` so the two can never resolve different workstreams.
351
+ */
352
+ function _activeWorkstreamFor(options) {
345
353
  const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
346
354
  ? options['workstream']
347
355
  : (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
348
356
  ? options['workstreamContext']['ws']
349
357
  : (process.env['GSD_WORKSTREAM'] || null);
358
+ return typeof activeWorkstream === 'string' ? activeWorkstream : null;
359
+ }
360
+ function _configPathsFor(cwd, ws) {
361
+ return {
362
+ rootConfigPath: ws ? node_path_1.default.join(planningRoot(cwd), 'config.json') : null,
363
+ configPath: node_path_1.default.join(planningDir(cwd, ws), 'config.json'),
364
+ };
365
+ }
366
+ /** `~/.gsd/defaults.json` (or under `GSD_HOME`): read only in a pre-project context (#1683). */
367
+ function _globalDefaultsPath() {
368
+ return node_path_1.default.join(process.env['GSD_HOME'] || node_os_1.default.homedir(), '.gsd', 'defaults.json');
369
+ }
370
+ /** The #1683 predicate: the global defaults are consulted only when the active planning dir is absent. */
371
+ function _readsGlobalDefaults(cwd, ws) {
372
+ return !node_fs_1.default.existsSync(planningDir(cwd, ws));
373
+ }
374
+ /**
375
+ * Every config file `loadConfig(cwd, options)` reads, as absolute paths in read
376
+ * order. ONE owner of "which config files does the loader read": `loadConfig`
377
+ * takes its paths from the same `_configPathsFor`, so a reader that must refuse
378
+ * an unreadable config (`unreadableConfigFiles` below) parses exactly these
379
+ * files without restating the workstream ladder.
380
+ *
381
+ * Why a caller needs it: `loadConfig` swallows a parse failure in every one of
382
+ * these files (root → "workstream config stands alone", active → the defaults),
383
+ * so its RESULT cannot say whether it read the project's config or read
384
+ * nothing. Files that do not exist are still listed; absence is not a failure
385
+ * for `loadConfig`, and the caller decides what it means.
386
+ */
387
+ function configFilesFor(cwd, options = {}) {
388
+ const ws = _activeWorkstreamFor(options);
389
+ const { rootConfigPath, configPath } = _configPathsFor(cwd, ws);
390
+ const files = [];
391
+ if (rootConfigPath)
392
+ files.push(rootConfigPath);
393
+ files.push(configPath);
394
+ if (_readsGlobalDefaults(cwd, ws))
395
+ files.push(_globalDefaultsPath());
396
+ return files;
397
+ }
398
+ /** The error text is bounded; composed text never reaches the cap, but it holds anyway. */
399
+ const UNREADABLE_ERROR_CAP = 200;
400
+ /**
401
+ * Describe why a config file could not be used WITHOUT echoing it. V8's
402
+ * `JSON.parse` messages sometimes quote the input (`Unexpected token '}',
403
+ * "{"a":tru}" is not valid JSON`), so only the position is carried over.
404
+ */
405
+ function _describeConfigReadFailure(err) {
406
+ const e = err;
407
+ if (e && typeof e.code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(e.code))
408
+ return `cannot be read (${e.code})`;
409
+ const message = e && typeof e.message === 'string' ? e.message : '';
410
+ const lineCol = /\(line (\d+) column (\d+)\)/.exec(message);
411
+ if (lineCol)
412
+ return `not valid JSON (line ${lineCol[1]}, column ${lineCol[2]})`;
413
+ const position = /at position (\d+)/.exec(message);
414
+ if (position)
415
+ return `not valid JSON (at position ${position[1]})`;
416
+ if (/Unexpected end of JSON input/.test(message))
417
+ return 'not valid JSON (the text ends before the JSON does)';
418
+ return 'not valid JSON';
419
+ }
420
+ /** How a refusal names a config file: never an absolute user path. */
421
+ function _configDisplayPath(cwd, abs) {
422
+ const under = (base) => {
423
+ const rel = node_path_1.default.relative(base, abs);
424
+ return rel !== '' && !rel.startsWith('..') && !node_path_1.default.isAbsolute(rel) ? rel.split(node_path_1.default.sep).join('/') : null;
425
+ };
426
+ const inProject = under(cwd);
427
+ if (inProject !== null)
428
+ return inProject;
429
+ const homeOverride = process.env['GSD_HOME'];
430
+ const inHomeOverride = homeOverride ? under(homeOverride) : null;
431
+ if (inHomeOverride !== null)
432
+ return `$GSD_HOME/${inHomeOverride}`;
433
+ const inHome = under(node_os_1.default.homedir());
434
+ if (inHome !== null)
435
+ return `~/${inHome}`;
436
+ return node_path_1.default.basename(abs);
437
+ }
438
+ /**
439
+ * Every config file the hook resolver would read that EXISTS but cannot be
440
+ * used: unreadable, not JSON, or JSON that is not an object (`loadConfig`
441
+ * reads none of an array's or a null's keys and answers the defaults, the same
442
+ * silent fallback as a parse failure). Empty when every file is usable or
443
+ * absent: a MISSING config is a fresh project and reads as the defaults.
444
+ *
445
+ * The files are the union, in first-seen order, of the loader's list
446
+ * (`configFilesFor`) and the activation resolver's raw levels
447
+ * (`activationConfigFilesFor`, which can name a root config the loader never
448
+ * opens under `GSD_PROJECT`). Reads go through `platformReadSync`, the reader
449
+ * `loadConfig` uses, so a file the loader can read is never called unreadable.
450
+ *
451
+ * This is the ONE owner of "can the project's config be read". Both swallowing
452
+ * readers (`loadConfig`, `resolveConfigKey`) stay tolerant for their many
453
+ * callers; a caller whose answer governs a gate (`loop render-hooks`, the vault
454
+ * load verdict) asks this first and refuses. Never throws.
455
+ */
456
+ function unreadableConfigFiles(cwd, options = {}) {
457
+ const out = [];
458
+ let files;
459
+ try {
460
+ files = [...new Set([...configFilesFor(cwd, options), ...activationConfigFilesFor(cwd)])];
461
+ }
462
+ catch (err) {
463
+ // An invalid GSD_WORKSTREAM / GSD_PROJECT segment: the config cannot be located, so it cannot be read.
464
+ return [{ file: '.planning/config.json', error: `cannot be located (${err instanceof Error ? err.message : String(err)})`.slice(0, UNREADABLE_ERROR_CAP) }];
465
+ }
466
+ for (const file of files) {
467
+ let error = null;
468
+ try {
469
+ const raw = (0, shell_command_projection_cjs_1.platformReadSync)(file);
470
+ if (raw === null)
471
+ continue;
472
+ const parsed = JSON.parse(raw);
473
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
474
+ error = 'valid JSON but not an object, so none of its settings can be read';
475
+ }
476
+ }
477
+ catch (err) {
478
+ error = _describeConfigReadFailure(err);
479
+ }
480
+ if (error !== null)
481
+ out.push({ file: _configDisplayPath(cwd, file), error: error.slice(0, UNREADABLE_ERROR_CAP) });
482
+ }
483
+ return out;
484
+ }
485
+ /** One line per unreadable file, `<file>: <error>`, for a refusal message. */
486
+ function describeUnreadableConfig(unreadable) {
487
+ return unreadable.map((u) => `${u.file}: ${u.error}`).join('; ');
488
+ }
489
+ function loadConfig(cwd, options = {}) {
350
490
  // When GSD_WORKSTREAM is set, load root config first so workstream config
351
491
  // can inherit from it. This prevents users from duplicating model_overrides,
352
492
  // workflow.*, etc. across every workstream config (#2714).
353
- const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null);
493
+ const ws = _activeWorkstreamFor(options);
494
+ const paths = _configPathsFor(cwd, ws);
354
495
  // #315 — per-call lazy memo: all three detection sites inside this loadConfig
355
496
  // call operate on the same cwd and the subrepo set cannot change mid-call, so
356
497
  // a single scan is sufficient. The memo is scoped to THIS call (not module-level)
@@ -364,8 +505,8 @@ function loadConfig(cwd, options = {}) {
364
505
  return cachedSubRepos.slice();
365
506
  };
366
507
  let rootParsed = null;
367
- if (ws) {
368
- const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
508
+ if (paths.rootConfigPath) {
509
+ const rootConfigPath = paths.rootConfigPath;
369
510
  try {
370
511
  const raw = (0, shell_command_projection_cjs_1.platformReadSync)(rootConfigPath);
371
512
  if (raw === null)
@@ -400,7 +541,7 @@ function loadConfig(cwd, options = {}) {
400
541
  // Root config missing or unparseable — workstream config stands alone
401
542
  }
402
543
  }
403
- const configPath = node_path_1.default.join(planningDir(cwd, ws), 'config.json');
544
+ const configPath = paths.configPath;
404
545
  const defaults = CONFIG_DEFAULTS;
405
546
  try {
406
547
  const raw = (0, shell_command_projection_cjs_1.platformReadSync)(configPath);
@@ -625,7 +766,7 @@ function loadConfig(cwd, options = {}) {
625
766
  }
626
767
  catch {
627
768
  // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
628
- if (node_fs_1.default.existsSync(planningDir(cwd, ws))) {
769
+ if (!_readsGlobalDefaults(cwd, ws)) {
629
770
  if (rootParsed) {
630
771
  // Workstream has no config.json: re-parse using root config as the sole source.
631
772
  // (FIX 2: overlay is applied recursively in the re-entrant loadConfig call)
@@ -642,8 +783,7 @@ function loadConfig(cwd, options = {}) {
642
783
  }
643
784
  }
644
785
  try {
645
- const home = process.env['GSD_HOME'] || node_os_1.default.homedir();
646
- const globalDefaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
786
+ const globalDefaultsPath = _globalDefaultsPath();
647
787
  const raw = (0, shell_command_projection_cjs_1.platformReadSync)(globalDefaultsPath);
648
788
  if (raw === null)
649
789
  throw new Error('missing');
@@ -698,6 +838,9 @@ function loadConfig(cwd, options = {}) {
698
838
  }
699
839
  module.exports = {
700
840
  loadConfig,
841
+ configFilesFor,
842
+ unreadableConfigFiles,
843
+ describeUnreadableConfig,
701
844
  isGitIgnored,
702
845
  CONFIG_DEFAULTS,
703
846
  _getConfigDefault,
@@ -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;
@@ -422,6 +422,20 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
422
422
  coreError('--active-cap requires a <capId> value (e.g. --active-cap tdd)');
423
423
  return;
424
424
  }
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);
437
+ return;
438
+ }
425
439
  const runtimeConfigDir = typeof options['configDir'] === 'string'
426
440
  ? options['configDir']
427
441
  : undefined;
@@ -37,6 +37,9 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
37
37
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
38
38
  const git_provider_cjs_1 = require("./git-provider.cjs");
39
39
  const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const configLoaderModule = require("./config-loader.cjs");
42
+ const { unreadableConfigFiles } = configLoaderModule;
40
43
  const CLONE_TIMEOUT_MS = 60_000; // explicit cap so a hung clone surfaces as failed → next tier
41
44
  const REVPARSE_TIMEOUT_MS = 10_000;
42
45
  // Default built-in config values — Phase 12 reads-with-default because the
@@ -1020,6 +1023,7 @@ function assertVaultWriteTarget(cwd, targetRepo, targetHost) {
1020
1023
  }
1021
1024
  const VAULT_VERDICTS = Object.freeze(['inert', 'loaded', 'degraded']);
1022
1025
  const VAULT_REASONS = Object.freeze([
1026
+ 'config_unreadable',
1023
1027
  'vault_disabled',
1024
1028
  'no_bindings',
1025
1029
  'never_fetched',
@@ -1045,6 +1049,7 @@ const VAULT_VERDICT_FIELDS = Object.freeze([
1045
1049
  ]);
1046
1050
  /** One static card line per reason (D-02). The card test enumerates VAULT_REASONS against this table. */
1047
1051
  const VAULT_REASON_LINES = Object.freeze({
1052
+ config_unreadable: 'The project config cannot be read, so vault.enabled and vault.required are unknown — both are treated as true.',
1048
1053
  vault_disabled: 'The client vault is disabled (vault.enabled is not true).',
1049
1054
  no_bindings: 'No client vault is bound: .platform-bindings.json needs client.vaultRepo and client.vaultRef.',
1050
1055
  never_fetched: 'The vault has not been fetched — .planning/vault/ holds no readable manifest.',
@@ -1055,6 +1060,7 @@ const VAULT_REASON_LINES = Object.freeze({
1055
1060
  client_loaded: 'The client vault is loaded at its pinned ref.',
1056
1061
  });
1057
1062
  const VAULT_REASON_REMEDIES = Object.freeze({
1063
+ config_unreadable: 'Remedy: repair or remove the config file `pp-tools loop render-hooks plan:pre` names, then re-run this step.',
1058
1064
  vault_disabled: '',
1059
1065
  no_bindings: 'Remedy: run /pp-init to bind the client vault at a pinned ref.',
1060
1066
  never_fetched: 'Remedy: run `pp-tools vault fetch`, then re-run this step.',
@@ -1127,6 +1133,32 @@ function isStaleForVerdict(cwd, cached) {
1127
1133
  * `proceed` when loaded, `none` when inert (D-03).
1128
1134
  */
1129
1135
  function computeVaultVerdict(cwd) {
1136
+ // Fail closed before reading a single key: `cfgGet` swallows a parse failure
1137
+ // into "absent", which would read a required vault as switched off (inert).
1138
+ if (unreadableConfigFiles(cwd).length > 0) {
1139
+ let binding = { bound: false, repo: null, ref: null };
1140
+ let cachedView = readCachedView('');
1141
+ try {
1142
+ binding = readClientBinding(cwd);
1143
+ cachedView = readCachedView(cwd);
1144
+ }
1145
+ catch {
1146
+ /* display-only; the verdict below does not depend on them */
1147
+ }
1148
+ return {
1149
+ enabled: true,
1150
+ required: true,
1151
+ requiredValid: true,
1152
+ verdict: 'degraded',
1153
+ reason: 'config_unreadable',
1154
+ action: 'stop',
1155
+ block: true,
1156
+ configuredRepo: redactCloneTarget(binding.repo),
1157
+ configuredRef: binding.ref,
1158
+ cached: cachedView,
1159
+ requiredRaw: null,
1160
+ };
1161
+ }
1130
1162
  const enabled = cfgGet(cwd, 'vault.enabled', undefined) === true;
1131
1163
  const requiredRawValue = cfgGet(cwd, 'vault.required', undefined);
1132
1164
  const requiredValid = requiredRawValue === undefined || typeof requiredRawValue === 'boolean';
@@ -1206,7 +1238,9 @@ function renderVaultCard(v, point) {
1206
1238
  return '';
1207
1239
  const subject = point === 'plan' ? 'Planning' : 'Build';
1208
1240
  const lines = [];
1209
- lines.push(`Client vault check (${subject}): ${v.verdict === 'loaded' ? 'LOADED' : 'DEGRADED'}${v.required ? ' — vault.required is true' : ''}`);
1241
+ const unreadableConfig = v.reason === 'config_unreadable';
1242
+ const headlineNote = unreadableConfig ? ' — the project config cannot be read' : v.required ? ' — vault.required is true' : '';
1243
+ lines.push(`Client vault check (${subject}): ${v.verdict === 'loaded' ? 'LOADED' : 'DEGRADED'}${headlineNote}`);
1210
1244
  lines.push(` Configured: ${v.configuredRepo || v.configuredRef ? coordText(v.configuredRepo, v.configuredRef) : 'no client vault bound'}`);
1211
1245
  const c = v.cached;
1212
1246
  if (c.present) {
@@ -1229,6 +1263,9 @@ function renderVaultCard(v, point) {
1229
1263
  else if (v.action === 'confirm') {
1230
1264
  lines.push(` ${subject} can continue WITHOUT the client's standards — confirm to proceed (vault.required is false).`);
1231
1265
  }
1266
+ else if (v.action === 'stop' && unreadableConfig) {
1267
+ lines.push(` STOP — ${subject.toLowerCase()} is blocked until the project config can be read.`);
1268
+ }
1232
1269
  else if (v.action === 'stop') {
1233
1270
  lines.push(` STOP — vault.required is true: ${subject.toLowerCase()} is blocked until the client vault loads.`);
1234
1271
  }
@@ -29,6 +29,13 @@ booleans are read.
29
29
  `activeHooks` is an array of enabled hook entries for the named point. It is empty (or absent)
30
30
  when no capability has registered an active hook at this point — treat that as a no-op.
31
31
 
32
+ ## Refusal
33
+
34
+ `render-hooks` refuses — exit 1, empty stdout, one stderr line naming each config file and its
35
+ parse error — when a project config file exists but cannot be read (not JSON, or not a JSON
36
+ object). A missing config is not refused. Every host writes `X=$(pp_run loop render-hooks …) || exit 1`,
37
+ so a refusal stops the workflow: relay the stderr line and stop. Never read a refusal as "no hooks".
38
+
32
39
  ## Dispatch rules by `kind`
33
40
 
34
41
  ### `contribution`
@@ -146,7 +146,7 @@ For each REQ-ID, determine status using all three sources:
146
146
  Skip if the Nyquist capability is inactive.
147
147
 
148
148
  ```bash
149
- VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw)
149
+ VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw) || exit 1
150
150
  ```
151
151
 
152
152
  Resolve active step hooks from `VERIFY_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "validate-phase"`.
@@ -273,7 +273,7 @@ Resolve active `plan:pre` hooks:
273
273
 
274
274
  ```bash
275
275
  UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
276
- HOOKS_JSON=$(pp_run loop render-hooks plan:pre --raw --phase "${PHASE_NUM}")
276
+ HOOKS_JSON=$(pp_run loop render-hooks plan:pre --raw --phase "${PHASE_NUM}") || exit 1
277
277
  ```
278
278
 
279
279
  Read the `activeHooks` array directly from `HOOKS_JSON` (in-context — do NOT invoke a shell pipeline). **Compute the active UI step hooks** = entries from `activeHooks` where `kind == "step"` and `ref.skill` is set. **If there are NO active step hooks → skip silently to 3b.** (This covers `workflow.ui_phase=false` — including configurations where only a gate-only entry is present, e.g. `ui_phase=false` + `ui_safety_gate=true` produces `activeHooks=[{kind:"gate"}]`. Autonomous never runs the plan:pre gate — it is always pipeline mode — so a gate-only active set is equivalent to no active step and is silently skipped here. This matches OLD §3a.5 behaviour.)
@@ -406,7 +406,7 @@ Auto-invoke code review and fix chain. Autonomous mode chains both review and fi
406
406
 
407
407
  **Capability dispatch:**
408
408
  ```bash
409
- EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw --phase "${PHASE_NUM}")
409
+ EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw --phase "${PHASE_NUM}") || exit 1
410
410
  ```
411
411
 
412
412
  Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
@@ -481,7 +481,7 @@ Resolve the active post-verification hooks and the UI-SPEC gate:
481
481
 
482
482
  ```bash
483
483
  UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
484
- HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw --phase "${PHASE_NUM}")
484
+ HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw --phase "${PHASE_NUM}") || exit 1
485
485
  ```
486
486
 
487
487
  Read the `activeHooks` array directly from the `HOOKS_JSON` value already in context (do not invoke a shell `jq` pipeline — parse as the JSON object it is). **If `activeHooks` is empty or absent:** skip silently to the iterate step.
@@ -77,7 +77,7 @@ FIX_REPORT_PATH="${PHASE_DIR}/${PADDED_PHASE}-REVIEW-FIX.md"
77
77
  Check if code review is active via the capability registry:
78
78
 
79
79
  ```bash
80
- EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw)
80
+ EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw) || exit 1
81
81
  ```
82
82
 
83
83
  Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
@@ -78,7 +78,7 @@ fi
78
78
  Check if code review is active via the capability registry:
79
79
 
80
80
  ```bash
81
- EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw)
81
+ EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw) || exit 1
82
82
  ```
83
83
 
84
84
  Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
@@ -299,7 +299,7 @@ Read `@~/.claude/pp-core/references/scout-codebase.md` — it contains the phase
299
299
 
300
300
  <step name="dispatch_discuss_pre_hooks">
301
301
  ```bash
302
- DISCUSS_PRE_HOOKS_JSON=$(pp_run loop render-hooks discuss:pre --raw)
302
+ DISCUSS_PRE_HOOKS_JSON=$(pp_run loop render-hooks discuss:pre --raw) || exit 1
303
303
  ```
304
304
  Apply each entry in `activeHooks` per @~/.claude/pp-core/references/loop-hook-dispatch.md. Empty list → continue to `analyze_phase`.
305
305
  </step>
@@ -418,7 +418,7 @@ Write the file.
418
418
 
419
419
  <step name="dispatch_discuss_post_hooks">
420
420
  ```bash
421
- DISCUSS_POST_HOOKS_JSON=$(pp_run loop render-hooks discuss:post --raw)
421
+ DISCUSS_POST_HOOKS_JSON=$(pp_run loop render-hooks discuss:post --raw) || exit 1
422
422
  ```
423
423
  Apply each entry in `activeHooks` per @~/.claude/pp-core/references/loop-hook-dispatch.md. Empty list → continue to `propose_tier`.
424
424
  </step>
@@ -70,7 +70,7 @@ Parse `$ARGUMENTS` before loading any context:
70
70
  - First positional token → `PHASE_ARG`
71
71
  - Optional `--wave N` → `WAVE_FILTER`
72
72
  - Optional `--gaps-only` → `GAPS_ONLY=true` (only `gap_closure: true` plans)
73
- - Optional `--tier light|standard|heavy` → `TIER_FLAG="--tier <value>"` (the phase's ceremony tier for this run; passed to init, dispatch plan and render-hooks)
73
+ - Optional `--tier light|standard|heavy` → `TIER_FLAG="--tier=<value>"` (one word, so zsh passes it as one argument; the phase's ceremony tier for this run; passed to init, dispatch plan and render-hooks)
74
74
  - Optional `--cross-ai` → `CROSS_AI_FORCE=true` (force all plans through cross-AI execution)
75
75
  - Optional `--no-cross-ai` → `CROSS_AI_DISABLED=true` (disable cross-AI for this run, overrides config and frontmatter)
76
76
  - Optional `--unattended` → `UNATTENDED=true` — set by `/pp:autonomous`; prompt sites marked `U-En` apply the rules in `pp-core/references/autonomous-best-effort.md` (Read it at the first such site)
@@ -183,8 +183,8 @@ Resolve `MVP_MODE` once via the centralized `phase.mvp-mode` query verb (precede
183
183
  MVP_FLAG_ARG=""
184
184
  if [[ "$ARGUMENTS" =~ (^|[[:space:]])--mvp([[:space:]]|$) ]]; then MVP_FLAG_ARG="--cli-flag"; fi
185
185
  MVP_MODE=$(pp_run query phase.mvp-mode "${PHASE_NUMBER}" $MVP_FLAG_ARG --pick active)
186
- EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw --phase "${PHASE_NUMBER}" ${TIER_FLAG})
187
- TDD_MODE=$(pp_run loop render-hooks execute:post --active-cap tdd)
186
+ EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw --phase "${PHASE_NUMBER}" ${TIER_FLAG}) || exit 1
187
+ TDD_MODE=$(pp_run loop render-hooks execute:post --active-cap tdd) || exit 1
188
188
  ```
189
189
 
190
190
  <step name="safe_resume_gate">
@@ -347,7 +347,7 @@ Read and execute `pp-core/workflows/execute-phase/steps/client-context-gate.md`
347
347
  </step>
348
348
 
349
349
  <step name="execute_pre_vault">
350
- Before any executor: `EXECUTE_PRE_HOOKS_JSON=$(pp_run loop render-hooks execute:pre --raw)`. For an active `capId == "vault"` step, Read and execute `pp-core/workflows/execute-phase/steps/vault-fetch.md`; then, for an active `vault.status` gate, set `VAULT_GATE_POINT=execute` and Read and execute `pp-core/workflows/execute-phase/steps/vault-load-gate.md`. None active → skip.
350
+ Before any executor: `EXECUTE_PRE_HOOKS_JSON=$(pp_run loop render-hooks execute:pre --raw) || exit 1`. For an active `capId == "vault"` step, Read and execute `pp-core/workflows/execute-phase/steps/vault-fetch.md`; then, for an active `vault.status` gate, set `VAULT_GATE_POINT=execute` and Read and execute `pp-core/workflows/execute-phase/steps/vault-load-gate.md`. None active → skip.
351
351
  </step>
352
352
 
353
353
  <step name="discover_and_group_plans">
@@ -851,7 +851,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
851
851
  After worktree merge, post-merge tests, and tracking updates, dispatch capability hooks registered at `execute:wave:post`. The primary hook is the `ui.safety-gate` gate from the UI capability — it verifies that any frontend files changed in this wave conform to the UI-SPEC contract.
852
852
 
853
853
  ```bash
854
- WAVE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:wave:post --raw)
854
+ WAVE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:wave:post --raw) || exit 1
855
855
  ```
856
856
 
857
857
  Read the `activeHooks` array from `WAVE_POST_HOOKS_JSON` in-context (do NOT pipe through a shell parser).
@@ -1016,7 +1016,7 @@ After all waves:
1016
1016
 
1017
1017
  **Security gate check:**
1018
1018
  ```bash
1019
- VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw --phase "${PHASE_NUMBER}" ${TIER_FLAG})
1019
+ VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw --phase "${PHASE_NUMBER}" ${TIER_FLAG}) || exit 1
1020
1020
  SECURITY_FILE=$(ls "${PHASE_DIR}"/*-SECURITY.md 2>/dev/null | head -1)
1021
1021
  ```
1022
1022
 
@@ -1074,7 +1074,7 @@ Selected wave finished successfully. This phase still has incomplete plans, so p
1074
1074
 
1075
1075
  **Capability gate:**
1076
1076
  ```bash
1077
- EXECUTE_POST_HOOKS_JSON=${EXECUTE_POST_HOOKS_JSON:-$(pp_run loop render-hooks execute:post --raw --phase "${PHASE_NUMBER}" ${TIER_FLAG})}
1077
+ EXECUTE_POST_HOOKS_JSON=${EXECUTE_POST_HOOKS_JSON:-$(pp_run loop render-hooks execute:post --raw --phase "${PHASE_NUMBER}" ${TIER_FLAG})} || exit 1
1078
1078
  ```
1079
1079
 
1080
1080
  Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
@@ -72,7 +72,7 @@ Load all context in one call (paths only to minimize orchestrator context):
72
72
  ```bash
73
73
  _PP_SHIM_NAME="pp-tools.cjs"; _pp_root() { ( for _v in $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$_v"; done; _c=$(git rev-parse --git-common-dir 2>/dev/null); if [ "${_c##*/}" = .git ] && cd "$_c/.." 2>/dev/null; then pwd -P; else git rev-parse --show-toplevel 2>/dev/null || pwd; fi ); }; pp_run() { case "$PP_TOOLS" in *.cjs) node "$PP_TOOLS" "$@";; *) "$PP_TOOLS" "$@";; esac; }; _PP_RUNTIME_ROOT="${RUNTIME_DIR:-$(_pp_root)}"; PP_TOOLS="${_PP_RUNTIME_ROOT}/pp-core/bin/${_PP_SHIM_NAME}"; if [ -f "$PP_TOOLS" ]; then :; elif [ -f "${_PP_RUNTIME_ROOT}/.claude/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${_PP_RUNTIME_ROOT}/.claude/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${_PP_RUNTIME_ROOT}/.codex/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${_PP_RUNTIME_ROOT}/.codex/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "$HOME/.claude/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="$HOME/.claude/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${HERMES_HOME:-$HOME/.hermes}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${CODEX_HOME:-$HOME/.codex}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${CODEX_HOME:-$HOME/.codex}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/pp-core/bin/${_PP_SHIM_NAME}"; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/pp-core/bin/${_PP_SHIM_NAME}"; elif command -v pp-tools >/dev/null 2>&1; then PP_TOOLS="$(command -v pp-tools)"; else echo "ERROR: pp-tools.cjs not found at $PP_TOOLS and pp-tools is not on PATH. Run: npx -y @pareto4/pp-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${PP_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${PP_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
74
74
  GRAN_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--granularity[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then GRAN_PARAM="--granularity ${BASH_REMATCH[2]}"; fi
75
- TIER_FLAG=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--tier[[:space:]=]+(light|standard|heavy) ]]; then TIER_FLAG="--tier ${BASH_REMATCH[2]}"; fi
75
+ TIER_FLAG=$(printf '%s\n' "$ARGUMENTS" | grep -oE -- '--tier[[:space:]=]+(light|standard|heavy)' | head -1 | sed -E 's/^--tier[[:space:]=]+/--tier=/')
76
76
  INIT=$(pp_run query init.plan-phase "$PHASE" $GRAN_PARAM $TIER_FLAG)
77
77
  if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
78
78
  AGENT_SKILLS_RESEARCHER=$(pp_run query agent-skills pp-phase-researcher)
@@ -432,7 +432,7 @@ If `AI_SPEC_FILE` is non-empty, pass `AI_SPEC_PATH` and `FRAMEWORK_LINE` to the
432
432
 
433
433
  ### 5.00. plan:pre vault load — ALWAYS, before the skip rule below
434
434
 
435
- Every path runs this before any agent: `PLAN_PRE_HOOKS_JSON=${PLAN_PRE_HOOKS_JSON:-$(pp_run loop render-hooks plan:pre --raw --phase "$PHASE" $TIER_FLAG)}`. Then Read and execute `pp-core/workflows/execute-phase/steps/vault-fetch.md` (active only for a `capId == "vault"` step hook; writes `.planning/vault/SUMMARY.md`; `onError:skip`). Then, if an active plan:pre gate has `check.query == "vault.status"`, set `VAULT_GATE_POINT=plan` and Read and execute `pp-core/workflows/execute-phase/steps/vault-load-gate.md` — it relays the CLI's vault card and acts on its `action` only (`stop` halts planning in every mode; `confirm` asks, or logs under --auto/--chain). Skip silently when neither hook is active.
435
+ Every path runs this before any agent: `PLAN_PRE_HOOKS_JSON=${PLAN_PRE_HOOKS_JSON:-$(pp_run loop render-hooks plan:pre --raw --phase "$PHASE" $TIER_FLAG)} || exit 1`. Then Read and execute `pp-core/workflows/execute-phase/steps/vault-fetch.md` (active only for a `capId == "vault"` step hook; writes `.planning/vault/SUMMARY.md`; `onError:skip`). Then, if an active plan:pre gate has `check.query == "vault.status"`, set `VAULT_GATE_POINT=plan` and Read and execute `pp-core/workflows/execute-phase/steps/vault-load-gate.md` — it relays the CLI's vault card and acts on its `action` only (`stop` halts planning in every mode; `confirm` asks, or logs under --auto/--chain). Skip silently when neither hook is active.
436
436
 
437
437
  **Skip if:** `--gaps` flag or `--skip-research` flag or `--reviews` flag.
438
438
 
@@ -501,7 +501,7 @@ fi
501
501
  ```bash
502
502
  PHASE_DESC=$(pp_run query roadmap.get-phase "${PHASE}" --pick section)
503
503
  if [ -z "${PLAN_PRE_HOOKS_JSON:-}" ]; then
504
- PLAN_PRE_HOOKS_JSON=$(pp_run loop render-hooks plan:pre --raw --phase "$PHASE" $TIER_FLAG)
504
+ PLAN_PRE_HOOKS_JSON=$(pp_run loop render-hooks plan:pre --raw --phase "$PHASE" $TIER_FLAG) || exit 1
505
505
  fi
506
506
  ```
507
507
 
@@ -584,7 +584,7 @@ test -f "${PHASE_DIR}/${PADDED_PHASE}-VALIDATION.md" && echo "VALIDATION_CREATED
584
584
  > Capability-driven dispatch. Resolves active `plan:pre` hooks via the capability registry; the security hook's `when` condition is evaluated by the registry.
585
585
 
586
586
  ```bash
587
- PLAN_PRE_HOOKS_JSON=$(pp_run loop render-hooks plan:pre --raw --phase "$PHASE" $TIER_FLAG)
587
+ PLAN_PRE_HOOKS_JSON=$(pp_run loop render-hooks plan:pre --raw --phase "$PHASE" $TIER_FLAG) || exit 1
588
588
  ```
589
589
 
590
590
  Resolve active contribution hooks from `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `capId == "security"`.
@@ -614,7 +614,7 @@ Continue to step 5.6. Security config is passed to the planner in step 8.
614
614
  > **Config semantics (cutover fix):** `workflow.ui_phase` gates UI-SPEC *generation* (step); `workflow.ui_safety_gate` gates the *planning block* (gate). Both-on = identical to OLD §5.6. Intended change: `{ui_phase:true, ui_safety_gate:false}` now auto-generates in pipelines but does NOT block manual planning (each key controls exactly what its description says).
615
615
 
616
616
  ```bash
617
- PLAN_PRE_HOOKS_JSON=${PLAN_PRE_HOOKS_JSON:-$(pp_run loop render-hooks plan:pre --raw --phase "$PHASE" $TIER_FLAG)}
617
+ PLAN_PRE_HOOKS_JSON=${PLAN_PRE_HOOKS_JSON:-$(pp_run loop render-hooks plan:pre --raw --phase "$PHASE" $TIER_FLAG)} || exit 1
618
618
  HOOKS_JSON="$PLAN_PRE_HOOKS_JSON"
619
619
  ```
620
620
 
@@ -1475,7 +1475,7 @@ Proactive, non-blocking coverage report gated on `workflow.post_planning_gaps`
1475
1475
  `<decisions>` and cross-references each REQ-ID / D-ID against `${PHASE_DIR}/*-PLAN.md`.
1476
1476
 
1477
1477
  ```bash
1478
- PLAN_POST_HOOKS_JSON=$(pp_run loop render-hooks plan:post --raw --phase "$PHASE" $TIER_FLAG)
1478
+ PLAN_POST_HOOKS_JSON=$(pp_run loop render-hooks plan:post --raw --phase "$PHASE" $TIER_FLAG) || exit 1
1479
1479
  PHASE_REQ_IDS=$(pp_run query init.plan-phase "$PHASE" --pick phase_req_ids 2>/dev/null || echo TBD)
1480
1480
  ```
1481
1481
 
@@ -808,7 +808,7 @@ Skip this step entirely if `$FULL_MODE` is false.
808
808
 
809
809
  **Capability gate:**
810
810
  ```bash
811
- EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw)
811
+ EXECUTE_POST_HOOKS_JSON=$(pp_run loop render-hooks execute:post --raw) || exit 1
812
812
  ```
813
813
 
814
814
  Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
@@ -26,7 +26,7 @@ Parse: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`.
26
26
 
27
27
  ```bash
28
28
  AUDITOR_MODEL=$(pp_run query resolve-model pp-security-auditor --raw)
29
- VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw)
29
+ VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw) || exit 1
30
30
  ```
31
31
 
32
32
  Resolve active step hooks from `VERIFY_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "secure-phase"`.
@@ -90,7 +90,7 @@ Verify the work is ready to ship:
90
90
  Resolve active `ship:pre` gate hooks from the capability registry — the registry evaluates each hook's `when` condition, so do **not** read `workflow.security_enforcement` directly:
91
91
 
92
92
  ```bash
93
- SHIP_PRE_HOOKS_JSON=$(pp_run loop render-hooks ship:pre --raw --phase "${phase_number}")
93
+ SHIP_PRE_HOOKS_JSON=$(pp_run loop render-hooks ship:pre --raw --phase "${phase_number}") || exit 1
94
94
  SECURITY_FILE=$(ls "${PHASE_DIR}"/*-SECURITY.md 2>/dev/null | head -1)
95
95
  ```
96
96
 
@@ -448,7 +448,7 @@ pp_run query commit "docs(${padded_phase}): ship phase ${PHASE_NUMBER} — PR #$
448
448
  > Capability-driven dispatch. Resolves active `ship:post` hooks via the capability registry; each hook's `when` is evaluated by the registry — no inline `config-get`. All `ship:post` hooks are post-ship and additive (`onError: skip`); a failure here never affects the already-created PR.
449
449
 
450
450
  ```bash
451
- SHIP_POST_HOOKS_JSON=$(pp_run loop render-hooks ship:post --raw)
451
+ SHIP_POST_HOOKS_JSON=$(pp_run loop render-hooks ship:post --raw) || exit 1
452
452
  ```
453
453
 
454
454
  Read the `activeHooks` array directly from `SHIP_POST_HOOKS_JSON` in-context (do NOT pipe it through a shell parser).
@@ -30,7 +30,7 @@ Parse: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`.
30
30
 
31
31
  ```bash
32
32
  AUDITOR_MODEL=$(pp_run query resolve-model pp-nyquist-auditor --raw)
33
- VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw)
33
+ VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw) || exit 1
34
34
  ```
35
35
 
36
36
  Resolve active step hooks from `VERIFY_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "validate-phase"`.
@@ -41,7 +41,7 @@ _PP_SHIM_NAME="pp-tools.cjs"; _pp_root() { ( for _v in $(env | sed -n 's/^\(GIT_
41
41
  GSD_WS=""
42
42
  echo "$ARGUMENTS" | grep -qE -- '--ws[[:space:]]+[^[:space:]]+' && GSD_WS=$(echo "$ARGUMENTS" | grep -oE -- '--ws[[:space:]]+[^[:space:]]+')
43
43
  PHASE_ARG=$(echo "$ARGUMENTS" | sed -E 's/--ws[[:space:]]+[^[:space:]]+//g' | xargs)
44
- TIER_FLAG=$(echo "$ARGUMENTS" | grep -oE -- '--tier[[:space:]]+(light|standard|heavy)' | head -1)
44
+ TIER_FLAG=$(printf '%s\n' "$ARGUMENTS" | grep -oE -- '--tier[[:space:]=]+(light|standard|heavy)' | head -1 | sed -E 's/^--tier[[:space:]=]+/--tier=/')
45
45
  PHASE_ARG=$(echo "$PHASE_ARG" | sed -E 's/--tier[[:space:]]+[a-z]+//g' | xargs)
46
46
 
47
47
  INIT=$(pp_run query init.verify-work "${PHASE_ARG}" ${GSD_WS} ${TIER_FLAG})
@@ -113,7 +113,7 @@ Continue to `create_uat_file`.
113
113
  Before UAT, check UI capability activation and whether Playwright/Puppeteer MCP tools are available.
114
114
 
115
115
  ```bash
116
- PLAN_HOOKS_JSON=$(pp_run loop render-hooks plan:pre --raw --phase "${phase_number}" ${TIER_FLAG})
116
+ PLAN_HOOKS_JSON=$(pp_run loop render-hooks plan:pre --raw --phase "${phase_number}" ${TIER_FLAG}) || exit 1
117
117
  UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
118
118
  ```
119
119
 
@@ -460,7 +460,7 @@ Present summary:
460
460
  **If issues == 0:**
461
461
 
462
462
  ```bash
463
- VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw --phase "${phase_number}" ${GSD_WS} ${TIER_FLAG})
463
+ VERIFY_POST_HOOKS_JSON=$(pp_run loop render-hooks verify:post --raw --phase "${phase_number}" ${GSD_WS} ${TIER_FLAG}) || exit 1
464
464
  SECURITY_FILE=$(ls "${PHASE_DIR}"/*-SECURITY.md 2>/dev/null | head -1)
465
465
  ```
466
466