@pareto4/pp-core 1.18.0 → 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 (147) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-fixer.md +9 -5
  3. package/agents/pp-code-reviewer.md +1 -0
  4. package/agents/pp-codebase-mapper.md +3 -0
  5. package/agents/pp-debugger.md +3 -2
  6. package/agents/pp-executor.md +16 -7
  7. package/agents/pp-integration-checker.md +1 -0
  8. package/agents/pp-phase-researcher.md +3 -1
  9. package/agents/pp-plan-checker.md +3 -0
  10. package/agents/pp-planner.md +5 -1
  11. package/agents/pp-research-synthesizer.md +1 -1
  12. package/agents/pp-ui-auditor.md +1 -0
  13. package/agents/pp-ui-researcher.md +3 -1
  14. package/agents/pp-verifier.md +10 -2
  15. package/commands/pp-add-tests.md +1 -1
  16. package/commands/pp-debug.md +1 -0
  17. package/commands/pp-init.md +4 -3
  18. package/commands/pp-quick.md +3 -0
  19. package/commands/pp-review-backlog.md +2 -1
  20. package/gemini-extension.json +1 -1
  21. package/package.json +1 -1
  22. package/pp-core/bin/lib/capability-registry.cjs +2 -2
  23. package/pp-core/bin/lib/check-command-router.cjs +60 -20
  24. package/pp-core/bin/lib/commands.cjs +79 -4
  25. package/pp-core/bin/lib/compose-bindings.cjs +97 -14
  26. package/pp-core/bin/lib/discovery-rollup.cjs +9 -6
  27. package/pp-core/bin/lib/discovery-screens.cjs +20 -2
  28. package/pp-core/bin/lib/discovery-sweep.cjs +12 -3
  29. package/pp-core/bin/lib/discovery-vault-check.cjs +37 -3
  30. package/pp-core/bin/lib/init-contract.cjs +48 -7
  31. package/pp-core/bin/lib/vault-coordinates.cjs +1 -1
  32. package/pp-core/bin/lib/vault-index.cjs +18 -5
  33. package/pp-core/bin/lib/vault-update.cjs +4 -0
  34. package/pp-core/bin/lib/vault.cjs +2 -2
  35. package/pp-core/bin/pp-tools.cjs +10 -4
  36. package/pp-core/org-profile.json +1 -1
  37. package/pp-core/references/autonomous-best-effort.md +3 -2
  38. package/pp-core/references/autonomous-lifecycle.md +1 -0
  39. package/pp-core/references/autonomous-smart-discuss.md +5 -1
  40. package/pp-core/references/discovery-id-conventions.md +35 -2
  41. package/pp-core/references/execute-mvp-tdd.md +3 -3
  42. package/pp-core/references/git-integration.md +57 -33
  43. package/pp-core/references/git-planning-commit.md +31 -9
  44. package/pp-core/references/phase-argument-parsing.md +1 -0
  45. package/pp-core/references/planner-gap-closure.md +1 -0
  46. package/pp-core/references/planner-revision.md +3 -1
  47. package/pp-core/references/planner-tdd.md +4 -4
  48. package/pp-core/references/planning-config.md +3 -3
  49. package/pp-core/references/shell-portability.md +28 -0
  50. package/pp-core/references/tdd.md +20 -16
  51. package/pp-core/references/worktree-path-safety.md +1 -0
  52. package/pp-core/templates/milestone.md +5 -5
  53. package/pp-core/workflows/add-backlog.md +1 -1
  54. package/pp-core/workflows/add-tests.md +5 -2
  55. package/pp-core/workflows/add-todo.md +3 -1
  56. package/pp-core/workflows/ai-integration-phase.md +2 -1
  57. package/pp-core/workflows/audit-fix.md +4 -3
  58. package/pp-core/workflows/audit-milestone.md +1 -0
  59. package/pp-core/workflows/autonomous.md +6 -0
  60. package/pp-core/workflows/check-todos.md +2 -1
  61. package/pp-core/workflows/cleanup.md +3 -1
  62. package/pp-core/workflows/code-review-fix.md +12 -5
  63. package/pp-core/workflows/code-review.md +15 -1
  64. package/pp-core/workflows/complete-milestone.md +13 -3
  65. package/pp-core/workflows/debug.md +2 -0
  66. package/pp-core/workflows/diagnose-issues.md +1 -1
  67. package/pp-core/workflows/discovery-sprint/session-13-gates.md +1 -1
  68. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +51 -7
  69. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +5 -3
  70. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +10 -8
  71. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +11 -2
  72. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +5 -5
  73. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +3 -3
  74. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +2 -2
  75. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +14 -2
  76. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +2 -2
  77. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +10 -10
  78. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +11 -8
  79. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +21 -18
  80. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +5 -3
  81. package/pp-core/workflows/discovery-sprint/templates/context-template.md +9 -3
  82. package/pp-core/workflows/discovery-sprint/vault-context.md +14 -3
  83. package/pp-core/workflows/discovery-sprint.md +11 -8
  84. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -0
  85. package/pp-core/workflows/discuss-phase-assumptions.md +5 -3
  86. package/pp-core/workflows/discuss-phase.md +8 -3
  87. package/pp-core/workflows/docs-update.md +1 -0
  88. package/pp-core/workflows/eval-review.md +2 -1
  89. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +7 -6
  90. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -0
  91. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -0
  92. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +12 -0
  93. package/pp-core/workflows/execute-phase.md +19 -7
  94. package/pp-core/workflows/execute-plan.md +18 -7
  95. package/pp-core/workflows/explore.md +1 -1
  96. package/pp-core/workflows/forensics.md +4 -2
  97. package/pp-core/workflows/health.md +1 -0
  98. package/pp-core/workflows/import.md +2 -1
  99. package/pp-core/workflows/ingest-docs.md +1 -0
  100. package/pp-core/workflows/init/steps/client-context-capture.md +1 -0
  101. package/pp-core/workflows/init/steps/github-provisioning.md +145 -0
  102. package/pp-core/workflows/init/steps/gitlab-provisioning.md +18 -4
  103. package/pp-core/workflows/init/steps/reachability-outcome.md +3 -1
  104. package/pp-core/workflows/init.md +56 -66
  105. package/pp-core/workflows/map-codebase.md +5 -1
  106. package/pp-core/workflows/milestone-summary.md +2 -1
  107. package/pp-core/workflows/mvp-phase.md +1 -0
  108. package/pp-core/workflows/new-milestone.md +9 -5
  109. package/pp-core/workflows/new-project.md +9 -8
  110. package/pp-core/workflows/new-workspace.md +1 -0
  111. package/pp-core/workflows/next.md +2 -1
  112. package/pp-core/workflows/pause-work.md +6 -4
  113. package/pp-core/workflows/plan-milestone-gaps.md +3 -1
  114. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +1 -1
  115. package/pp-core/workflows/plan-phase.md +21 -6
  116. package/pp-core/workflows/plan-review-convergence.md +2 -0
  117. package/pp-core/workflows/plant-seed.md +4 -2
  118. package/pp-core/workflows/pr-branch.md +2 -0
  119. package/pp-core/workflows/progress.md +5 -0
  120. package/pp-core/workflows/quick.md +11 -4
  121. package/pp-core/workflows/reapply-patches.md +3 -0
  122. package/pp-core/workflows/remove-phase.md +1 -1
  123. package/pp-core/workflows/resume-project.md +3 -0
  124. package/pp-core/workflows/review.md +7 -1
  125. package/pp-core/workflows/secure-phase.md +2 -1
  126. package/pp-core/workflows/session-report.md +1 -0
  127. package/pp-core/workflows/ship.md +8 -1
  128. package/pp-core/workflows/sketch-wrap-up.md +1 -1
  129. package/pp-core/workflows/sketch.md +3 -2
  130. package/pp-core/workflows/spec-phase.md +2 -1
  131. package/pp-core/workflows/spike-wrap-up.md +1 -1
  132. package/pp-core/workflows/spike.md +4 -3
  133. package/pp-core/workflows/sync-skills.md +5 -2
  134. package/pp-core/workflows/thread.md +3 -2
  135. package/pp-core/workflows/transition.md +6 -0
  136. package/pp-core/workflows/ui-phase.md +3 -1
  137. package/pp-core/workflows/ui-review.md +2 -1
  138. package/pp-core/workflows/ultraplan-phase.md +1 -0
  139. package/pp-core/workflows/undo.md +14 -10
  140. package/pp-core/workflows/update.md +5 -0
  141. package/pp-core/workflows/validate-phase.md +6 -3
  142. package/pp-core/workflows/vault-review.md +2 -1
  143. package/pp-core/workflows/vault-write-back.md +5 -0
  144. package/pp-core/workflows/verify-phase.md +5 -0
  145. package/pp-core/workflows/verify-work.md +8 -1
  146. package/scripts/prompt-injection-scan.sh +1 -1
  147. package/scripts/sync-runtime-launcher.cjs +115 -5
@@ -771,6 +771,57 @@ function cmdUiSafetyGate(projectDir, args, raw) {
771
771
  }
772
772
  output(computeUiSafetyGate(projectDir, phase), raw, undefined);
773
773
  }
774
+ /** Subject types the TDD review gate looks for. */
775
+ const TDD_GATE_TYPES = new Set(['test', 'feat', 'refactor']);
776
+ /**
777
+ * Parse one commit message into the TDD gate keys it satisfies, each
778
+ * `<type>\0<plan id>`. The type comes from the Conventional Commit subject
779
+ * (`test(auth): …`, `test: …`, `test!: …`); the plan id from every
780
+ * `Phase: <id>` line in the message's last paragraph (the git trailer block).
781
+ * A subject whose scope IS the plan id (`test(01-02): …`, the convention
782
+ * before the trailer) yields that id too, so older history still counts.
783
+ */
784
+ function tddGateKeys(message) {
785
+ const text = message.replace(/\r\n/g, '\n').trim();
786
+ const subject = text.split('\n', 1)[0] || '';
787
+ const m = /^([a-z]+)(?:\(([^)]*)\))?!?:/.exec(subject);
788
+ if (!m || !TDD_GATE_TYPES.has(m[1]))
789
+ return [];
790
+ const type = m[1];
791
+ const ids = new Set();
792
+ const paragraphs = text.split(/\n[ \t]*\n/);
793
+ if (paragraphs.length > 1) {
794
+ for (const line of paragraphs[paragraphs.length - 1].split('\n')) {
795
+ const t = /^Phase:[ \t]*(\S+)[ \t]*$/.exec(line);
796
+ if (t)
797
+ ids.add(t[1]);
798
+ }
799
+ }
800
+ // Legacy: a scope shaped like a plan id (starts with a digit), never a codebase noun.
801
+ if (m[2] && /^\d[\w.-]*$/.test(m[2].trim()))
802
+ ids.add(m[2].trim());
803
+ return [...ids].map((id) => `${type}\0${id}`);
804
+ }
805
+ /**
806
+ * Every TDD gate key satisfied by a commit that touches the working tree
807
+ * (`-- .`, as before), read in one `git log`. An unreadable history (no git,
808
+ * no commits) yields the empty set, so every gate reads as missing.
809
+ */
810
+ function readTddGateCommits(projectDir) {
811
+ const keys = new Set();
812
+ let log = '';
813
+ try {
814
+ log = (0, node_child_process_1.execFileSync)('git', ['log', '--format=%x1e%B', '--', '.'], {
815
+ cwd: projectDir, encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
816
+ });
817
+ }
818
+ catch { /* git unavailable or no commits */ }
819
+ for (const record of log.split('\x1e')) {
820
+ for (const key of tddGateKeys(record))
821
+ keys.add(key);
822
+ }
823
+ return keys;
824
+ }
774
825
  function cmdTddReviewCheckpoint(projectDir, args, raw) {
775
826
  // args[0] = 'check', args[1] = 'tdd-review-checkpoint' (normalized), args[2] = phase
776
827
  const phase = args[2] || '';
@@ -830,31 +881,19 @@ function cmdTddReviewCheckpoint(projectDir, args, raw) {
830
881
  return;
831
882
  }
832
883
  // For each TDD plan, extract the plan ID (padded plan number) and check git log
884
+ const gateCommits = readTddGateCommits(projectDir);
833
885
  const rows = [];
834
886
  for (const planPath of tddPlanFiles) {
835
887
  // Extract plan ID from filename (e.g. "01-02-PLAN.md" → "01-02", or "03-PLAN.md" → "03")
836
888
  const basename = node_path_1.default.basename(planPath, '-PLAN.md');
837
889
  // planId for commit grep: phase-plan format, e.g. "01-02"
838
890
  const planId = basename;
839
- // Check for RED gate commit: test({planId}):
840
- let red = false;
841
- let green = false;
842
- let refactor = false;
843
- try {
844
- const redCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^test(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
845
- red = redCommit.trim().length > 0;
846
- }
847
- catch { /* git unavailable or no match */ }
848
- try {
849
- const greenCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^feat(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
850
- green = greenCommit.trim().length > 0;
851
- }
852
- catch { /* git unavailable or no match */ }
853
- try {
854
- const refactorCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^refactor(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
855
- refactor = refactorCommit.trim().length > 0;
856
- }
857
- catch { /* git unavailable or no match */ }
891
+ // Gate commits are found by type and the `Phase: {planId}` trailer — the
892
+ // scope names a part of the codebase, never the plan. A commit made before
893
+ // the trailer convention (`test({planId}): …`) still counts.
894
+ const red = gateCommits.has(`test\0${planId}`);
895
+ const green = gateCommits.has(`feat\0${planId}`);
896
+ const refactor = gateCommits.has(`refactor\0${planId}`);
858
897
  const missing = [];
859
898
  if (!red)
860
899
  missing.push('RED');
@@ -884,7 +923,7 @@ function cmdTddReviewCheckpoint(projectDir, args, raw) {
884
923
  table += '\n\n⚠ Gate violations are advisory — review before advancing.';
885
924
  for (const r of rows.filter(row => row.status === 'FAIL')) {
886
925
  table += `\n Plan ${r.planId} missing: ${r.missing.join(', ')} gate commit(s).`;
887
- table += `\n Expected commit pattern: test(${r.planId}): ... → feat(${r.planId}): ...`;
926
+ table += `\n Expected commits: test(<area>): ... → feat(<area>): ..., each with the trailer Phase: ${r.planId}`;
888
927
  }
889
928
  }
890
929
  const result = {
@@ -3154,6 +3193,7 @@ module.exports = {
3154
3193
  computeUiSafetyGate,
3155
3194
  cmdGapAnalysisPlanPost,
3156
3195
  cmdTddReviewCheckpoint,
3196
+ tddGateKeys,
3157
3197
  cmdShipPlatformDrift,
3158
3198
  cmdVaultStatus,
3159
3199
  // Exported so tests HARVEST the volatility sentence rather than restating its
@@ -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,
@@ -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 {
@@ -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}: ` : '';