@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
@@ -37,6 +37,10 @@ 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
+ const org_profile_cjs_1 = require("./org-profile.cjs");
41
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const configLoaderModule = require("./config-loader.cjs");
43
+ const { unreadableConfigFiles } = configLoaderModule;
40
44
  const CLONE_TIMEOUT_MS = 60_000; // explicit cap so a hung clone surfaces as failed → next tier
41
45
  const REVPARSE_TIMEOUT_MS = 10_000;
42
46
  // Default built-in config values — Phase 12 reads-with-default because the
@@ -330,6 +334,15 @@ function readManifest(vaultDirPath) {
330
334
  return null;
331
335
  }
332
336
  }
337
+ /**
338
+ * Does a manifest record a miss — a fetch that reached no vault content? The
339
+ * one predicate for that fact: the verdict, the vault summary, the practices
340
+ * block and the practice trace all read it here, never by comparing `source`
341
+ * inline, so the surfaces cannot disagree about a miss.
342
+ */
343
+ function manifestRecordsMiss(m) {
344
+ return m?.source === 'miss';
345
+ }
333
346
  /** Write the manifest with two-space JSON via the repo's normalizing writer (display-safe, D-07). */
334
347
  function writeManifest(vaultDirPath, manifest) {
335
348
  (0, shell_command_projection_cjs_1.platformEnsureDir)(vaultDirPath);
@@ -470,6 +483,201 @@ function cloneTier(tier, exec, destDir, warnings) {
470
483
  return { ok: true };
471
484
  return { ok: false, stderr: gi.stderr || hostCliStderr };
472
485
  }
486
+ // ─── Keep the cache out of git (shared info/exclude) ──────────────────────────────
487
+ /** The fixed warning when the shared exclude cannot be written; only the errno code is appended. */
488
+ const VAULT_EXCLUDE_WARNING = "the repository's shared git info/exclude could not be updated, so the vault cache (.planning/vault/) may show as untracked";
489
+ function realPathOrSelf(p) {
490
+ try {
491
+ return node_fs_1.default.realpathSync(p);
492
+ }
493
+ catch {
494
+ return node_path_1.default.resolve(p);
495
+ }
496
+ }
497
+ /**
498
+ * Resolve the shared git directory and the work tree's top level for `cwd` from
499
+ * the filesystem alone — no subprocess, so the fresh-cache reuse path still
500
+ * spawns nothing and a fetch spawns exactly what its clone needs.
501
+ *
502
+ * Walks up from `cwd` the way git's discovery does. At the first `.git`: a
503
+ * directory is the common dir and its parent the top level; a file is followed
504
+ * to the admin directory it names, whose `commondir` file (a linked worktree)
505
+ * leads to the shared directory — with no `commondir` (a submodule's
506
+ * `.git/modules/<name>`) the admin directory is the git dir itself. No
507
+ * environment variable is read, so an inherited `GIT_DIR` cannot redirect it.
508
+ * It must agree with `git rev-parse --git-common-dir --show-toplevel`; a test
509
+ * pins the two against each other across real layouts. `null` when no work
510
+ * tree is found.
511
+ */
512
+ function resolveGitDirsFromFs(cwd) {
513
+ try {
514
+ let dir = node_path_1.default.resolve(cwd);
515
+ for (let depth = 0; depth < 256; depth++) {
516
+ const dotGit = node_path_1.default.join(dir, '.git');
517
+ let st = null;
518
+ try {
519
+ st = node_fs_1.default.statSync(dotGit);
520
+ }
521
+ catch {
522
+ st = null;
523
+ }
524
+ if (st !== null) {
525
+ if (st.isDirectory())
526
+ return { commonDir: dotGit, toplevel: dir };
527
+ if (!st.isFile())
528
+ return null;
529
+ const named = /^gitdir:\s*(.+?)\s*$/m.exec(readHeadBounded(dotGit, 4096));
530
+ if (!named)
531
+ return null;
532
+ const admin = node_path_1.default.resolve(dir, named[1]);
533
+ const common = readHeadBounded(node_path_1.default.join(admin, 'commondir'), 4096).trim();
534
+ return { commonDir: common === '' ? admin : node_path_1.default.resolve(admin, common), toplevel: dir };
535
+ }
536
+ const parent = node_path_1.default.dirname(dir);
537
+ if (parent === dir)
538
+ return null;
539
+ dir = parent;
540
+ }
541
+ return null;
542
+ }
543
+ catch {
544
+ return null;
545
+ }
546
+ }
547
+ /** True when `child` is `parent` or lies under it, compared by real path. */
548
+ function isInsideDir(parent, child) {
549
+ const P = realPathOrSelf(parent);
550
+ const C = realPathOrSelf(child);
551
+ return C === P || C.startsWith(P + node_path_1.default.sep);
552
+ }
553
+ /**
554
+ * Does `<cwd>/.git` name an admin directory under `<commonDir>/worktrees/` whose
555
+ * own `gitdir` file names `<cwd>/.git` back? That is the layout `git worktree
556
+ * add` writes. Compared by real path; any read or resolution failure is false.
557
+ */
558
+ function worktreeLinksBack(cwd, commonDir) {
559
+ try {
560
+ const dotGit = node_path_1.default.join(cwd, '.git');
561
+ const named = /^gitdir:\s*(.+?)\s*$/m.exec(readHeadBounded(dotGit, 4096));
562
+ if (!named)
563
+ return false;
564
+ const admin = node_fs_1.default.realpathSync(node_path_1.default.resolve(cwd, named[1]));
565
+ if (node_path_1.default.dirname(admin) !== node_path_1.default.join(node_fs_1.default.realpathSync(commonDir), 'worktrees'))
566
+ return false;
567
+ const backLink = readHeadBounded(node_path_1.default.join(admin, 'gitdir'), 4096).trim();
568
+ if (backLink === '')
569
+ return false;
570
+ return node_fs_1.default.realpathSync(node_path_1.default.resolve(admin, backLink)) === node_fs_1.default.realpathSync(dotGit);
571
+ }
572
+ catch {
573
+ return false;
574
+ }
575
+ }
576
+ /**
577
+ * The vault cache directory a READER should use for `cwd`. An executor in a
578
+ * linked git worktree has no `.planning/vault/` (the cache is untracked), so a
579
+ * practice trace written there would always be degraded; the owning main
580
+ * checkout, where the plan was written, carries it.
581
+ *
582
+ * Read-only and spawn-free: it returns a path, and nothing that writes the
583
+ * cache uses it. Answers `<cwd>/.planning/vault` (`local`) unless every check
584
+ * holds: no local manifest; `<cwd>/.git` is a FILE (the root of a linked
585
+ * worktree); the shared git directory `resolveGitDirsFromFs` finds is named
586
+ * `.git` and its worktree entry links back to `cwd`; the owner's
587
+ * `.planning/vault/.manifest.json` exists and resolves inside the owner. Any
588
+ * doubt lands on the local path, which then degrades exactly as before.
589
+ */
590
+ function resolveVaultCacheDir(cwd) {
591
+ const local = { dir: node_path_1.default.join(cwd, '.planning', 'vault'), source: 'local' };
592
+ try {
593
+ if (node_fs_1.default.existsSync(node_path_1.default.join(local.dir, '.manifest.json')))
594
+ return local;
595
+ const dotGit = node_path_1.default.join(cwd, '.git');
596
+ if (!node_fs_1.default.existsSync(dotGit) || !node_fs_1.default.lstatSync(dotGit).isFile())
597
+ return local;
598
+ const dirs = resolveGitDirsFromFs(cwd);
599
+ if (dirs === null || node_path_1.default.basename(dirs.commonDir) !== '.git')
600
+ return local;
601
+ if (!worktreeLinksBack(cwd, dirs.commonDir))
602
+ return local;
603
+ const owner = node_path_1.default.dirname(dirs.commonDir);
604
+ const ownerCache = node_path_1.default.join(owner, '.planning', 'vault');
605
+ const ownerManifest = node_path_1.default.join(ownerCache, '.manifest.json');
606
+ if (!node_fs_1.default.existsSync(ownerManifest))
607
+ return local;
608
+ if (!isInsideDir(owner, ownerCache) || !isInsideDir(owner, ownerManifest))
609
+ return local;
610
+ return { dir: ownerCache, source: 'main-worktree' };
611
+ }
612
+ catch {
613
+ return local;
614
+ }
615
+ }
616
+ /**
617
+ * The one writer of the vault-cache exclude line. `null` dirs, a common dir
618
+ * that is not an existing directory, or a cache outside the top level is
619
+ * `'skipped'`, silently. The pattern is `/<cache relative to top level>/`,
620
+ * derived from paths this process owns. A line that already equals it is
621
+ * `'present'` and nothing is written. A read or write failure pushes ONE
622
+ * bounded warning (fixed sentence plus the errno `code`, never `err.message`,
623
+ * never a path) and returns `'failed'`. Never throws.
624
+ */
625
+ function writeVaultExcludeLine(dirs, finalDir, warnings) {
626
+ let pattern;
627
+ try {
628
+ if (dirs === null)
629
+ return 'skipped';
630
+ if (!node_fs_1.default.existsSync(dirs.commonDir) || !node_fs_1.default.statSync(dirs.commonDir).isDirectory())
631
+ return 'skipped';
632
+ const top = realPathOrSelf(dirs.toplevel);
633
+ const fin = realPathOrSelf(finalDir);
634
+ if (fin !== top && !fin.startsWith(top + node_path_1.default.sep))
635
+ return 'skipped';
636
+ const rel = node_path_1.default.relative(top, fin).split(node_path_1.default.sep).join('/');
637
+ if (rel === '' || rel.startsWith('..'))
638
+ return 'skipped';
639
+ pattern = `/${rel}/`;
640
+ }
641
+ catch {
642
+ return 'skipped';
643
+ }
644
+ try {
645
+ const excludePath = node_path_1.default.join(dirs.commonDir, 'info', 'exclude');
646
+ let existing = '';
647
+ try {
648
+ existing = node_fs_1.default.readFileSync(excludePath, 'utf-8');
649
+ }
650
+ catch (e) {
651
+ if (e.code !== 'ENOENT')
652
+ throw e;
653
+ }
654
+ if (existing.split(/\r?\n/).some((l) => l.trim() === pattern))
655
+ return 'present';
656
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(excludePath), { recursive: true });
657
+ const lead = existing !== '' && !existing.endsWith('\n') ? '\n' : '';
658
+ node_fs_1.default.appendFileSync(excludePath, `${lead}# pp-core vault fetch: its client-vault cache is a nested clone, never committed\n${pattern}\n`);
659
+ return 'added';
660
+ }
661
+ catch (e) {
662
+ const code = e?.code;
663
+ warnings.push(`${VAULT_EXCLUDE_WARNING} (${typeof code === 'string' && code !== '' ? code : 'unknown'}).`);
664
+ return 'failed';
665
+ }
666
+ }
667
+ /**
668
+ * Keep the vault cache out of git — the ONE owner, called by every path that
669
+ * leaves a cache in the work tree: the post-swap fetch, the fresh-cache reuse
670
+ * and the keep-prior-cache degrade.
671
+ *
672
+ * WHY: `.planning/vault/` is a nested clone written into the project's work
673
+ * tree. Nothing ignored it, so a `git add -A` could commit the client vault.
674
+ * The pattern goes into the repository's SHARED `info/exclude` (the git common
675
+ * dir, so every linked worktree is covered by one write) rather than into the
676
+ * project's own committed `.gitignore`. Spawns nothing. Never throws.
677
+ */
678
+ function ensureVaultCacheExcluded(cwd, finalDir, warnings) {
679
+ return writeVaultExcludeLine(resolveGitDirsFromFs(cwd), finalDir, warnings);
680
+ }
473
681
  // ─── Degrade (never-abort terminal states) ─────────────────────────────────────
474
682
  function priorContentExists(vaultDirPath) {
475
683
  return node_fs_1.default.existsSync(vaultDirPath) && usableFiles(vaultDirPath).length > 0;
@@ -627,7 +835,14 @@ function vaultFetch(cwd, deps = {}) {
627
835
  if (prior && prior.source === 'fetched' && ttlHours > 0 && effectiveRef && prior.ref === effectiveRef) {
628
836
  const age = Date.now() - new Date(prior.fetchedAt).getTime();
629
837
  if (Number.isFinite(age) && age < ttlHours * 3_600_000) {
630
- return { ...prior, source: 'cache' };
838
+ // A reused cache is still on disk in the work tree, so it is owed the
839
+ // exclusion too — spawn-free, so this path still makes zero exec calls.
840
+ const reuseWarnings = [];
841
+ ensureVaultCacheExcluded(cwd, finalDir, reuseWarnings);
842
+ const reused = { ...prior, source: 'cache' };
843
+ if (reuseWarnings.length > 0)
844
+ reused.warnings = [...(prior.warnings ?? []), ...reuseWarnings];
845
+ return reused;
631
846
  }
632
847
  }
633
848
  // Walk the ladder; stop at the first tier producing >=1 usable file.
@@ -679,6 +894,10 @@ function vaultFetch(cwd, deps = {}) {
679
894
  void e;
680
895
  continue;
681
896
  }
897
+ // Fresh bytes just landed in .planning/vault/ through the swap above, so
898
+ // the exclusion is owed now; before the manifest, so a 'failed' warning
899
+ // reaches manifest.warnings.
900
+ ensureVaultCacheExcluded(cwd, finalDir, warnings);
682
901
  const manifest = {
683
902
  source: 'fetched',
684
903
  tier: tier.kind,
@@ -708,6 +927,10 @@ function vaultFetch(cwd, deps = {}) {
708
927
  ? `${fallbackReason}; ${suppressedReason}`
709
928
  : suppressedReason
710
929
  : fallbackReason;
930
+ // degrade keeps a prior cache on disk when one exists, so that cache is
931
+ // owed the exclusion as well (a miss leaves nothing to exclude).
932
+ if (priorContentExists(finalDir))
933
+ ensureVaultCacheExcluded(cwd, finalDir, warnings);
711
934
  return degrade(finalDir, degradeReason, warnings);
712
935
  }
713
936
  catch (e) {
@@ -908,7 +1131,12 @@ function vaultSummary(cwd) {
908
1131
  'excluded from this listing. Remove credential files from the vault repo.\n');
909
1132
  }
910
1133
  if (shown.length === 0) {
911
- lines.push('\n_Vault is cached but contains no usable files._\n');
1134
+ // A miss manifest is not a cache: the last fetch reached no vault, so the
1135
+ // index says the client's standards could not be checked rather than
1136
+ // describing an empty vault that was read.
1137
+ lines.push(manifestRecordsMiss(readManifest(vaultDirPath))
1138
+ ? '\n_No vault content was reached: the last fetch recorded a miss, so the client\'s standards could not be checked._\n'
1139
+ : '\n_Vault is cached but contains no usable files._\n');
912
1140
  }
913
1141
  else {
914
1142
  lines.push('');
@@ -1020,6 +1248,7 @@ function assertVaultWriteTarget(cwd, targetRepo, targetHost) {
1020
1248
  }
1021
1249
  const VAULT_VERDICTS = Object.freeze(['inert', 'loaded', 'degraded']);
1022
1250
  const VAULT_REASONS = Object.freeze([
1251
+ 'config_unreadable',
1023
1252
  'vault_disabled',
1024
1253
  'no_bindings',
1025
1254
  'never_fetched',
@@ -1045,6 +1274,7 @@ const VAULT_VERDICT_FIELDS = Object.freeze([
1045
1274
  ]);
1046
1275
  /** One static card line per reason (D-02). The card test enumerates VAULT_REASONS against this table. */
1047
1276
  const VAULT_REASON_LINES = Object.freeze({
1277
+ config_unreadable: 'The project config cannot be read, so vault.enabled and vault.required are unknown — both are treated as true.',
1048
1278
  vault_disabled: 'The client vault is disabled (vault.enabled is not true).',
1049
1279
  no_bindings: 'No client vault is bound: .platform-bindings.json needs client.vaultRepo and client.vaultRef.',
1050
1280
  never_fetched: 'The vault has not been fetched — .planning/vault/ holds no readable manifest.',
@@ -1055,6 +1285,7 @@ const VAULT_REASON_LINES = Object.freeze({
1055
1285
  client_loaded: 'The client vault is loaded at its pinned ref.',
1056
1286
  });
1057
1287
  const VAULT_REASON_REMEDIES = Object.freeze({
1288
+ config_unreadable: 'Remedy: repair or remove the config file `pp-tools loop render-hooks plan:pre` names, then re-run this step.',
1058
1289
  vault_disabled: '',
1059
1290
  no_bindings: 'Remedy: run /pp-init to bind the client vault at a pinned ref.',
1060
1291
  never_fetched: 'Remedy: run `pp-tools vault fetch`, then re-run this step.',
@@ -1080,7 +1311,7 @@ function boundText(s, max = 160) {
1080
1311
  const flat = s.replace(/[\u0000-\u001F\u007F]+/g, ' ').trim();
1081
1312
  return flat.length > max ? `${flat.slice(0, max)}…` : flat;
1082
1313
  }
1083
- function readCachedView(cwd) {
1314
+ function readCachedView(cwd, vaultDir) {
1084
1315
  const empty = {
1085
1316
  present: false,
1086
1317
  source: null,
@@ -1092,7 +1323,7 @@ function readCachedView(cwd) {
1092
1323
  fileCount: 0,
1093
1324
  fallbackReason: null,
1094
1325
  };
1095
- const m = readManifest(node_path_1.default.join(cwd, '.planning', 'vault'));
1326
+ const m = readManifest(typeof vaultDir === 'string' && vaultDir !== '' ? vaultDir : node_path_1.default.join(cwd, '.planning', 'vault'));
1096
1327
  if (!m)
1097
1328
  return empty;
1098
1329
  const source = m.source === 'fetched' || m.source === 'cache' || m.source === 'miss' ? m.source : null;
@@ -1125,12 +1356,47 @@ function isStaleForVerdict(cwd, cached) {
1125
1356
  * spawns, never throws. `block = enabled && required && verdict !== 'loaded'`;
1126
1357
  * `action` is `stop` when blocking, `confirm` when degraded and not required,
1127
1358
  * `proceed` when loaded, `none` when inert (D-03).
1359
+ *
1360
+ * `opts.vaultDir` names the cache to judge instead of `<cwd>/.planning/vault`
1361
+ * (the practice trace passes `resolveVaultCacheDir`'s answer, so an executor in
1362
+ * a linked worktree is judged against the owning checkout's cache). Config and
1363
+ * bindings are always read from `cwd`.
1128
1364
  */
1129
- function computeVaultVerdict(cwd) {
1365
+ function computeVaultVerdict(cwd, opts = {}) {
1366
+ const vaultDir = typeof opts.vaultDir === 'string' && opts.vaultDir !== '' ? opts.vaultDir : undefined;
1367
+ // Fail closed before reading a single key: `cfgGet` swallows a parse failure
1368
+ // into "absent", which would read a required vault as switched off (inert).
1369
+ if (unreadableConfigFiles(cwd).length > 0) {
1370
+ let binding = { bound: false, repo: null, ref: null };
1371
+ let cachedView = readCachedView('');
1372
+ try {
1373
+ binding = readClientBinding(cwd);
1374
+ cachedView = readCachedView(cwd, vaultDir);
1375
+ }
1376
+ catch {
1377
+ /* display-only; the verdict below does not depend on them */
1378
+ }
1379
+ return {
1380
+ enabled: true,
1381
+ required: true,
1382
+ requiredValid: true,
1383
+ verdict: 'degraded',
1384
+ reason: 'config_unreadable',
1385
+ action: 'stop',
1386
+ block: true,
1387
+ configuredRepo: redactCloneTarget(binding.repo),
1388
+ configuredRef: binding.ref,
1389
+ cached: cachedView,
1390
+ requiredRaw: null,
1391
+ requiredSource: 'config',
1392
+ };
1393
+ }
1130
1394
  const enabled = cfgGet(cwd, 'vault.enabled', undefined) === true;
1131
- const requiredRawValue = cfgGet(cwd, 'vault.required', undefined);
1132
- const requiredValid = requiredRawValue === undefined || typeof requiredRawValue === 'boolean';
1133
- const required = requiredRawValue === true;
1395
+ // One ladder: project config, then the org profile, then the default false.
1396
+ const requiredResolution = (0, org_profile_cjs_1.resolveVaultRequired)(cwd);
1397
+ const requiredRawValue = requiredResolution.raw;
1398
+ const requiredValid = requiredResolution.valid;
1399
+ const required = requiredResolution.required;
1134
1400
  let requiredRaw = null;
1135
1401
  if (!requiredValid) {
1136
1402
  try {
@@ -1145,14 +1411,14 @@ function computeVaultVerdict(cwd) {
1145
1411
  let reason;
1146
1412
  try {
1147
1413
  binding = readClientBinding(cwd);
1148
- cached = readCachedView(cwd);
1414
+ cached = readCachedView(cwd, vaultDir);
1149
1415
  if (!enabled)
1150
1416
  reason = 'vault_disabled';
1151
1417
  else if (!binding.bound)
1152
1418
  reason = 'no_bindings';
1153
1419
  else if (!cached.present)
1154
1420
  reason = 'never_fetched';
1155
- else if (cached.source === 'miss' || cached.fileCount === 0)
1421
+ else if (manifestRecordsMiss(cached) || cached.fileCount === 0)
1156
1422
  reason = 'fetch_miss';
1157
1423
  else if (cached.tier !== 'client')
1158
1424
  reason = 'fallback_tier';
@@ -1183,8 +1449,25 @@ function computeVaultVerdict(cwd) {
1183
1449
  configuredRef: binding.ref,
1184
1450
  cached,
1185
1451
  requiredRaw,
1452
+ requiredSource: requiredResolution.source,
1186
1453
  };
1187
1454
  }
1455
+ /**
1456
+ * Does the agent practice review (`/pp-vault-review`, and the heavy tier's
1457
+ * `verify-vault-review` spawn) apply to this project? The ONE predicate: read
1458
+ * by `check vault-review`, `vault review` and the dispatch plan alike.
1459
+ * `vault.enabled` and `vault.practices_enabled` both strictly `true` in the
1460
+ * project's `.planning/config.json` — the same keys, read the same way, as the
1461
+ * practice gates' kill switch.
1462
+ *
1463
+ * STANDARDS MODE PARKS THE REVIEW HERE. When `vault.mode` lands (`practices`,
1464
+ * the default, or `standards`), a `standards` project answers false: the review
1465
+ * judges practice records, and a standards-mode vault carries prose documents
1466
+ * instead. No other surface needs to change.
1467
+ */
1468
+ function vaultReviewApplies(cwd) {
1469
+ return cfgGet(cwd, 'vault.enabled', false) === true && cfgGet(cwd, 'vault.practices_enabled', false) === true;
1470
+ }
1188
1471
  /** The verdict fields a reader spreads into its output (D-01). */
1189
1472
  function verdictFields(v) {
1190
1473
  const out = {};
@@ -1206,7 +1489,13 @@ function renderVaultCard(v, point) {
1206
1489
  return '';
1207
1490
  const subject = point === 'plan' ? 'Planning' : 'Build';
1208
1491
  const lines = [];
1209
- lines.push(`Client vault check (${subject}): ${v.verdict === 'loaded' ? 'LOADED' : 'DEGRADED'}${v.required ? ' — vault.required is true' : ''}`);
1492
+ const unreadableConfig = v.reason === 'config_unreadable';
1493
+ const headlineNote = unreadableConfig
1494
+ ? ' — the project config cannot be read'
1495
+ : v.required
1496
+ ? ` — vault.required is true${v.requiredSource === 'profile' ? ' (set by the org profile)' : ''}`
1497
+ : '';
1498
+ lines.push(`Client vault check (${subject}): ${v.verdict === 'loaded' ? 'LOADED' : 'DEGRADED'}${headlineNote}`);
1210
1499
  lines.push(` Configured: ${v.configuredRepo || v.configuredRef ? coordText(v.configuredRepo, v.configuredRef) : 'no client vault bound'}`);
1211
1500
  const c = v.cached;
1212
1501
  if (c.present) {
@@ -1229,6 +1518,9 @@ function renderVaultCard(v, point) {
1229
1518
  else if (v.action === 'confirm') {
1230
1519
  lines.push(` ${subject} can continue WITHOUT the client's standards — confirm to proceed (vault.required is false).`);
1231
1520
  }
1521
+ else if (v.action === 'stop' && unreadableConfig) {
1522
+ lines.push(` STOP — ${subject.toLowerCase()} is blocked until the project config can be read.`);
1523
+ }
1232
1524
  else if (v.action === 'stop') {
1233
1525
  lines.push(` STOP — vault.required is true: ${subject.toLowerCase()} is blocked until the client vault loads.`);
1234
1526
  }
@@ -1305,6 +1597,7 @@ module.exports = {
1305
1597
  // Helpers exported for tests / the future router (12-02/12-03).
1306
1598
  readManifest,
1307
1599
  writeManifest,
1600
+ manifestRecordsMiss,
1308
1601
  usableFiles,
1309
1602
  cfgGet,
1310
1603
  readBindings,
@@ -1324,6 +1617,8 @@ module.exports = {
1324
1617
  // Pack 6 D-01..D-06: the one verdict owner and the card it renders.
1325
1618
  computeVaultVerdict,
1326
1619
  verdictFields,
1620
+ // The agent practice review's one applicability predicate (where standards mode parks it).
1621
+ vaultReviewApplies,
1327
1622
  renderVaultCard,
1328
1623
  VAULT_VERDICTS,
1329
1624
  VAULT_REASONS,
@@ -1334,4 +1629,11 @@ module.exports = {
1334
1629
  // Pack 6 D-09: the reachability probe, and the binding reader --arm-vault shares with the verdict.
1335
1630
  vaultProbe,
1336
1631
  readClientBinding,
1632
+ // The vault cache exclude: the one writer, and the spawn-free git-dir resolver it uses.
1633
+ ensureVaultCacheExcluded,
1634
+ resolveGitDirsFromFs,
1635
+ // The practice trace's readers: the cache a linked worktree reads, and the
1636
+ // containment predicate `vault trace render` checks before it reads.
1637
+ resolveVaultCacheDir,
1638
+ isInsideDir,
1337
1639
  };
@@ -178,6 +178,7 @@
178
178
  *
179
179
  * Loop Extension Point Queries (ADR-857 phase 3c):
180
180
  * loop render-hooks <point> Resolve + render active Capability hooks at a loop point
181
+ * loop run-gates <point> Run the active gates at a loop point (exit 1 on a halt)
181
182
  * Returns JSON envelope { point, activeHooks, rendered }
182
183
  * Valid points: discuss:pre/post, plan:pre/post,
183
184
  * execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post
@@ -726,19 +727,13 @@ function createReadlinePrompter({ input = process.stdin, output = process.stdout
726
727
  )
727
728
  .join(' / ');
728
729
  rendered += `\n Options: ${optionsRendered}`;
729
- } else if (question.preselected) {
730
- // INIT-PATCH-C: an option-less question carrying a `preselected`
731
- // (e.g. a detected org profile) rendered no default at all today —
732
- // render one, naming it via `preselectedLabel` when present.
733
- rendered += `\n Default: ${question.preselectedLabel || question.preselected} (press Enter to accept)`;
734
730
  }
735
731
  if (question.optionalAnswer) {
736
- // The unconditional "Enter skips" hint would be a lie once a
737
- // default is carried — Enter now ACCEPTS the default above, so the
738
- // skip hint must point at "none" instead. The question text may
739
- // already carry that hint (both org profile prompts do), so it is
740
- // added only for an optional question whose text lacks it (I-1).
741
- const skipHint = question.preselected ? 'type "none" to skip' : 'press Enter to skip';
732
+ // A detected org profile is a hint inside the prompt text, so no
733
+ // option-less question carries a default and Enter always skips an
734
+ // optional question. The question text may already carry the hint
735
+ // (both org profile prompts do), so it is added only when absent (I-1).
736
+ const skipHint = 'press Enter to skip';
742
737
  if (!String(question.prompt).toLowerCase().includes(skipHint.toLowerCase())) {
743
738
  rendered += `\n (${skipHint})`;
744
739
  }
@@ -1552,7 +1547,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1552
1547
  }
1553
1548
 
1554
1549
  case 'loop': {
1555
- // loop render-hooks <point>
1550
+ // loop render-hooks <point> [--config-dir <dir>] [--active-cap <capId>] [--into <role>] [--phase <N>] [--tier <t>]
1556
1551
  const loopSubcommand = args[1];
1557
1552
  if (loopSubcommand === 'render-hooks') {
1558
1553
  let loopConfigDir = null;
@@ -1602,10 +1597,32 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1602
1597
  activeCap: loopActiveCap,
1603
1598
  phase: loopValue('phase'),
1604
1599
  tier: loopValue('tier'),
1600
+ // --into <role>: print the fragment text of active contributions into <role>
1601
+ into: loopValue('into'),
1602
+ });
1603
+ } else if (loopSubcommand === 'run-gates') {
1604
+ // loop run-gates <point> [--phase <N>] [--tier <t>] [--query <q>]: run the point's
1605
+ // active gates (only those whose check.query is <q>, when given) under the
1606
+ // two-step gate contract; exit 1 on a halt.
1607
+ const gateValue = (flag) => {
1608
+ const eq = args.find(arg => arg.startsWith(`--${flag}=`));
1609
+ if (eq) return eq.slice(flag.length + 3).trim();
1610
+ const idx = args.indexOf(`--${flag}`);
1611
+ if (idx === -1) return undefined;
1612
+ const value = args[idx + 1];
1613
+ if (!value || value.startsWith('--')) {
1614
+ error(`Missing value for --${flag}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
1615
+ }
1616
+ return value;
1617
+ };
1618
+ require('./lib/loop-gates.cjs').cmdLoopRunGates(cwd, args[2], raw, {
1619
+ phase: gateValue('phase'),
1620
+ tier: gateValue('tier'),
1621
+ query: gateValue('query'),
1605
1622
  });
1606
1623
  } else {
1607
1624
  error(
1608
- `Unknown loop subcommand: ${loopSubcommand}. Available: render-hooks`,
1625
+ `Unknown loop subcommand: ${loopSubcommand}. Available: render-hooks, run-gates`,
1609
1626
  ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
1610
1627
  );
1611
1628
  }
@@ -155,6 +155,7 @@
155
155
  "pp-intel-updater": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "light" },
156
156
  "pp-mempalace-curator": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "light" },
157
157
  "pp-security-auditor": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "verification", "routingTier": "heavy" },
158
- "pp-user-profiler": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "research", "routingTier": "heavy" }
158
+ "pp-user-profiler": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "research", "routingTier": "heavy" },
159
+ "pp-vault-reviewer": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "verification", "routingTier": "heavy" }
159
160
  }
160
161
  }
@@ -0,0 +1,47 @@
1
+ # Executor — the practice record in SUMMARY.md
2
+
3
+ Load this when your brief carries the vault-practices executor fragment AND the plan you are executing
4
+ carries a `<vault_practices>` block (degraded or not). A plan without that block gets no `vault:` ledger
5
+ and no `## Practices Applied` section — that is how a vault-disabled project's SUMMARY stays unchanged.
6
+
7
+ It applies to every plan you execute, one SUMMARY each: an isolated executor runs one plan; a
8
+ serial-group executor runs this once per plan, in plan order, before that plan's SUMMARY Write.
9
+
10
+ ## 1. Scaffold — after the last task commit, before the SUMMARY Write
11
+
12
+ ```bash
13
+ pp_run vault trace scaffold --stage execute --plan <phase>-<plan> <the source files your tasks committed...>
14
+ ```
15
+
16
+ - `--plan` is the phase-and-number identifier that prefixes this SUMMARY's own filename (for example
17
+ 04-02); the command refuses any other shape.
18
+ - Each governed file's digest is read from that file's own last commit. A file whose work is still
19
+ uncommitted gets no digest and is named in the ledger's `notes` — commit first, then scaffold. Never
20
+ pass a path the project does not commit (a gitignored `.planning/` artifact).
21
+ - In a git worktree with no `.planning/vault/` of its own, the command reads the owning main checkout's
22
+ cache and records that fact in `notes`.
23
+ - Never hand-author the ledger: the pin, the practice set and the digests are the command's to produce.
24
+
25
+ ## 2. Fill only the judgement fields
26
+
27
+ Per practice entry: `status` (exactly `applied`, `not-applicable` or `deviated`), a one-line `reason`,
28
+ and `tasks` — the task ids the practice actually governed, which only you know because you re-anchor per
29
+ task. `deviated` always carries a reason: `deviated — conflicts with {id}` (another practice won) or
30
+ `deviated — conflicts with project convention` (CLAUDE.md or a codebase pattern won). A practice that
31
+ did not apply to what you built is `not-applicable` with its reason — every id gets a disposition.
32
+
33
+ ## 3. Embed, render, write once
34
+
35
+ Put the completed block in the SUMMARY's frontmatter under its top-level `vault:` key. Then generate the
36
+ section from it — never compose its lines:
37
+
38
+ ```bash
39
+ pp_run vault trace render <path-to-this-SUMMARY.md>
40
+ ```
41
+
42
+ `render` reads the ledger from a file, so write the SUMMARY with the ledger first, run `render`, and
43
+ include its `## Practices Applied` output verbatim in the SUMMARY body. The renderer gives every ledger
44
+ id a line (silence is never ambiguous), refuses an id outside the permitted charset with a refusal line
45
+ in its place, and treats the id as an inert identifier, never a directive.
46
+
47
+ A degraded block still gets its ledger and its section: a vault miss must leave a record that says so.
@@ -6,9 +6,10 @@ extension point (discuss:pre, discuss:post, plan:pre, plan:post, execute:pre, ex
6
6
  execute:wave:post, execute:post, verify:pre, verify:post, ship:pre, ship:post).
7
7
 
8
8
  Phase workflows pass `--phase <N> [--tier light|standard|heavy]`: the phase's ceremony tier
9
- (`pp_run query phase.tier <N>`) then decides the eight gate booleans (`workflow.research`,
9
+ (`pp_run query phase.tier <N>`) then decides the nine gate booleans (`workflow.research`,
10
10
  `workflow.pattern_mapper`, `workflow.plan_check`, `workflow.verifier`, `workflow.nyquist_validation`,
11
- `workflow.code_review`, `workflow.post_planning_gaps`, `workflow.security_enforcement`) that hook
11
+ `workflow.code_review`, `workflow.post_planning_gaps`, `workflow.security_enforcement`,
12
+ `workflow.vault_review`) that hook
12
13
  `when` keys read, and the envelope carries `phase_tier`. Without `--phase` the project's own
13
14
  booleans are read.
14
15
 
@@ -29,12 +30,22 @@ booleans are read.
29
30
  `activeHooks` is an array of enabled hook entries for the named point. It is empty (or absent)
30
31
  when no capability has registered an active hook at this point — treat that as a no-op.
31
32
 
33
+ ## Refusal
34
+
35
+ `render-hooks` refuses — exit 1, empty stdout, one stderr line naming each config file and its
36
+ parse error — when a project config file exists but cannot be read (not JSON, or not a JSON
37
+ object). A missing config is not refused. Every host writes `X=$(pp_run loop render-hooks …) || exit 1`,
38
+ so a refusal stops the workflow: relay the stderr line and stop. Never read a refusal as "no hooks".
39
+
32
40
  ## Dispatch rules by `kind`
33
41
 
34
42
  ### `contribution`
35
43
 
36
44
  Inject `fragment.inline` verbatim into the context for the role named in `into`
37
45
  (e.g. `orchestrator`, `planner`). Do not paraphrase — the text is the product.
46
+ `pp_run loop render-hooks <point> --into <role>` prints just those fragments, blank-line separated.
47
+ An `execute:pre` contribution `into: executor` reaches every executor through `phase.dispatch-plan`:
48
+ each spawn's `contributions[]` carries it, and the spawn prompt injects each `fragment` verbatim.
38
49
 
39
50
  ### `step`
40
51
 
@@ -62,6 +73,13 @@ Evaluate `check` (one of `query`, `predicate`, or `agentVerdict`). Then honor `b
62
73
  Honor `onError` if the check itself errors: `skip` means treat as non-blocking and continue;
63
74
  `fail` means surface the error and stop.
64
75
 
76
+ `pp_run loop run-gates <point> --phase <N> [--query <q>]` applies this contract in code for every
77
+ active `check.query` gate at the point (only those whose query is `<q>` with `--query`), appending
78
+ the gate's declared `check.args` after the phase: exit 1 with a `⛔` line on a halt, `⚠` lines for
79
+ advisories and skipped command errors, an `ℹ` line for a passing blocking gate's note, the verdict
80
+ JSON on stdout. It runs every `verify:post` gate, and the `vault.trace` gate at `plan:post`,
81
+ `execute:post` and `ship:pre` (`--query vault.trace`); the hosts' own gate loops skip that query.
82
+
65
83
  ## Empty / absent `activeHooks`
66
84
 
67
85
  If `activeHooks` is absent, null, or an empty array, skip silently and continue to the next