@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
@@ -1,905 +0,0 @@
1
- 'use strict';
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- /**
6
- * src/vault-write-back.cts — Vault write-back promote arm, deterministic
7
- * guard core (milestone v1.10.0 Vault-Guided Code Generation, Phase 29-01).
8
- *
9
- * Responsibility (Phase 29-01 only — the guard-chain half of the arm):
10
- * A curated in-repo secret-content scanner (`SECRET_SHAPES` /
11
- * `scanForSecrets`) plus the preflight guard chain (`promotePreflight`)
12
- * that runs before any git subprocess exists in this module: the
13
- * activation gate, the Phase 24 format validator reused verbatim, the
14
- * secret scan, and the `assertVaultWriteTarget` write-target check. That
15
- * guard's identity is now the resolved `(repo, host)` pair for both
16
- * tiers (Phase 31, CFG-02/D-10) — it gained a required target-host
17
- * parameter, and this arm passes one derived from the coordinate
18
- * resolver rather than a config-only host chain. Every stage emits the
19
- * uniform refusal contract (D-16) via the `refusal` helper.
20
- *
21
- * Deliberately a NEW module (not folded into `src/vault.cts` or
22
- * `src/vault-command-router.cts`) — the name is load-bearing: it forms a
23
- * fresh `vault-write-back` test-file cluster for
24
- * `scripts/lint-test-file-count.cjs` instead of being swept into the
25
- * `vault` cluster, which is already at its allowlisted cap of five files.
26
- *
27
- * Responsibility (Phase 29-02 additions — the read-only half of the write
28
- * path): `previewPromotion` clones the client vault into a disposable temp
29
- * directory, captures the current content of the destination as a pinned
30
- * baseline, stages the promoted content, and asks git itself for the diff
31
- * (or the full content on a create) — so the thing an operator confirms is
32
- * generated from exactly the working-tree state a later confirmed run
33
- * would push. `buildExpectToken` pins both sides (local content + remote
34
- * baseline) into one opaque token 29-03's concurrent-edit guard checks
35
- * against. This is the ONLY function in this module that calls `execGit`,
36
- * and it never creates a commit or moves a remote ref.
37
- *
38
- * Responsibility (Phase 29-03 additions — the write half of the write
39
- * path): `confirmPromotion` re-runs the full 29-01 preflight chain, refuses
40
- * before any clone when the opaque `expect` token is missing or its pinned
41
- * LOCAL hash no longer matches the freshly-read file, clones fresh (never
42
- * reusing the preview's clone or the read cache), fetches the resolved ref
43
- * and fast-forward-only pulls to absorb any unrelated remote movement, then
44
- * re-derives the remote baseline from the now-current working tree and
45
- * compares it against the token's pinned REMOTE hash (file-scoped —
46
- * commit/tree shas are never compared, so an unrelated promotion never
47
- * blocks this one). On a match it stages exactly the destination path,
48
- * commits once, and pushes once with no force flag on any code path — a
49
- * genuine non-fast-forward rejection from git is the intended structural
50
- * backstop and is never retried. On success it refreshes the local read
51
- * cache via `./vault.cjs`'s `vaultFetch` verbatim (D-11), treating a
52
- * refresh failure as non-fatal (the push already succeeded). `promotePractice`
53
- * is the single entry point 29-04's router calls, dispatching to
54
- * `previewPromotion` or `confirmPromotion` on the `confirmed` flag.
55
- *
56
- * Responsibility (Phase 29-04 addition — the human-readable report half):
57
- * `renderPromoteReport` renders any verdict `promotePractice` can produce as
58
- * plain English (COMM-01), selected by the CLI router when `--raw` is
59
- * absent.
60
- *
61
- * Out of scope here (later plans): the workflow/command docs (29-05).
62
- *
63
- * Reuses (no new runtime dependency):
64
- * - `./vault-practices.cjs` (`parsePracticeFile`) — the ONLY format
65
- * validator in the write path (D-03). Reused verbatim, never
66
- * re-derived.
67
- * - `./vault.cjs` (`cfgGet`, `readBindings`, `assertVaultWriteTarget`) —
68
- * this module delegates to `assertVaultWriteTarget` and reimplements
69
- * none of it (D-05); the guard's signature gained a required target
70
- * host in Phase 31 (D-10), which this call site passes from the
71
- * coordinate resolver.
72
- * - `./io.cjs` (`ERROR_REASON`) — the typed refusal reason codes added
73
- * alongside this module.
74
- *
75
- * Test seams: pass `_vault` / `_practices` to inject mock modules (the
76
- * repo's established `_`-prefix convention — see
77
- * `src/vault-command-router.cts`). Production callers omit both; the real
78
- * `./vault.cjs` / `./vault-practices.cjs` are lazy-required inside
79
- * `promotePreflight` so they only load when a promotion actually runs.
80
- *
81
- * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
82
- * compiles to the git-ignored `pp-core/bin/lib/vault-write-back.cjs`; do NOT
83
- * hand-edit the compiled output.
84
- */
85
- const node_fs_1 = __importDefault(require("node:fs"));
86
- const node_os_1 = __importDefault(require("node:os"));
87
- const node_path_1 = __importDefault(require("node:path"));
88
- const node_crypto_1 = __importDefault(require("node:crypto"));
89
- const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
90
- const git_provider_cjs_1 = require("./git-provider.cjs");
91
- const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
92
- // eslint-disable-next-line @typescript-eslint/no-require-imports
93
- const io = require("./io.cjs");
94
- const { ERROR_REASON } = io;
95
- // ─── Guard identity (PROMOTE_GUARD) ─────────────────────────────────────────────
96
- // 29-02/29-03 reuse CONCURRENT_EDIT and TRANSPORT — declare all seven now so
97
- // the enum is not edited twice.
98
- const PROMOTE_GUARD = Object.freeze({
99
- ACTIVATION: 'activation',
100
- INPUT: 'input',
101
- VALIDATION: 'validation',
102
- SECRET_SCAN: 'secret_scan',
103
- WRITE_TARGET: 'write_target',
104
- CONCURRENT_EDIT: 'concurrent_edit',
105
- TRANSPORT: 'transport',
106
- // 13-07 (D-14): standards mode — write-back has no practice layer to write to.
107
- MODE: 'mode',
108
- });
109
- // ─── Secret-content scanner (WRB-03 / D-13 / D-14) ──────────────────────────────
110
- //
111
- // Transliterated from scripts/secret-scan.sh's SECRET_PATTERNS (18 labeled
112
- // POSIX ERE patterns) into JavaScript RegExp, preserving every human label
113
- // verbatim, plus one ADDED shape the CI scanner is missing: a GitLab access
114
- // token keyed on the `glpat-` prefix (confirmed absent from
115
- // scripts/secret-scan.sh but already referenced in ad-hoc greps at
116
- // pp-core/workflows/docs-update.md and pp-core/workflows/map-codebase.md).
117
- //
118
- // Order is declaration order and is load-bearing: the Anthropic shape is
119
- // placed before the OpenAI shape so a key of the form `sk-ant-<alnum>` is
120
- // never reported under the OpenAI label (the OpenAI pattern's `[A-Za-z0-9]`
121
- // class alone would also match the alnum-only tail of an Anthropic key).
122
- //
123
- // Deliberately NOT ported: the `.secretscanignore` mechanism, an exclusion
124
- // parameter, a placeholder carve-out, or an entropy heuristic — D-14's
125
- // no-override rule is deliberate, and the generic assignment shapes' false-
126
- // positive exposure is an accepted residual (T-29-02b).
127
- const SECRET_SHAPES = Object.freeze([
128
- { label: 'AWS Access Key', re: /AKIA[0-9A-Z]{16}/ },
129
- { label: 'AWS Secret Key', re: /aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}/ },
130
- { label: 'Anthropic API Key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
131
- { label: 'OpenAI API Key', re: /sk-[A-Za-z0-9]{20,}/ },
132
- { label: 'GitHub PAT', re: /ghp_[A-Za-z0-9]{36}/ },
133
- { label: 'GitHub OAuth', re: /gho_[A-Za-z0-9]{36}/ },
134
- { label: 'GitHub App Token', re: /ghs_[A-Za-z0-9]{36}/ },
135
- { label: 'GitHub Fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
136
- { label: 'GitLab access token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
137
- { label: 'Stripe Secret Key', re: /sk_live_[A-Za-z0-9]{24,}/ },
138
- { label: 'Stripe Publishable Key', re: /pk_live_[A-Za-z0-9]{24,}/ },
139
- { label: 'Private Key Header', re: /-----BEGIN\s+(RSA|EC|DSA|OPENSSH)?\s*PRIVATE\s+KEY-----/ },
140
- { label: 'Slack Bot Token', re: /xoxb-[0-9]{10,}-[A-Za-z0-9]{20,}/ },
141
- {
142
- label: 'Slack Webhook',
143
- re: /hooks\.slack\.com\/services\/T[A-Z0-9]{8,}\/B[A-Z0-9]{8,}\/[A-Za-z0-9]{24}/,
144
- },
145
- { label: 'Google API Key', re: /AIza[A-Za-z0-9_-]{35}/ },
146
- { label: 'NPM Token', re: /npm_[A-Za-z0-9]{36}/ },
147
- {
148
- label: 'Env Variable Leak',
149
- re: /(DATABASE_URL|DB_PASSWORD|REDIS_URL|MONGO_URI|JWT_SECRET|SESSION_SECRET|ENCRYPTION_KEY)\s*=\s*[^\s]{8,}/,
150
- },
151
- { label: 'Generic API Key Assignment', re: /api[_-]?key\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
152
- { label: 'Generic Secret Assignment', re: /secret\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
153
- { label: 'Generic Token Assignment', re: /token\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
154
- { label: 'Generic Password Assignment', re: /password\s*[:=]\s*['"][^'"]{8,}['"]/i },
155
- ]);
156
- /**
157
- * Scan `content` line by line, walking SECRET_SHAPES in declaration order
158
- * for each line, and return the FIRST match as `{ label, line }` (1-based).
159
- * Returns null on clean content. Never places the matched substring, a
160
- * slice of it, or a match index into the return value — name and line only
161
- * (T-29-01).
162
- */
163
- function scanForSecrets(content) {
164
- const lines = content.split(/\r?\n/);
165
- for (let i = 0; i < lines.length; i++) {
166
- const line = lines[i];
167
- for (const shape of SECRET_SHAPES) {
168
- if (shape.re.test(line)) {
169
- return { label: shape.label, line: i + 1 };
170
- }
171
- }
172
- }
173
- return null;
174
- }
175
- // ─── Refusal contract (D-16) ─────────────────────────────────────────────────────
176
- /**
177
- * Build a uniform refusal verdict. `message` is collapsed to a single line
178
- * (any CR/LF run becomes a space) so a client-authored fragment carried
179
- * into `extra` or the cause clause can never inject a fake second line into
180
- * operator output.
181
- */
182
- function refusal(guard, reason, message, extra = {}) {
183
- return {
184
- status: 'refused',
185
- guard,
186
- reason,
187
- message: message.replace(/[\r\n]+/g, ' '),
188
- ...extra,
189
- };
190
- }
191
- /** Bound an untrusted value before it is echoed into a refusal message. */
192
- function boundedToken(v, max = 80) {
193
- let s;
194
- try {
195
- s = typeof v === 'string' ? v : JSON.stringify(v);
196
- }
197
- catch {
198
- s = String(v);
199
- }
200
- s = s.replace(/[\r\n]+/g, ' ');
201
- return s.length > max ? `${s.slice(0, max)}…` : s;
202
- }
203
- // ─── Preflight guard chain ───────────────────────────────────────────────────────
204
- /**
205
- * Run the promote preflight guard chain: activation, mode (13-07), input, format
206
- * validation (D-03), secret scan (D-13/D-14), target resolution, and the
207
- * write-target guard (D-05) — strictly in that order, refusing at the
208
- * first failing stage and never continuing past it. Makes no network
209
- * access and spawns no subprocess.
210
- */
211
- function promotePreflight({ cwd, filePath, _vault, _practices }) {
212
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
213
- const vault = _vault ?? require('./vault.cjs');
214
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
215
- const practices = _practices ?? require('./vault-practices.cjs');
216
- // 1. Activation — the vault-practices capability must be on. Runs before
217
- // any filesystem access; rides the two existing default-false config keys
218
- // (no new config key, no capability surface widening). Phase 15 D-24/D-25:
219
- // asked of the ONE practices-switch reader, `isPracticesGateActive`, which
220
- // resolves both keys exactly as the vault-practices gates' `when` does. The
221
- // `cfgGet` pair it replaces read only the root config, so a workstream-scoped
222
- // switch refused here while the gates rendered (15-RESEARCH C-1). Read from
223
- // the REAL vault leaf, not the `_vault` seam, like the mode guard below: a
224
- // mocked config read must not be able to switch promotion on.
225
- // eslint-disable-next-line @typescript-eslint/no-require-imports
226
- const switchOwner = require('./vault.cjs');
227
- if (!switchOwner.isPracticesGateActive(cwd)) {
228
- return refusal(PROMOTE_GUARD.ACTIVATION, ERROR_REASON.VAULT_PROMOTE_DISABLED, 'This practice was not promoted: vault write-back is switched off for this project. ' +
229
- 'Set both vault.enabled and vault.practices_enabled to true in .planning/config.json and run the promotion again.');
230
- }
231
- // 1b. Mode (13-07, D-14, PARK-03) — in standards mode the vault carries
232
- // standards documents maintained through its INDEX.md, not practice files,
233
- // so there is nothing a promotion could write. Refused here, after the
234
- // activation guard and BEFORE the input guard reads `filePath` or any clone
235
- // runs, so the preview and `--confirmed` share it (both call this chain
236
- // first). Read from the REAL vault leaf, not the `_vault` seam: a governance
237
- // decision must not be switchable by a test mock (the 13-06 precedent). The
238
- // envelope carries NO mode key — a promote verdict already uses that key for
239
- // create/update — so the mode is named in the message only.
240
- // eslint-disable-next-line @typescript-eslint/no-require-imports
241
- const modeOwner = require('./vault.cjs');
242
- if (modeOwner.isStandardsModeActive(cwd)) {
243
- // eslint-disable-next-line @typescript-eslint/no-require-imports
244
- const { notApplicablePromoteMessage } = require('./vault-mode.cjs');
245
- return refusal(PROMOTE_GUARD.MODE, ERROR_REASON.VAULT_PROMOTE_NOT_APPLICABLE, notApplicablePromoteMessage());
246
- }
247
- // 2. Input — a path to an already-authored practice file (D-02); this
248
- // module never composes, rewrites, reformats, or extracts a practice.
249
- if (typeof filePath !== 'string' ||
250
- filePath.length === 0 ||
251
- !filePath.endsWith('.md') ||
252
- !node_fs_1.default.existsSync(filePath) ||
253
- !node_fs_1.default.statSync(filePath).isFile()) {
254
- return refusal(PROMOTE_GUARD.INPUT, ERROR_REASON.SDK_MISSING_ARG, `This practice was not promoted: ${boundedToken(filePath)} is not an existing practice file. ` +
255
- 'Point at an existing .md practice file and run the promotion again.');
256
- }
257
- // Read the FULL file content — not the bounded prefix discovery uses —
258
- // because both the validation oracle and the secret scan must see the
259
- // whole file.
260
- const raw = node_fs_1.default.readFileSync(filePath, 'utf-8');
261
- // 3. Validation — the Phase 24 parser is the only format validator in
262
- // this write path (D-03). No second validator, no new format rule.
263
- const parsed = practices.parsePracticeFile(node_path_1.default.basename(filePath), raw);
264
- if (!parsed.ok) {
265
- const cause = parsed.warning.replace(/^.*?\s+skipped:\s*/, '');
266
- return refusal(PROMOTE_GUARD.VALIDATION, ERROR_REASON.VAULT_PROMOTE_INVALID_PRACTICE, `This practice was not promoted: ${cause} Fix the practice file and run the promotion again.`);
267
- }
268
- // 3b. Degraded-parse refusal (CHECK-02e, 05-01).
269
- //
270
- // WRITE-SIDE STRICTNESS IS DELIBERATE, and the asymmetry with the read side
271
- // is the point. On the READ side a practice the engine does not fully
272
- // understand is retained: it came from the vault, it already governs work,
273
- // and dropping it would silently un-govern that work (ADR-71924 §5). On the
274
- // WRITE side the same practice is refused, because promoting it pushes the
275
- // defect INTO the fleet-wide vault that every developer machine and CI runner
276
- // fetches — one unreviewed key becomes everyone's degraded warning. The read
277
- // side tolerates what the write side refuses.
278
- //
279
- // Both consumers of DOCUMENTED_FRONTMATTER_KEYS therefore moved in the one
280
- // change that widened it (T-4-49): had only the reader moved, an unknown key
281
- // would have quietly become promotable.
282
- if (parsed.degradedWarnings !== undefined && parsed.degradedWarnings.length > 0) {
283
- const cause = parsed.degradedWarnings[0].replace(/^.*?\s+degraded:\s*/, '');
284
- return refusal(PROMOTE_GUARD.VALIDATION, ERROR_REASON.VAULT_PROMOTE_INVALID_PRACTICE, `This practice was not promoted: ${cause} Fix the practice file and run the promotion again.`);
285
- }
286
- // 3c. Invalid-check-block refusal (CR-05 / G-3, 05-11).
287
- //
288
- // An invalid `check:` block is refused on the write side for the same reason
289
- // 3b refuses a degraded parse, and the consequence here is worse. The reader
290
- // reports this defect on a THIRD channel — `practice.checkInvalid` — which is
291
- // neither `!parsed.ok` nor `degradedWarnings`, so both rungs above miss it;
292
- // and the folding of `checkInvalid` into `degraded[]` happens inside
293
- // `discoverPracticeFiles`, which this path never calls.
294
- //
295
- // Once such a practice is in the fleet vault the defect is not a warning for
296
- // the next reader: `verdictForPractice`'s FIRST rung maps `checkInvalid` to a
297
- // fail-closed FAIL(check_invalid), so it becomes `block: true` at ship:pre and
298
- // verify:post on every machine that fetches the vault. One typo would stop the
299
- // whole fleet's builds. The refusal covers ANY invalid block regardless of
300
- // which assertion kind it declared (D-05-05), because `checkInvalid` is the
301
- // single fail-closed signal for both.
302
- if (typeof parsed.practice.checkInvalid === 'string' && parsed.practice.checkInvalid.length > 0) {
303
- return refusal(PROMOTE_GUARD.VALIDATION, ERROR_REASON.VAULT_PROMOTE_INVALID_PRACTICE, `This practice was not promoted: ${parsed.practice.checkInvalid} ` +
304
- 'Fix the practice file and run the promotion again.');
305
- }
306
- // 4. Secret scan — a hit refuses hard, no override (D-14). The matched
307
- // text is never carried, quoted, echoed, logged, or truncated into the
308
- // refusal — a truncated secret is still a partial disclosure.
309
- const hit = scanForSecrets(raw);
310
- if (hit) {
311
- return refusal(PROMOTE_GUARD.SECRET_SCAN, ERROR_REASON.VAULT_PROMOTE_SECRET_DETECTED, `This practice was not pushed: line ${hit.line} looks like ${hit.label}. Remove it and run the promotion again.`, { label: hit.label, line: hit.line });
312
- }
313
- // 5. Target resolution — client.vaultRepo / client.vaultRef / host.
314
- // Deliberately does NOT call resolveBaseBranch (src/git-base-branch.cts)
315
- // — that resolver answers a question about this project's OWN
316
- // repository, a different repository from the client vault.
317
- const bindings = vault.readBindings(cwd);
318
- const client = bindings && typeof bindings.client === 'object' && bindings.client !== null
319
- ? bindings.client
320
- : null;
321
- const targetRepo = client && typeof client.vaultRepo === 'string' ? client.vaultRepo : null;
322
- if (!targetRepo) {
323
- return refusal(PROMOTE_GUARD.WRITE_TARGET, ERROR_REASON.VAULT_PROMOTE_TARGET_REFUSED, 'This practice was not promoted: no client vault repository is configured. ' +
324
- 'Check client.vaultRepo in .platform-bindings.json and run the promotion again.');
325
- }
326
- // T-29-26: a repo or ref that git would read as an option is refused here,
327
- // before any transport exists, rather than being handed to git and surfacing
328
- // as a confusing "repository does not exist". Fails closed.
329
- const flagShaped = [
330
- ['client.vaultRepo', targetRepo],
331
- ['client.vaultRef', client && typeof client.vaultRef === 'string' ? client.vaultRef : ''],
332
- ].find(([, v]) => typeof v === 'string' && v.trim().startsWith('-'));
333
- if (flagShaped) {
334
- return refusal(PROMOTE_GUARD.WRITE_TARGET, ERROR_REASON.VAULT_PROMOTE_TARGET_REFUSED, `This practice was not promoted: ${flagShaped[0]} starts with "-", which git would read as an option rather than a location. ` +
335
- 'Fix it in .platform-bindings.json and run the promotion again.');
336
- }
337
- const targetRef = client && typeof client.vaultRef === 'string' && client.vaultRef.trim() ? client.vaultRef.trim() : 'main';
338
- // Resolve the provider ONCE, here in the preflight (PROV-02/T-30-16) — both
339
- // previewPromotion and confirmPromotion re-run this preflight and read the
340
- // same `provider` field back off the result, so they cannot compose two
341
- // different remotes for the same promotion. Moved ahead of target-host
342
- // resolution (Phase 31, D-10): the host now comes from the coordinate
343
- // resolver below, which itself takes the provider as an argument.
344
- const providerResolution = (0, git_provider_cjs_1.resolveProvider)(cwd);
345
- const provider = providerResolution.provider;
346
- const providerWarnings = providerResolution.warnings.slice();
347
- // Target host — from the coordinate resolver (Phase 31, D-10), not a
348
- // config-only host chain. `client.vaultHost` wins when set; otherwise
349
- // `.platform-bindings.json`'s `platform.host` (D-09), warned; otherwise
350
- // the provider's default host. The `.planning/config.json`-only
351
- // `platform.host` read this preflight used to fall back to last is gone
352
- // — leaving it in place beside the resolver would not fix D-09, it would
353
- // add a third disagreeing source of the same value.
354
- const clientCoord = (0, vault_coordinates_cjs_1.resolveClientCoordinates)(cwd, provider);
355
- const targetHost = clientCoord.host;
356
- if (clientCoord.warnings.length > 0)
357
- providerWarnings.push(...clientCoord.warnings);
358
- // Shared-tier coordinates — resolved ONCE here in the preflight (D-09,
359
- // T-31-09), carried forward on the success shape below rather than
360
- // resolved a second time by `previewPromotion`. This is the same
361
- // construction already used for `provider` and for `clientCoord` above:
362
- // both `previewPromotion` and `confirmPromotion` re-run this preflight and
363
- // read fields back off its result, so a promotion cannot end up resolving
364
- // two disagreeing answers for its own shared-shadow lookup.
365
- //
366
- // `applyBuiltInDefault: false` is load-bearing, not a stylistic default.
367
- // This path has never applied the built-in shared-vault default — it used
368
- // to read `vault.fallback_repo` with an empty-string fallback, so a
369
- // project that never set the key performed no shared-shadow clone at all.
370
- // Passing `true` here would give every existing gitlab install a shared
371
- // clone it does not perform today, breaking ROADMAP SC3. The read-path
372
- // ladder and `assertVaultWriteTarget` both pass `true` because both of
373
- // them already apply the built-in default today (they always have) — this
374
- // is the one caller that deliberately withholds it.
375
- const sharedCoord = (0, vault_coordinates_cjs_1.resolveSharedCoordinates)(cwd, provider, { applyBuiltInDefault: false });
376
- if (sharedCoord.warnings.length > 0)
377
- providerWarnings.push(...sharedCoord.warnings);
378
- // D-13/WR-01: read the resolver's OWN verdict straight off `sharedCoord`
379
- // — no local re-derivation. Before this fix, `sharedCoord.suppressed` was
380
- // unconditionally `false` on this call regardless of provider, because
381
- // withholding the built-in default (`applyBuiltInDefault: false`, above)
382
- // also disabled the resolver's suppression branch. The resolver now
383
- // reports `suppressed`/`suppressedKind` correctly even with the built-in
384
- // default withheld, so this module no longer needs to (and must not)
385
- // approximate the fact from `sharedRepo`/`provider` itself — that
386
- // approximation was exactly the WR-01 defect (PROJECT.md: an operator who
387
- // deliberately disabled the shared tier via an empty `vault.fallback_repo`
388
- // was shown a note asking them to set the very key they already set,
389
- // because this module could not tell that apart from "never configured").
390
- const sharedSuppressed = sharedCoord.suppressed;
391
- const sharedSuppressedKind = sharedCoord.suppressedKind;
392
- // Any warning composing the primary clone target for THIS
393
- // provider/repo/host (an operator-supplied `.git` suffix under azure) is
394
- // folded in here too, alongside the provider and coordinate warnings
395
- // already collected above, so a single `notes` push in the preview
396
- // surfaces all of them.
397
- if (!(0, git_provider_cjs_1.isLiteralCloneTarget)(targetRepo.trim())) {
398
- const composedForWarnings = (0, git_provider_cjs_1.composeCloneTarget)(provider, targetRepo, targetHost);
399
- if (composedForWarnings.warnings.length > 0)
400
- providerWarnings.push(...composedForWarnings.warnings);
401
- }
402
- // 6. Write-target guard (D-05, D-10) — this arm delegates to
403
- // assertVaultWriteTarget and never re-derives, wraps, relaxes, or
404
- // reimplements any of its three refusals; it passes the resolved target
405
- // repo and target host straight through.
406
- try {
407
- vault.assertVaultWriteTarget(cwd, targetRepo, targetHost);
408
- }
409
- catch (e) {
410
- const msg = (e instanceof Error ? e.message : String(e)).replace(/^vault write refused:\s*/, '');
411
- return refusal(PROMOTE_GUARD.WRITE_TARGET, ERROR_REASON.VAULT_PROMOTE_TARGET_REFUSED, `This practice was not promoted: ${msg} Check client.vaultRepo in .platform-bindings.json and run the promotion again.`);
412
- }
413
- // Destination is derived from the stable frontmatter id — never the
414
- // local filename, never an operator-supplied value (D-04).
415
- const destination = node_path_1.default.posix.join('practices', `${parsed.practice.id}.md`);
416
- return {
417
- status: 'ok',
418
- practice: parsed.practice,
419
- raw,
420
- repo: targetRepo,
421
- ref: targetRef,
422
- host: targetHost,
423
- destination,
424
- provider,
425
- providerWarnings,
426
- sharedRepo: sharedCoord.repo,
427
- sharedRef: sharedCoord.ref,
428
- sharedHost: sharedCoord.host,
429
- sharedSuppressed,
430
- sharedSuppressedKind,
431
- };
432
- }
433
- // ─── Preview (read-only half of the write path) ─────────────────────────────────
434
- /**
435
- * Build the opaque `expect` token that pins BOTH what the operator confirmed
436
- * locally and what the vault held at that moment, without any state file
437
- * across two separate process invocations: the hex sha256 of the local raw
438
- * content, a period, then either the hex sha256 of the remote baseline or
439
- * the literal `absent` when the destination file does not (yet) exist in
440
- * the vault. Callers must treat the result as opaque.
441
- */
442
- function buildExpectToken(localRaw, remoteBaseline) {
443
- const localHash = node_crypto_1.default.createHash('sha256').update(localRaw).digest('hex');
444
- const remoteHash = remoteBaseline === null ? 'absent' : node_crypto_1.default.createHash('sha256').update(remoteBaseline).digest('hex');
445
- return `${localHash}.${remoteHash}`;
446
- }
447
- /**
448
- * Preview a promotion: clone the client vault into a disposable temp
449
- * directory, capture the destination's current content as a pinned
450
- * baseline, stage the promoted content, and ask git for a diff (update) or
451
- * hand back the full content (create). Runs the full 29-01 preflight guard
452
- * chain first and returns its refusal unchanged, with no clone attempted,
453
- * on any refusal. Never creates a commit, never moves a remote ref, and
454
- * never reads or writes the project's own vault read cache (a fresh
455
- * mkdtemp working copy is used and always removed, even on a thrown error).
456
- */
457
- // `isLiteralCloneTarget` and clone-target composition — the single
458
- // definitions live in `src/git-provider.cts` (imported above) and are shared
459
- // with the read path (`src/vault.cts`, 30-02). Do NOT reintroduce a local
460
- // copy of either here: a read/write divergence in this predicate was defect
461
- // P-1 in Phase 29 (the write path used `client.vaultRepo` verbatim while the
462
- // read path always composed a URL, so a slug broke writing and a path broke
463
- // reading) — duplicating the fix a second time is exactly how that class of
464
- // bug recurs.
465
- function previewPromotion({ cwd, filePath, _exec, _vault, _practices }) {
466
- const gitExec = _exec ?? shell_command_projection_cjs_1.execGitLocal;
467
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
468
- const vault = _vault ?? require('./vault.cjs');
469
- // 1. Run the full preflight guard chain; refuse unchanged with no clone.
470
- const pre = promotePreflight({ cwd, filePath, _vault, _practices });
471
- if (pre.status !== 'ok')
472
- return pre;
473
- // No local `./vault.cjs` handle is needed here any more (Phase 31,
474
- // Task 1): the shared-shadow lookup below reads its coordinates off `pre`
475
- // instead of calling `vault.cfgGet` directly, so this function no longer
476
- // touches the vault module at all — `_vault` is still threaded through to
477
- // `promotePreflight` above for the test-seam injection convention.
478
- const notes = [];
479
- // Surface any warning resolving the provider or composing the primary
480
- // clone target produced (an unrecognized `vault.provider` value, or an
481
- // operator-supplied `.git` suffix under azure) — visible in the preview an
482
- // operator reads before confirming, never a silent degrade.
483
- notes.push(...pre.providerWarnings);
484
- // The disposable working copy is a fresh mkdtemp directory — never the
485
- // read cache, which is often a shallow/sparse clone from a different tier
486
- // and is refreshed independently after a push. Removed on every exit path.
487
- let cloneDir = null;
488
- try {
489
- cloneDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-promote-'));
490
- // 2. Clone the resolved client repo at the resolved ref — a full
491
- // single-branch clone (no shallow-depth flag: the confirmed path needs
492
- // real history to fast-forward against). The target goes through the
493
- // provider seam's composeCloneTarget so a `group/project` slug becomes a
494
- // real URL on the resolved host and provider, instead of a relative path
495
- // git cannot find.
496
- const cl = gitExec([
497
- 'clone',
498
- '--branch',
499
- pre.ref,
500
- '--single-branch',
501
- '--',
502
- (0, git_provider_cjs_1.composeCloneTarget)(pre.provider, pre.repo, pre.host).url,
503
- cloneDir,
504
- ], { timeout: 120_000 });
505
- if (cl.exitCode !== 0) {
506
- const cause = boundedToken((cl.stderr || '').split(/\r?\n/)[0] || 'unknown error');
507
- return refusal(PROMOTE_GUARD.TRANSPORT, ERROR_REASON.VAULT_PROMOTE_TRANSPORT_FAILED, `This practice was not pushed: git could not reach the vault (${cause}). Check your access to the vault and run the promotion again.`);
508
- }
509
- // 3. Capture the baseline BEFORE writing — an id already present in the
510
- // client vault is an update, not a refusal and not an opt-in flag
511
- // (D-06). The baseline captured here is what 29-03's concurrent-edit
512
- // check pins against.
513
- const destAbs = node_path_1.default.join(cloneDir, pre.destination);
514
- const baselineExists = node_fs_1.default.existsSync(destAbs);
515
- const baseline = baselineExists ? node_fs_1.default.readFileSync(destAbs, 'utf-8') : null;
516
- const mode = baselineExists ? 'update' : 'create';
517
- // 4. Write the raw content unchanged — no reformatting, no
518
- // re-serialization, no line-ending normalization.
519
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(destAbs), { recursive: true });
520
- node_fs_1.default.writeFileSync(destAbs, pre.raw, 'utf-8');
521
- // 5. Ask git for the preview rather than computing one. `core.quotepath=off`
522
- // so a non-ASCII destination path is not escaped in operator-read output.
523
- let diff;
524
- let content;
525
- if (mode === 'update') {
526
- const diffRes = gitExec(['-c', 'core.quotepath=off', 'diff', '--', pre.destination], { cwd: cloneDir });
527
- diff = diffRes.stdout;
528
- }
529
- else {
530
- content = pre.raw;
531
- }
532
- // 6. Shared-shadow lookup — the shared coordinates are resolved exactly
533
- // ONCE, in the preflight above, with the built-in default explicitly
534
- // withheld (D-09, T-31-09); this function reads them back off `pre`
535
- // rather than resolving them a second time. Skipped entirely (flag stays
536
- // false) when no shared repo resolved, or it equals the client repo —
537
- // the same skip shape as before, now expressed against the resolved
538
- // field: a `null` `pre.sharedRepo` is treated the way the empty string
539
- // used to be treated.
540
- //
541
- // The shared vault is addressed on the resolved shared host, in D-03's
542
- // order: `vault.fallback_host` first, then the authoritative
543
- // `platform.host` (`.platform-bindings.json` first, `.planning/config.json`
544
- // as a warned fallback per D-09), then the provider-derived default —
545
- // never the client's own `client.vaultHost`, mirroring the fallback tier
546
- // in `src/vault.cts`'s `cloneTier` so a client with a custom vaultHost
547
- // never sends the shared-shadow lookup to their own host. Composed under
548
- // the SAME resolved provider as the client clone (deliberate and
549
- // symmetric with `cloneTier`'s one-provider-per-fetch rule).
550
- //
551
- // Per-tier provider selection does NOT arrive with this phase's CFG-01:
552
- // D-01 answered that with a deliberate, permanent no. One provider per
553
- // project stands — a client does not straddle two providers — and the
554
- // fix for the azure-composing-a-gitlab-URL hazard is that the shared
555
- // coordinates became per-client config, which is exactly what resolving
556
- // them once in the preflight, above, does.
557
- let sharedShadow = false;
558
- let sharedShadowNote = null;
559
- const sharedRepo = pre.sharedRepo;
560
- const sharedRef = pre.sharedRef;
561
- const sharedHost = pre.sharedHost;
562
- if (sharedRepo && sharedRepo !== pre.repo) {
563
- let sharedDir = null;
564
- try {
565
- sharedDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-shared-'));
566
- // Shallow single-branch: only file presence matters, no history needed.
567
- const sc = gitExec([
568
- 'clone',
569
- '--depth',
570
- '1',
571
- '--branch',
572
- sharedRef,
573
- '--single-branch',
574
- '--',
575
- (0, git_provider_cjs_1.composeCloneTarget)(pre.provider, sharedRepo, sharedHost).url,
576
- sharedDir,
577
- ], { timeout: 120_000 });
578
- if (sc.exitCode === 0) {
579
- if (node_fs_1.default.existsSync(node_path_1.default.join(sharedDir, pre.destination))) {
580
- sharedShadow = true;
581
- sharedShadowNote =
582
- "This practice id already exists in the shared Pareto vault. After this promotion the client's version is the one that applies for this client.";
583
- }
584
- }
585
- else {
586
- // An unreachable/failed shared vault must never block, fail, or
587
- // slow-fail a preview — leave the flag false and note it.
588
- notes.push('the shared vault could not be checked');
589
- }
590
- }
591
- catch {
592
- notes.push('the shared vault could not be checked');
593
- }
594
- finally {
595
- if (sharedDir)
596
- node_fs_1.default.rmSync(sharedDir, { recursive: true, force: true });
597
- }
598
- }
599
- // Suppression note (D-02, D-11, D-13/WR-01) — pushed ONLY when the
600
- // shared resolution reports suppression AND the kind is
601
- // 'provider-default-unavailable'. A gitlab project with no
602
- // `vault.fallback_repo` resolves `sharedSuppressed: false` (nothing was
603
- // suppressed — no built-in default was withheld from a provider that
604
- // would otherwise have gotten one) and emits nothing here, exactly as it
605
- // did before this phase — this note is SC3-safe by construction.
606
- //
607
- // D-13/WR-01: an operator who set `vault.fallback_repo` to an empty
608
- // string deliberately disabled the shared tier — `sharedSuppressedKind`
609
- // is `'explicit-opt-out'` for that case, and telling them to set the key
610
- // they already set is exactly the defect this closes. NO second note is
611
- // added for the opt-out case: silence is the correct output for a
612
- // choice the operator already made, and inventing a new note would be
613
- // new behaviour rather than a fix. D-11's requirement to surface the
614
- // skipped shared check is already met by the write guard's own refusal
615
- // sentence (plan 03); this note additionally explains why, in the
616
- // read-only preview an operator reads before confirming.
617
- if (pre.sharedSuppressed && pre.sharedSuppressedKind === 'provider-default-unavailable') {
618
- notes.push(`No shared vault is configured for the ${pre.provider} provider — the built-in Pareto shared default applies only to the github provider. Set vault.fallback_repo to enable the shared tier.`);
619
- }
620
- // 7 & 8. Pin both sides in the opaque expect token, then return.
621
- const expect = buildExpectToken(pre.raw, baseline);
622
- return {
623
- status: 'preview',
624
- repo: vault.redactCloneTarget(pre.repo),
625
- ref: pre.ref,
626
- destination: pre.destination,
627
- mode,
628
- id: boundedToken(pre.practice.id),
629
- diff,
630
- content,
631
- sharedShadow,
632
- sharedShadowNote,
633
- expect,
634
- notes,
635
- };
636
- }
637
- finally {
638
- if (cloneDir)
639
- node_fs_1.default.rmSync(cloneDir, { recursive: true, force: true });
640
- }
641
- }
642
- // ─── Confirm (write half of the write path) ──────────────────────────────────────
643
- /** Build a TRANSPORT refusal from a failed git invocation's stderr (first line only). */
644
- function transportRefusal(res) {
645
- const cause = boundedToken((res.stderr || '').split(/\r?\n/)[0] || 'unknown error');
646
- return refusal(PROMOTE_GUARD.TRANSPORT, ERROR_REASON.VAULT_PROMOTE_TRANSPORT_FAILED, `This practice was not pushed: git could not reach the vault (${cause}). Check your access to the vault and run the promotion again.`);
647
- }
648
- /**
649
- * Confirm a promotion: re-run the full 29-01 preflight chain (never trust
650
- * the preview), re-verify the opaque `expect` token against a freshly read
651
- * local file AND a freshly cloned remote baseline, absorb unrelated remote
652
- * movement with a fast-forward-only pull, then stage/commit/push exactly
653
- * the destination path with no force flag on any code path. On success,
654
- * refreshes the local read cache via `./vault.cjs`'s `vaultFetch` verbatim.
655
- *
656
- * Deliberate deviation from a literal `git show`-based remote-baseline read
657
- * (Rule 1 bug avoidance): `execGit`'s shared `_spawnResult` unconditionally
658
- * `.trim()`s captured stdout, which would silently strip a file's trailing
659
- * newline and make every legitimate hash comparison spuriously mismatch.
660
- * Instead this re-derives the remote baseline from the filesystem AFTER the
661
- * fast-forward-only pull (the same fs-read technique `previewPromotion`
662
- * already uses for its own baseline capture) — byte-exact, and the pull
663
- * step the plan already requires does the "sync to the fetched ref" work
664
- * for free, so no separate `git show`/`cat-file` call is needed.
665
- */
666
- function confirmPromotion({ cwd, filePath, expect, _exec, _vault, _practices }) {
667
- const gitExec = _exec ?? shell_command_projection_cjs_1.execGitLocal;
668
- // 1. Full preflight re-run — never trust the preview. Re-derives repo,
669
- // ref, host, and destination from scratch; never accepts a caller-
670
- // supplied id/destination/repo/ref.
671
- const pre = promotePreflight({ cwd, filePath, _vault, _practices });
672
- if (pre.status !== 'ok')
673
- return pre;
674
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
675
- const vault = _vault ?? require('./vault.cjs');
676
- // 2. Missing/empty token → refuse before any clone.
677
- if (!expect || typeof expect !== 'string' || expect.length === 0) {
678
- return refusal(PROMOTE_GUARD.CONCURRENT_EDIT, ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT, 'This practice was not pushed: the confirmation is missing the preview token it needs. ' +
679
- 'Run the promotion without confirming first, then pass the token it prints.');
680
- }
681
- // 3. Split the token; a malformed token (no separator) naturally fails the
682
- // local-hash comparison below rather than needing a distinct code path.
683
- const sepIdx = expect.indexOf('.');
684
- const pinnedLocal = sepIdx === -1 ? expect : expect.slice(0, sepIdx);
685
- const pinnedRemote = sepIdx === -1 ? '' : expect.slice(sepIdx + 1);
686
- // 4. Recompute the local hash from the FRESHLY read content (pre.raw came
687
- // from this invocation's own promotePreflight, not the preview's). A
688
- // mismatch means the local file changed since the operator previewed it —
689
- // this is what makes D-10's byte-identity claim hold across two separate
690
- // process invocations (T-29-13).
691
- const localHash = node_crypto_1.default.createHash('sha256').update(pre.raw).digest('hex');
692
- if (localHash !== pinnedLocal) {
693
- return refusal(PROMOTE_GUARD.CONCURRENT_EDIT, ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT, 'This practice was not pushed: the file on your machine changed after you previewed it. ' +
694
- 'Run the promotion again to preview the new version.');
695
- }
696
- // 5. Clone into a fresh disposable mkdtemp directory — exactly as the
697
- // preview does, but NEVER the preview's own clone directory and NEVER the
698
- // read cache.
699
- let cloneDir = null;
700
- try {
701
- cloneDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-confirm-'));
702
- const cl = gitExec([
703
- 'clone',
704
- '--branch',
705
- pre.ref,
706
- '--single-branch',
707
- '--',
708
- (0, git_provider_cjs_1.composeCloneTarget)(pre.provider, pre.repo, pre.host).url,
709
- cloneDir,
710
- ], { timeout: 120_000 });
711
- if (cl.exitCode !== 0)
712
- return transportRefusal(cl);
713
- // 6. Fetch the resolved ref from origin, then absorb any unrelated
714
- // remote movement with a fast-forward-only pull. The file-scoped
715
- // baseline check below runs AFTER this on purpose: a target-file race
716
- // is still caught by the hash comparison, and a race that instead
717
- // defeats the transport layer (an unrelated push landing between this
718
- // pull and this module's own push, below) fails closed on the push
719
- // itself — the intended structural backstop (T-29-04), never retried.
720
- const ft = gitExec(['fetch', 'origin', pre.ref], { cwd: cloneDir, timeout: 120_000 });
721
- if (ft.exitCode !== 0)
722
- return transportRefusal(ft);
723
- const pull = gitExec(['pull', '--ff-only', 'origin', pre.ref], { cwd: cloneDir, timeout: 120_000 });
724
- if (pull.exitCode !== 0)
725
- return transportRefusal(pull);
726
- // 7. Re-derive the remote baseline from the now fast-forwarded working
727
- // tree (fs-level read, byte-exact — never git-stdout-derived, which is
728
- // silently trimmed by the shared exec helper). Scoped to the
729
- // destination file only (D-15): commit/tree shas are never compared,
730
- // so an unrelated promotion never blocks this one.
731
- const destAbs = node_path_1.default.join(cloneDir, pre.destination);
732
- const remoteExists = node_fs_1.default.existsSync(destAbs);
733
- const remoteContent = remoteExists ? node_fs_1.default.readFileSync(destAbs, 'utf-8') : null;
734
- const remoteHash = remoteContent === null ? 'absent' : node_crypto_1.default.createHash('sha256').update(remoteContent).digest('hex');
735
- if (remoteHash !== pinnedRemote) {
736
- return refusal(PROMOTE_GUARD.CONCURRENT_EDIT, ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT, `This practice was not pushed: someone else changed ${boundedToken(pre.destination)} in the vault since you previewed it. ` +
737
- 'Run the promotion again to see their version before you push.');
738
- }
739
- const mode = remoteExists ? 'update' : 'create';
740
- // 8. Write the promoted content, then stage exactly that one path.
741
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(destAbs), { recursive: true });
742
- node_fs_1.default.writeFileSync(destAbs, pre.raw, 'utf-8');
743
- const add = gitExec(['add', '--', pre.destination], { cwd: cloneDir });
744
- if (add.exitCode !== 0)
745
- return transportRefusal(add);
746
- // 9. Configure identity only if git reports none — preserve an
747
- // operator's own configured identity when present.
748
- const idCheck = gitExec(['config', 'user.email'], { cwd: cloneDir });
749
- if (idCheck.exitCode !== 0 || !idCheck.stdout.trim()) {
750
- gitExec(['config', 'user.email', 'pp-core-vault-promote@pareto.local'], { cwd: cloneDir });
751
- gitExec(['config', 'user.name', 'pp-core vault promote'], { cwd: cloneDir });
752
- }
753
- // 10. Commit once.
754
- const id = pre.practice.id;
755
- const commitMessage = `practice(${mode}): ${boundedToken(id)}\n\nPromoted from this project via pp-core's vault write-back arm.`;
756
- const commitRes = gitExec(['commit', '-m', commitMessage], { cwd: cloneDir });
757
- if (commitRes.exitCode !== 0)
758
- return transportRefusal(commitRes);
759
- // 11. Push once — origin, local HEAD mapped to the resolved ref. No
760
- // force flag, no lease flag, no short-form alias, not on this call, not
761
- // on a retry. A non-fast-forward rejection is the intended structural
762
- // backstop for a race that defeats the file-scoped check above; never
763
- // re-run, never looped, never escalated.
764
- const push = gitExec(['push', 'origin', `HEAD:${pre.ref}`], { cwd: cloneDir, timeout: 120_000 });
765
- if (push.exitCode !== 0) {
766
- return refusal(PROMOTE_GUARD.TRANSPORT, ERROR_REASON.VAULT_PROMOTE_TRANSPORT_FAILED, 'This practice was not pushed: the vault moved ahead while the push was running. ' +
767
- 'Run the promotion again to preview against the current version.');
768
- }
769
- const shaRes = gitExec(['rev-parse', '--short', 'HEAD'], { cwd: cloneDir });
770
- const commit = shaRes.stdout.trim();
771
- // 12. Refresh the local read cache by reusing vaultFetch verbatim
772
- // (D-11) — no new caching logic, never a direct write into
773
- // .planning/vault/. A refresh failure is non-fatal: the push already
774
- // succeeded and must be reported as such.
775
- //
776
- // vaultFetch has its own TTL-based freshness short-circuit (returns the
777
- // PRIOR manifest untouched when it is recent and the ref matches — see
778
- // src/vault.cts, frozen/unmodified here). Left alone, a promote that
779
- // runs shortly after any recent fetch (routine — e.g. the plan:pre gate
780
- // fetched minutes ago) would short-circuit on the now-stale prior
781
- // manifest and serve pre-promotion content, defeating D-11's entire
782
- // purpose ("the operator's next plan/review/ship sees the promoted
783
- // practice"). Clearing ONLY the staleness marker (never writing cache
784
- // content — that stays vaultFetch's job) forces a genuine re-fetch.
785
- try {
786
- node_fs_1.default.rmSync(node_path_1.default.join(cwd, '.planning', 'vault', '.manifest.json'), { force: true });
787
- }
788
- catch {
789
- /* best-effort; a failed clear just means vaultFetch may short-circuit below */
790
- }
791
- let cacheRefreshed = false;
792
- const notes = [];
793
- try {
794
- const manifest = vault.vaultFetch(cwd);
795
- cacheRefreshed = !!manifest && manifest.source === 'fetched';
796
- }
797
- catch {
798
- cacheRefreshed = false;
799
- }
800
- if (!cacheRefreshed) {
801
- notes.push('the local copy could not be refreshed; the next fetch will pick it up');
802
- }
803
- return {
804
- status: 'pushed',
805
- repo: vault.redactCloneTarget(pre.repo),
806
- ref: pre.ref,
807
- destination: pre.destination,
808
- mode,
809
- id: boundedToken(id),
810
- commit,
811
- cacheRefreshed,
812
- notes,
813
- };
814
- }
815
- finally {
816
- if (cloneDir)
817
- node_fs_1.default.rmSync(cloneDir, { recursive: true, force: true });
818
- }
819
- }
820
- /**
821
- * Single entry point 29-04's router calls: dispatches to `confirmPromotion`
822
- * when `confirmed` is true, otherwise to `previewPromotion`.
823
- */
824
- function promotePractice({ cwd, filePath, confirmed, expect, _exec, _vault, _practices, }) {
825
- if (confirmed) {
826
- return confirmPromotion({ cwd, filePath, expect, _exec, _vault, _practices });
827
- }
828
- return previewPromotion({ cwd, filePath, _exec, _vault, _practices });
829
- }
830
- // ─── Report rendering (human-readable half of the D-16 output contract) ────────
831
- /**
832
- * Render any verdict `promotePractice` can produce as plain English (the
833
- * COMM-01 bar, `docs/practices-plain-english-standard.md`) — the form 29-04's
834
- * CLI router selects when `--raw` is absent. Every vault-authored token (id,
835
- * repo, destination, notes) passes through `boundedToken` before
836
- * interpolation (T-29-07) — these values ultimately derive from an
837
- * operator-supplied practice file, not this module's own logic. For a
838
- * refusal this is exactly the single sentence `refusal()` already built. For
839
- * a preview it states the target repo, the destination path, whether the
840
- * promotion creates or updates a practice, the shared-vault disclosure
841
- * sentence when that flag is set, a line stating the secret scan found
842
- * nothing, the token line the confirmed run needs, and then the complete
843
- * diff or complete content under a heading — never elided, because the
844
- * operator is confirming exactly these bytes. For a push it states the
845
- * repo, the destination, the new commit, and whether the local copy was
846
- * refreshed.
847
- */
848
- function renderPromoteReport(verdict) {
849
- if (verdict.status === 'refused') {
850
- return verdict.message;
851
- }
852
- if (verdict.status === 'preview') {
853
- const lines = [];
854
- const action = verdict.mode === 'create' ? 'creates a new practice' : 'updates an existing practice';
855
- lines.push(`This promotes "${boundedToken(verdict.id)}" to ${boundedToken(verdict.repo)} at ${boundedToken(verdict.destination)}.`);
856
- lines.push(`This ${action} in the vault.`);
857
- if (verdict.sharedShadow && verdict.sharedShadowNote) {
858
- lines.push(boundedToken(verdict.sharedShadowNote));
859
- }
860
- lines.push('The secret scan found nothing to flag.');
861
- // NOT boundedToken: the expect token is generated by buildExpectToken (a
862
- // sha256 pair, fixed length, no untrusted input), and it is the one value
863
- // on this page the operator must copy VERBATIM. Bounding it truncated the
864
- // remote half with an ellipsis on every update — create mode escaped only
865
- // because its remote half is the short literal "absent" — and pasting the
866
- // truncated value produced the concurrent-edit refusal, telling the
867
- // operator someone else had changed the practice when nobody had.
868
- lines.push(`To push this, run the promotion again with --confirmed --expect ${verdict.expect}.`);
869
- for (const note of verdict.notes) {
870
- lines.push(`Note: ${boundedToken(note)}`);
871
- }
872
- lines.push('');
873
- if (verdict.mode === 'update') {
874
- lines.push('## Full diff');
875
- lines.push(verdict.diff && verdict.diff.length > 0 ? verdict.diff : '(no changes)');
876
- }
877
- else {
878
- lines.push('## Full content');
879
- lines.push(verdict.content ?? '');
880
- }
881
- return lines.join('\n');
882
- }
883
- // verdict.status === 'pushed'
884
- const pushedLines = [];
885
- pushedLines.push(`Pushed "${boundedToken(verdict.id)}" to ${boundedToken(verdict.repo)} at ${boundedToken(verdict.destination)} — commit ${boundedToken(verdict.commit)}.`);
886
- pushedLines.push(verdict.cacheRefreshed
887
- ? 'Your local copy was refreshed with the new content.'
888
- : 'Your local copy was not refreshed automatically; the next fetch will pick it up.');
889
- for (const note of verdict.notes) {
890
- pushedLines.push(`Note: ${boundedToken(note)}`);
891
- }
892
- return pushedLines.join('\n');
893
- }
894
- module.exports = {
895
- SECRET_SHAPES,
896
- scanForSecrets,
897
- PROMOTE_GUARD,
898
- promotePreflight,
899
- refusal,
900
- buildExpectToken,
901
- previewPromotion,
902
- confirmPromotion,
903
- promotePractice,
904
- renderPromoteReport,
905
- };