@pareto4/pp-core 1.9.0 → 1.10.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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/pp-code-fixer.md +11 -0
- package/agents/pp-code-reviewer.md +77 -1
- package/agents/pp-executor.md +34 -1
- package/agents/pp-planner.md +24 -103
- package/commands/pp-ns-manage.md +2 -1
- package/commands/pp-vault-promote.md +31 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +111 -2
- package/pp-core/bin/lib/capability-state.cjs +41 -3
- package/pp-core/bin/lib/check-command-router.cjs +473 -3
- package/pp-core/bin/lib/clusters.cjs +3 -0
- package/pp-core/bin/lib/io.cjs +7 -0
- package/pp-core/bin/lib/practices-review-check.cjs +938 -0
- package/pp-core/bin/lib/vault-command-router.cjs +108 -2
- package/pp-core/bin/lib/vault-practices.cjs +982 -0
- package/pp-core/bin/lib/vault-write-back.cjs +756 -0
- package/pp-core/bin/lib/vault.cjs +45 -3
- package/pp-core/bin/pp-tools.cjs +26 -0
- package/pp-core/references/planner-checkpoints.md +72 -0
- package/pp-core/references/planner-tdd.md +55 -0
- package/pp-core/references/planner-vault-practices.md +67 -0
- package/pp-core/templates/practices/README.md +122 -0
- package/pp-core/templates/practices/error-handling.md +18 -0
- package/pp-core/templates/practices/minimal-comments.md +14 -0
- package/pp-core/templates/practices/naming-convention.md +18 -0
- package/pp-core/templates/practices/practice-template.md +23 -0
- package/pp-core/workflows/code-review.md +74 -18
- package/pp-core/workflows/ship.md +1 -0
- package/pp-core/workflows/vault-write-back.md +213 -0
- package/scripts/gen-capability-registry.cjs +68 -8
- package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
- package/scripts/lint-descriptions.cjs +3 -1
- package/scripts/lint-legacy-dir-name.cjs +5 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
|
@@ -0,0 +1,756 @@
|
|
|
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 frozen `assertVaultWriteTarget` write-target
|
|
15
|
+
* check. Every stage emits the uniform refusal contract (D-16) via the
|
|
16
|
+
* `refusal` helper.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately a NEW module (not folded into `src/vault.cts` or
|
|
19
|
+
* `src/vault-command-router.cts`) — the name is load-bearing: it forms a
|
|
20
|
+
* fresh `vault-write-back` test-file cluster for
|
|
21
|
+
* `scripts/lint-test-file-count.cjs` instead of being swept into the
|
|
22
|
+
* `vault` cluster, which is already at its allowlisted cap of five files.
|
|
23
|
+
*
|
|
24
|
+
* Responsibility (Phase 29-02 additions — the read-only half of the write
|
|
25
|
+
* path): `previewPromotion` clones the client vault into a disposable temp
|
|
26
|
+
* directory, captures the current content of the destination as a pinned
|
|
27
|
+
* baseline, stages the promoted content, and asks git itself for the diff
|
|
28
|
+
* (or the full content on a create) — so the thing an operator confirms is
|
|
29
|
+
* generated from exactly the working-tree state a later confirmed run
|
|
30
|
+
* would push. `buildExpectToken` pins both sides (local content + remote
|
|
31
|
+
* baseline) into one opaque token 29-03's concurrent-edit guard checks
|
|
32
|
+
* against. This is the ONLY function in this module that calls `execGit`,
|
|
33
|
+
* and it never creates a commit or moves a remote ref.
|
|
34
|
+
*
|
|
35
|
+
* Responsibility (Phase 29-03 additions — the write half of the write
|
|
36
|
+
* path): `confirmPromotion` re-runs the full 29-01 preflight chain, refuses
|
|
37
|
+
* before any clone when the opaque `expect` token is missing or its pinned
|
|
38
|
+
* LOCAL hash no longer matches the freshly-read file, clones fresh (never
|
|
39
|
+
* reusing the preview's clone or the read cache), fetches the resolved ref
|
|
40
|
+
* and fast-forward-only pulls to absorb any unrelated remote movement, then
|
|
41
|
+
* re-derives the remote baseline from the now-current working tree and
|
|
42
|
+
* compares it against the token's pinned REMOTE hash (file-scoped —
|
|
43
|
+
* commit/tree shas are never compared, so an unrelated promotion never
|
|
44
|
+
* blocks this one). On a match it stages exactly the destination path,
|
|
45
|
+
* commits once, and pushes once with no force flag on any code path — a
|
|
46
|
+
* genuine non-fast-forward rejection from git is the intended structural
|
|
47
|
+
* backstop and is never retried. On success it refreshes the local read
|
|
48
|
+
* cache via `./vault.cjs`'s `vaultFetch` verbatim (D-11), treating a
|
|
49
|
+
* refresh failure as non-fatal (the push already succeeded). `promotePractice`
|
|
50
|
+
* is the single entry point 29-04's router calls, dispatching to
|
|
51
|
+
* `previewPromotion` or `confirmPromotion` on the `confirmed` flag.
|
|
52
|
+
*
|
|
53
|
+
* Responsibility (Phase 29-04 addition — the human-readable report half):
|
|
54
|
+
* `renderPromoteReport` renders any verdict `promotePractice` can produce as
|
|
55
|
+
* plain English (COMM-01), selected by the CLI router when `--raw` is
|
|
56
|
+
* absent.
|
|
57
|
+
*
|
|
58
|
+
* Out of scope here (later plans): the workflow/command docs (29-05).
|
|
59
|
+
*
|
|
60
|
+
* Reuses (no new runtime dependency):
|
|
61
|
+
* - `./vault-practices.cjs` (`parsePracticeFile`) — the ONLY format
|
|
62
|
+
* validator in the write path (D-03). Reused verbatim, never
|
|
63
|
+
* re-derived.
|
|
64
|
+
* - `./vault.cjs` (`cfgGet`, `readBindings`, `assertVaultWriteTarget`) —
|
|
65
|
+
* `assertVaultWriteTarget` is called verbatim and unmodified (D-05).
|
|
66
|
+
* - `./io.cjs` (`ERROR_REASON`) — the typed refusal reason codes added
|
|
67
|
+
* alongside this module.
|
|
68
|
+
*
|
|
69
|
+
* Test seams: pass `_vault` / `_practices` to inject mock modules (the
|
|
70
|
+
* repo's established `_`-prefix convention — see
|
|
71
|
+
* `src/vault-command-router.cts`). Production callers omit both; the real
|
|
72
|
+
* `./vault.cjs` / `./vault-practices.cjs` are lazy-required inside
|
|
73
|
+
* `promotePreflight` so they only load when a promotion actually runs.
|
|
74
|
+
*
|
|
75
|
+
* ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
|
|
76
|
+
* compiles to the git-ignored `pp-core/bin/lib/vault-write-back.cjs`; do NOT
|
|
77
|
+
* hand-edit the compiled output.
|
|
78
|
+
*/
|
|
79
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
80
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
81
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
82
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
83
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
|
+
const io = require("./io.cjs");
|
|
86
|
+
const { ERROR_REASON } = io;
|
|
87
|
+
// ─── Guard identity (PROMOTE_GUARD) ─────────────────────────────────────────────
|
|
88
|
+
// 29-02/29-03 reuse CONCURRENT_EDIT and TRANSPORT — declare all seven now so
|
|
89
|
+
// the enum is not edited twice.
|
|
90
|
+
const PROMOTE_GUARD = Object.freeze({
|
|
91
|
+
ACTIVATION: 'activation',
|
|
92
|
+
INPUT: 'input',
|
|
93
|
+
VALIDATION: 'validation',
|
|
94
|
+
SECRET_SCAN: 'secret_scan',
|
|
95
|
+
WRITE_TARGET: 'write_target',
|
|
96
|
+
CONCURRENT_EDIT: 'concurrent_edit',
|
|
97
|
+
TRANSPORT: 'transport',
|
|
98
|
+
});
|
|
99
|
+
// ─── Secret-content scanner (WRB-03 / D-13 / D-14) ──────────────────────────────
|
|
100
|
+
//
|
|
101
|
+
// Transliterated from scripts/secret-scan.sh's SECRET_PATTERNS (18 labeled
|
|
102
|
+
// POSIX ERE patterns) into JavaScript RegExp, preserving every human label
|
|
103
|
+
// verbatim, plus one ADDED shape the CI scanner is missing: a GitLab access
|
|
104
|
+
// token keyed on the `glpat-` prefix (confirmed absent from
|
|
105
|
+
// scripts/secret-scan.sh but already referenced in ad-hoc greps at
|
|
106
|
+
// pp-core/workflows/docs-update.md and pp-core/workflows/map-codebase.md).
|
|
107
|
+
//
|
|
108
|
+
// Order is declaration order and is load-bearing: the Anthropic shape is
|
|
109
|
+
// placed before the OpenAI shape so a key of the form `sk-ant-<alnum>` is
|
|
110
|
+
// never reported under the OpenAI label (the OpenAI pattern's `[A-Za-z0-9]`
|
|
111
|
+
// class alone would also match the alnum-only tail of an Anthropic key).
|
|
112
|
+
//
|
|
113
|
+
// Deliberately NOT ported: the `.secretscanignore` mechanism, an exclusion
|
|
114
|
+
// parameter, a placeholder carve-out, or an entropy heuristic — D-14's
|
|
115
|
+
// no-override rule is deliberate, and the generic assignment shapes' false-
|
|
116
|
+
// positive exposure is an accepted residual (T-29-02b).
|
|
117
|
+
const SECRET_SHAPES = Object.freeze([
|
|
118
|
+
{ label: 'AWS Access Key', re: /AKIA[0-9A-Z]{16}/ },
|
|
119
|
+
{ label: 'AWS Secret Key', re: /aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}/ },
|
|
120
|
+
{ label: 'Anthropic API Key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
121
|
+
{ label: 'OpenAI API Key', re: /sk-[A-Za-z0-9]{20,}/ },
|
|
122
|
+
{ label: 'GitHub PAT', re: /ghp_[A-Za-z0-9]{36}/ },
|
|
123
|
+
{ label: 'GitHub OAuth', re: /gho_[A-Za-z0-9]{36}/ },
|
|
124
|
+
{ label: 'GitHub App Token', re: /ghs_[A-Za-z0-9]{36}/ },
|
|
125
|
+
{ label: 'GitHub Fine-grained PAT', re: /github_pat_[A-Za-z0-9_]{20,}/ },
|
|
126
|
+
{ label: 'GitLab access token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
|
|
127
|
+
{ label: 'Stripe Secret Key', re: /sk_live_[A-Za-z0-9]{24,}/ },
|
|
128
|
+
{ label: 'Stripe Publishable Key', re: /pk_live_[A-Za-z0-9]{24,}/ },
|
|
129
|
+
{ label: 'Private Key Header', re: /-----BEGIN\s+(RSA|EC|DSA|OPENSSH)?\s*PRIVATE\s+KEY-----/ },
|
|
130
|
+
{ label: 'Slack Bot Token', re: /xoxb-[0-9]{10,}-[A-Za-z0-9]{20,}/ },
|
|
131
|
+
{
|
|
132
|
+
label: 'Slack Webhook',
|
|
133
|
+
re: /hooks\.slack\.com\/services\/T[A-Z0-9]{8,}\/B[A-Z0-9]{8,}\/[A-Za-z0-9]{24}/,
|
|
134
|
+
},
|
|
135
|
+
{ label: 'Google API Key', re: /AIza[A-Za-z0-9_-]{35}/ },
|
|
136
|
+
{ label: 'NPM Token', re: /npm_[A-Za-z0-9]{36}/ },
|
|
137
|
+
{
|
|
138
|
+
label: 'Env Variable Leak',
|
|
139
|
+
re: /(DATABASE_URL|DB_PASSWORD|REDIS_URL|MONGO_URI|JWT_SECRET|SESSION_SECRET|ENCRYPTION_KEY)\s*=\s*[^\s]{8,}/,
|
|
140
|
+
},
|
|
141
|
+
{ label: 'Generic API Key Assignment', re: /api[_-]?key\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
|
|
142
|
+
{ label: 'Generic Secret Assignment', re: /secret\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
|
|
143
|
+
{ label: 'Generic Token Assignment', re: /token\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i },
|
|
144
|
+
{ label: 'Generic Password Assignment', re: /password\s*[:=]\s*['"][^'"]{8,}['"]/i },
|
|
145
|
+
]);
|
|
146
|
+
/**
|
|
147
|
+
* Scan `content` line by line, walking SECRET_SHAPES in declaration order
|
|
148
|
+
* for each line, and return the FIRST match as `{ label, line }` (1-based).
|
|
149
|
+
* Returns null on clean content. Never places the matched substring, a
|
|
150
|
+
* slice of it, or a match index into the return value — name and line only
|
|
151
|
+
* (T-29-01).
|
|
152
|
+
*/
|
|
153
|
+
function scanForSecrets(content) {
|
|
154
|
+
const lines = content.split(/\r?\n/);
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const line = lines[i];
|
|
157
|
+
for (const shape of SECRET_SHAPES) {
|
|
158
|
+
if (shape.re.test(line)) {
|
|
159
|
+
return { label: shape.label, line: i + 1 };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// ─── Refusal contract (D-16) ─────────────────────────────────────────────────────
|
|
166
|
+
/**
|
|
167
|
+
* Build a uniform refusal verdict. `message` is collapsed to a single line
|
|
168
|
+
* (any CR/LF run becomes a space) so a client-authored fragment carried
|
|
169
|
+
* into `extra` or the cause clause can never inject a fake second line into
|
|
170
|
+
* operator output.
|
|
171
|
+
*/
|
|
172
|
+
function refusal(guard, reason, message, extra = {}) {
|
|
173
|
+
return {
|
|
174
|
+
status: 'refused',
|
|
175
|
+
guard,
|
|
176
|
+
reason,
|
|
177
|
+
message: message.replace(/[\r\n]+/g, ' '),
|
|
178
|
+
...extra,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Bound an untrusted value before it is echoed into a refusal message. */
|
|
182
|
+
function boundedToken(v, max = 80) {
|
|
183
|
+
let s;
|
|
184
|
+
try {
|
|
185
|
+
s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
s = String(v);
|
|
189
|
+
}
|
|
190
|
+
s = s.replace(/[\r\n]+/g, ' ');
|
|
191
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
192
|
+
}
|
|
193
|
+
// ─── Preflight guard chain ───────────────────────────────────────────────────────
|
|
194
|
+
/**
|
|
195
|
+
* Run the promote preflight guard chain: activation, input, format
|
|
196
|
+
* validation (D-03), secret scan (D-13/D-14), target resolution, and the
|
|
197
|
+
* write-target guard (D-05) — strictly in that order, refusing at the
|
|
198
|
+
* first failing stage and never continuing past it. Makes no network
|
|
199
|
+
* access and spawns no subprocess.
|
|
200
|
+
*/
|
|
201
|
+
function promotePreflight({ cwd, filePath, _vault, _practices }) {
|
|
202
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
203
|
+
const vault = _vault ?? require('./vault.cjs');
|
|
204
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
205
|
+
const practices = _practices ?? require('./vault-practices.cjs');
|
|
206
|
+
// 1. Activation — both vault.enabled and vault.practices_enabled must be
|
|
207
|
+
// strictly true. Runs before any filesystem access; rides the two
|
|
208
|
+
// existing default-false config keys (no new config key, no capability
|
|
209
|
+
// surface widening).
|
|
210
|
+
const enabled = vault.cfgGet(cwd, 'vault.enabled', false);
|
|
211
|
+
const practicesEnabled = vault.cfgGet(cwd, 'vault.practices_enabled', false);
|
|
212
|
+
if (enabled !== true || practicesEnabled !== true) {
|
|
213
|
+
return refusal(PROMOTE_GUARD.ACTIVATION, ERROR_REASON.VAULT_PROMOTE_DISABLED, 'This practice was not promoted: vault write-back is switched off for this project. ' +
|
|
214
|
+
'Set both vault.enabled and vault.practices_enabled to true in .planning/config.json and run the promotion again.');
|
|
215
|
+
}
|
|
216
|
+
// 2. Input — a path to an already-authored practice file (D-02); this
|
|
217
|
+
// module never composes, rewrites, reformats, or extracts a practice.
|
|
218
|
+
if (typeof filePath !== 'string' ||
|
|
219
|
+
filePath.length === 0 ||
|
|
220
|
+
!filePath.endsWith('.md') ||
|
|
221
|
+
!node_fs_1.default.existsSync(filePath) ||
|
|
222
|
+
!node_fs_1.default.statSync(filePath).isFile()) {
|
|
223
|
+
return refusal(PROMOTE_GUARD.INPUT, ERROR_REASON.SDK_MISSING_ARG, `This practice was not promoted: ${boundedToken(filePath)} is not an existing practice file. ` +
|
|
224
|
+
'Point at an existing .md practice file and run the promotion again.');
|
|
225
|
+
}
|
|
226
|
+
// Read the FULL file content — not the bounded prefix discovery uses —
|
|
227
|
+
// because both the validation oracle and the secret scan must see the
|
|
228
|
+
// whole file.
|
|
229
|
+
const raw = node_fs_1.default.readFileSync(filePath, 'utf-8');
|
|
230
|
+
// 3. Validation — the Phase 24 parser is the only format validator in
|
|
231
|
+
// this write path (D-03). No second validator, no new format rule.
|
|
232
|
+
const parsed = practices.parsePracticeFile(node_path_1.default.basename(filePath), raw);
|
|
233
|
+
if (!parsed.ok) {
|
|
234
|
+
const cause = parsed.warning.replace(/^.*?\s+skipped:\s*/, '');
|
|
235
|
+
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.`);
|
|
236
|
+
}
|
|
237
|
+
// 4. Secret scan — a hit refuses hard, no override (D-14). The matched
|
|
238
|
+
// text is never carried, quoted, echoed, logged, or truncated into the
|
|
239
|
+
// refusal — a truncated secret is still a partial disclosure.
|
|
240
|
+
const hit = scanForSecrets(raw);
|
|
241
|
+
if (hit) {
|
|
242
|
+
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 });
|
|
243
|
+
}
|
|
244
|
+
// 5. Target resolution — client.vaultRepo / client.vaultRef / host.
|
|
245
|
+
// Deliberately does NOT call resolveBaseBranch (src/git-base-branch.cts)
|
|
246
|
+
// — that resolver answers a question about this project's OWN
|
|
247
|
+
// repository, a different repository from the client vault.
|
|
248
|
+
const bindings = vault.readBindings(cwd);
|
|
249
|
+
const client = bindings && typeof bindings.client === 'object' && bindings.client !== null
|
|
250
|
+
? bindings.client
|
|
251
|
+
: null;
|
|
252
|
+
const targetRepo = client && typeof client.vaultRepo === 'string' ? client.vaultRepo : null;
|
|
253
|
+
if (!targetRepo) {
|
|
254
|
+
return refusal(PROMOTE_GUARD.WRITE_TARGET, ERROR_REASON.VAULT_PROMOTE_TARGET_REFUSED, 'This practice was not promoted: no client vault repository is configured. ' +
|
|
255
|
+
'Check client.vaultRepo in .platform-bindings.json and run the promotion again.');
|
|
256
|
+
}
|
|
257
|
+
// T-29-26: a repo or ref that git would read as an option is refused here,
|
|
258
|
+
// before any transport exists, rather than being handed to git and surfacing
|
|
259
|
+
// as a confusing "repository does not exist". Fails closed.
|
|
260
|
+
const flagShaped = [
|
|
261
|
+
['client.vaultRepo', targetRepo],
|
|
262
|
+
['client.vaultRef', client && typeof client.vaultRef === 'string' ? client.vaultRef : ''],
|
|
263
|
+
].find(([, v]) => typeof v === 'string' && v.trim().startsWith('-'));
|
|
264
|
+
if (flagShaped) {
|
|
265
|
+
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. ` +
|
|
266
|
+
'Fix it in .platform-bindings.json and run the promotion again.');
|
|
267
|
+
}
|
|
268
|
+
const targetRef = client && typeof client.vaultRef === 'string' && client.vaultRef.trim() ? client.vaultRef.trim() : 'main';
|
|
269
|
+
const platformHost = vault.cfgGet(cwd, 'platform.host', null);
|
|
270
|
+
const targetHost = client && typeof client.vaultHost === 'string' && client.vaultHost.trim()
|
|
271
|
+
? client.vaultHost.trim()
|
|
272
|
+
: typeof platformHost === 'string' && platformHost.trim()
|
|
273
|
+
? platformHost.trim()
|
|
274
|
+
: 'gitlab.com';
|
|
275
|
+
// 6. Write-target guard — assertVaultWriteTarget is called verbatim and
|
|
276
|
+
// unmodified (D-05); this arm never re-derives, wraps, relaxes, or
|
|
277
|
+
// reimplements any of its three refusals.
|
|
278
|
+
try {
|
|
279
|
+
vault.assertVaultWriteTarget(cwd, targetRepo);
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
const msg = (e instanceof Error ? e.message : String(e)).replace(/^vault write refused:\s*/, '');
|
|
283
|
+
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.`);
|
|
284
|
+
}
|
|
285
|
+
// Destination is derived from the stable frontmatter id — never the
|
|
286
|
+
// local filename, never an operator-supplied value (D-04).
|
|
287
|
+
const destination = node_path_1.default.posix.join('practices', `${parsed.practice.id}.md`);
|
|
288
|
+
return {
|
|
289
|
+
status: 'ok',
|
|
290
|
+
practice: parsed.practice,
|
|
291
|
+
raw,
|
|
292
|
+
repo: targetRepo,
|
|
293
|
+
ref: targetRef,
|
|
294
|
+
host: targetHost,
|
|
295
|
+
destination,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// ─── Preview (read-only half of the write path) ─────────────────────────────────
|
|
299
|
+
/**
|
|
300
|
+
* Build the opaque `expect` token that pins BOTH what the operator confirmed
|
|
301
|
+
* locally and what the vault held at that moment, without any state file
|
|
302
|
+
* across two separate process invocations: the hex sha256 of the local raw
|
|
303
|
+
* content, a period, then either the hex sha256 of the remote baseline or
|
|
304
|
+
* the literal `absent` when the destination file does not (yet) exist in
|
|
305
|
+
* the vault. Callers must treat the result as opaque.
|
|
306
|
+
*/
|
|
307
|
+
function buildExpectToken(localRaw, remoteBaseline) {
|
|
308
|
+
const localHash = node_crypto_1.default.createHash('sha256').update(localRaw).digest('hex');
|
|
309
|
+
const remoteHash = remoteBaseline === null ? 'absent' : node_crypto_1.default.createHash('sha256').update(remoteBaseline).digest('hex');
|
|
310
|
+
return `${localHash}.${remoteHash}`;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Preview a promotion: clone the client vault into a disposable temp
|
|
314
|
+
* directory, capture the destination's current content as a pinned
|
|
315
|
+
* baseline, stage the promoted content, and ask git for a diff (update) or
|
|
316
|
+
* hand back the full content (create). Runs the full 29-01 preflight guard
|
|
317
|
+
* chain first and returns its refusal unchanged, with no clone attempted,
|
|
318
|
+
* on any refusal. Never creates a commit, never moves a remote ref, and
|
|
319
|
+
* never reads or writes the project's own vault read cache (a fresh
|
|
320
|
+
* mkdtemp working copy is used and always removed, even on a thrown error).
|
|
321
|
+
*/
|
|
322
|
+
/**
|
|
323
|
+
* True when `value` is already something git can clone directly.
|
|
324
|
+
*
|
|
325
|
+
* MUST mirror `isLiteralCloneTarget` in `src/vault.cts`.
|
|
326
|
+
*
|
|
327
|
+
* Security (T-29-26): a value that git would read as an OPTION must never take
|
|
328
|
+
* the verbatim branch. Two rules enforce that:
|
|
329
|
+
* - the scheme test is anchored at the START (`^scheme://`). An earlier
|
|
330
|
+
* version used `value.includes('://')`, so `--upload-pack=…#x://` — a git
|
|
331
|
+
* option carrying `://` anywhere — passed through untouched and reached
|
|
332
|
+
* `git clone` as a flag.
|
|
333
|
+
* - the scp-style test may not begin with `-`. `[\w.-]+@` accepted a leading
|
|
334
|
+
* hyphen, so `--config=core.sshCommand=…@x` matched too.
|
|
335
|
+
* A leading `-` is additionally refused outright by `flagShapedRepoRefusal`, and
|
|
336
|
+
* every clone call site passes `--` before the target, so all three would have
|
|
337
|
+
* to fail together for an option to reach git.
|
|
338
|
+
*/
|
|
339
|
+
function isLiteralCloneTarget(value) {
|
|
340
|
+
if (value.startsWith('-'))
|
|
341
|
+
return false;
|
|
342
|
+
return (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) ||
|
|
343
|
+
/^[\w.][\w.-]*@/.test(value) ||
|
|
344
|
+
value.startsWith('/') ||
|
|
345
|
+
value.startsWith('./') ||
|
|
346
|
+
value.startsWith('../') ||
|
|
347
|
+
value.startsWith('~') ||
|
|
348
|
+
/^[A-Za-z]:[\\/]/.test(value));
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Resolve a configured vault repo value into something `git clone` can reach.
|
|
352
|
+
*
|
|
353
|
+
* `client.vaultRepo` carries two legitimate shapes, and before this resolver
|
|
354
|
+
* existed only the first one worked on the write path:
|
|
355
|
+
*
|
|
356
|
+
* - a literal clone target — an absolute path, a `file://`/`https://`/`ssh://`
|
|
357
|
+
* URL, or scp-style `git@host:group/project` — used verbatim. Every test
|
|
358
|
+
* fixture and every disposable bare-repo harness uses this shape, which is
|
|
359
|
+
* why the gap below stayed invisible to the suite.
|
|
360
|
+
* - a `group/project` slug, which is the ONLY shape
|
|
361
|
+
* `schemas/platform-bindings.schema.json` (`vaultRepo.pattern`) and its own
|
|
362
|
+
* documented example `pareto4-clients/acme/acme-vault` actually permit. A
|
|
363
|
+
* slug handed straight to `git clone` is read as a RELATIVE PATH and fails
|
|
364
|
+
* with "repository does not exist" — so the write-back arm could never
|
|
365
|
+
* reach a real client vault, and the refusal blamed the operator's access.
|
|
366
|
+
*
|
|
367
|
+
* The URL built for a slug mirrors `cloneTier` in `src/vault.cts` byte for byte
|
|
368
|
+
* (`https://<host>/<repo>.git`), so the read and write paths address the same
|
|
369
|
+
* remote instead of disagreeing about what `client.vaultRepo` means.
|
|
370
|
+
*/
|
|
371
|
+
function resolveCloneTarget(repo, host) {
|
|
372
|
+
const value = repo.trim();
|
|
373
|
+
if (isLiteralCloneTarget(value))
|
|
374
|
+
return value;
|
|
375
|
+
return `https://${host}/${value}.git`;
|
|
376
|
+
}
|
|
377
|
+
function previewPromotion({ cwd, filePath, _exec, _vault, _practices }) {
|
|
378
|
+
const gitExec = _exec ?? shell_command_projection_cjs_1.execGit;
|
|
379
|
+
// 1. Run the full preflight guard chain; refuse unchanged with no clone.
|
|
380
|
+
const pre = promotePreflight({ cwd, filePath, _vault, _practices });
|
|
381
|
+
if (pre.status !== 'ok')
|
|
382
|
+
return pre;
|
|
383
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
384
|
+
const vault = _vault ?? require('./vault.cjs');
|
|
385
|
+
const notes = [];
|
|
386
|
+
// The disposable working copy is a fresh mkdtemp directory — never the
|
|
387
|
+
// read cache, which is often a shallow/sparse clone from a different tier
|
|
388
|
+
// and is refreshed independently after a push. Removed on every exit path.
|
|
389
|
+
let cloneDir = null;
|
|
390
|
+
try {
|
|
391
|
+
cloneDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-promote-'));
|
|
392
|
+
// 2. Clone the resolved client repo at the resolved ref — a full
|
|
393
|
+
// single-branch clone (no shallow-depth flag: the confirmed path needs
|
|
394
|
+
// real history to fast-forward against). The target goes through
|
|
395
|
+
// resolveCloneTarget so a `group/project` slug becomes a real URL on the
|
|
396
|
+
// resolved host instead of a relative path git cannot find.
|
|
397
|
+
const cl = gitExec(['clone', '--branch', pre.ref, '--single-branch', '--', resolveCloneTarget(pre.repo, pre.host), cloneDir], { timeout: 120_000 });
|
|
398
|
+
if (cl.exitCode !== 0) {
|
|
399
|
+
const cause = boundedToken((cl.stderr || '').split(/\r?\n/)[0] || 'unknown error');
|
|
400
|
+
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.`);
|
|
401
|
+
}
|
|
402
|
+
// 3. Capture the baseline BEFORE writing — an id already present in the
|
|
403
|
+
// client vault is an update, not a refusal and not an opt-in flag
|
|
404
|
+
// (D-06). The baseline captured here is what 29-03's concurrent-edit
|
|
405
|
+
// check pins against.
|
|
406
|
+
const destAbs = node_path_1.default.join(cloneDir, pre.destination);
|
|
407
|
+
const baselineExists = node_fs_1.default.existsSync(destAbs);
|
|
408
|
+
const baseline = baselineExists ? node_fs_1.default.readFileSync(destAbs, 'utf-8') : null;
|
|
409
|
+
const mode = baselineExists ? 'update' : 'create';
|
|
410
|
+
// 4. Write the raw content unchanged — no reformatting, no
|
|
411
|
+
// re-serialization, no line-ending normalization.
|
|
412
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(destAbs), { recursive: true });
|
|
413
|
+
node_fs_1.default.writeFileSync(destAbs, pre.raw, 'utf-8');
|
|
414
|
+
// 5. Ask git for the preview rather than computing one. `core.quotepath=off`
|
|
415
|
+
// so a non-ASCII destination path is not escaped in operator-read output.
|
|
416
|
+
let diff;
|
|
417
|
+
let content;
|
|
418
|
+
if (mode === 'update') {
|
|
419
|
+
const diffRes = gitExec(['-c', 'core.quotepath=off', 'diff', '--', pre.destination], { cwd: cloneDir });
|
|
420
|
+
diff = diffRes.stdout;
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
content = pre.raw;
|
|
424
|
+
}
|
|
425
|
+
// 6. Shared-shadow lookup — resolve through the vault module's cfgGet so
|
|
426
|
+
// the existing defaults apply. Skipped entirely (flag stays false) when
|
|
427
|
+
// the shared repo is unset or equals the client repo.
|
|
428
|
+
let sharedShadow = false;
|
|
429
|
+
let sharedShadowNote = null;
|
|
430
|
+
const sharedRepoRaw = vault.cfgGet(cwd, 'vault.fallback_repo', '');
|
|
431
|
+
const sharedRefRaw = vault.cfgGet(cwd, 'vault.fallback_ref', 'main');
|
|
432
|
+
const sharedRepo = typeof sharedRepoRaw === 'string' ? sharedRepoRaw : '';
|
|
433
|
+
const sharedRef = typeof sharedRefRaw === 'string' && sharedRefRaw ? sharedRefRaw : 'main';
|
|
434
|
+
if (sharedRepo && sharedRepo !== pre.repo) {
|
|
435
|
+
let sharedDir = null;
|
|
436
|
+
try {
|
|
437
|
+
sharedDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-shared-'));
|
|
438
|
+
// Shallow single-branch: only file presence matters, no history needed.
|
|
439
|
+
// The shared vault is addressed on the PLATFORM host, not the client's
|
|
440
|
+
// — mirroring the fallback tier in src/vault.cts's cloneTier, so a
|
|
441
|
+
// client with a custom vaultHost never sends the shared-shadow lookup
|
|
442
|
+
// to their own host.
|
|
443
|
+
const sharedPlatformHostRaw = vault.cfgGet(cwd, 'platform.host', null);
|
|
444
|
+
const sharedHost = typeof sharedPlatformHostRaw === 'string' && sharedPlatformHostRaw.trim()
|
|
445
|
+
? sharedPlatformHostRaw.trim()
|
|
446
|
+
: 'gitlab.com';
|
|
447
|
+
const sc = gitExec([
|
|
448
|
+
'clone',
|
|
449
|
+
'--depth',
|
|
450
|
+
'1',
|
|
451
|
+
'--branch',
|
|
452
|
+
sharedRef,
|
|
453
|
+
'--single-branch',
|
|
454
|
+
'--',
|
|
455
|
+
resolveCloneTarget(sharedRepo, sharedHost),
|
|
456
|
+
sharedDir,
|
|
457
|
+
], { timeout: 120_000 });
|
|
458
|
+
if (sc.exitCode === 0) {
|
|
459
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(sharedDir, pre.destination))) {
|
|
460
|
+
sharedShadow = true;
|
|
461
|
+
sharedShadowNote =
|
|
462
|
+
"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.";
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
// An unreachable/failed shared vault must never block, fail, or
|
|
467
|
+
// slow-fail a preview — leave the flag false and note it.
|
|
468
|
+
notes.push('the shared vault could not be checked');
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
notes.push('the shared vault could not be checked');
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
if (sharedDir)
|
|
476
|
+
node_fs_1.default.rmSync(sharedDir, { recursive: true, force: true });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// 7 & 8. Pin both sides in the opaque expect token, then return.
|
|
480
|
+
const expect = buildExpectToken(pre.raw, baseline);
|
|
481
|
+
return {
|
|
482
|
+
status: 'preview',
|
|
483
|
+
repo: pre.repo,
|
|
484
|
+
ref: pre.ref,
|
|
485
|
+
destination: pre.destination,
|
|
486
|
+
mode,
|
|
487
|
+
id: boundedToken(pre.practice.id),
|
|
488
|
+
diff,
|
|
489
|
+
content,
|
|
490
|
+
sharedShadow,
|
|
491
|
+
sharedShadowNote,
|
|
492
|
+
expect,
|
|
493
|
+
notes,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
if (cloneDir)
|
|
498
|
+
node_fs_1.default.rmSync(cloneDir, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// ─── Confirm (write half of the write path) ──────────────────────────────────────
|
|
502
|
+
/** Build a TRANSPORT refusal from a failed git invocation's stderr (first line only). */
|
|
503
|
+
function transportRefusal(res) {
|
|
504
|
+
const cause = boundedToken((res.stderr || '').split(/\r?\n/)[0] || 'unknown error');
|
|
505
|
+
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.`);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Confirm a promotion: re-run the full 29-01 preflight chain (never trust
|
|
509
|
+
* the preview), re-verify the opaque `expect` token against a freshly read
|
|
510
|
+
* local file AND a freshly cloned remote baseline, absorb unrelated remote
|
|
511
|
+
* movement with a fast-forward-only pull, then stage/commit/push exactly
|
|
512
|
+
* the destination path with no force flag on any code path. On success,
|
|
513
|
+
* refreshes the local read cache via `./vault.cjs`'s `vaultFetch` verbatim.
|
|
514
|
+
*
|
|
515
|
+
* Deliberate deviation from a literal `git show`-based remote-baseline read
|
|
516
|
+
* (Rule 1 bug avoidance): `execGit`'s shared `_spawnResult` unconditionally
|
|
517
|
+
* `.trim()`s captured stdout, which would silently strip a file's trailing
|
|
518
|
+
* newline and make every legitimate hash comparison spuriously mismatch.
|
|
519
|
+
* Instead this re-derives the remote baseline from the filesystem AFTER the
|
|
520
|
+
* fast-forward-only pull (the same fs-read technique `previewPromotion`
|
|
521
|
+
* already uses for its own baseline capture) — byte-exact, and the pull
|
|
522
|
+
* step the plan already requires does the "sync to the fetched ref" work
|
|
523
|
+
* for free, so no separate `git show`/`cat-file` call is needed.
|
|
524
|
+
*/
|
|
525
|
+
function confirmPromotion({ cwd, filePath, expect, _exec, _vault, _practices }) {
|
|
526
|
+
const gitExec = _exec ?? shell_command_projection_cjs_1.execGit;
|
|
527
|
+
// 1. Full preflight re-run — never trust the preview. Re-derives repo,
|
|
528
|
+
// ref, host, and destination from scratch; never accepts a caller-
|
|
529
|
+
// supplied id/destination/repo/ref.
|
|
530
|
+
const pre = promotePreflight({ cwd, filePath, _vault, _practices });
|
|
531
|
+
if (pre.status !== 'ok')
|
|
532
|
+
return pre;
|
|
533
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
534
|
+
const vault = _vault ?? require('./vault.cjs');
|
|
535
|
+
// 2. Missing/empty token → refuse before any clone.
|
|
536
|
+
if (!expect || typeof expect !== 'string' || expect.length === 0) {
|
|
537
|
+
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. ' +
|
|
538
|
+
'Run the promotion without confirming first, then pass the token it prints.');
|
|
539
|
+
}
|
|
540
|
+
// 3. Split the token; a malformed token (no separator) naturally fails the
|
|
541
|
+
// local-hash comparison below rather than needing a distinct code path.
|
|
542
|
+
const sepIdx = expect.indexOf('.');
|
|
543
|
+
const pinnedLocal = sepIdx === -1 ? expect : expect.slice(0, sepIdx);
|
|
544
|
+
const pinnedRemote = sepIdx === -1 ? '' : expect.slice(sepIdx + 1);
|
|
545
|
+
// 4. Recompute the local hash from the FRESHLY read content (pre.raw came
|
|
546
|
+
// from this invocation's own promotePreflight, not the preview's). A
|
|
547
|
+
// mismatch means the local file changed since the operator previewed it —
|
|
548
|
+
// this is what makes D-10's byte-identity claim hold across two separate
|
|
549
|
+
// process invocations (T-29-13).
|
|
550
|
+
const localHash = node_crypto_1.default.createHash('sha256').update(pre.raw).digest('hex');
|
|
551
|
+
if (localHash !== pinnedLocal) {
|
|
552
|
+
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. ' +
|
|
553
|
+
'Run the promotion again to preview the new version.');
|
|
554
|
+
}
|
|
555
|
+
// 5. Clone into a fresh disposable mkdtemp directory — exactly as the
|
|
556
|
+
// preview does, but NEVER the preview's own clone directory and NEVER the
|
|
557
|
+
// read cache.
|
|
558
|
+
let cloneDir = null;
|
|
559
|
+
try {
|
|
560
|
+
cloneDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-confirm-'));
|
|
561
|
+
const cl = gitExec(['clone', '--branch', pre.ref, '--single-branch', '--', resolveCloneTarget(pre.repo, pre.host), cloneDir], { timeout: 120_000 });
|
|
562
|
+
if (cl.exitCode !== 0)
|
|
563
|
+
return transportRefusal(cl);
|
|
564
|
+
// 6. Fetch the resolved ref from origin, then absorb any unrelated
|
|
565
|
+
// remote movement with a fast-forward-only pull. The file-scoped
|
|
566
|
+
// baseline check below runs AFTER this on purpose: a target-file race
|
|
567
|
+
// is still caught by the hash comparison, and a race that instead
|
|
568
|
+
// defeats the transport layer (an unrelated push landing between this
|
|
569
|
+
// pull and this module's own push, below) fails closed on the push
|
|
570
|
+
// itself — the intended structural backstop (T-29-04), never retried.
|
|
571
|
+
const ft = gitExec(['fetch', 'origin', pre.ref], { cwd: cloneDir, timeout: 120_000 });
|
|
572
|
+
if (ft.exitCode !== 0)
|
|
573
|
+
return transportRefusal(ft);
|
|
574
|
+
const pull = gitExec(['pull', '--ff-only', 'origin', pre.ref], { cwd: cloneDir, timeout: 120_000 });
|
|
575
|
+
if (pull.exitCode !== 0)
|
|
576
|
+
return transportRefusal(pull);
|
|
577
|
+
// 7. Re-derive the remote baseline from the now fast-forwarded working
|
|
578
|
+
// tree (fs-level read, byte-exact — never git-stdout-derived, which is
|
|
579
|
+
// silently trimmed by the shared exec helper). Scoped to the
|
|
580
|
+
// destination file only (D-15): commit/tree shas are never compared,
|
|
581
|
+
// so an unrelated promotion never blocks this one.
|
|
582
|
+
const destAbs = node_path_1.default.join(cloneDir, pre.destination);
|
|
583
|
+
const remoteExists = node_fs_1.default.existsSync(destAbs);
|
|
584
|
+
const remoteContent = remoteExists ? node_fs_1.default.readFileSync(destAbs, 'utf-8') : null;
|
|
585
|
+
const remoteHash = remoteContent === null ? 'absent' : node_crypto_1.default.createHash('sha256').update(remoteContent).digest('hex');
|
|
586
|
+
if (remoteHash !== pinnedRemote) {
|
|
587
|
+
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. ` +
|
|
588
|
+
'Run the promotion again to see their version before you push.');
|
|
589
|
+
}
|
|
590
|
+
const mode = remoteExists ? 'update' : 'create';
|
|
591
|
+
// 8. Write the promoted content, then stage exactly that one path.
|
|
592
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(destAbs), { recursive: true });
|
|
593
|
+
node_fs_1.default.writeFileSync(destAbs, pre.raw, 'utf-8');
|
|
594
|
+
const add = gitExec(['add', '--', pre.destination], { cwd: cloneDir });
|
|
595
|
+
if (add.exitCode !== 0)
|
|
596
|
+
return transportRefusal(add);
|
|
597
|
+
// 9. Configure identity only if git reports none — preserve an
|
|
598
|
+
// operator's own configured identity when present.
|
|
599
|
+
const idCheck = gitExec(['config', 'user.email'], { cwd: cloneDir });
|
|
600
|
+
if (idCheck.exitCode !== 0 || !idCheck.stdout.trim()) {
|
|
601
|
+
gitExec(['config', 'user.email', 'pp-core-vault-promote@pareto.local'], { cwd: cloneDir });
|
|
602
|
+
gitExec(['config', 'user.name', 'pp-core vault promote'], { cwd: cloneDir });
|
|
603
|
+
}
|
|
604
|
+
// 10. Commit once.
|
|
605
|
+
const id = pre.practice.id;
|
|
606
|
+
const commitMessage = `practice(${mode}): ${boundedToken(id)}\n\nPromoted from this project via pp-core's vault write-back arm.`;
|
|
607
|
+
const commitRes = gitExec(['commit', '-m', commitMessage], { cwd: cloneDir });
|
|
608
|
+
if (commitRes.exitCode !== 0)
|
|
609
|
+
return transportRefusal(commitRes);
|
|
610
|
+
// 11. Push once — origin, local HEAD mapped to the resolved ref. No
|
|
611
|
+
// force flag, no lease flag, no short-form alias, not on this call, not
|
|
612
|
+
// on a retry. A non-fast-forward rejection is the intended structural
|
|
613
|
+
// backstop for a race that defeats the file-scoped check above; never
|
|
614
|
+
// re-run, never looped, never escalated.
|
|
615
|
+
const push = gitExec(['push', 'origin', `HEAD:${pre.ref}`], { cwd: cloneDir, timeout: 120_000 });
|
|
616
|
+
if (push.exitCode !== 0) {
|
|
617
|
+
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. ' +
|
|
618
|
+
'Run the promotion again to preview against the current version.');
|
|
619
|
+
}
|
|
620
|
+
const shaRes = gitExec(['rev-parse', '--short', 'HEAD'], { cwd: cloneDir });
|
|
621
|
+
const commit = shaRes.stdout.trim();
|
|
622
|
+
// 12. Refresh the local read cache by reusing vaultFetch verbatim
|
|
623
|
+
// (D-11) — no new caching logic, never a direct write into
|
|
624
|
+
// .planning/vault/. A refresh failure is non-fatal: the push already
|
|
625
|
+
// succeeded and must be reported as such.
|
|
626
|
+
//
|
|
627
|
+
// vaultFetch has its own TTL-based freshness short-circuit (returns the
|
|
628
|
+
// PRIOR manifest untouched when it is recent and the ref matches — see
|
|
629
|
+
// src/vault.cts, frozen/unmodified here). Left alone, a promote that
|
|
630
|
+
// runs shortly after any recent fetch (routine — e.g. the plan:pre gate
|
|
631
|
+
// fetched minutes ago) would short-circuit on the now-stale prior
|
|
632
|
+
// manifest and serve pre-promotion content, defeating D-11's entire
|
|
633
|
+
// purpose ("the operator's next plan/review/ship sees the promoted
|
|
634
|
+
// practice"). Clearing ONLY the staleness marker (never writing cache
|
|
635
|
+
// content — that stays vaultFetch's job) forces a genuine re-fetch.
|
|
636
|
+
try {
|
|
637
|
+
node_fs_1.default.rmSync(node_path_1.default.join(cwd, '.planning', 'vault', '.manifest.json'), { force: true });
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
/* best-effort; a failed clear just means vaultFetch may short-circuit below */
|
|
641
|
+
}
|
|
642
|
+
let cacheRefreshed = false;
|
|
643
|
+
const notes = [];
|
|
644
|
+
try {
|
|
645
|
+
const manifest = vault.vaultFetch(cwd);
|
|
646
|
+
cacheRefreshed = !!manifest && manifest.source === 'fetched';
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
cacheRefreshed = false;
|
|
650
|
+
}
|
|
651
|
+
if (!cacheRefreshed) {
|
|
652
|
+
notes.push('the local copy could not be refreshed; the next fetch will pick it up');
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
status: 'pushed',
|
|
656
|
+
repo: pre.repo,
|
|
657
|
+
ref: pre.ref,
|
|
658
|
+
destination: pre.destination,
|
|
659
|
+
mode,
|
|
660
|
+
id: boundedToken(id),
|
|
661
|
+
commit,
|
|
662
|
+
cacheRefreshed,
|
|
663
|
+
notes,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
if (cloneDir)
|
|
668
|
+
node_fs_1.default.rmSync(cloneDir, { recursive: true, force: true });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Single entry point 29-04's router calls: dispatches to `confirmPromotion`
|
|
673
|
+
* when `confirmed` is true, otherwise to `previewPromotion`.
|
|
674
|
+
*/
|
|
675
|
+
function promotePractice({ cwd, filePath, confirmed, expect, _exec, _vault, _practices, }) {
|
|
676
|
+
if (confirmed) {
|
|
677
|
+
return confirmPromotion({ cwd, filePath, expect, _exec, _vault, _practices });
|
|
678
|
+
}
|
|
679
|
+
return previewPromotion({ cwd, filePath, _exec, _vault, _practices });
|
|
680
|
+
}
|
|
681
|
+
// ─── Report rendering (human-readable half of the D-16 output contract) ────────
|
|
682
|
+
/**
|
|
683
|
+
* Render any verdict `promotePractice` can produce as plain English (the
|
|
684
|
+
* COMM-01 bar, `docs/practices-plain-english-standard.md`) — the form 29-04's
|
|
685
|
+
* CLI router selects when `--raw` is absent. Every vault-authored token (id,
|
|
686
|
+
* repo, destination, notes) passes through `boundedToken` before
|
|
687
|
+
* interpolation (T-29-07) — these values ultimately derive from an
|
|
688
|
+
* operator-supplied practice file, not this module's own logic. For a
|
|
689
|
+
* refusal this is exactly the single sentence `refusal()` already built. For
|
|
690
|
+
* a preview it states the target repo, the destination path, whether the
|
|
691
|
+
* promotion creates or updates a practice, the shared-vault disclosure
|
|
692
|
+
* sentence when that flag is set, a line stating the secret scan found
|
|
693
|
+
* nothing, the token line the confirmed run needs, and then the complete
|
|
694
|
+
* diff or complete content under a heading — never elided, because the
|
|
695
|
+
* operator is confirming exactly these bytes. For a push it states the
|
|
696
|
+
* repo, the destination, the new commit, and whether the local copy was
|
|
697
|
+
* refreshed.
|
|
698
|
+
*/
|
|
699
|
+
function renderPromoteReport(verdict) {
|
|
700
|
+
if (verdict.status === 'refused') {
|
|
701
|
+
return verdict.message;
|
|
702
|
+
}
|
|
703
|
+
if (verdict.status === 'preview') {
|
|
704
|
+
const lines = [];
|
|
705
|
+
const action = verdict.mode === 'create' ? 'creates a new practice' : 'updates an existing practice';
|
|
706
|
+
lines.push(`This promotes "${boundedToken(verdict.id)}" to ${boundedToken(verdict.repo)} at ${boundedToken(verdict.destination)}.`);
|
|
707
|
+
lines.push(`This ${action} in the vault.`);
|
|
708
|
+
if (verdict.sharedShadow && verdict.sharedShadowNote) {
|
|
709
|
+
lines.push(boundedToken(verdict.sharedShadowNote));
|
|
710
|
+
}
|
|
711
|
+
lines.push('The secret scan found nothing to flag.');
|
|
712
|
+
// NOT boundedToken: the expect token is generated by buildExpectToken (a
|
|
713
|
+
// sha256 pair, fixed length, no untrusted input), and it is the one value
|
|
714
|
+
// on this page the operator must copy VERBATIM. Bounding it truncated the
|
|
715
|
+
// remote half with an ellipsis on every update — create mode escaped only
|
|
716
|
+
// because its remote half is the short literal "absent" — and pasting the
|
|
717
|
+
// truncated value produced the concurrent-edit refusal, telling the
|
|
718
|
+
// operator someone else had changed the practice when nobody had.
|
|
719
|
+
lines.push(`To push this, run the promotion again with --confirmed --expect ${verdict.expect}.`);
|
|
720
|
+
for (const note of verdict.notes) {
|
|
721
|
+
lines.push(`Note: ${boundedToken(note)}`);
|
|
722
|
+
}
|
|
723
|
+
lines.push('');
|
|
724
|
+
if (verdict.mode === 'update') {
|
|
725
|
+
lines.push('## Full diff');
|
|
726
|
+
lines.push(verdict.diff && verdict.diff.length > 0 ? verdict.diff : '(no changes)');
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
lines.push('## Full content');
|
|
730
|
+
lines.push(verdict.content ?? '');
|
|
731
|
+
}
|
|
732
|
+
return lines.join('\n');
|
|
733
|
+
}
|
|
734
|
+
// verdict.status === 'pushed'
|
|
735
|
+
const pushedLines = [];
|
|
736
|
+
pushedLines.push(`Pushed "${boundedToken(verdict.id)}" to ${boundedToken(verdict.repo)} at ${boundedToken(verdict.destination)} — commit ${boundedToken(verdict.commit)}.`);
|
|
737
|
+
pushedLines.push(verdict.cacheRefreshed
|
|
738
|
+
? 'Your local copy was refreshed with the new content.'
|
|
739
|
+
: 'Your local copy was not refreshed automatically; the next fetch will pick it up.');
|
|
740
|
+
for (const note of verdict.notes) {
|
|
741
|
+
pushedLines.push(`Note: ${boundedToken(note)}`);
|
|
742
|
+
}
|
|
743
|
+
return pushedLines.join('\n');
|
|
744
|
+
}
|
|
745
|
+
module.exports = {
|
|
746
|
+
SECRET_SHAPES,
|
|
747
|
+
scanForSecrets,
|
|
748
|
+
PROMOTE_GUARD,
|
|
749
|
+
promotePreflight,
|
|
750
|
+
refusal,
|
|
751
|
+
buildExpectToken,
|
|
752
|
+
previewPromotion,
|
|
753
|
+
confirmPromotion,
|
|
754
|
+
promotePractice,
|
|
755
|
+
renderPromoteReport,
|
|
756
|
+
};
|