@pareto4/pp-core 1.17.1 → 1.19.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 (199) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -3
  3. package/agents/pp-code-fixer.md +9 -16
  4. package/agents/pp-code-reviewer.md +2 -78
  5. package/agents/pp-codebase-mapper.md +3 -0
  6. package/agents/pp-debugger.md +3 -2
  7. package/agents/pp-executor.md +25 -30
  8. package/agents/pp-integration-checker.md +1 -0
  9. package/agents/pp-phase-researcher.md +3 -1
  10. package/agents/pp-plan-checker.md +3 -0
  11. package/agents/pp-planner.md +16 -12
  12. package/agents/pp-research-synthesizer.md +1 -1
  13. package/agents/pp-ui-auditor.md +1 -0
  14. package/agents/pp-ui-researcher.md +3 -1
  15. package/agents/pp-vault-reviewer.md +43 -189
  16. package/agents/pp-verifier.md +10 -8
  17. package/commands/pp-add-tests.md +1 -1
  18. package/commands/pp-debug.md +1 -0
  19. package/commands/pp-init.md +4 -3
  20. package/commands/pp-ns-manage.md +1 -1
  21. package/commands/pp-ns-review.md +1 -1
  22. package/commands/pp-quick.md +3 -0
  23. package/commands/pp-review-backlog.md +2 -1
  24. package/commands/pp-vault-promote.md +7 -8
  25. package/commands/pp-vault-review.md +6 -7
  26. package/gemini-extension.json +1 -1
  27. package/package.json +3 -6
  28. package/pp-core/bin/lib/capability-registry.cjs +15 -357
  29. package/pp-core/bin/lib/capability-state.cjs +15 -79
  30. package/pp-core/bin/lib/capability-writer.cjs +2 -16
  31. package/pp-core/bin/lib/check-command-router.cjs +342 -2298
  32. package/pp-core/bin/lib/clusters.cjs +2 -2
  33. package/pp-core/bin/lib/commands.cjs +79 -4
  34. package/pp-core/bin/lib/compose-bindings.cjs +99 -16
  35. package/pp-core/bin/lib/config.cjs +3 -6
  36. package/pp-core/bin/lib/discovery-rollup.cjs +9 -6
  37. package/pp-core/bin/lib/discovery-screens.cjs +20 -2
  38. package/pp-core/bin/lib/discovery-sweep.cjs +12 -3
  39. package/pp-core/bin/lib/discovery-vault-check.cjs +37 -3
  40. package/pp-core/bin/lib/git-base-branch.cjs +10 -13
  41. package/pp-core/bin/lib/git-provider.cjs +5 -6
  42. package/pp-core/bin/lib/init-contract.cjs +55 -42
  43. package/pp-core/bin/lib/init-interactive.cjs +6 -20
  44. package/pp-core/bin/lib/io.cjs +2 -8
  45. package/pp-core/bin/lib/loop-resolver.cjs +8 -32
  46. package/pp-core/bin/lib/org-profile.cjs +16 -92
  47. package/pp-core/bin/lib/vault-command-router.cjs +174 -1206
  48. package/pp-core/bin/lib/vault-coordinates.cjs +4 -4
  49. package/pp-core/bin/lib/vault-index.cjs +44 -74
  50. package/pp-core/bin/lib/vault-seal.cjs +37 -122
  51. package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
  52. package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
  53. package/pp-core/bin/lib/vault-standards.cjs +15 -16
  54. package/pp-core/bin/lib/vault-trace.cjs +40 -3016
  55. package/pp-core/bin/lib/vault-update.cjs +5 -26
  56. package/pp-core/bin/lib/vault.cjs +122 -316
  57. package/pp-core/bin/pp-tools.cjs +15 -36
  58. package/pp-core/org-profile.json +1 -1
  59. package/pp-core/references/autonomous-best-effort.md +4 -3
  60. package/pp-core/references/autonomous-lifecycle.md +1 -0
  61. package/pp-core/references/autonomous-smart-discuss.md +5 -1
  62. package/pp-core/references/discovery-id-conventions.md +35 -2
  63. package/pp-core/references/execute-mvp-tdd.md +3 -3
  64. package/pp-core/references/git-integration.md +57 -33
  65. package/pp-core/references/git-planning-commit.md +31 -9
  66. package/pp-core/references/phase-argument-parsing.md +1 -0
  67. package/pp-core/references/planner-gap-closure.md +1 -0
  68. package/pp-core/references/planner-revision.md +3 -1
  69. package/pp-core/references/planner-tdd.md +4 -4
  70. package/pp-core/references/planner-vault-standards.md +16 -25
  71. package/pp-core/references/planning-config.md +3 -3
  72. package/pp-core/references/shell-portability.md +28 -0
  73. package/pp-core/references/tdd.md +20 -16
  74. package/pp-core/references/worktree-path-safety.md +1 -0
  75. package/pp-core/templates/README.md +1 -1
  76. package/pp-core/templates/VAULT-REVIEW.md +24 -170
  77. package/pp-core/templates/milestone.md +5 -5
  78. package/pp-core/workflows/add-backlog.md +1 -1
  79. package/pp-core/workflows/add-tests.md +8 -5
  80. package/pp-core/workflows/add-todo.md +3 -1
  81. package/pp-core/workflows/ai-integration-phase.md +2 -1
  82. package/pp-core/workflows/audit-fix.md +7 -6
  83. package/pp-core/workflows/audit-milestone.md +1 -0
  84. package/pp-core/workflows/autonomous.md +6 -0
  85. package/pp-core/workflows/check-todos.md +2 -1
  86. package/pp-core/workflows/cleanup.md +3 -1
  87. package/pp-core/workflows/code-review-fix.md +15 -8
  88. package/pp-core/workflows/code-review.md +16 -58
  89. package/pp-core/workflows/complete-milestone.md +13 -3
  90. package/pp-core/workflows/debug.md +4 -2
  91. package/pp-core/workflows/diagnose-issues.md +1 -1
  92. package/pp-core/workflows/discovery-sprint/prototype.md +2 -2
  93. package/pp-core/workflows/discovery-sprint/session-13-gates.md +1 -1
  94. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +51 -7
  95. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +5 -3
  96. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +10 -8
  97. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +11 -2
  98. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +5 -5
  99. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +3 -3
  100. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +2 -2
  101. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +14 -2
  102. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +2 -2
  103. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +10 -10
  104. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +11 -8
  105. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +21 -18
  106. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +5 -3
  107. package/pp-core/workflows/discovery-sprint/templates/context-template.md +9 -3
  108. package/pp-core/workflows/discovery-sprint/vault-context.md +17 -12
  109. package/pp-core/workflows/discovery-sprint.md +11 -8
  110. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -0
  111. package/pp-core/workflows/discuss-phase-assumptions.md +5 -3
  112. package/pp-core/workflows/discuss-phase.md +8 -3
  113. package/pp-core/workflows/docs-update.md +1 -0
  114. package/pp-core/workflows/eval-review.md +2 -1
  115. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +13 -14
  116. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -0
  117. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +5 -25
  118. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -0
  119. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +18 -6
  120. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +6 -109
  121. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +1 -17
  122. package/pp-core/workflows/execute-phase.md +21 -12
  123. package/pp-core/workflows/execute-plan.md +24 -15
  124. package/pp-core/workflows/explore.md +1 -1
  125. package/pp-core/workflows/forensics.md +4 -2
  126. package/pp-core/workflows/health.md +1 -0
  127. package/pp-core/workflows/help/modes/full.md +1 -1
  128. package/pp-core/workflows/import.md +2 -1
  129. package/pp-core/workflows/ingest-docs.md +1 -0
  130. package/pp-core/workflows/init/steps/client-context-capture.md +1 -0
  131. package/pp-core/workflows/init/steps/github-provisioning.md +145 -0
  132. package/pp-core/workflows/init/steps/gitlab-provisioning.md +18 -4
  133. package/pp-core/workflows/init/steps/reachability-outcome.md +3 -1
  134. package/pp-core/workflows/init.md +56 -66
  135. package/pp-core/workflows/map-codebase.md +5 -1
  136. package/pp-core/workflows/milestone-summary.md +2 -1
  137. package/pp-core/workflows/mvp-phase.md +1 -0
  138. package/pp-core/workflows/new-milestone.md +9 -5
  139. package/pp-core/workflows/new-project.md +9 -8
  140. package/pp-core/workflows/new-workspace.md +1 -0
  141. package/pp-core/workflows/next.md +2 -1
  142. package/pp-core/workflows/pause-work.md +6 -4
  143. package/pp-core/workflows/plan-milestone-gaps.md +3 -1
  144. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +1 -1
  145. package/pp-core/workflows/plan-phase.md +21 -6
  146. package/pp-core/workflows/plan-review-convergence.md +2 -0
  147. package/pp-core/workflows/plant-seed.md +4 -2
  148. package/pp-core/workflows/pr-branch.md +2 -0
  149. package/pp-core/workflows/progress.md +5 -0
  150. package/pp-core/workflows/quick.md +19 -12
  151. package/pp-core/workflows/reapply-patches.md +3 -0
  152. package/pp-core/workflows/remove-phase.md +1 -1
  153. package/pp-core/workflows/resume-project.md +3 -0
  154. package/pp-core/workflows/review.md +7 -1
  155. package/pp-core/workflows/secure-phase.md +2 -1
  156. package/pp-core/workflows/session-report.md +1 -0
  157. package/pp-core/workflows/ship.md +8 -1
  158. package/pp-core/workflows/sketch-wrap-up.md +1 -1
  159. package/pp-core/workflows/sketch.md +3 -2
  160. package/pp-core/workflows/spec-phase.md +2 -1
  161. package/pp-core/workflows/spike-wrap-up.md +1 -1
  162. package/pp-core/workflows/spike.md +4 -3
  163. package/pp-core/workflows/sync-skills.md +5 -2
  164. package/pp-core/workflows/thread.md +3 -2
  165. package/pp-core/workflows/transition.md +6 -0
  166. package/pp-core/workflows/ui-phase.md +3 -1
  167. package/pp-core/workflows/ui-review.md +2 -1
  168. package/pp-core/workflows/ultraplan-phase.md +1 -0
  169. package/pp-core/workflows/undo.md +14 -10
  170. package/pp-core/workflows/update.md +5 -0
  171. package/pp-core/workflows/validate-phase.md +9 -6
  172. package/pp-core/workflows/vault-review.md +65 -186
  173. package/pp-core/workflows/vault-write-back.md +78 -110
  174. package/pp-core/workflows/verify-phase.md +5 -0
  175. package/pp-core/workflows/verify-work.md +9 -2
  176. package/scripts/gen-capability-registry.cjs +12 -93
  177. package/scripts/lint-test-file-count.allowlist.json +1 -19
  178. package/scripts/lint-test-file-count.cjs +5 -6
  179. package/scripts/prompt-injection-scan.sh +1 -4
  180. package/scripts/sync-runtime-launcher.cjs +115 -5
  181. package/pp-core/bin/lib/practices-review-check.cjs +0 -944
  182. package/pp-core/bin/lib/vault-audit.cjs +0 -2594
  183. package/pp-core/bin/lib/vault-mode.cjs +0 -235
  184. package/pp-core/bin/lib/vault-practices.cjs +0 -2057
  185. package/pp-core/bin/lib/vault-review.cjs +0 -2102
  186. package/pp-core/bin/lib/vault-write-back.cjs +0 -905
  187. package/pp-core/references/planner-vault-practices.md +0 -95
  188. package/pp-core/references/practice-arithmetic-contract.md +0 -121
  189. package/pp-core/references/verifier-practice-verdicts.md +0 -263
  190. package/pp-core/templates/practices/README.md +0 -248
  191. package/pp-core/templates/practices/error-handling.md +0 -18
  192. package/pp-core/templates/practices/minimal-comments.md +0 -14
  193. package/pp-core/templates/practices/naming-convention.md +0 -18
  194. package/pp-core/templates/practices/practice-template.md +0 -23
  195. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +0 -165
  196. package/scripts/gen-practice-arithmetic-contract.cjs +0 -412
  197. package/scripts/practice-arithmetic-contract.allow.json +0 -35
  198. package/scripts/record-vault-mode-golden.cjs +0 -89
  199. package/scripts/vault-calibrate.cjs +0 -996
@@ -45,8 +45,8 @@ exports.CLUSTERS = Object.freeze({
45
45
  'add-tests',
46
46
  'secure-phase',
47
47
  // Retroactive, non-gating review of a completed phase against the client
48
- // vault's practices (Vault Assurance #71924, 06-07) — the same family as
49
- // secure-phase / ui-review / validate-phase.
48
+ // vault's standards documents (`check vault-review`, a report) — the same
49
+ // family as secure-phase / ui-review / validate-phase.
50
50
  'vault-review',
51
51
  ]),
52
52
  milestone: Object.freeze([
@@ -14,7 +14,7 @@ const node_path_1 = __importDefault(require("node:path"));
14
14
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
15
15
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
16
  const ioMod = require("./io.cjs");
17
- const { output, error } = ioMod;
17
+ const { output, error, ERROR_REASON } = ioMod;
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const configLoaderMod = require("./config-loader.cjs");
20
20
  const { loadConfig, isGitIgnored } = configLoaderMod;
@@ -423,10 +423,76 @@ function cmdEffortSync(cwd, raw, opts) {
423
423
  }
424
424
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, synced > 0 ? 'changed' : 'ok');
425
425
  }
426
- function cmdCommit(cwd, message, files, raw, amend, noVerify) {
426
+ // ─── Commit trailers (Phase: / Quick:) ────────────────────────────────────────
427
+ //
428
+ // The Conventional Commit scope names a part of the codebase (`docs(planning)`,
429
+ // `feat(auth)`), never a phase or plan number. The phase, plan or quick-task
430
+ // identity a commit belongs to travels as ONE git trailer instead —
431
+ // `Phase: 02-03`, `Phase: 02` or `Quick: <id>` — so readers find it with
432
+ // `%(trailers:key=Phase)` rather than by parsing the subject.
433
+ /** Loose id shape: digits, letters, dots, dashes, underscores; no whitespace. */
434
+ const COMMIT_TRAILER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
435
+ /**
436
+ * Pull `--phase <id>` and `--quick <id>` (and their `--flag=<id>` forms) out of
437
+ * a `commit` / `commit-to-subrepo` argv, so neither the value nor the flag is
438
+ * taken for part of the message or for a `--files` path. A flag with no value
439
+ * yields the empty string, which `buildCommitTrailer` rejects.
440
+ */
441
+ function extractCommitTrailerArgs(args) {
442
+ const rest = [];
443
+ let phase;
444
+ let quick;
445
+ for (let i = 0; i < args.length; i++) {
446
+ const a = args[i];
447
+ const eq = /^--(phase|quick)=(.*)$/s.exec(a);
448
+ if (eq) {
449
+ if (eq[1] === 'phase')
450
+ phase = eq[2];
451
+ else
452
+ quick = eq[2];
453
+ continue;
454
+ }
455
+ if (a === '--phase' || a === '--quick') {
456
+ const next = args[i + 1];
457
+ const hasValue = next !== undefined && !next.startsWith('--');
458
+ const value = hasValue ? next : '';
459
+ if (hasValue)
460
+ i++;
461
+ if (a === '--phase')
462
+ phase = value;
463
+ else
464
+ quick = value;
465
+ continue;
466
+ }
467
+ rest.push(a);
468
+ }
469
+ return { args: rest, phase, quick };
470
+ }
471
+ /**
472
+ * The trailer paragraph for a commit, or null when neither id is given.
473
+ * Both ids may be given; they share one paragraph so git reads both as
474
+ * trailers. Errors (exit 1, reason `usage`) on an id that is empty or holds
475
+ * anything but letters, digits, dots, dashes or underscores — a newline in
476
+ * an id would otherwise forge further trailers.
477
+ */
478
+ function buildCommitTrailer(opts) {
479
+ const lines = [];
480
+ for (const [key, flag, value] of [['Phase', '--phase', opts.phase], ['Quick', '--quick', opts.quick]]) {
481
+ if (value === undefined)
482
+ continue;
483
+ if (!COMMIT_TRAILER_ID_RE.test(value)) {
484
+ error(`${flag} must be a phase/plan or quick-task id such as 02 or 02-03 (letters, digits, dots, dashes; got ${JSON.stringify(value)})`, ERROR_REASON.USAGE);
485
+ }
486
+ lines.push(`${key}: ${value}`);
487
+ }
488
+ return lines.length > 0 ? lines.join('\n') : null;
489
+ }
490
+ function cmdCommit(cwd, message, files, raw, amend, noVerify, trailerIds = {}) {
427
491
  if (!message && !amend) {
428
492
  error('commit message required');
429
493
  }
494
+ // --amend keeps the previous message (--no-edit), so a trailer is ignored there.
495
+ const trailer = amend ? null : buildCommitTrailer(trailerIds);
430
496
  // Sanitize commit message: strip invisible chars and injection markers
431
497
  // that could hijack agent context when commit messages are read back
432
498
  let sanitizedMessage = message;
@@ -513,6 +579,9 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
513
579
  }
514
580
  // Commit (--no-verify skips pre-commit hooks, used by parallel executor agents)
515
581
  const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', sanitizedMessage];
582
+ // A second -m is its own paragraph, which git reads as the trailer block.
583
+ if (trailer)
584
+ commitArgs.push('-m', trailer);
516
585
  if (noVerify)
517
586
  commitArgs.push('--no-verify');
518
587
  const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd });
@@ -591,10 +660,11 @@ function groupFilesBySubrepo(files, subRepos) {
591
660
  }
592
661
  return { grouped, unmatched };
593
662
  }
594
- function cmdCommitToSubrepo(cwd, message, files, raw) {
663
+ function cmdCommitToSubrepo(cwd, message, files, raw, trailerIds = {}) {
595
664
  if (!message) {
596
665
  error('commit message required');
597
666
  }
667
+ const trailer = buildCommitTrailer(trailerIds);
598
668
  const config = loadConfig(cwd);
599
669
  const subRepos = config['sub_repos'];
600
670
  if (!subRepos || subRepos.length === 0) {
@@ -617,7 +687,10 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
617
687
  (0, shell_command_projection_cjs_1.execGit)(['add', relativePath], { cwd: repoCwd });
618
688
  }
619
689
  // Commit
620
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(['commit', '-m', message], { cwd: repoCwd });
690
+ const commitArgs = ['commit', '-m', message];
691
+ if (trailer)
692
+ commitArgs.push('-m', trailer);
693
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
621
694
  if (commitResult.exitCode !== 0) {
622
695
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
623
696
  repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
@@ -1252,6 +1325,8 @@ module.exports = {
1252
1325
  cmdEffortSync,
1253
1326
  cmdCommit,
1254
1327
  cmdCommitToSubrepo,
1328
+ extractCommitTrailerArgs,
1329
+ buildCommitTrailer,
1255
1330
  cmdSummaryExtract,
1256
1331
  cmdWebsearch,
1257
1332
  cmdProgressRender,
@@ -31,8 +31,8 @@
31
31
  * `JSON.stringify`/object literals — never by `.md` shell string
32
32
  * concatenation.
33
33
  *
34
- * Its importers today are `src/vault-update.cts`, `src/init-contract.cts`,
35
- * `src/vault-practices.cts` and (quick 261003-lqi) `src/check-command-router.cts`
34
+ * Its importers today are `src/vault-update.cts`, `src/init-contract.cts`
35
+ * and (quick 261003-lqi) `src/check-command-router.cts`
36
36
  * — and this module imports none of them, so its top-level imports of `./org-profile.cjs`, `./platform-bindings.cjs`,
37
37
  * `./git-provider.cjs`, `./vault-coordinates.cjs`, `./io.cjs`,
38
38
  * `./project-root.cjs`, `./package-identity.cjs` and
@@ -54,6 +54,8 @@ exports.resolveLatestVaultTag = resolveLatestVaultTag;
54
54
  exports.resolvePlatformRepoUrl = resolvePlatformRepoUrl;
55
55
  exports.resolveLatestPlatformTag = resolveLatestPlatformTag;
56
56
  exports.resolveCreatedWithFrom = resolveCreatedWithFrom;
57
+ exports.clientVaultChoice = clientVaultChoice;
58
+ exports.validateClientVaultAnswer = validateClientVaultAnswer;
57
59
  exports.composeBindings = composeBindings;
58
60
  exports.redactComposeResultForEmission = redactComposeResultForEmission;
59
61
  exports.cmdComposeBindings = cmdComposeBindings;
@@ -579,7 +581,7 @@ function resolveCreatedWith() {
579
581
  * string. Every emitted field is one the schema's `additionalProperties:
580
582
  * false` property sets already allow.
581
583
  */
582
- function composeCandidate(cwd, seed, provider, opts) {
584
+ function composeCandidate(cwd, seed, provider, opts, existingClient) {
583
585
  const candidate = {};
584
586
  const provenance = {};
585
587
  const warnings = [];
@@ -614,23 +616,37 @@ function composeCandidate(cwd, seed, provider, opts) {
614
616
  // client.name / client.slug (project-answer)
615
617
  setField('client.name', cfgGet(cwd, 'project.client_name', undefined), 'project-answer');
616
618
  setField('client.slug', cfgGet(cwd, 'project.client_slug', undefined), 'project-answer');
617
- // client.vaultRepo / vaultRef / vaultHost (profile-seed)
618
- setField('client.vaultRepo', seed.clientVaultRepo, 'profile-seed');
619
- setField('client.vaultRef', seed.clientVaultRef, 'profile-seed');
620
- setField('client.vaultHost', seed.clientVaultHost, 'profile-seed');
621
- // client.vaultRef (resolved-tag) — FORK-ONLY (PBI 74316). When the seed
622
- // names a vault repo but no ref, propose the repo's latest stable tag.
623
- if (isUsableString(seed.clientVaultRepo) && !isUsableString(seed.clientVaultRef)) {
619
+ // client.vaultRepo (project-answer or profile-seed). The seeded ref and
620
+ // host describe the seeded repo, so they are proposed only while that repo
621
+ // is the one the binding will name.
622
+ const answerRepo = isUsableString(opts.clientVaultRepo) ? opts.clientVaultRepo.trim() : undefined;
623
+ const boundRepo = existingClient.vaultRepo ?? answerRepo ?? seed.clientVaultRepo;
624
+ const seedIsBound = answerRepo === undefined && boundRepo === seed.clientVaultRepo;
625
+ if (answerRepo !== undefined) {
626
+ setField('client.vaultRepo', answerRepo, 'project-answer');
627
+ }
628
+ else {
629
+ setField('client.vaultRepo', seed.clientVaultRepo, 'profile-seed');
630
+ }
631
+ if (seedIsBound) {
632
+ setField('client.vaultRef', seed.clientVaultRef, 'profile-seed');
633
+ setField('client.vaultHost', seed.clientVaultHost, 'profile-seed');
634
+ }
635
+ // client.vaultRef (resolved-tag) — FORK-ONLY (PBI 74316). When the bound
636
+ // vault repo has no ref yet, propose that repo's latest stable tag.
637
+ const boundRef = existingClient.vaultRef ?? (seedIsBound ? seed.clientVaultRef : undefined);
638
+ if (isUsableString(boundRepo) && !isUsableString(boundRef)) {
624
639
  if (opts.offline === true) {
625
640
  pushDeduped(warnings, [
626
641
  bound('client.vaultRef was not proposed (offline) — the vault resolves "main" until the project is pinned with "pp-tools vault update <tag>".'),
627
642
  ]);
628
643
  }
629
644
  else {
630
- const vaultHost = isUsableString(seed.clientVaultHost)
631
- ? seed.clientVaultHost
632
- : (hostRes.host ?? (0, vault_coordinates_cjs_1.defaultHostForProvider)(provider));
633
- const vaultTag = resolveLatestVaultTag(seed.clientVaultRepo, vaultHost, provider, opts);
645
+ const vaultHost = existingClient.vaultHost ??
646
+ (seedIsBound && isUsableString(seed.clientVaultHost) ? seed.clientVaultHost : undefined) ??
647
+ hostRes.host ??
648
+ (0, vault_coordinates_cjs_1.defaultHostForProvider)(provider);
649
+ const vaultTag = resolveLatestVaultTag(boundRepo, vaultHost, provider, opts);
634
650
  pushDeduped(warnings, vaultTag.warnings);
635
651
  if (vaultTag.tag) {
636
652
  setField('client.vaultRef', vaultTag.tag, 'resolved-tag');
@@ -746,6 +762,73 @@ function readExistingBindings(cwd) {
746
762
  };
747
763
  }
748
764
  }
765
+ /** Read the usable `client.vault*` strings of an existing binding; the merge
766
+ * keeps them, so the ref proposal must follow the repo they name. */
767
+ function existingVaultCoordinates(data) {
768
+ const client = data.client;
769
+ if (!isObject(client))
770
+ return {};
771
+ const out = {};
772
+ if (isUsableString(client.vaultRepo))
773
+ out.vaultRepo = client.vaultRepo;
774
+ if (isUsableString(client.vaultRef))
775
+ out.vaultRef = client.vaultRef;
776
+ if (isUsableString(client.vaultHost))
777
+ out.vaultHost = client.vaultHost;
778
+ return out;
779
+ }
780
+ /**
781
+ * Whether `/pp-init` asks for a client vault: whenever the project has none
782
+ * bound yet and a binding can be composed at all. Without a `platform.repo`
783
+ * from the org profile or an existing binding nothing is written (INIT-02),
784
+ * so an answer would be dropped silently. `defaultRepo` is the org
785
+ * profile's seeded vault a blank answer keeps, display-safe
786
+ * (`redactCoordinateForEmission`), or `null` when nothing is seeded and a
787
+ * blank answer binds no client vault. Never throws.
788
+ */
789
+ function clientVaultChoice(cwd, opts = {}) {
790
+ try {
791
+ const seed = readSeed(cwd, opts).values;
792
+ const seedRepo = seed.clientVaultRepo;
793
+ const existing = readExistingBindings(cwd);
794
+ const existingPlatform = existing.data.platform;
795
+ const composable = isUsableString(seed.platformRepo) || (isObject(existingPlatform) && isUsableString(existingPlatform.repo));
796
+ const offered = composable && !existing.malformed && existingVaultCoordinates(existing.data).vaultRepo === undefined;
797
+ return { offered, defaultRepo: offered && isUsableString(seedRepo) ? String((0, platform_bindings_cjs_1.redactCoordinateForEmission)(seedRepo)) : null };
798
+ }
799
+ catch {
800
+ return { offered: false, defaultRepo: null };
801
+ }
802
+ }
803
+ // ─── validateClientVaultAnswer ────────────────────────────────────────────────
804
+ /**
805
+ * Validate the `/pp-init` client-vault answer. Blank or `none` means "keep
806
+ * the org profile's seeded vault, if any" (`value: null`). Anything else must pass the
807
+ * seed scalar guard, `validateBindings`' own `client.vaultRepo` rule without
808
+ * its machine-specific warning, and carry nothing the emission redactor would
809
+ * hide, so this adds no second pattern for the field.
810
+ */
811
+ function validateClientVaultAnswer(answer) {
812
+ if (typeof answer !== 'string' || answer.trim() === '' || answer.trim().toLowerCase() === 'none') {
813
+ return { ok: true, value: null };
814
+ }
815
+ const scalar = dispositionSeedValue(`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRef`, answer);
816
+ if (!scalar.ok)
817
+ return { ok: false, reason: scalar.reason };
818
+ const probe = (0, platform_bindings_cjs_1.validateBindings)({
819
+ schemaVersion: platform_bindings_cjs_1.CONTRACT_SCHEMA_VERSION,
820
+ platform: { repo: 'placeholder/placeholder' },
821
+ client: { vaultRepo: scalar.value },
822
+ });
823
+ // The binding is committed: a machine-specific path resolves nowhere else, and a credential would be published.
824
+ const vaultIssues = [...probe.errors, ...probe.warnings].filter((e) => leadingToken(e).startsWith('client.vaultRepo'));
825
+ if (vaultIssues.length > 0)
826
+ return { ok: false, reason: vaultIssues.join('; ') };
827
+ if ((0, platform_bindings_cjs_1.redactCoordinateForEmission)(scalar.value) !== scalar.value) {
828
+ return { ok: false, reason: 'a vault repo must not carry credentials — the binding is committed; use a credential helper' };
829
+ }
830
+ return { ok: true, value: scalar.value };
831
+ }
749
832
  /**
750
833
  * D-08's absent-only merge. `merged` starts as a deep clone of `existing` —
751
834
  * every field the existing file already carries (including the provisional
@@ -816,9 +899,9 @@ function composeBindings(cwd, opts = {}) {
816
899
  pushDeduped(warnings, providerWarnings);
817
900
  const seedResult = readSeed(cwd, { _globalRoot: opts._globalRoot });
818
901
  pushDeduped(warnings, seedResult.warnings);
819
- const candidateResult = composeCandidate(cwd, seedResult.values, provider, opts);
820
- pushDeduped(warnings, candidateResult.warnings);
821
902
  const existing = readExistingBindings(cwd);
903
+ const candidateResult = composeCandidate(cwd, seedResult.values, provider, opts, existingVaultCoordinates(existing.data));
904
+ pushDeduped(warnings, candidateResult.warnings);
822
905
  pushDeduped(warnings, existing.warnings);
823
906
  if (existing.malformed) {
824
907
  return {
@@ -527,7 +527,6 @@ function isStringMember(list, value) {
527
527
  */
528
528
  const BOOLEAN_ONLY_CONFIG_KEYS = Object.freeze([
529
529
  'vault.enabled',
530
- 'vault.practices_enabled',
531
530
  'vault.required',
532
531
  'workflow.schema_drift_gate',
533
532
  'workflow.nyquist_validation',
@@ -641,8 +640,8 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
641
640
  error(`Invalid plan_review.source_grounding_authority '${val}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`);
642
641
  }
643
642
  // Capability enum keys, in ONE place (Vault Assurance 13-05, D-07). Measured
644
- // 2026-10-09: `config-set workflow.code_review_depth bogus` and
645
- // `config-set vault.mode whatever` both wrote at exit 0, because no guard
643
+ // 2026-10-09: `config-set workflow.code_review_depth bogus` wrote at exit 0,
644
+ // because no guard
646
645
  // consulted the registry's declared `values`. This runs AFTER every
647
646
  // hand-coded guard above, so a key that already has its own guard
648
647
  // (`workflow.drift_action`) keeps its message byte-identical; the
@@ -653,9 +652,7 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
653
652
  // The TYPE is checked before membership (13-REVIEW CR-01): a value starting
654
653
  // with `[` is JSON-parsed above, and `String(['standards']) === 'standards'`,
655
654
  // so a membership-only test let a one-element array through and wrote it —
656
- // which `resolveVaultMode` then refused on every vault command, and which
657
- // `federated-config` silently reads back as the default for the other enum
658
- // keys. Every declared capability enum value is a string, so a non-string
655
+ // which `federated-config` silently reads back as the default. Every declared capability enum value is a string, so a non-string
659
656
  // parse (array, object, number, boolean) can never be a member.
660
657
  const enumValues = capabilityEnumValues(kp);
661
658
  if (enumValues && !isStringMember(enumValues, parsedValue)) {
@@ -12,11 +12,12 @@
12
12
  * relation suffix (`refines` / `absorbed into`) whose target requirement ID
13
13
  * must exist in the same table. Grammar single-source: same reference file.
14
14
  *
15
- * Phase 3 (D-44), extended by Phase 4 (D-50): a Prefix Registry row may not
16
- * claim one of the six reserved prefixes (`D-` `A-` `SP-` `DG-` `RS-` `SCR-`)
17
- * — they all match the requirement-ID regex, so registering one collides with
18
- * the decision / action / sparring / data-gathering / research / screen ID
19
- * families. Reserved set single-source: same reference file.
15
+ * Phase 3 (D-44), extended by Phase 4 (D-50) and issue #12: a Prefix Registry
16
+ * row may not claim one of the seven reserved prefixes (`D-` `A-` `SP-` `DG-`
17
+ * `RS-` `SCR-` `PRC-`) — they all match the requirement-ID regex, so
18
+ * registering one collides with the decision / action / sparring /
19
+ * data-gathering / research / screen / process ID families. Reserved set
20
+ * single-source: same reference file.
20
21
  *
21
22
  * Fail-loud contract (D-08): any unparseable row, malformed/duplicate ID,
22
23
  * unknown prefix, out-of-enum MoSCoW/Disposition, or strike/disposition
@@ -46,7 +47,9 @@ const MOSCOW = Object.freeze({ MUST: 'Must', SHOULD: 'Should', COULD: 'Could', W
46
47
  // `SCR-<nn>` sequence, so an engagement registering `SCR-` as a domain prefix
47
48
  // would make `SCR-01` ambiguous between the screen inventory and the
48
49
  // requirements table.
49
- const RESERVED_PREFIXES = new Set(['D-', 'A-', 'SP-', 'DG-', 'RS-', 'SCR-']);
50
+ // Issue #12 added `PRC-`: Session 2 numbers its Process Inventory `PRC-<nn>`,
51
+ // which a later session cites beside requirement IDs.
52
+ const RESERVED_PREFIXES = new Set(['D-', 'A-', 'SP-', 'DG-', 'RS-', 'SCR-', 'PRC-']);
50
53
  const ROW_ID_RE = /^([A-Z][A-Z0-9]*-)(\d+)$/;
51
54
  const DROPPED_RE = /^Dropped \((D-\d{2}-\d+)\)$/;
52
55
  const SEPARATOR_ROW_RE = /^\s*\|[\s:|-]+\|\s*$/;
@@ -1287,6 +1287,23 @@ const SCAN_SKIP_DIRS = Object.freeze(new Set([
1287
1287
  '.next', '.nuxt', '.turbo', '.cache', 'coverage',
1288
1288
  '.venv', 'venv', '__pycache__', 'vendor', 'target',
1289
1289
  ]));
1290
+ /**
1291
+ * True when `dir` holds a pp-core install (`<dir>/pp-core/VERSION`, the marker
1292
+ * `update-context` reads): a project-local runtime directory such as
1293
+ * `.claude` or `.codex`. The walk never enters one. Its engine files, agents
1294
+ * and commands spell the instrumentation literals out, and none of it ships
1295
+ * in the engagement's build, so walking it reports pp-core itself as a leak
1296
+ * (issue #12, item 7). Keyed on the marker rather than on directory names so
1297
+ * every runtime's install directory is covered without a third list of them.
1298
+ */
1299
+ function isPpCoreInstallDir(dir) {
1300
+ try {
1301
+ return node_fs_1.default.statSync(node_path_1.default.join(dir, 'pp-core', 'VERSION')).isFile();
1302
+ }
1303
+ catch {
1304
+ return false;
1305
+ }
1306
+ }
1290
1307
  /**
1291
1308
  * The file extensions the scan actually READS. An allowlist rather than a
1292
1309
  * denylist, because the bound has to hold for file types nobody has thought of
@@ -1475,7 +1492,8 @@ function loadScanAllowlist(loaded) {
1475
1492
  * - every entry is resolved and confirmed strictly beneath the resolved
1476
1493
  * project root before any stat or read, the same posture the roll-up
1477
1494
  * traversal guard takes (T-04-16);
1478
- * - `SCAN_SKIP_DIRS` keeps the walk out of the dependency tree;
1495
+ * - `SCAN_SKIP_DIRS` keeps the walk out of the dependency tree, and
1496
+ * `isPpCoreInstallDir` out of a project-local pp-core install;
1479
1497
  * - `SCAN_EXTENSIONS` bounds what is read;
1480
1498
  * - the file and byte caps stop the walk.
1481
1499
  *
@@ -1536,7 +1554,7 @@ function scanBuildTree(cwd, options = {}) {
1536
1554
  if (stat.isSymbolicLink())
1537
1555
  continue;
1538
1556
  if (stat.isDirectory()) {
1539
- if (!SCAN_SKIP_DIRS.has(name))
1557
+ if (!SCAN_SKIP_DIRS.has(name) && !isPpCoreInstallDir(full))
1540
1558
  stack.push(full);
1541
1559
  continue;
1542
1560
  }
@@ -63,12 +63,21 @@ function parseAttrs(src) {
63
63
  }
64
64
  return attrs;
65
65
  }
66
- function isFunctional(tag, attrs) {
66
+ /**
67
+ * A multi-word PascalCase name (`UiButton`, `UiSelect`): the component spelling
68
+ * of a kebab-case custom element (`ui-button`), which a string template keeps
69
+ * and lowercasing would hide (issue #12, item 8). A single word (`Div`) or an
70
+ * all-caps tag (`BUTTON`) is plain HTML.
71
+ */
72
+ function isPascalComponent(rawTag) {
73
+ return /^[A-Z][a-z0-9]+[A-Z]/.test(rawTag);
74
+ }
75
+ function isFunctional(tag, attrs, rawTag = tag) {
67
76
  if (attrs.has('data-req'))
68
77
  return true;
69
78
  if (tag === 'input' && (attrs.get('type') || '').toLowerCase() === 'hidden')
70
79
  return false;
71
- if (FUNCTIONAL_TAGS.has(tag) || tag.includes('-'))
80
+ if (FUNCTIONAL_TAGS.has(tag) || tag.includes('-') || isPascalComponent(rawTag))
72
81
  return true;
73
82
  const role = (attrs.get('role') || '').toLowerCase();
74
83
  return FUNCTIONAL_ROLES.has(role);
@@ -107,7 +116,7 @@ function scanPage(page, html, violations) {
107
116
  const parentScreen = stack.length ? stack[stack.length - 1].screen : null;
108
117
  const screen = screenFrom(tag, attrs) ?? parentScreen;
109
118
  const line = lineAt(text, m.index);
110
- if (isFunctional(tag, attrs)) {
119
+ if (isFunctional(tag, attrs, m[2])) {
111
120
  const id = attrs.get('id') ?? null;
112
121
  let reqs = [];
113
122
  if (attrs.has('data-req')) {
@@ -12,7 +12,11 @@
12
12
  * - every entry is one well-formed line (`- `<path>` — <why>`, or
13
13
  * `- (none) — <why>` when nothing was selected);
14
14
  * - every selected document was USED: cited `[vault: <path>]` somewhere in
15
- * the session notes, or marked `— not used: <reason>` on its own line.
15
+ * the session notes, or marked `— not used: <reason>` on its own line;
16
+ * - when the selection used `INDEX.md`, every entry the vault marks
17
+ * `[cross-cutting]` has a record line (issue #12, items 16-17): those rules
18
+ * bind every session, so leaving one out is how an approval route went
19
+ * unseen until Session 12.
16
20
  *
17
21
  * The selection itself is the facilitator's judgement and is not re-derived
18
22
  * here; what is checked is that the record exists, is complete, and that what
@@ -109,7 +113,7 @@ function parseEntry(n, text) {
109
113
  * A citation counts only OUTSIDE the section: the record naming a path is not
110
114
  * evidence that the path was used.
111
115
  */
112
- function checkVaultContext(content) {
116
+ function checkVaultContext(content, crossCutting = []) {
113
117
  const violations = [];
114
118
  const section = extractSection(content);
115
119
  if (section === null) {
@@ -163,8 +167,38 @@ function checkVaultContext(content) {
163
167
  violations.push({ reason: 'document_unused', line: doc.line, path: doc.path });
164
168
  }
165
169
  }
170
+ // The cross-cutting entries are owed a line only when the selection read the
171
+ // vault's own index: SUMMARY.md carries no mark, and `none` read nothing.
172
+ if (index === 'INDEX.md') {
173
+ const recorded = new Set(docs.map((d) => d.path));
174
+ for (const p of crossCutting) {
175
+ if (!recorded.has(p))
176
+ violations.push({ reason: 'cross_cutting_missing', path: p });
177
+ }
178
+ }
166
179
  return { ok: violations.length === 0, index, documents: docs.length, cited, not_used: notUsed, violations };
167
180
  }
181
+ /**
182
+ * The paths the cached `INDEX.md` marks `[cross-cutting]`, read through the
183
+ * vault's one index-state function so the bounded read, the withheld filter
184
+ * and the path rule are the engine's own. Withheld entries never reach it;
185
+ * unsafe ones are dropped, since no record line could name them. A vault that
186
+ * is off, uncached or unreadable owes nothing here — `vault status` reports
187
+ * that state.
188
+ */
189
+ function crossCuttingPaths(cwd) {
190
+ try {
191
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
192
+ const vault = require('./vault.cjs');
193
+ const state = vault.computeVaultIndexState(cwd);
194
+ if (state === null)
195
+ return [];
196
+ return state.entries.filter((e) => e.crossCutting === true && e.resolution !== 'unsafe').map((e) => e.path);
197
+ }
198
+ catch {
199
+ return [];
200
+ }
201
+ }
168
202
  /** Parse the session argument: a bare integer 1–13, leading zeros allowed (`03` is session 3). */
169
203
  function parseSessionArg(raw) {
170
204
  if (typeof raw !== 'string' || !/^\d{1,3}$/.test(raw))
@@ -189,7 +223,7 @@ function checkSessionVaultContext(cwd, n) {
189
223
  violations: [{ reason: 'session_file_missing' }],
190
224
  };
191
225
  }
192
- return { session: n, file: rel, ...checkVaultContext(content) };
226
+ return { session: n, file: rel, ...checkVaultContext(content, crossCuttingPaths(cwd)) };
193
227
  }
194
228
  function formatViolation(v) {
195
229
  const where = v.line !== undefined ? `line ${v.line}: ` : '';
@@ -118,12 +118,11 @@ function isPlausibleBranchRef(value) {
118
118
  }
119
119
  /**
120
120
  * WHAT A CALLER SAYS WHEN THE OVERRIDE IS REFUSED. One sentence, exported, for
121
- * every channel — three call sites state it and none of them spells it.
121
+ * every channel — a caller states it and never spells it.
122
122
  *
123
123
  * A second spelling of this sentence would be a second definition of what the
124
- * mechanism did, and this phase has already paid for that twice. The
125
- * `check vault-review` payload, the `check vault.audit` notes and the
126
- * `check ship.practices` message all carry these exact bytes.
124
+ * mechanism did, so every channel that states the refusal carries these exact
125
+ * bytes.
127
126
  *
128
127
  * The reason carries NO copy of the refused value: the value is client-supplied
129
128
  * and one of its shapes is an absolute filesystem path, so echoing it back onto
@@ -296,15 +295,13 @@ function resolveBaseBranchResult(cwd, deps) {
296
295
  * Consults the full precedence ladder and always returns a non-empty string.
297
296
  * Never throws.
298
297
  *
299
- * The bare-string form. EVERY consumer that computes a range now takes
300
- * `resolveBaseBranchResult` instead and states the refusal (W16): the two that
301
- * took this wrapper — `vault-audit.cts`, which is the shipped BLOCKING command,
302
- * and `cmdShipPractices`, a declared ship:pre gate — refused in silence, so a
303
- * gate ran over a range the project never configured and reported success on
304
- * it. What remains here is `cmdGitBaseBranch`, whose entire contract is one
305
- * branch name on stdout and which therefore genuinely has nowhere to put a
306
- * sentence. A new caller reaching for this form should ask whether that is true
307
- * of it as well; if it has a channel, it owes the operator the reason.
298
+ * The bare-string form. A consumer that computes a range takes
299
+ * `resolveBaseBranchResult` instead and states the refusal (W16): a gate that
300
+ * refused in silence would run over a range the project never configured and
301
+ * report success on it. What remains here is `cmdGitBaseBranch`, whose entire
302
+ * contract is one branch name on stdout and which therefore genuinely has
303
+ * nowhere to put a sentence. A new caller reaching for this form should ask
304
+ * whether that is true of it as well; if it has a channel, it owes the operator the reason.
308
305
  */
309
306
  function resolveBaseBranch(cwd, deps) {
310
307
  return resolveBaseBranchResult(cwd, deps).branch;
@@ -14,9 +14,9 @@
14
14
  * provider, what environment a clone needs, and whether a host CLI (`glab`)
15
15
  * may be spawned at all. It imports node builtins plus `./org-profile.cjs` —
16
16
  * still never `./vault.cjs`, `./vault-coordinates.cjs`, `./io.cjs` or
17
- * `./shell-command-projection.cjs` — so that both `src/vault.cts` and
18
- * `src/vault-write-back.cts` can import it (since 30-02 and 30-03) without
19
- * forming a require cycle in the CommonJS graph `require()` loads.
17
+ * `./shell-command-projection.cjs` — so that `src/vault.cts`, the vault write
18
+ * path and the other vault modules can import it without forming a require
19
+ * cycle in the CommonJS graph `require()` loads.
20
20
  * `./org-profile.cjs` is the one exception: it is itself a zero-import leaf
21
21
  * (node builtins only, see its own header), so importing it here creates no
22
22
  * load-time cycle even though it lazily requires this module back at
@@ -176,9 +176,8 @@ const compiledLiteralCloneTargetPatterns = exports.LITERAL_CLONE_TARGET_PATTERNS
176
176
  * absolute path, a scheme-qualified URL, or scp-style `user@host:path` —
177
177
  * rather than a `group/project` slug that must be composed onto a host.
178
178
  *
179
- * This is now the SINGLE definition. `src/vault.cts` and
180
- * `src/vault-write-back.cts` import it from here (30-02, 30-03) rather than
181
- * each keeping a copy — a read/write divergence in this predicate was defect
179
+ * This is now the SINGLE definition. `src/vault.cts` imports it from here
180
+ * (30-02) rather than keeping a copy — a read/write divergence in this predicate was defect
182
181
  * P-1 in Phase 29 (the write path used the value verbatim while the read path
183
182
  * always composed a URL, so a slug broke writing and a path broke reading).
184
183
  *