@pareto4/pp-core 1.12.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-reviewer.md +1 -1
  3. package/agents/pp-executor.md +2 -13
  4. package/agents/pp-planner.md +1 -1
  5. package/agents/pp-vault-reviewer.md +51 -0
  6. package/commands/pp-ns-review.md +2 -1
  7. package/commands/pp-vault-review.md +33 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +1 -1
  10. package/pp-core/bin/lib/capability-registry.cjs +200 -10
  11. package/pp-core/bin/lib/check-command-router.cjs +1242 -37
  12. package/pp-core/bin/lib/clusters.cjs +1 -0
  13. package/pp-core/bin/lib/command-aliases.cjs +7 -0
  14. package/pp-core/bin/lib/compose-bindings.cjs +41 -27
  15. package/pp-core/bin/lib/decisions.cjs +104 -27
  16. package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
  17. package/pp-core/bin/lib/frontmatter.cjs +37 -0
  18. package/pp-core/bin/lib/git-base-branch.cjs +136 -10
  19. package/pp-core/bin/lib/init-contract.cjs +180 -66
  20. package/pp-core/bin/lib/init-interactive.cjs +4 -20
  21. package/pp-core/bin/lib/loop-gates.cjs +213 -0
  22. package/pp-core/bin/lib/loop-resolver.cjs +107 -45
  23. package/pp-core/bin/lib/org-profile.cjs +218 -44
  24. package/pp-core/bin/lib/phase-command-router.cjs +7 -1
  25. package/pp-core/bin/lib/phase-manifest.cjs +527 -0
  26. package/pp-core/bin/lib/phase-tier.cjs +15 -2
  27. package/pp-core/bin/lib/phase.cjs +51 -1
  28. package/pp-core/bin/lib/vault-audit.cjs +2070 -0
  29. package/pp-core/bin/lib/vault-command-router.cjs +460 -1
  30. package/pp-core/bin/lib/vault-practices.cjs +1142 -104
  31. package/pp-core/bin/lib/vault-review.cjs +2359 -0
  32. package/pp-core/bin/lib/vault-trace.cjs +2831 -0
  33. package/pp-core/bin/lib/vault-update.cjs +266 -0
  34. package/pp-core/bin/lib/vault-write-back.cjs +19 -0
  35. package/pp-core/bin/lib/vault.cjs +277 -12
  36. package/pp-core/bin/pp-tools.cjs +30 -13
  37. package/pp-core/bin/shared/model-catalog.json +2 -1
  38. package/pp-core/references/executor-vault-trace.md +47 -0
  39. package/pp-core/references/loop-hook-dispatch.md +13 -2
  40. package/pp-core/references/planner-vault-practices.md +46 -1
  41. package/pp-core/references/planning-config.md +1 -1
  42. package/pp-core/templates/practices/README.md +140 -11
  43. package/pp-core/templates/project-charter.md +226 -0
  44. package/pp-core/workflows/add-tests.md +4 -0
  45. package/pp-core/workflows/audit-fix.md +2 -0
  46. package/pp-core/workflows/check-todos.md +1 -1
  47. package/pp-core/workflows/code-review-fix.md +2 -0
  48. package/pp-core/workflows/debug.md +4 -0
  49. package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
  50. package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
  51. package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
  52. package/pp-core/workflows/discovery-sprint.md +1 -1
  53. package/pp-core/workflows/discovery-to-build.md +1 -1
  54. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
  55. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
  56. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
  57. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
  58. package/pp-core/workflows/execute-phase.md +22 -5
  59. package/pp-core/workflows/execute-plan.md +4 -0
  60. package/pp-core/workflows/fast.md +4 -0
  61. package/pp-core/workflows/help/modes/full.md +3 -2
  62. package/pp-core/workflows/init.md +2 -2
  63. package/pp-core/workflows/new-project.md +1 -1
  64. package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
  65. package/pp-core/workflows/plan-phase.md +7 -4
  66. package/pp-core/workflows/quick.md +4 -0
  67. package/pp-core/workflows/ship.md +5 -1
  68. package/pp-core/workflows/undo.md +1 -1
  69. package/pp-core/workflows/validate-phase.md +4 -0
  70. package/pp-core/workflows/vault-review.md +65 -0
  71. package/pp-core/workflows/verify-work.md +3 -0
  72. package/scripts/gen-capability-registry.cjs +11 -0
  73. package/scripts/lint-test-file-count.allowlist.json +10 -1
  74. package/scripts/prompt-injection-scan.sh +2 -0
@@ -37,6 +37,7 @@ 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");
40
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
41
42
  const configLoaderModule = require("./config-loader.cjs");
42
43
  const { unreadableConfigFiles } = configLoaderModule;
@@ -333,6 +334,15 @@ function readManifest(vaultDirPath) {
333
334
  return null;
334
335
  }
335
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
+ }
336
346
  /** Write the manifest with two-space JSON via the repo's normalizing writer (display-safe, D-07). */
337
347
  function writeManifest(vaultDirPath, manifest) {
338
348
  (0, shell_command_projection_cjs_1.platformEnsureDir)(vaultDirPath);
@@ -473,6 +483,201 @@ function cloneTier(tier, exec, destDir, warnings) {
473
483
  return { ok: true };
474
484
  return { ok: false, stderr: gi.stderr || hostCliStderr };
475
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
+ }
476
681
  // ─── Degrade (never-abort terminal states) ─────────────────────────────────────
477
682
  function priorContentExists(vaultDirPath) {
478
683
  return node_fs_1.default.existsSync(vaultDirPath) && usableFiles(vaultDirPath).length > 0;
@@ -630,7 +835,14 @@ function vaultFetch(cwd, deps = {}) {
630
835
  if (prior && prior.source === 'fetched' && ttlHours > 0 && effectiveRef && prior.ref === effectiveRef) {
631
836
  const age = Date.now() - new Date(prior.fetchedAt).getTime();
632
837
  if (Number.isFinite(age) && age < ttlHours * 3_600_000) {
633
- 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;
634
846
  }
635
847
  }
636
848
  // Walk the ladder; stop at the first tier producing >=1 usable file.
@@ -682,6 +894,10 @@ function vaultFetch(cwd, deps = {}) {
682
894
  void e;
683
895
  continue;
684
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);
685
901
  const manifest = {
686
902
  source: 'fetched',
687
903
  tier: tier.kind,
@@ -711,6 +927,10 @@ function vaultFetch(cwd, deps = {}) {
711
927
  ? `${fallbackReason}; ${suppressedReason}`
712
928
  : suppressedReason
713
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);
714
934
  return degrade(finalDir, degradeReason, warnings);
715
935
  }
716
936
  catch (e) {
@@ -911,7 +1131,12 @@ function vaultSummary(cwd) {
911
1131
  'excluded from this listing. Remove credential files from the vault repo.\n');
912
1132
  }
913
1133
  if (shown.length === 0) {
914
- 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');
915
1140
  }
916
1141
  else {
917
1142
  lines.push('');
@@ -1086,7 +1311,7 @@ function boundText(s, max = 160) {
1086
1311
  const flat = s.replace(/[\u0000-\u001F\u007F]+/g, ' ').trim();
1087
1312
  return flat.length > max ? `${flat.slice(0, max)}…` : flat;
1088
1313
  }
1089
- function readCachedView(cwd) {
1314
+ function readCachedView(cwd, vaultDir) {
1090
1315
  const empty = {
1091
1316
  present: false,
1092
1317
  source: null,
@@ -1098,7 +1323,7 @@ function readCachedView(cwd) {
1098
1323
  fileCount: 0,
1099
1324
  fallbackReason: null,
1100
1325
  };
1101
- 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'));
1102
1327
  if (!m)
1103
1328
  return empty;
1104
1329
  const source = m.source === 'fetched' || m.source === 'cache' || m.source === 'miss' ? m.source : null;
@@ -1131,8 +1356,14 @@ function isStaleForVerdict(cwd, cached) {
1131
1356
  * spawns, never throws. `block = enabled && required && verdict !== 'loaded'`;
1132
1357
  * `action` is `stop` when blocking, `confirm` when degraded and not required,
1133
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`.
1134
1364
  */
1135
- function computeVaultVerdict(cwd) {
1365
+ function computeVaultVerdict(cwd, opts = {}) {
1366
+ const vaultDir = typeof opts.vaultDir === 'string' && opts.vaultDir !== '' ? opts.vaultDir : undefined;
1136
1367
  // Fail closed before reading a single key: `cfgGet` swallows a parse failure
1137
1368
  // into "absent", which would read a required vault as switched off (inert).
1138
1369
  if (unreadableConfigFiles(cwd).length > 0) {
@@ -1140,7 +1371,7 @@ function computeVaultVerdict(cwd) {
1140
1371
  let cachedView = readCachedView('');
1141
1372
  try {
1142
1373
  binding = readClientBinding(cwd);
1143
- cachedView = readCachedView(cwd);
1374
+ cachedView = readCachedView(cwd, vaultDir);
1144
1375
  }
1145
1376
  catch {
1146
1377
  /* display-only; the verdict below does not depend on them */
@@ -1157,12 +1388,15 @@ function computeVaultVerdict(cwd) {
1157
1388
  configuredRef: binding.ref,
1158
1389
  cached: cachedView,
1159
1390
  requiredRaw: null,
1391
+ requiredSource: 'config',
1160
1392
  };
1161
1393
  }
1162
1394
  const enabled = cfgGet(cwd, 'vault.enabled', undefined) === true;
1163
- const requiredRawValue = cfgGet(cwd, 'vault.required', undefined);
1164
- const requiredValid = requiredRawValue === undefined || typeof requiredRawValue === 'boolean';
1165
- 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;
1166
1400
  let requiredRaw = null;
1167
1401
  if (!requiredValid) {
1168
1402
  try {
@@ -1177,14 +1411,14 @@ function computeVaultVerdict(cwd) {
1177
1411
  let reason;
1178
1412
  try {
1179
1413
  binding = readClientBinding(cwd);
1180
- cached = readCachedView(cwd);
1414
+ cached = readCachedView(cwd, vaultDir);
1181
1415
  if (!enabled)
1182
1416
  reason = 'vault_disabled';
1183
1417
  else if (!binding.bound)
1184
1418
  reason = 'no_bindings';
1185
1419
  else if (!cached.present)
1186
1420
  reason = 'never_fetched';
1187
- else if (cached.source === 'miss' || cached.fileCount === 0)
1421
+ else if (manifestRecordsMiss(cached) || cached.fileCount === 0)
1188
1422
  reason = 'fetch_miss';
1189
1423
  else if (cached.tier !== 'client')
1190
1424
  reason = 'fallback_tier';
@@ -1215,8 +1449,25 @@ function computeVaultVerdict(cwd) {
1215
1449
  configuredRef: binding.ref,
1216
1450
  cached,
1217
1451
  requiredRaw,
1452
+ requiredSource: requiredResolution.source,
1218
1453
  };
1219
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
+ }
1220
1471
  /** The verdict fields a reader spreads into its output (D-01). */
1221
1472
  function verdictFields(v) {
1222
1473
  const out = {};
@@ -1239,7 +1490,11 @@ function renderVaultCard(v, point) {
1239
1490
  const subject = point === 'plan' ? 'Planning' : 'Build';
1240
1491
  const lines = [];
1241
1492
  const unreadableConfig = v.reason === 'config_unreadable';
1242
- const headlineNote = unreadableConfig ? ' — the project config cannot be read' : v.required ? ' — vault.required is true' : '';
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
+ : '';
1243
1498
  lines.push(`Client vault check (${subject}): ${v.verdict === 'loaded' ? 'LOADED' : 'DEGRADED'}${headlineNote}`);
1244
1499
  lines.push(` Configured: ${v.configuredRepo || v.configuredRef ? coordText(v.configuredRepo, v.configuredRef) : 'no client vault bound'}`);
1245
1500
  const c = v.cached;
@@ -1342,6 +1597,7 @@ module.exports = {
1342
1597
  // Helpers exported for tests / the future router (12-02/12-03).
1343
1598
  readManifest,
1344
1599
  writeManifest,
1600
+ manifestRecordsMiss,
1345
1601
  usableFiles,
1346
1602
  cfgGet,
1347
1603
  readBindings,
@@ -1361,6 +1617,8 @@ module.exports = {
1361
1617
  // Pack 6 D-01..D-06: the one verdict owner and the card it renders.
1362
1618
  computeVaultVerdict,
1363
1619
  verdictFields,
1620
+ // The agent practice review's one applicability predicate (where standards mode parks it).
1621
+ vaultReviewApplies,
1364
1622
  renderVaultCard,
1365
1623
  VAULT_VERDICTS,
1366
1624
  VAULT_REASONS,
@@ -1371,4 +1629,11 @@ module.exports = {
1371
1629
  // Pack 6 D-09: the reachability probe, and the binding reader --arm-vault shares with the verdict.
1372
1630
  vaultProbe,
1373
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,
1374
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
 
@@ -42,6 +43,9 @@ so a refusal stops the workflow: relay the stderr line and stop. Never read a re
42
43
 
43
44
  Inject `fragment.inline` verbatim into the context for the role named in `into`
44
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.
45
49
 
46
50
  ### `step`
47
51
 
@@ -69,6 +73,13 @@ Evaluate `check` (one of `query`, `predicate`, or `agentVerdict`). Then honor `b
69
73
  Honor `onError` if the check itself errors: `skip` means treat as non-blocking and continue;
70
74
  `fail` means surface the error and stop.
71
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
+
72
83
  ## Empty / absent `activeHooks`
73
84
 
74
85
  If `activeHooks` is absent, null, or an empty array, skip silently and continue to the next
@@ -53,7 +53,7 @@ Applies at the `write_phase_prompt` step.
53
53
 
54
54
  **Vault practices block (only when the vault-practices capability is active):** This whole subsection applies ONLY when the injected `plan:pre -> into: planner` fragment for vault-practices is present in your context — i.e. `vault.enabled` AND `vault.practices_enabled` are both true for this project. When that fragment is absent, do nothing described here: write no `<vault_practices>` block at all, and this PLAN.md stays byte-identical to a plan produced without the capability.
55
55
 
56
- When the fragment IS present: for EACH plan, after that plan's `files_modified` list is finalized and BEFORE calling Write on that plan's PLAN.md, run `pp-tools vault practices match <files_modified...> --block` as an ordinary Bash action against that plan's own files — this call happens in-turn, mid-planning, not before planning starts (the CLI genuinely cannot run any earlier, since `files_modified` does not exist yet at `plan:pre` time).
56
+ When the fragment IS present: for EACH plan, after that plan's `files_modified` list is finalized and BEFORE calling Write on that plan's PLAN.md, run `pp_run vault practices match <files_modified...> --block` as an ordinary Bash action against that plan's own files — this call happens in-turn, mid-planning, not before planning starts (the CLI genuinely cannot run any earlier, since `files_modified` does not exist yet at `plan:pre` time).
57
57
 
58
58
  **Embed that command's output VERBATIM as the plan's `<vault_practices>` block — byte for byte, from `<vault_practices>` to `</vault_practices>`.** Do not rename a field, re-order an entry, add or move a fence marker, re-wrap, summarise, or omit anything. The CLI already emits the finished block in the exact shape `docs/practices-format.md` defines: the opening notice, the scoped and global groups, the documented field names, and the whole-entry external-content fencing. It ALWAYS includes every practice the matcher returns with `scope: global`, labeled global — that is now guaranteed by the command rather than left to a plan-time judgement call (D-03), and it is never yours to drop one. It also emits the correct degraded one-line state on its own when there is nothing to show, so the block is never silently absent.
59
59
 
@@ -65,3 +65,48 @@ Write the block using exactly one of these three degraded one-line states — ve
65
65
  - Matcher ran, zero matched AND zero global practices returned: `Practices checked — none apply to this plan's files.` (D-13)
66
66
  - Matcher ran, no practices directory found in any fetched vault tier: `Practices checked — none found in the vault.` (D-14)
67
67
  - The matcher CLI call itself failed: planning continues regardless — this is advisory, not a blocker, mirroring the vault fetch step's own skip-on-error discipline — and the block carries `Practices could not be checked: <reason>.` (D-15)
68
+
69
+ ### Frontmatter trace ledger — after the plan graph, then fill
70
+
71
+ **When:** last. Write the plans, run `pp_run query phase-plan-index "$PHASE"`, write back each plan's
72
+ computed `wave` / `serial_group`, and apply any `merge_candidates` merge FIRST (`planner-plan-graph.md`).
73
+ A merge changes a plan's `files_modified` and its identifier, and the ledger's subject list and practice
74
+ set are derived from exactly those, so a ledger written before the plan graph settled describes a
75
+ different plan. Only then, for each final plan, re-run the `--block` match if its `files_modified`
76
+ changed, and add the ledger.
77
+
78
+ **What to run:** `pp_run vault trace scaffold --stage plan --plan <plan-id> <files_modified...>`. Put the
79
+ returned block in that plan's **frontmatter**, under its top-level `vault:` key, exactly as emitted —
80
+ after `title:`, `wave`, `serial_group` and `chain_reason`, the same verbatim discipline the body block
81
+ gets. A later `pp_run frontmatter set` on the plan keeps the ledger's lines as they are.
82
+
83
+ **The `<plan-id>` is not free-form.** The plan identifier must be the plan's phase-and-number identifier, exactly the prefix of its artifact filename (for example 04-02), not a bare plan number, a task label or a slug. The command refuses any other shape outright rather than emitting a block you would have to redo.
84
+
85
+ **What you fill, and only this:** per practice entry, the `status`, a one-line `reason`, and `tasks` —
86
+ the task ids in this plan the practice is expected to govern. **The pin and the practice list are not
87
+ yours to write.** Never type a `ref`, a `resolvedSha` or a digest, and never re-derive, re-order or trim
88
+ the practice list: those are the fields the command exists to produce.
89
+
90
+ **The three status words** are the existing vocabulary and are never re-coined:
91
+ - `applied` — the practice governs the work and will be followed.
92
+ - `not-applicable` — the practice was considered and ruled out for this plan.
93
+ - `deviated` — the practice loses to something that outranks it, and the `reason` says to what.
94
+
95
+ **A ruled-out practice gets a line, not silence.** A practice you considered and set aside is recorded
96
+ `not-applicable` **with its reason**. That auditable ruled-out set is the point of the ledger: an
97
+ omitted entry and a never-considered one look the same to every later reader.
98
+
99
+ **The ledger and the body block have different jobs.** The `vault:` ledger is the **audit record**: our
100
+ keys, our values, one bounded client-authored token (the practice `id`), and **no client prose** — YAML
101
+ has no fence, so practice text copied into frontmatter has left the fence. The `<vault_practices>` body
102
+ block is the **reference content** the executor re-anchors against per task; it **keeps its fences**.
103
+
104
+ **Degraded or absent vault:** the ledger is still obtained and embedded, carrying the degraded state and
105
+ its reason — a degraded run must leave an artifact that says so, not one indistinguishable from a
106
+ project that was never vault-enabled. The three degraded one-line states above still govern the body
107
+ block. The `override` field is always `null`; it is never yours to write.
108
+
109
+ **Re-run rule:** if a plan-checker revision or `plan_bounce` later changes that plan's `files_modified`,
110
+ re-run the scaffold and replace the block, exactly as the body block is re-run.
111
+
112
+ **Shape:** `docs/vault-trace-format.md` is the contract for the block's fields — read the shape there.