@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
@@ -0,0 +1,577 @@
1
+ 'use strict';
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ /**
6
+ * src/vault-standards-promote.cts — `vault standards promote`: add a standards
7
+ * document written in this project to the client vault, together with its
8
+ * INDEX.md entry, after an explicit confirmation (FORK-ONLY, the standards-only
9
+ * vault).
10
+ *
11
+ * Two runs, so the confirmation works the same for a person and for an agent
12
+ * shell:
13
+ * - without `--confirmed` it clones the client vault into a disposable
14
+ * directory, writes the document and the INDEX.md change there, and asks
15
+ * git for the diff. It returns that diff and an opaque `expect` token that
16
+ * pins the local document, the entry, and what the vault held for both
17
+ * files. Nothing is committed or pushed.
18
+ * - with `--confirmed --expect <token>` it re-runs every guard, clones again,
19
+ * fast-forwards, refuses when the local document or either vault file
20
+ * changed since the preview, then commits both files once and pushes once,
21
+ * with no force flag on any path. It then refreshes the local vault cache.
22
+ *
23
+ * Guards, in order, each refusing with the uniform refusal envelope:
24
+ * 1. activation — the vault gate is on for this project;
25
+ * 2. input — an existing `.md` file;
26
+ * 3. validation — the document has a level-1 heading and is not empty or
27
+ * oversized; the vault path is a safe, displayable `.md`
28
+ * path that is not INDEX.md or SUMMARY.md; the INDEX entry
29
+ * text is one display-safe line with a description and a
30
+ * `; used when …` clause (required for a new document);
31
+ * 4. secret_scan — no line of the document or the entry looks like a
32
+ * credential (the matched text is never echoed);
33
+ * 5. write_target — `client.vaultRepo` is configured, not flag-shaped, and
34
+ * passes `assertVaultWriteTarget` (the shared vault is
35
+ * read-only).
36
+ * The transport and concurrent-edit refusals belong to the preview and the
37
+ * confirmed run.
38
+ *
39
+ * The INDEX.md line is written in the grammar `src/vault-index.cts` owns
40
+ * (`- \`path\` — what it is; used when …`), and the edited INDEX is parsed back
41
+ * before anything is staged: the new entry must read back as written and no
42
+ * other entry may change.
43
+ *
44
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
45
+ * compiles to the git-ignored `pp-core/bin/lib/vault-standards-promote.cjs`.
46
+ */
47
+ const node_fs_1 = __importDefault(require("node:fs"));
48
+ const node_os_1 = __importDefault(require("node:os"));
49
+ const node_path_1 = __importDefault(require("node:path"));
50
+ const node_crypto_1 = __importDefault(require("node:crypto"));
51
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
52
+ const git_provider_cjs_1 = require("./git-provider.cjs");
53
+ const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ const io = require("./io.cjs");
56
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
57
+ const vaultIndexLeaf = require("./vault-index.cjs");
58
+ const { ERROR_REASON } = io;
59
+ // ─── Guard identity ──────────────────────────────────────────────────────────
60
+ const PROMOTE_GUARD = Object.freeze({
61
+ ACTIVATION: 'activation',
62
+ INPUT: 'input',
63
+ VALIDATION: 'validation',
64
+ SECRET_SCAN: 'secret_scan',
65
+ WRITE_TARGET: 'write_target',
66
+ CONCURRENT_EDIT: 'concurrent_edit',
67
+ TRANSPORT: 'transport',
68
+ });
69
+ /** The vault directory a document goes to when no `--path` is given. */
70
+ const DEFAULT_VAULT_DIR = 'standards';
71
+ /** The largest document this command promotes: the INDEX read cap, 256 KiB. */
72
+ const DOCUMENT_BYTE_CAP = vaultIndexLeaf.VAULT_INDEX_BYTE_CAP;
73
+ /** Files the vault engine owns or writes, never a promotion destination. */
74
+ const RESERVED_VAULT_FILES = new Set([vaultIndexLeaf.VAULT_INDEX_FILENAME.toLowerCase(), 'summary.md']);
75
+ // ─── Secret-content scanner ──────────────────────────────────────────────────
76
+ //
77
+ // The labelled shapes of scripts/secret-scan.sh, plus the GitLab `glpat-`
78
+ // token. Order is load-bearing: the Anthropic shape precedes the OpenAI one so
79
+ // an `sk-ant-` key is never reported under the OpenAI label. No override and no
80
+ // placeholder carve-out: a false positive costs a re-run, a missed credential
81
+ // is pushed to every machine that fetches the vault.
82
+ const SECRET_SHAPES = Object.freeze([
83
+ { label: 'AWS Access Key', re: /AKIA[0-9A-Z]{16}/ },
84
+ { label: 'AWS Secret Key', re: /aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}/ },
85
+ { label: 'Anthropic API Key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
86
+ { label: 'OpenAI API Key', re: /sk-[A-Za-z0-9]{20,}/ },
87
+ { label: 'GitHub PAT', re: /ghp_[A-Za-z0-9]{36}/ },
88
+ { label: 'GitHub OAuth', re: /gho_[A-Za-z0-9]{36}/ },
89
+ { label: 'GitHub App Token', re: /ghs_[A-Za-z0-9]{36}/ },
90
+ { label: 'GitHub Fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
91
+ { label: 'GitLab access token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
92
+ { label: 'Stripe Secret Key', re: /sk_live_[A-Za-z0-9]{24,}/ },
93
+ { label: 'Stripe Publishable Key', re: /pk_live_[A-Za-z0-9]{24,}/ },
94
+ { label: 'Private Key Header', re: /-----BEGIN\s+(RSA|EC|DSA|OPENSSH)?\s*PRIVATE\s+KEY-----/ },
95
+ { label: 'Slack Bot Token', re: /xoxb-[0-9]{10,}-[A-Za-z0-9]{20,}/ },
96
+ { label: 'Slack Webhook', re: /hooks\.slack\.com\/services\/T[A-Z0-9]{8,}\/B[A-Z0-9]{8,}\/[A-Za-z0-9]{24}/ },
97
+ { label: 'Google API Key', re: /AIza[A-Za-z0-9_-]{35}/ },
98
+ { label: 'NPM Token', re: /npm_[A-Za-z0-9]{36}/ },
99
+ {
100
+ label: 'Env Variable Leak',
101
+ re: /(DATABASE_URL|DB_PASSWORD|REDIS_URL|MONGO_URI|JWT_SECRET|SESSION_SECRET|ENCRYPTION_KEY)\s*=\s*[^\s]{8,}/,
102
+ },
103
+ { label: 'Generic API Key Assignment', re: /api[_-]?key\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
104
+ { label: 'Generic Secret Assignment', re: /secret\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
105
+ { label: 'Generic Token Assignment', re: /token\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
106
+ { label: 'Generic Password Assignment', re: /password\s*[:=]\s*['"][^'"]{8,}['"]/i },
107
+ ]);
108
+ /** The first line that looks like a credential, by name and 1-based line; never the matched text. */
109
+ function scanForSecrets(content) {
110
+ const lines = content.split(/\r?\n/);
111
+ for (let i = 0; i < lines.length; i++) {
112
+ for (const shape of SECRET_SHAPES) {
113
+ if (shape.re.test(lines[i]))
114
+ return { label: shape.label, line: i + 1 };
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+ // ─── Refusal contract ────────────────────────────────────────────────────────
120
+ /** A uniform refusal; the message is collapsed to one line so no value can forge a second. */
121
+ function refusal(guard, reason, message, extra = {}) {
122
+ return { status: 'refused', guard, reason, message: message.replace(/[\r\n]+/g, ' '), ...extra };
123
+ }
124
+ /** Bound a value before it is echoed into a message. */
125
+ function boundedToken(v, max = 80) {
126
+ let s;
127
+ try {
128
+ s = typeof v === 'string' ? v : JSON.stringify(v);
129
+ }
130
+ catch {
131
+ s = String(v);
132
+ }
133
+ s = s.replace(/[\r\n]+/g, ' ');
134
+ return s.length > max ? `${s.slice(0, max)}…` : s;
135
+ }
136
+ const NOT_PROMOTED = 'This standards document was not promoted:';
137
+ const NOT_PUSHED = 'This standards document was not pushed:';
138
+ // ─── Validation ──────────────────────────────────────────────────────────────
139
+ /** Why a vault destination path is refused, or null when it is usable. */
140
+ function vaultPathProblem(p) {
141
+ const cls = vaultIndexLeaf.classifyVaultPath(p);
142
+ if (!cls.ok)
143
+ return `the vault path ${vaultIndexLeaf.displayVaultPath(p)} is not a safe vault path (${cls.reason})`;
144
+ if (!vaultIndexLeaf.isDisplaySafeVaultPath(p)) {
145
+ return `the vault path ${vaultIndexLeaf.displayVaultPath(p)} cannot be shown as written`;
146
+ }
147
+ if (!p.toLowerCase().endsWith('.md'))
148
+ return `the vault path ${p} does not end in .md`;
149
+ if (RESERVED_VAULT_FILES.has(node_path_1.default.posix.basename(p).toLowerCase())) {
150
+ return `${p} is a file the vault engine owns, not a standards document`;
151
+ }
152
+ return null;
153
+ }
154
+ /** Why an INDEX entry text is refused, or null when it is usable. */
155
+ function entryProblem(entry) {
156
+ if (entry.trim() === '')
157
+ return 'the INDEX entry text is empty';
158
+ if (/[\r\n]/.test(entry))
159
+ return 'the INDEX entry text must be one line';
160
+ if (entry.includes('`'))
161
+ return 'the INDEX entry text may not contain a backtick';
162
+ if (vaultIndexLeaf.DISPLAY_UNSAFE_TEXT_CHAR_RE.test(entry) || vaultIndexLeaf.sanitiseVaultText(entry) !== entry.trim()) {
163
+ return 'the INDEX entry text carries a control, invisible or over-long run of characters';
164
+ }
165
+ const m = vaultIndexLeaf.VAULT_USED_WHEN_RE.exec(entry);
166
+ if (!m)
167
+ return `the INDEX entry text needs a "${vaultIndexLeaf.VAULT_USED_WHEN_MARKER.trim()} …" clause saying when the document applies`;
168
+ if (entry.slice(0, m.index).trim() === '')
169
+ return 'the INDEX entry text needs a description before the used-when clause';
170
+ if (entry.slice(m.index + m[0].length).trim() === '')
171
+ return 'the used-when clause of the INDEX entry text is empty';
172
+ return null;
173
+ }
174
+ /** Why a section title is refused, or null when it is usable. */
175
+ function sectionProblem(section) {
176
+ if (section.trim() === '')
177
+ return 'the INDEX section title is empty';
178
+ if (/[\r\n]/.test(section) || section.includes('#'))
179
+ return 'the INDEX section title must be one line without "#"';
180
+ if (vaultIndexLeaf.sanitiseVaultText(section) !== section.trim())
181
+ return 'the INDEX section title carries a control or invisible character';
182
+ return null;
183
+ }
184
+ /** Why a document body is refused, or null when it is usable. */
185
+ function documentProblem(raw) {
186
+ if (raw.trim() === '')
187
+ return 'the document is empty';
188
+ if (Buffer.byteLength(raw, 'utf-8') > DOCUMENT_BYTE_CAP)
189
+ return `the document is larger than ${DOCUMENT_BYTE_CAP} bytes`;
190
+ if (raw.includes('\u0000'))
191
+ return 'the document contains a NUL byte';
192
+ if (!/^# \S/m.test(raw))
193
+ return 'the document has no level-1 heading ("# Title")';
194
+ return null;
195
+ }
196
+ // ─── Preflight guard chain ───────────────────────────────────────────────────
197
+ /**
198
+ * The guard chain, strictly in order, refusing at the first failing stage.
199
+ * Makes no network access and spawns no subprocess. Whether the entry is
200
+ * REQUIRED (a document the vault does not list yet) can only be known after
201
+ * the clone, so the preview and the confirmed run check that themselves.
202
+ */
203
+ function promotePreflight({ cwd, filePath, vaultPath, entry, section }, vault) {
204
+ // 1. Activation — from the REAL vault leaf, never a test seam, so a mocked
205
+ // config read cannot switch promotion on.
206
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
207
+ const gate = require('./vault.cjs');
208
+ if (!gate.isVaultGateActive(cwd)) {
209
+ return refusal(PROMOTE_GUARD.ACTIVATION, ERROR_REASON.VAULT_PROMOTE_DISABLED, `${NOT_PROMOTED} the vault is switched off for this project. Set vault.enabled to true in .planning/config.json and run the promotion again.`);
210
+ }
211
+ // 2. Input — an already-written document; this command never composes one.
212
+ if (typeof filePath !== 'string' || filePath.length === 0 || !filePath.toLowerCase().endsWith('.md')
213
+ || !node_fs_1.default.existsSync(filePath) || !node_fs_1.default.statSync(filePath).isFile()) {
214
+ return refusal(PROMOTE_GUARD.INPUT, ERROR_REASON.SDK_MISSING_ARG, `${NOT_PROMOTED} ${boundedToken(filePath)} is not an existing .md file. Point at the standards document and run the promotion again.`);
215
+ }
216
+ const raw = node_fs_1.default.readFileSync(filePath, 'utf-8');
217
+ // 3. Validation — the document, the destination, the entry, the section.
218
+ const destination = typeof vaultPath === 'string' && vaultPath !== ''
219
+ ? vaultPath
220
+ : node_path_1.default.posix.join(DEFAULT_VAULT_DIR, node_path_1.default.basename(filePath));
221
+ const invalid = documentProblem(raw)
222
+ ?? vaultPathProblem(destination)
223
+ ?? (typeof entry === 'string' ? entryProblem(entry) : null)
224
+ ?? (typeof section === 'string' ? sectionProblem(section) : null);
225
+ if (invalid !== null) {
226
+ return refusal(PROMOTE_GUARD.VALIDATION, ERROR_REASON.VAULT_PROMOTE_INVALID_DOCUMENT, `${NOT_PROMOTED} ${invalid}. Fix it and run the promotion again.`);
227
+ }
228
+ // 4. Secret scan — over the document AND the entry text, which both reach
229
+ // the vault. Name and line only.
230
+ const docHit = scanForSecrets(raw);
231
+ const entryHit = typeof entry === 'string' ? scanForSecrets(entry) : null;
232
+ if (docHit || entryHit) {
233
+ const where = docHit ? `line ${docHit.line} of the document` : 'the INDEX entry text';
234
+ const label = (docHit ?? entryHit)?.label ?? '';
235
+ return refusal(PROMOTE_GUARD.SECRET_SCAN, ERROR_REASON.VAULT_PROMOTE_SECRET_DETECTED, `${NOT_PUSHED} ${where} looks like ${label}. Remove it and run the promotion again.`, { label, ...(docHit ? { line: docHit.line } : {}) });
236
+ }
237
+ // 5. Write target — the client vault, never the shared one.
238
+ const bindings = vault.readBindings(cwd);
239
+ const client = bindings && typeof bindings.client === 'object' && bindings.client !== null
240
+ ? bindings.client
241
+ : null;
242
+ const repo = client && typeof client.vaultRepo === 'string' ? client.vaultRepo : null;
243
+ if (!repo) {
244
+ return refusal(PROMOTE_GUARD.WRITE_TARGET, ERROR_REASON.VAULT_PROMOTE_TARGET_REFUSED, `${NOT_PROMOTED} no client vault repository is configured. Check client.vaultRepo in .platform-bindings.json and run the promotion again.`);
245
+ }
246
+ const rawRef = client && typeof client.vaultRef === 'string' ? client.vaultRef : '';
247
+ const flagShaped = [['client.vaultRepo', repo], ['client.vaultRef', rawRef]].find(([, v]) => v.trim().startsWith('-'));
248
+ if (flagShaped) {
249
+ return refusal(PROMOTE_GUARD.WRITE_TARGET, ERROR_REASON.VAULT_PROMOTE_TARGET_REFUSED, `${NOT_PROMOTED} ${flagShaped[0]} starts with "-", which git would read as an option. Fix it in .platform-bindings.json and run the promotion again.`);
250
+ }
251
+ const ref = rawRef.trim() !== '' ? rawRef.trim() : 'main';
252
+ const providerResolution = (0, git_provider_cjs_1.resolveProvider)(cwd);
253
+ const provider = providerResolution.provider;
254
+ const notes = providerResolution.warnings.slice();
255
+ const clientCoord = (0, vault_coordinates_cjs_1.resolveClientCoordinates)(cwd, provider);
256
+ const host = clientCoord.host;
257
+ notes.push(...clientCoord.warnings);
258
+ if (!(0, git_provider_cjs_1.isLiteralCloneTarget)(repo.trim()))
259
+ notes.push(...(0, git_provider_cjs_1.composeCloneTarget)(provider, repo, host).warnings);
260
+ try {
261
+ vault.assertVaultWriteTarget(cwd, repo, host);
262
+ }
263
+ catch (e) {
264
+ const msg = (e instanceof Error ? e.message : String(e)).replace(/^vault write refused:\s*/, '');
265
+ return refusal(PROMOTE_GUARD.WRITE_TARGET, ERROR_REASON.VAULT_PROMOTE_TARGET_REFUSED, `${NOT_PROMOTED} ${msg} Check client.vaultRepo in .platform-bindings.json and run the promotion again.`);
266
+ }
267
+ return {
268
+ status: 'ok',
269
+ raw,
270
+ destination,
271
+ entry: typeof entry === 'string' ? entry.trim() : null,
272
+ section: typeof section === 'string' ? section.trim() : null,
273
+ repo,
274
+ ref,
275
+ host,
276
+ provider,
277
+ notes,
278
+ };
279
+ }
280
+ // ─── The INDEX.md edit ───────────────────────────────────────────────────────
281
+ /** The INDEX line for one document, in the grammar vault-index owns. */
282
+ function formatIndexEntry(destination, entry) {
283
+ return `${vaultIndexLeaf.VAULT_ENTRY_PREFIX}${destination}\`${vaultIndexLeaf.VAULT_ENTRY_SEPARATOR}${entry}`;
284
+ }
285
+ /**
286
+ * The INDEX.md text after this promotion, decided from the vault's current
287
+ * INDEX (`null` when the vault has none). A listed document keeps its line
288
+ * unless a new entry text is given, which replaces it in place. An unlisted
289
+ * document needs an entry text: its line is appended after the last entry of
290
+ * `section` (a new `## section` at the end when that section does not exist),
291
+ * or at the end of the file when no section is named. The result is parsed
292
+ * back: the new line must read back as written and every other entry must be
293
+ * unchanged, or nothing is written.
294
+ */
295
+ function editIndex(current, destination, entry, section) {
296
+ const before = vaultIndexLeaf.parseVaultIndex(current ?? '');
297
+ const listed = before.entries.filter((e) => e.path === destination);
298
+ if (listed.length > 1) {
299
+ return { ok: false, message: `INDEX.md lists ${destination} ${listed.length} times; fix the vault's INDEX.md first` };
300
+ }
301
+ const eol = current !== null && current.includes('\r\n') ? '\r\n' : '\n';
302
+ const lines = current === null ? [] : current.split(/\r?\n/);
303
+ let change;
304
+ if (listed.length === 1) {
305
+ if (entry === null)
306
+ return { ok: true, text: current, change: 'unchanged' };
307
+ const line = formatIndexEntry(destination, entry);
308
+ if (lines[listed[0].line - 1] === line)
309
+ return { ok: true, text: current, change: 'unchanged' };
310
+ lines[listed[0].line - 1] = line;
311
+ change = 'replaced';
312
+ }
313
+ else {
314
+ if (entry === null) {
315
+ return {
316
+ ok: false,
317
+ message: `the vault's INDEX.md does not list ${destination} yet, so --entry "<what it is>; used when <…>" is required`,
318
+ };
319
+ }
320
+ const line = formatIndexEntry(destination, entry);
321
+ if (lines.length === 0)
322
+ lines.push('# Index', '');
323
+ while (lines.length > 0 && lines[lines.length - 1] === '')
324
+ lines.pop();
325
+ const inSection = section === null ? [] : before.entries.filter((e) => e.section === section);
326
+ if (inSection.length > 0) {
327
+ lines.splice(inSection[inSection.length - 1].line, 0, line);
328
+ }
329
+ else if (section !== null) {
330
+ lines.push('', `${vaultIndexLeaf.VAULT_SECTION_HEADING_PREFIX}${section}`, '', line);
331
+ }
332
+ else {
333
+ lines.push(line);
334
+ }
335
+ lines.push('');
336
+ change = 'added';
337
+ }
338
+ const text = lines.join(eol);
339
+ const after = vaultIndexLeaf.parseVaultIndex(text);
340
+ const mine = after.entries.filter((e) => e.path === destination);
341
+ const others = (p) => JSON.stringify(p.entries.filter((e) => e.path !== destination)
342
+ .map((e) => [e.section, e.path, e.description, e.usedWhen ?? null]));
343
+ const expectedEntry = entry ?? '';
344
+ const readsBack = mine.length === 1
345
+ && (entry === null || `${mine[0].description}${vaultIndexLeaf.VAULT_USED_WHEN_MARKER}${mine[0].usedWhen ?? ''}` === normaliseEntry(expectedEntry))
346
+ && others(after) === others(before)
347
+ && after.diagnostics.length <= before.diagnostics.length;
348
+ if (!readsBack) {
349
+ return { ok: false, message: 'the edited INDEX.md would not read back as written; fix the vault\'s INDEX.md or the entry text' };
350
+ }
351
+ return { ok: true, text, change };
352
+ }
353
+ /** An entry text in the parser's own normal form: description, the canonical marker, the clause. */
354
+ function normaliseEntry(entry) {
355
+ const m = vaultIndexLeaf.VAULT_USED_WHEN_RE.exec(entry);
356
+ if (!m)
357
+ return entry.trim();
358
+ return `${entry.slice(0, m.index).trim()}${vaultIndexLeaf.VAULT_USED_WHEN_MARKER}${entry.slice(m.index + m[0].length).trim()}`;
359
+ }
360
+ // ─── The two runs ────────────────────────────────────────────────────────────
361
+ const sha256 = (s) => (s === null ? 'absent' : node_crypto_1.default.createHash('sha256').update(s).digest('hex'));
362
+ /**
363
+ * The opaque `expect` token: what was confirmed locally (the document, the
364
+ * entry and the section) and what the vault held for the document and for
365
+ * INDEX.md at preview time. Four hashes, dot-separated; callers treat it as
366
+ * opaque.
367
+ */
368
+ function buildExpectToken(pre, remoteDocument, remoteIndex) {
369
+ const local = sha256(`${pre.raw}\u0000${pre.entry ?? ''}\u0000${pre.section ?? ''}\u0000${pre.destination}`);
370
+ return `${local}.${sha256(remoteDocument)}.${sha256(remoteIndex)}`;
371
+ }
372
+ function transportRefusal(res) {
373
+ const cause = boundedToken((res.stderr || '').split(/\r?\n/)[0] || 'unknown error');
374
+ return refusal(PROMOTE_GUARD.TRANSPORT, ERROR_REASON.VAULT_PROMOTE_TRANSPORT_FAILED, `${NOT_PUSHED} git could not reach the vault (${cause}). Check your access to the vault and run the promotion again.`);
375
+ }
376
+ function readOrNull(abs) {
377
+ return node_fs_1.default.existsSync(abs) ? node_fs_1.default.readFileSync(abs, 'utf-8') : null;
378
+ }
379
+ /** Clone the client vault at its ref into a fresh disposable directory. */
380
+ function cloneVault(gitExec, pre, prefix) {
381
+ const dir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), prefix));
382
+ const cl = gitExec(['clone', '--branch', pre.ref, '--single-branch', '--', (0, git_provider_cjs_1.composeCloneTarget)(pre.provider, pre.repo, pre.host).url, dir], { timeout: 120_000 });
383
+ if (cl.exitCode !== 0) {
384
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
385
+ return transportRefusal(cl);
386
+ }
387
+ return { dir };
388
+ }
389
+ /** Write the document and the INDEX change into a clone; the planned index text comes from `editIndex`. */
390
+ function applyToClone(dir, pre, indexText) {
391
+ const docAbs = node_path_1.default.join(dir, pre.destination);
392
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(docAbs), { recursive: true });
393
+ node_fs_1.default.writeFileSync(docAbs, pre.raw, 'utf-8');
394
+ if (indexText !== null)
395
+ node_fs_1.default.writeFileSync(node_path_1.default.join(dir, vaultIndexLeaf.VAULT_INDEX_FILENAME), indexText, 'utf-8');
396
+ }
397
+ function previewPromotion(opts, vault) {
398
+ const gitExec = opts._exec ?? shell_command_projection_cjs_1.execGitLocal;
399
+ const pre = promotePreflight(opts, vault);
400
+ if (pre.status !== 'ok')
401
+ return pre;
402
+ const cloned = cloneVault(gitExec, pre, 'pp-vault-promote-');
403
+ if ('status' in cloned)
404
+ return cloned;
405
+ try {
406
+ const docAbs = node_path_1.default.join(cloned.dir, pre.destination);
407
+ const remoteDocument = readOrNull(docAbs);
408
+ const remoteIndex = readOrNull(node_path_1.default.join(cloned.dir, vaultIndexLeaf.VAULT_INDEX_FILENAME));
409
+ const edit = editIndex(remoteIndex, pre.destination, pre.entry, pre.section);
410
+ if (!edit.ok) {
411
+ return refusal(PROMOTE_GUARD.VALIDATION, ERROR_REASON.VAULT_PROMOTE_INVALID_DOCUMENT, `${NOT_PROMOTED} ${edit.message}.`);
412
+ }
413
+ applyToClone(cloned.dir, pre, edit.change === 'unchanged' ? null : edit.text);
414
+ // A new file shows in the diff only once git knows its path.
415
+ gitExec(['add', '--intent-to-add', '--', pre.destination, vaultIndexLeaf.VAULT_INDEX_FILENAME], { cwd: cloned.dir });
416
+ const diffRes = gitExec(['-c', 'core.quotepath=off', 'diff', '--', pre.destination, vaultIndexLeaf.VAULT_INDEX_FILENAME], { cwd: cloned.dir });
417
+ return {
418
+ status: 'preview',
419
+ repo: vault.redactCloneTarget(pre.repo),
420
+ ref: pre.ref,
421
+ destination: pre.destination,
422
+ mode: remoteDocument === null ? 'create' : 'update',
423
+ indexChange: edit.change,
424
+ diff: diffRes.stdout,
425
+ expect: buildExpectToken(pre, remoteDocument, remoteIndex),
426
+ notes: pre.notes,
427
+ };
428
+ }
429
+ finally {
430
+ node_fs_1.default.rmSync(cloned.dir, { recursive: true, force: true });
431
+ }
432
+ }
433
+ function confirmPromotion(opts, vault) {
434
+ const gitExec = opts._exec ?? shell_command_projection_cjs_1.execGitLocal;
435
+ // Never trust the preview: every guard runs again.
436
+ const pre = promotePreflight(opts, vault);
437
+ if (pre.status !== 'ok')
438
+ return pre;
439
+ const expect = opts.expect;
440
+ if (typeof expect !== 'string' || expect === '') {
441
+ return refusal(PROMOTE_GUARD.CONCURRENT_EDIT, ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT, `${NOT_PUSHED} the confirmation is missing the preview token. Run the promotion without --confirmed first, then pass the token it prints.`);
442
+ }
443
+ const [pinnedLocal, pinnedDocument, pinnedIndex] = expect.split('.');
444
+ if (pinnedLocal !== sha256(`${pre.raw}\u0000${pre.entry ?? ''}\u0000${pre.section ?? ''}\u0000${pre.destination}`)) {
445
+ return refusal(PROMOTE_GUARD.CONCURRENT_EDIT, ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT, `${NOT_PUSHED} the document, the entry text or the vault path changed after the preview. Run the promotion again to preview the new version.`);
446
+ }
447
+ const cloned = cloneVault(gitExec, pre, 'pp-vault-confirm-');
448
+ if ('status' in cloned)
449
+ return cloned;
450
+ try {
451
+ const dir = cloned.dir;
452
+ const ft = gitExec(['fetch', 'origin', pre.ref], { cwd: dir, timeout: 120_000 });
453
+ if (ft.exitCode !== 0)
454
+ return transportRefusal(ft);
455
+ const pull = gitExec(['pull', '--ff-only', 'origin', pre.ref], { cwd: dir, timeout: 120_000 });
456
+ if (pull.exitCode !== 0)
457
+ return transportRefusal(pull);
458
+ // Byte-exact reads from the working tree, never trimmed git stdout.
459
+ const remoteDocument = readOrNull(node_path_1.default.join(dir, pre.destination));
460
+ const remoteIndex = readOrNull(node_path_1.default.join(dir, vaultIndexLeaf.VAULT_INDEX_FILENAME));
461
+ if (sha256(remoteDocument) !== pinnedDocument || sha256(remoteIndex) !== pinnedIndex) {
462
+ return refusal(PROMOTE_GUARD.CONCURRENT_EDIT, ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT, `${NOT_PUSHED} someone changed ${boundedToken(pre.destination)} or INDEX.md in the vault since the preview. Run the promotion again to see their version before you push.`);
463
+ }
464
+ const edit = editIndex(remoteIndex, pre.destination, pre.entry, pre.section);
465
+ if (!edit.ok) {
466
+ return refusal(PROMOTE_GUARD.VALIDATION, ERROR_REASON.VAULT_PROMOTE_INVALID_DOCUMENT, `${NOT_PROMOTED} ${edit.message}.`);
467
+ }
468
+ applyToClone(dir, pre, edit.change === 'unchanged' ? null : edit.text);
469
+ const add = gitExec(['add', '--', pre.destination, vaultIndexLeaf.VAULT_INDEX_FILENAME], { cwd: dir });
470
+ if (add.exitCode !== 0)
471
+ return transportRefusal(add);
472
+ const idCheck = gitExec(['config', 'user.email'], { cwd: dir });
473
+ if (idCheck.exitCode !== 0 || !idCheck.stdout.trim()) {
474
+ gitExec(['config', 'user.email', 'pp-core-vault-promote@pareto.local'], { cwd: dir });
475
+ gitExec(['config', 'user.name', 'pp-core vault promote'], { cwd: dir });
476
+ }
477
+ const mode = remoteDocument === null ? 'create' : 'update';
478
+ const message = `standards(${mode}): ${boundedToken(pre.destination)}\n\nPromoted from a project with pp-tools vault standards promote.`;
479
+ const commitRes = gitExec(['commit', '-m', message], { cwd: dir });
480
+ if (commitRes.exitCode !== 0)
481
+ return transportRefusal(commitRes);
482
+ // One push, no force flag on any path; a non-fast-forward is the backstop.
483
+ const push = gitExec(['push', 'origin', `HEAD:${pre.ref}`], { cwd: dir, timeout: 120_000 });
484
+ if (push.exitCode !== 0) {
485
+ return refusal(PROMOTE_GUARD.TRANSPORT, ERROR_REASON.VAULT_PROMOTE_TRANSPORT_FAILED, `${NOT_PUSHED} the vault moved ahead while the push was running. Run the promotion again to preview against the current version.`);
486
+ }
487
+ const commit = gitExec(['rev-parse', '--short', 'HEAD'], { cwd: dir }).stdout.trim();
488
+ // Refresh the local cache: clear only the staleness marker so the fetch
489
+ // cannot short-circuit on the pre-promotion manifest.
490
+ try {
491
+ node_fs_1.default.rmSync(node_path_1.default.join(opts.cwd, '.planning', 'vault', '.manifest.json'), { force: true });
492
+ }
493
+ catch {
494
+ /* best-effort */
495
+ }
496
+ let cacheRefreshed = false;
497
+ try {
498
+ const manifest = vault.vaultFetch(opts.cwd);
499
+ cacheRefreshed = !!manifest && manifest.source === 'fetched';
500
+ }
501
+ catch {
502
+ cacheRefreshed = false;
503
+ }
504
+ const notes = pre.notes.slice();
505
+ if (!cacheRefreshed)
506
+ notes.push('the local copy could not be refreshed; the next fetch will pick it up');
507
+ return {
508
+ status: 'pushed',
509
+ repo: vault.redactCloneTarget(pre.repo),
510
+ ref: pre.ref,
511
+ destination: pre.destination,
512
+ mode,
513
+ indexChange: edit.change,
514
+ commit,
515
+ cacheRefreshed,
516
+ notes,
517
+ };
518
+ }
519
+ finally {
520
+ node_fs_1.default.rmSync(cloned.dir, { recursive: true, force: true });
521
+ }
522
+ }
523
+ /** The one entry point the router calls: the preview, or the confirmed push. */
524
+ function promoteStandard(opts) {
525
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
526
+ const vault = opts._vault ?? require('./vault.cjs');
527
+ return opts.confirmed ? confirmPromotion(opts, vault) : previewPromotion(opts, vault);
528
+ }
529
+ // ─── Report rendering ────────────────────────────────────────────────────────
530
+ /**
531
+ * Plain-English rendering for the non-`--raw` CLI path. A preview states the
532
+ * vault, the destination, what changes in INDEX.md, the secret-scan result,
533
+ * the token line, and the COMPLETE diff, never elided: the operator confirms
534
+ * exactly these bytes. The token is printed unbounded because it must be
535
+ * copied verbatim.
536
+ */
537
+ function renderPromoteReport(verdict) {
538
+ if (verdict.status === 'refused')
539
+ return verdict.message;
540
+ const indexLine = {
541
+ added: 'INDEX.md gains a line for it.',
542
+ replaced: 'Its INDEX.md line is replaced with the new entry text.',
543
+ unchanged: 'INDEX.md already lists it and stays as it is.',
544
+ };
545
+ if (verdict.status === 'preview') {
546
+ const lines = [
547
+ `This promotes ${boundedToken(verdict.destination)} to ${boundedToken(verdict.repo)} (${boundedToken(verdict.ref)}).`,
548
+ `It ${verdict.mode === 'create' ? 'creates a new' : 'updates an existing'} standards document. ${indexLine[verdict.indexChange]}`,
549
+ 'The secret scan found nothing to flag.',
550
+ `To push this, run the promotion again with --confirmed --expect ${verdict.expect}.`,
551
+ ...verdict.notes.map((n) => `Note: ${boundedToken(n, 200)}`),
552
+ '',
553
+ '## Full diff',
554
+ verdict.diff.length > 0 ? verdict.diff : '(no changes)',
555
+ ];
556
+ return lines.join('\n');
557
+ }
558
+ return [
559
+ `Pushed ${boundedToken(verdict.destination)} to ${boundedToken(verdict.repo)} (${boundedToken(verdict.ref)}) — commit ${boundedToken(verdict.commit)}. ${indexLine[verdict.indexChange]}`,
560
+ verdict.cacheRefreshed
561
+ ? 'Your local copy was refreshed with the new content.'
562
+ : 'Your local copy was not refreshed automatically; the next fetch will pick it up.',
563
+ ...verdict.notes.map((n) => `Note: ${boundedToken(n, 200)}`),
564
+ ].join('\n');
565
+ }
566
+ module.exports = {
567
+ SECRET_SHAPES,
568
+ scanForSecrets,
569
+ PROMOTE_GUARD,
570
+ DEFAULT_VAULT_DIR,
571
+ promotePreflight,
572
+ editIndex,
573
+ formatIndexEntry,
574
+ buildExpectToken,
575
+ promoteStandard,
576
+ renderPromoteReport,
577
+ };