pincer-workflow 0.5.0 → 0.7.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/README.md +106 -19
- package/bin/pincer.js +17 -1
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +88 -14
- package/template/.agents/skills/pincer-evaluate/SKILL.md +42 -13
- package/template/.agents/skills/pincer-narrow/SKILL.md +53 -12
- package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
- package/template/.agents/skills/pincer-release/SKILL.md +18 -0
- package/template/.agents/skills/pincer-status/SKILL.md +29 -5
- package/template/.claude/commands/pincer-code.md +88 -14
- package/template/.claude/commands/pincer-evaluate.md +42 -13
- package/template/.claude/commands/pincer-narrow.md +53 -12
- package/template/.claude/commands/pincer-plan.md +12 -4
- package/template/.claude/commands/pincer-release.md +18 -0
- package/template/.claude/commands/pincer-status.md +29 -5
- package/template/.claude/hooks/hook-policy.cjs +13 -6
- package/template/.claude/references/prd-template.md +11 -4
- package/template/.codex/README.md +1 -1
- package/template/.github/prompts/pincer-code.prompt.md +88 -14
- package/template/.github/prompts/pincer-evaluate.prompt.md +42 -13
- package/template/.github/prompts/pincer-narrow.prompt.md +53 -12
- package/template/.github/prompts/pincer-plan.prompt.md +12 -4
- package/template/.github/prompts/pincer-release.prompt.md +18 -0
- package/template/.github/prompts/pincer-status.prompt.md +29 -5
- package/template/AGENTS.md +17 -1
- package/template/docs/dry-run-checklist.md +30 -3
- package/template/docs/release-checklist.md +3 -1
- package/template/docs/runtime-contracts.md +1428 -96
- package/template/scripts/pincer-evidence.cjs +9 -7
- package/template/scripts/pincer-runtime/adopt.cjs +132 -0
- package/template/scripts/pincer-runtime/agreement.cjs +240 -0
- package/template/scripts/pincer-runtime/authorization.cjs +167 -0
- package/template/scripts/pincer-runtime/changes.cjs +517 -0
- package/template/scripts/pincer-runtime/checks.cjs +48 -0
- package/template/scripts/pincer-runtime/coverage.cjs +361 -0
- package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
- package/template/scripts/pincer-runtime/evidence.cjs +303 -18
- package/template/scripts/pincer-runtime/gates.cjs +73 -0
- package/template/scripts/pincer-runtime/identity.cjs +21 -4
- package/template/scripts/pincer-runtime/impact.cjs +177 -0
- package/template/scripts/pincer-runtime/io.cjs +41 -0
- package/template/scripts/pincer-runtime/lifecycle.cjs +34 -12
- package/template/scripts/pincer-runtime/locator.cjs +158 -0
- package/template/scripts/pincer-runtime/migrate.cjs +140 -63
- package/template/scripts/pincer-runtime/parse.cjs +20 -1
- package/template/scripts/pincer-runtime/phases.cjs +245 -0
- package/template/scripts/pincer-runtime/readiness.cjs +15 -2
- package/template/scripts/pincer-runtime/requirements.cjs +255 -0
- package/template/scripts/pincer-runtime/resume.cjs +273 -0
- package/template/scripts/pincer-runtime/routing.cjs +54 -0
- package/template/scripts/pincer-runtime/runner.cjs +24 -6
- package/template/scripts/pincer-runtime/scaffold.cjs +254 -0
- package/template/scripts/pincer-runtime/state.cjs +29 -7
- package/template/scripts/pincer-runtime/status.cjs +178 -22
- package/template/scripts/pincer-runtime/transaction.cjs +200 -0
- package/template/scripts/pincer-runtime/transitions.cjs +134 -0
- package/template/scripts/pincer-runtime.cjs +412 -76
- package/template/scripts/pincer-status.sh +1 -1
- package/template/scripts/pincer-ticket.sh +1 -1
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// PINCER runtime — status (docs/runtime-contracts.md, "Readiness and reason
|
|
3
3
|
// codes"). Gathers the artifacts on disk, computes readiness once, and renders
|
|
4
4
|
// the human report (line for line the v0.4.1 format plus a `Runtime` line) or
|
|
5
|
-
// the status JSON. Read-only: never executes a check, never writes.
|
|
5
|
+
// the status JSON. Read-only: never executes a check, never writes. In changes
|
|
6
|
+
// mode (schema 2 records) the report covers the locally selected change, or the
|
|
7
|
+
// one named with --change, and never picks a change on its own.
|
|
6
8
|
const fs = require('node:fs');
|
|
7
9
|
const os = require('node:os');
|
|
8
10
|
const path = require('node:path');
|
|
@@ -12,6 +14,11 @@ const source = require('./source.cjs');
|
|
|
12
14
|
const state = require('./state.cjs');
|
|
13
15
|
const readiness = require('./readiness.cjs');
|
|
14
16
|
const evidence = require('./evidence.cjs');
|
|
17
|
+
const changes = require('./changes.cjs');
|
|
18
|
+
const transaction = require('./transaction.cjs');
|
|
19
|
+
const agreement = require('./agreement.cjs');
|
|
20
|
+
const authorization = require('./authorization.cjs');
|
|
21
|
+
const locator = require('./locator.cjs');
|
|
15
22
|
const { tryGit } = require('./fsutil.cjs');
|
|
16
23
|
|
|
17
24
|
const RUNTIME = 1;
|
|
@@ -108,24 +115,42 @@ function evidenceLine(root, prd) {
|
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
// --- Gather ------------------------------------------------------------------
|
|
111
|
-
function gather(root, { budget } = {}) {
|
|
118
|
+
function gather(root, { budget, change = null } = {}) {
|
|
112
119
|
const now = Math.floor(Date.now() / 1000);
|
|
113
120
|
const out = { lines: [], warnings: [], exit: 0, json: { schema: 1, runtime: RUNTIME, generated: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), root, mode: 'legacy', change: null, prd: null, tickets: [], history: 0, candidate: null, reasons: [], next: null } };
|
|
114
121
|
const line = s => out.lines.push(s);
|
|
115
122
|
const j = out.json;
|
|
116
123
|
line(`PINCER status · ${new Date().toISOString().replace(/:\d{2}\.\d{3}Z$/, 'Z')} · ${root}`);
|
|
117
124
|
|
|
125
|
+
// A committed-but-unapplied transaction (a migration or transition killed
|
|
126
|
+
// mid-rename) is reported in every mode and never interpreted or repaired here.
|
|
127
|
+
const pendingTxn = transaction.pending(root);
|
|
128
|
+
if (pendingTxn.committed.length) {
|
|
129
|
+
const t = pendingTxn.committed[0];
|
|
130
|
+
const detail = `a committed transaction (${t.command || t.id}) was not fully applied; run: node scripts/pincer-runtime.cjs recover`;
|
|
131
|
+
j.mode = changes.scan(root).mode;
|
|
132
|
+
if (j.mode === 'changes') { j.schema = 3; j.runtime = changes.RUNTIME_STRICT; j.selection = { change: null, problem: null }; j.changes = []; }
|
|
133
|
+
line(`WARN STATE_INCOMPLETE: ${detail}`);
|
|
134
|
+
j.reasons.push({ code: 'STATE_INCOMPLETE', detail });
|
|
135
|
+
j.next = 'node scripts/pincer-runtime.cjs recover';
|
|
136
|
+
line(`Next ${j.next}`);
|
|
137
|
+
out.exit = 4; return out;
|
|
138
|
+
}
|
|
118
139
|
// Mode and selected PRD.
|
|
119
140
|
const bindingResult = identity.loadBinding(root);
|
|
141
|
+
if (bindingResult.code === 'CHANGES_MODE') return gatherChanges(root, out, { budget, now, change });
|
|
142
|
+
if (change) { line(`WARN --change applies to change records only (this project is ${bindingResult.code ? 'not in changes mode' : 'migrated'})`); line('Next inspect with status without --change'); out.exit = 4; return out; }
|
|
120
143
|
let mode = 'legacy', binding = null, prd = null, prdResult = null;
|
|
121
144
|
if (bindingResult.binding && !bindingResult.code) {
|
|
122
145
|
mode = 'migrated'; binding = bindingResult.binding; prd = binding.prd; prdResult = bindingResult.prd;
|
|
123
|
-
} else if (bindingResult.code === 'REVISION_CHANGED' || bindingResult.code === 'INPUT_INVALID') {
|
|
146
|
+
} else if ((bindingResult.code === 'REVISION_CHANGED' || bindingResult.code === 'INPUT_INVALID') && bindingResult.binding) {
|
|
124
147
|
mode = 'migrated'; binding = bindingResult.binding; prd = binding.prd;
|
|
125
148
|
const v = parse.validatePrd(root, prd);
|
|
126
149
|
if (!v.ok) { line(`WARN invalid PRD: ${v.file || prd}: ${v.problems[0]}`); line('Next repair PRD input before continuing'); out.exit = 4; return out; }
|
|
127
150
|
prdResult = v;
|
|
128
|
-
} else if (['MALFORMED', 'AMBIGUOUS', 'UNSUPPORTED_SCHEMA'].includes(bindingResult.code)) {
|
|
151
|
+
} else if (['MALFORMED', 'AMBIGUOUS', 'UNSUPPORTED_SCHEMA', 'INPUT_INVALID'].includes(bindingResult.code)) {
|
|
152
|
+
// Unreadable or mixed records: neither legacy nor migrated (never a fallback).
|
|
153
|
+
j.mode = 'invalid';
|
|
129
154
|
line(`WARN invalid change binding: ${bindingResult.problem}`);
|
|
130
155
|
line('Next repair .prd/changes/ before continuing (remove or restore the binding; see docs/runtime-contracts.md)');
|
|
131
156
|
j.reasons.push({ code: 'INPUT_INVALID', detail: bindingResult.problem });
|
|
@@ -136,6 +161,7 @@ function gather(root, { budget } = {}) {
|
|
|
136
161
|
prd = latest.prd; prdResult = latest.prdResult;
|
|
137
162
|
}
|
|
138
163
|
j.mode = mode;
|
|
164
|
+
j.coverage = { strict: false, label: 'unverified', reason: mode === 'legacy' ? 'legacy project (no change record); strict coverage needs change records' : 'v0.5.0 binding (migrated mode); migrate to change records, then adopt' };
|
|
139
165
|
const prdStatus = prdResult ? prdResult.fields.status : '';
|
|
140
166
|
if (!prd) line('PRD none');
|
|
141
167
|
else {
|
|
@@ -152,7 +178,20 @@ function gather(root, { budget } = {}) {
|
|
|
152
178
|
} else {
|
|
153
179
|
line(`Runtime legacy · no change binding${prd ? ` · migrate with node scripts/pincer-runtime.cjs migrate --preview --prd ${prd}` : ''}`);
|
|
154
180
|
}
|
|
181
|
+
const result = gatherBody(root, out, { mode, binding, prd, prdResult, bindingResult, budget, now });
|
|
182
|
+
// Old modes label their coverage unverified in the human report too.
|
|
183
|
+
const at = result.lines.findIndex(l => l.startsWith('Notes '));
|
|
184
|
+
if (at !== -1) result.lines.splice(at, 0, `Coverage unverified · ${j.coverage.reason}`);
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
155
187
|
|
|
188
|
+
// Tickets, candidate and next action for the selected PRD. `ctx.decideNext`, when
|
|
189
|
+
// given, replaces the default next-action rule (changes mode).
|
|
190
|
+
function gatherBody(root, out, ctx) {
|
|
191
|
+
const { mode, binding, prd, prdResult, bindingResult, budget, now } = ctx;
|
|
192
|
+
const line = s => out.lines.push(s);
|
|
193
|
+
const j = out.json;
|
|
194
|
+
const prdStatus = prdResult ? prdResult.fields.status : '';
|
|
156
195
|
// Tickets: reject malformed input instead of guessing.
|
|
157
196
|
const set = parse.validateTicketSet(root);
|
|
158
197
|
if (!set.ok) {
|
|
@@ -177,12 +216,13 @@ function gather(root, { budget } = {}) {
|
|
|
177
216
|
|
|
178
217
|
// Current inputs for migrated readiness (computed once; read-only).
|
|
179
218
|
let current = {}, sourceProblems = [], manifestNow = null;
|
|
180
|
-
|
|
219
|
+
const runtimeMode = mode !== 'legacy';
|
|
220
|
+
if (runtimeMode) {
|
|
181
221
|
manifestNow = source.snapshot(root);
|
|
182
222
|
sourceProblems = manifestNow.problems;
|
|
183
223
|
current = { prdRevision: binding.prd_revision, sourceDigest: manifestNow.digest };
|
|
184
224
|
}
|
|
185
|
-
const indexRead =
|
|
225
|
+
const indexRead = runtimeMode && state.exists(root) ? state.readIndex(root) : { index: null };
|
|
186
226
|
if (indexRead.error) { line(`WARN invalid runtime state: ${indexRead.error}`); line('Next repair or remove .pincer/runtime (see docs/runtime-contracts.md); run recover for a diagnosis'); j.reasons.push({ code: 'INPUT_INVALID', detail: indexRead.error }); out.exit = 4; return out; }
|
|
187
227
|
const byId = new Map(tickets.map(t => [t.fields.ticket, t]));
|
|
188
228
|
const readinessOf = new Map();
|
|
@@ -199,7 +239,7 @@ function gather(root, { budget } = {}) {
|
|
|
199
239
|
if (before) changedPaths = source.diffManifests(before, manifestNow);
|
|
200
240
|
}
|
|
201
241
|
const legacyReceipt = (binding.legacy_receipts && binding.legacy_receipts[t.fields.ticket]) || (t.fields.verified || t.fields.last_check ? { verified: t.fields.verified, last_check: t.fields.last_check } : null);
|
|
202
|
-
r = readiness.migratedTicketReadiness({ text: t.text, fields: t.fields, timeout: t.timeout, attempt, legacyReceipt, current, sourceProblems, changedPaths, contextKey: key, pointedId: indexRead.index ? indexRead.index.current[key] || null : null });
|
|
242
|
+
r = readiness.migratedTicketReadiness({ text: t.text, fields: t.fields, timeout: t.timeout, attempt, legacyReceipt, current, sourceProblems, changedPaths, contextKey: key, pointedId: indexRead.index ? indexRead.index.current[key] || null : null, mode, strict: Boolean(binding && binding.strict) });
|
|
203
243
|
r.attempt = attempt;
|
|
204
244
|
}
|
|
205
245
|
readinessOf.set(t.file, r);
|
|
@@ -209,7 +249,7 @@ function gather(root, { budget } = {}) {
|
|
|
209
249
|
let nOpen = 0, nProg = 0, nDone = 0, firstStart = null, localMissing = 0;
|
|
210
250
|
// Without local runtime state (a fresh clone) done tickets rely on the saved
|
|
211
251
|
// candidate evidence; they are not re-verify work until verified here.
|
|
212
|
-
const localUnavailable =
|
|
252
|
+
const localUnavailable = runtimeMode && !state.hasIndex(root);
|
|
213
253
|
const inProg = [], reverify = [];
|
|
214
254
|
let nextOpen = null;
|
|
215
255
|
const rows = [];
|
|
@@ -223,7 +263,7 @@ function gather(root, { budget } = {}) {
|
|
|
223
263
|
if (mode === 'legacy' && st !== 'done' && f.last_check && !/ passed /.test(` ${f.last_check} `)) {
|
|
224
264
|
out.warnings.push(` WARN ${id} latest verification: ${f.last_check} — re-run verify`);
|
|
225
265
|
}
|
|
226
|
-
if (
|
|
266
|
+
if (runtimeMode && st !== 'done' && r.attempt && !r.ready) {
|
|
227
267
|
// Unticked criteria are expected while work is in progress; anything else
|
|
228
268
|
// (a failed, stale or superseded attempt) is a warning here too.
|
|
229
269
|
const blocking = r.reasons.find(x => x.code !== 'CRITERIA_UNTICKED');
|
|
@@ -274,18 +314,24 @@ function gather(root, { budget } = {}) {
|
|
|
274
314
|
}
|
|
275
315
|
}
|
|
276
316
|
|
|
277
|
-
// Candidate.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
317
|
+
// Candidate: NOTES.md in legacy and migrated mode; the change's evaluation
|
|
318
|
+
// locator in changes mode (NOTES.md is then a compatibility summary).
|
|
319
|
+
const notes = ctx.notes ? ctx.notes() : prd ? notesCurrent(root, prd) : { text: 'missing', state: 'missing' };
|
|
320
|
+
if (ctx.notes) {
|
|
321
|
+
const compat = prd && fs.existsSync(path.join(root, 'NOTES.md')) ? notesCurrent(root, prd).text : 'missing';
|
|
322
|
+
line(`Notes NOTES.md: ${compat} (compatibility summary; the evaluation locator decides)`);
|
|
323
|
+
line(`Evaluation ${ctx.locatorFile}: ${notes.text}`);
|
|
324
|
+
} else line(`Notes NOTES.md: ${notes.text}`);
|
|
325
|
+
const ev = ctx.evidence ? ctx.evidence() : evidenceLine(root, prd);
|
|
281
326
|
if (ev) line(`Evidence ${ev.manifest} · ${ev.ok ? 'ok' : ev.reason}`);
|
|
282
327
|
j.candidate = {
|
|
283
328
|
notes: notes.state, reason: notes.state === 'current' ? null : notes.text,
|
|
284
329
|
candidate: notes.candidate || (notes.fields && notes.fields.candidate) || null, base: notes.base || (notes.fields && notes.fields.base) || null,
|
|
285
|
-
evidence: ev ? { manifest: ev.manifest, schema: ev.schema, provenance: ev.schema
|
|
286
|
-
local_attempts:
|
|
330
|
+
evidence: ev ? { manifest: ev.manifest, schema: ev.schema, provenance: ev.schema >= 2 ? 'runtime' : ev.schema === 1 ? 'legacy' : null, verdict: ev.ok ? 'ok' : ev.reason } : null,
|
|
331
|
+
local_attempts: runtimeMode ? (state.hasIndex(root) ? 'available' : 'unavailable') : 'not applicable (legacy mode)',
|
|
287
332
|
newer_attempts: [],
|
|
288
333
|
reasons: notes.state === 'current' ? [] : [{ code: notes.state === 'missing' ? 'EVIDENCE_MISSING' : 'CANDIDATE_STALE', detail: notes.text }],
|
|
334
|
+
...(ctx.notes ? { locator: ctx.locatorFile, evaluation: notes.entry ? { candidate: notes.entry.candidate, base: notes.entry.base, manifest: notes.entry.manifest, recorded: notes.entry.recorded, agreement: notes.entry.agreement } : null } : {}),
|
|
289
335
|
};
|
|
290
336
|
// Provenance and newer local attempts (contract "Evidence schema 2"): a newer
|
|
291
337
|
// nonpassing attempt for the same check and candidate on the same source inputs
|
|
@@ -293,9 +339,9 @@ function gather(root, { budget } = {}) {
|
|
|
293
339
|
// state only the saved record can be validated.
|
|
294
340
|
const newerBlockers = [];
|
|
295
341
|
if (ev && ev.ok) {
|
|
296
|
-
if (ev.schema
|
|
297
|
-
else if (!state.
|
|
298
|
-
line(
|
|
342
|
+
if (ev.schema < 2) line('Provenance legacy (schema 1, authored command results)');
|
|
343
|
+
else if (!state.hasIndex(root)) {
|
|
344
|
+
line(`Provenance runtime (schema ${ev.schema}) · local verification history unavailable; saved candidate evidence validated only`);
|
|
299
345
|
} else {
|
|
300
346
|
let manifestDoc = null;
|
|
301
347
|
try { manifestDoc = JSON.parse(fs.readFileSync(path.resolve(root, ev.manifest), 'utf8')); } catch { manifestDoc = null; }
|
|
@@ -307,7 +353,7 @@ function gather(root, { budget } = {}) {
|
|
|
307
353
|
// Every attempt newer than the exported one counts: a same-source
|
|
308
354
|
// nonpassing attempt blocks until the candidate is re-exported, even
|
|
309
355
|
// when a later attempt passed again.
|
|
310
|
-
const key = state.contextKey({ kind: 'candidate', candidate: cand, check: check.id });
|
|
356
|
+
const key = state.contextKey({ kind: 'candidate', change: binding.change, candidate: cand, check: check.id, mode });
|
|
311
357
|
const newer = idx ? state.listAttempts(root, key).filter(a => a.sequence > check.attempt.sequence && a.outcome !== 'passed') : [];
|
|
312
358
|
for (const latest of newer) {
|
|
313
359
|
const sameSource = latest.source && latest.source.before === check.attempt.source_before;
|
|
@@ -319,7 +365,7 @@ function gather(root, { budget } = {}) {
|
|
|
319
365
|
} else details.push(`${check.id} ${latest.outcome} (${latest.id}, different source: historical)`);
|
|
320
366
|
}
|
|
321
367
|
}
|
|
322
|
-
line(`Provenance runtime (schema
|
|
368
|
+
line(`Provenance runtime (schema ${ev.schema}) · local attempts ${details.length ? details.join('; ') : 'consistent with the exported checks'}`);
|
|
323
369
|
j.candidate.reasons.push(...newerBlockers);
|
|
324
370
|
}
|
|
325
371
|
}
|
|
@@ -328,7 +374,7 @@ function gather(root, { budget } = {}) {
|
|
|
328
374
|
let next;
|
|
329
375
|
if (!prd) next = '/pincer-plan <brief> — no PRD yet';
|
|
330
376
|
else if (unresolved > 0) next = 'resolve PRD association with pincer-ticket.sh bind T-NN .prd/prd-vN.md before continuing';
|
|
331
|
-
else if (bindingResult.code === 'REVISION_CHANGED') next = `node scripts/pincer-runtime.cjs register --prd ${prd} --rebind — the PRD content changed since registration; readiness for the old revision no longer applies`;
|
|
377
|
+
else if (bindingResult && bindingResult.code === 'REVISION_CHANGED') next = `node scripts/pincer-runtime.cjs register --prd ${prd} --rebind — the PRD content changed since registration; readiness for the old revision no longer applies`;
|
|
332
378
|
else if (sourceProblems.length) next = `repair the source view: ${sourceProblems[0].code} ${sourceProblems[0].detail}`;
|
|
333
379
|
else if (prdStatus === 'draft') next = '/pincer-narrow — current PRD is draft; earlier tickets and notes do not complete it';
|
|
334
380
|
else if (tickets.length === 0) next = '/pincer-narrow — PRD exists, no tickets yet';
|
|
@@ -341,12 +387,122 @@ function gather(root, { budget } = {}) {
|
|
|
341
387
|
} else if (newerBlockers.length) {
|
|
342
388
|
next = `/pincer-code — a newer local attempt is not passing for ${newerBlockers.map(b => b.detail.split(':')[0]).join(', ')}; repair, re-run the check and /pincer-evaluate before release`;
|
|
343
389
|
} else next = '/pincer-release — evaluation matches the current PRD and candidate; audit the artifacts';
|
|
390
|
+
const computed = { defaultNext: next, inProg, nOpen, nextOpen, reverify, notes, prdStatus, newerBlockers, unresolved, sourceProblems, nDone, tickets: tickets.length, allReady: tickets.length > 0 && nOpen === 0 && nProg === 0 && reverify.length === 0 && unresolved === 0 };
|
|
391
|
+
// The gathered view is available to the next-action rule (strict coverage reads it).
|
|
392
|
+
out.gathered = { mode, binding, prd, prdResult, tickets, computeReadiness, notes, unresolved, inProg, nOpen, reverify, computed };
|
|
393
|
+
if (ctx.decideNext) next = ctx.decideNext(computed);
|
|
344
394
|
line(`Next ${next}`);
|
|
345
395
|
j.next = next;
|
|
346
396
|
for (const t of j.tickets) for (const r of t.readiness.reasons) j.reasons.push({ code: r.code, detail: `${t.id}: ${r.detail}` });
|
|
347
397
|
for (const r of j.candidate.reasons) j.reasons.push(r);
|
|
348
398
|
if (unresolved > 0) out.exit = 4;
|
|
349
|
-
out.gathered = { mode, binding, prd, prdResult, tickets, computeReadiness, notes, unresolved, inProg, nOpen, reverify };
|
|
399
|
+
out.gathered = { mode, binding, prd, prdResult, tickets, computeReadiness, notes, unresolved, inProg, nOpen, reverify, computed };
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Changes mode (schema 2 records): status JSON schema 2 over the selected change
|
|
404
|
+
// (or the one named with --change, without selecting it). Without a local
|
|
405
|
+
// selection nothing is selected — never the highest PRD or the only record.
|
|
406
|
+
function gatherChanges(root, out, { budget, now, change }) {
|
|
407
|
+
const j = out.json;
|
|
408
|
+
const line = s => out.lines.push(s);
|
|
409
|
+
j.schema = 3; j.runtime = changes.RUNTIME_STRICT; j.mode = 'changes';
|
|
410
|
+
const resolved = changes.resolveSelected(root, { change });
|
|
411
|
+
const loaded = resolved.loaded;
|
|
412
|
+
const selection = resolved.selection;
|
|
413
|
+
j.selection = selection ? { change: selection.change, problem: null } : { change: null, problem: resolved.selectionProblem ? { code: resolved.selectionProblem.code, detail: resolved.selectionProblem.problem } : null };
|
|
414
|
+
j.changes = [...loaded.records.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, e]) => { const v = loaded.problems.length ? null : authorization.verdict(root, e.record); return { ...changes.summarize(id, e), selected: Boolean(selection && selection.change === id), authorization: v ? v.verdict : null, agreement: v ? v.current : null }; });
|
|
415
|
+
const retained = j.changes.map(c => c.id).join(', ') || 'none';
|
|
416
|
+
if (loaded.problems.length && (resolved.code || !resolved.record)) {
|
|
417
|
+
for (const p of loaded.problems) { line(`WARN invalid change records: ${p.code}: ${p.detail}`); j.reasons.push({ code: p.code, detail: p.detail }); }
|
|
418
|
+
j.next = loaded.problems[0].code === 'STATE_INCOMPLETE' ? 'node scripts/pincer-runtime.cjs recover' : 'repair .prd/changes/ by hand before continuing (see docs/runtime-contracts.md, "Change records")';
|
|
419
|
+
line(`Next ${j.next}`);
|
|
420
|
+
out.exit = 4; return out;
|
|
421
|
+
}
|
|
422
|
+
if (resolved.code) {
|
|
423
|
+
// No selection, a dangling one, or an unknown --change: report, never choose.
|
|
424
|
+
const problem = { code: resolved.code, detail: resolved.problem };
|
|
425
|
+
j.reasons.push(problem);
|
|
426
|
+
if (resolved.code === 'SELECTION_INVALID' || resolved.code === 'SELECTION_REQUIRED') j.selection = { change: selection ? selection.change : null, problem };
|
|
427
|
+
line('PRD none selected');
|
|
428
|
+
line(resolved.code === 'SELECTION_REQUIRED' ? 'Runtime changes · no selection · change select <id>' : `Runtime changes · ${resolved.code}: ${resolved.problem}`);
|
|
429
|
+
line(`Changes ${j.changes.length} retained: ${j.changes.map(c => `${c.id} (${c.state})`).join(', ') || 'none'}`);
|
|
430
|
+
const set = parse.validateTicketSet(root);
|
|
431
|
+
if (set.ok) { j.history = set.files.length; if (set.files.length) line(`History ${set.files.length} ticket(s); none belongs to a selected change`); }
|
|
432
|
+
line('Tickets none (no change selected)');
|
|
433
|
+
line('Notes none (no change selected)');
|
|
434
|
+
j.candidate = null;
|
|
435
|
+
j.next = resolved.code === 'SELECTION_REQUIRED' ? `node scripts/pincer-runtime.cjs change select <id> — select the change to work on (retained: ${retained}${j.changes.length ? '' : '; register one first'})`
|
|
436
|
+
: resolved.code === 'SELECTION_INVALID' ? `node scripts/pincer-runtime.cjs change select <id> — the selection is not usable (retained: ${retained})`
|
|
437
|
+
: `${resolved.code}: ${resolved.problem}`;
|
|
438
|
+
line(`Next ${j.next}`);
|
|
439
|
+
if (['MALFORMED', 'INPUT_INVALID'].includes(resolved.code)) out.exit = 4;
|
|
440
|
+
out.gathered = { mode: 'changes', binding: null, prd: null, prdResult: null, tickets: [], computeReadiness: null, notes: null, unresolved: 0, inProg: [], nOpen: 0, reverify: [] };
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
const record = resolved.record;
|
|
444
|
+
const id = resolved.id;
|
|
445
|
+
const v = changes.view(root, record);
|
|
446
|
+
const prdResult = v.prdResult;
|
|
447
|
+
const prd = record.prd;
|
|
448
|
+
const prdStatus = prdResult.ok ? prdResult.fields.status : '?';
|
|
449
|
+
const binding = { change: id, prd, prd_revision: prdResult.ok ? parse.prdDigest(prdResult.text) : null, base: record.base, legacy_receipts: record.legacy.receipts, strict: changes.isStrict(record) };
|
|
450
|
+
// The current agreement (read-only): the digest of the authored inputs now, and
|
|
451
|
+
// which recorded entry it equals, if any.
|
|
452
|
+
const agreed = prdResult.ok ? agreement.compute(root, record) : { code: 'INPUT_INVALID', problem: prdResult.problems[0] };
|
|
453
|
+
const entry = agreed.code ? null : agreement.entryFor(record, agreed.digest);
|
|
454
|
+
const latest = agreement.latestEntry(record);
|
|
455
|
+
let difference = null;
|
|
456
|
+
if (!agreed.code && !entry && latest) { const snap = agreement.readSnapshot(root, record, latest); if (!snap.code) difference = agreement.difference(snap.snapshot, agreed); }
|
|
457
|
+
const auth = authorization.verdict(root, record, agreed);
|
|
458
|
+
j.change = { id, prd, prd_revision: binding.prd_revision, base: record.base, sequence: record.sequence, lifecycle: { ...record.lifecycle }, agreement: { current: agreed.code ? null : agreed.digest, recorded: entry ? entry.id : null, latest: latest ? { id: latest.id, digest: latest.digest, recorded: latest.recorded } : null, difference, authorized: auth.authorized ? { id: auth.authorized.id, agreement: auth.authorized.agreement, digest: auth.authorized.digest, disposition: auth.authorized.disposition, recorded: auth.authorized.recorded } : null, verdict: auth.verdict, verdict_detail: auth.detail, open_decisions: auth.open }, view: { head: v.head, branch: v.branch, base_is_ancestor: v.base_is_ancestor, dirty: v.dirty } };
|
|
459
|
+
if (prdResult.ok) { line(`PRD ${prd} · status: ${prdStatus} · profile: ${prdResult.profile} · date: ${prdResult.fields.date || ''}`); j.prd = { path: prd, status: prdStatus, profile: prdResult.profile, date: prdResult.fields.date || null }; }
|
|
460
|
+
else line(`PRD ${prd} · invalid: ${prdResult.problems[0]}`);
|
|
461
|
+
const agreementText = agreed.code ? 'agreement unavailable' : `agreement ${short(agreed.digest)}${entry ? ` (${entry.id})` : latest ? ` (≠ ${latest.id}: ${agreement.renderDifference(difference || { same: false, prd_changed: false, tickets_added: [], tickets_removed: [], tickets_changed: [], decisions_added: [], decisions_removed: [] })})` : ' (not recorded)'}`;
|
|
462
|
+
line(`Runtime changes · ${resolved.explicit ? 'inspecting' : 'selected'} ${id} · ${record.lifecycle.state} · ${agreementText} · authorization ${auth.verdict}${auth.authorized ? ` (${auth.authorized.id})` : ''} · base ${record.base.slice(0, 7)}`);
|
|
463
|
+
line(`Changes ${j.changes.length} retained: ${j.changes.map(c => `${c.id} (${c.state}${c.selected ? ', selected' : ''})`).join(', ')}`);
|
|
464
|
+
line(`View HEAD ${v.head ? v.head.slice(0, 7) : 'none'} · branch ${v.branch || 'detached'} · base ${v.base_is_ancestor ? 'is an ancestor' : 'is NOT an ancestor'} · dirty ${v.dirty.length} path(s)${v.dirty.length ? `: ${v.dirty.slice(0, 5).join(', ')}${v.dirty.length > 5 ? ` (+${v.dirty.length - 5})` : ''}` : ''}`);
|
|
465
|
+
if (record.lifecycle.reason || record.lifecycle.note) line(`Handoff (authored) ${record.lifecycle.reason ? `reason: ${record.lifecycle.reason}` : ''}${record.lifecycle.reason && record.lifecycle.note ? ' · ' : ''}${record.lifecycle.note ? `note: ${record.lifecycle.note}` : ''}`);
|
|
466
|
+
for (const p of v.problems) { line(`WARN ${p.code}: ${p.detail}`); j.reasons.push(p); }
|
|
467
|
+
if (!prdResult.ok) { line('Next repair the PRD input before continuing'); j.next = 'repair the PRD input before continuing'; out.exit = 4; return out; }
|
|
468
|
+
const running = transaction.runningAttempts(root, id);
|
|
469
|
+
// Strict coverage summary (docs/runtime-contracts.md, "Coverage and impact commands"):
|
|
470
|
+
// computed once the tickets are gathered (below), consumed by the Coverage line,
|
|
471
|
+
// the JSON and the next action. A change without the capability is labeled unverified.
|
|
472
|
+
const phases = require('./phases.cjs');
|
|
473
|
+
let coverageReport = null;
|
|
474
|
+
const coverageOf = gathered => { if (!coverageReport) coverageReport = phases.compute(root, record, { gathered, verdict: auth }); return coverageReport; };
|
|
475
|
+
const decideNext = computed => {
|
|
476
|
+
const cmd = sub => `node scripts/pincer-runtime.cjs change ${sub} ${id}`;
|
|
477
|
+
const st = record.lifecycle.state;
|
|
478
|
+
if (running.length) return `attempt ${running[0].id} of this change is running: wait for it, or run node scripts/pincer-runtime.cjs recover if its owner died`;
|
|
479
|
+
if (changes.TERMINAL.includes(st)) return `change ${id} is ${st}${record.lifecycle.superseded_by ? ` by ${record.lifecycle.superseded_by}` : ''}: inspect it with ${cmd('show')}; execution needs a new change (register one and reference this record)`;
|
|
480
|
+
if (v.problems.length) return `${v.problems[0].code}: ${v.problems[0].detail}`;
|
|
481
|
+
if (computed.unresolved > 0 || computed.sourceProblems.length) return computed.defaultNext;
|
|
482
|
+
if (auth.verdict !== 'current' && !agreed.code) return `${auth.verdict}: ${auth.detail}`;
|
|
483
|
+
if (changes.isStrict(record) && out.gathered) {
|
|
484
|
+
const cov = coverageOf(out.gathered);
|
|
485
|
+
const gap = cov.structure.problems[0];
|
|
486
|
+
if (gap) return `${gap.code}: ${gap.detail} — see: node scripts/pincer-runtime.cjs coverage`;
|
|
487
|
+
}
|
|
488
|
+
if (st === 'planned') return `${cmd('activate')} — activate the change before executing tickets`;
|
|
489
|
+
if (st === 'paused') return `${cmd('resume')} — the change is paused${record.lifecycle.reason ? ` (${record.lifecycle.reason})` : ''}; resume it before executing tickets`;
|
|
490
|
+
if (st === 'active' && computed.allReady && computed.prdStatus !== 'draft') return `${cmd('complete')} — every ticket is done and ready; complete the change before choosing the candidate`;
|
|
491
|
+
return computed.defaultNext;
|
|
492
|
+
};
|
|
493
|
+
gatherBody(root, out, { mode: 'changes', binding, prd, prdResult, bindingResult: null, budget, now, decideNext, notes: () => locator.current(root, record), evidence: () => locator.evidenceLine(root, record), locatorFile: locator.file(id) });
|
|
494
|
+
if (out.gathered) {
|
|
495
|
+
out.gathered.record = record;
|
|
496
|
+
const cov = coverageOf(out.gathered);
|
|
497
|
+
j.coverage = { ...phases.summary(cov), next: cov.blockers[0] ? `${cov.blockers[0].code}: ${cov.blockers[0].detail}` : null };
|
|
498
|
+
const coverageLine = phases.summaryLine(cov, record);
|
|
499
|
+
const at = out.lines.findIndex(l => l.startsWith('Notes '));
|
|
500
|
+
out.lines.splice(at === -1 ? out.lines.length : at, 0, coverageLine);
|
|
501
|
+
if (changes.isStrict(record)) for (const p of cov.structure.problems) if (!j.reasons.some(r => r.code === p.code && r.detail === p.detail)) j.reasons.push({ code: p.code, detail: p.detail });
|
|
502
|
+
} else j.coverage = { strict: changes.isStrict(record), label: changes.isStrict(record) ? 'strict' : 'unverified', reason: changes.isStrict(record) ? null : 'strict coverage not adopted', structure: null, implementation: null, candidate: null, next: null };
|
|
503
|
+
// Reasons in gate order: repository view first, then the authorization verdict, then the rest.
|
|
504
|
+
const front = [...v.problems, ...(auth.verdict !== 'current' && !agreed.code ? [{ code: auth.verdict, detail: auth.detail }] : [])];
|
|
505
|
+
j.reasons = [...front, ...j.reasons.filter(r => !front.includes(r))];
|
|
350
506
|
return out;
|
|
351
507
|
}
|
|
352
508
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — transactions (docs/runtime-contracts.md, "Transactions and
|
|
3
|
+
// recovery"). Every write of a change record, agreement snapshot, selection,
|
|
4
|
+
// evaluation locator or migration goes through `run`: the worktree lock is held
|
|
5
|
+
// for the whole validate-and-write operation, the caller's function computes the
|
|
6
|
+
// outcome in memory, the new files are staged under the journal, a manifest is
|
|
7
|
+
// written last as the commit point, and the staged files are renamed onto their
|
|
8
|
+
// targets. A process killed before the manifest leaves the old state; killed
|
|
9
|
+
// after it, the next transaction or `recover` completes the renames. A
|
|
10
|
+
// projection is therefore never observable without its event.
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const crypto = require('node:crypto');
|
|
15
|
+
const state = require('./state.cjs');
|
|
16
|
+
const { nowIso, readJson } = require('./fsutil.cjs');
|
|
17
|
+
|
|
18
|
+
const MANIFEST_SCHEMA = 1;
|
|
19
|
+
const TXN_PREFIX = 'txn-';
|
|
20
|
+
const MAX_TEXT = 2000;
|
|
21
|
+
const SAFE_RELATIVE = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))[^\0]+$/;
|
|
22
|
+
|
|
23
|
+
class Refusal extends Error {
|
|
24
|
+
constructor(code, message, extra = {}) { super(message); this.code = code; this.refusal = true; Object.assign(this, extra); }
|
|
25
|
+
}
|
|
26
|
+
const refuse = (code, message, extra) => { throw new Refusal(code, message, extra); };
|
|
27
|
+
|
|
28
|
+
const journalDir = root => state.paths(root).journal;
|
|
29
|
+
const compact = () => nowIso().replace(/[-:]/g, '');
|
|
30
|
+
const serialize = content => (typeof content === 'string' || Buffer.isBuffer(content) ? content : `${JSON.stringify(content, null, 2)}\n`);
|
|
31
|
+
|
|
32
|
+
// --- Pending transactions (read-only) -------------------------------------------
|
|
33
|
+
// A staging directory with a manifest is a committed transaction that was not
|
|
34
|
+
// fully applied; one without a manifest is uncommitted staging. Never writes.
|
|
35
|
+
function pending(root) {
|
|
36
|
+
const dir = journalDir(root);
|
|
37
|
+
if (!fs.existsSync(dir)) return { committed: [], uncommitted: [] };
|
|
38
|
+
const committed = [], uncommitted = [];
|
|
39
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
40
|
+
if (!name.startsWith(TXN_PREFIX)) continue;
|
|
41
|
+
const staging = path.join(dir, name);
|
|
42
|
+
let stat = null;
|
|
43
|
+
try { stat = fs.statSync(staging); } catch { continue; }
|
|
44
|
+
if (!stat.isDirectory()) continue;
|
|
45
|
+
const read = readJson(path.join(staging, 'manifest.json'));
|
|
46
|
+
if (read.error === 'missing') { uncommitted.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}` }); continue; }
|
|
47
|
+
if (read.error || !validManifest(read.data)) { committed.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}`, manifest: null, problem: read.error || 'malformed manifest' }); continue; }
|
|
48
|
+
committed.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}`, manifest: read.data, command: read.data.command, started: read.data.started });
|
|
49
|
+
}
|
|
50
|
+
return { committed, uncommitted };
|
|
51
|
+
}
|
|
52
|
+
function validManifest(m) {
|
|
53
|
+
return m && typeof m === 'object' && !Array.isArray(m) && m.schema === MANIFEST_SCHEMA && typeof m.id === 'string' && typeof m.command === 'string'
|
|
54
|
+
&& typeof m.started === 'string' && Array.isArray(m.writes)
|
|
55
|
+
&& m.writes.every(w => w && typeof w === 'object' && typeof w.target === 'string' && SAFE_RELATIVE.test(w.target) && typeof w.staged === 'string' && /^[0-9]{2,}-[^/\0]+$/.test(w.staged));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Recovery (caller holds the lock) -------------------------------------------
|
|
59
|
+
// Complete every committed transaction (each rename is idempotent: a staged file
|
|
60
|
+
// that is already gone was renamed before the crash) and discard uncommitted
|
|
61
|
+
// staging. Returns what was done. A manifest that cannot be read is left in place
|
|
62
|
+
// and reported: completing it would mean guessing its targets.
|
|
63
|
+
function recoverPending(root) {
|
|
64
|
+
const report = { completed: [], discarded: [], unreadable: [] };
|
|
65
|
+
const found = pending(root);
|
|
66
|
+
for (const t of found.committed) {
|
|
67
|
+
if (!t.manifest) { report.unreadable.push({ id: t.id, problem: t.problem }); continue; }
|
|
68
|
+
applyManifest(root, path.join(root, t.dir), t.manifest);
|
|
69
|
+
report.completed.push({ id: t.id, command: t.manifest.command, targets: t.manifest.writes.map(w => w.target) });
|
|
70
|
+
}
|
|
71
|
+
for (const t of found.uncommitted) {
|
|
72
|
+
fs.rmSync(path.join(root, t.dir), { recursive: true, force: true });
|
|
73
|
+
report.discarded.push({ id: t.id });
|
|
74
|
+
}
|
|
75
|
+
return report;
|
|
76
|
+
}
|
|
77
|
+
function applyManifest(root, staging, manifest, hooks = null) {
|
|
78
|
+
manifest.writes.forEach((w, i) => {
|
|
79
|
+
const staged = path.join(staging, w.staged);
|
|
80
|
+
const target = path.join(root, w.target);
|
|
81
|
+
if (fs.existsSync(staged)) {
|
|
82
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
83
|
+
fs.renameSync(staged, target);
|
|
84
|
+
}
|
|
85
|
+
if (hooks) hooks(`rename:${i}`);
|
|
86
|
+
});
|
|
87
|
+
fs.rmSync(path.join(staging, 'manifest.json'), { force: true });
|
|
88
|
+
if (hooks) hooks('cleanup');
|
|
89
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Running attempts of a change ---------------------------------------------
|
|
93
|
+
// Attempts listed as running in the index whose context names the change. The
|
|
94
|
+
// owner's liveness is reported so callers can name `recover` as the next step
|
|
95
|
+
// for a dead owner; the transaction never terminates an attempt itself.
|
|
96
|
+
function runningAttempts(root, changeId) {
|
|
97
|
+
if (!state.exists(root)) return [];
|
|
98
|
+
const read = state.readIndex(root);
|
|
99
|
+
if (read.error) refuse('INPUT_INVALID', read.error);
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const id of read.index.running) {
|
|
102
|
+
const attempt = state.readAttempt(root, id).attempt;
|
|
103
|
+
if (!attempt || attempt.outcome !== 'running') continue;
|
|
104
|
+
if (!attempt.context || attempt.context.change !== changeId) continue;
|
|
105
|
+
const owner = attempt.owner || {};
|
|
106
|
+
out.push({ id, context: attempt.context, owner, alive: owner.host === os.hostname() ? state.isAlive(owner.pid) : null });
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
function requireIdle(root, changeId) {
|
|
111
|
+
const running = runningAttempts(root, changeId);
|
|
112
|
+
if (!running.length) return;
|
|
113
|
+
const a = running[0];
|
|
114
|
+
const hint = a.alive === false ? ' (its owner is no longer running: run recover first)' : a.alive === true ? ` (pid ${a.owner.pid} is still running)` : ` (owned by ${a.owner.host || 'another host'})`;
|
|
115
|
+
refuse('ATTEMPT_RUNNING', `attempt ${a.id} of change ${changeId} is running${hint}; the transition is refused and the attempt is not terminated`, { attempt: a.id });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- The transaction --------------------------------------------------------------
|
|
119
|
+
// run(root, { command, waitMs, hooks }, fn): fn receives a context and returns
|
|
120
|
+
// the result to hand back. Inside fn, ctx.read(rel) reads a repository-relative
|
|
121
|
+
// JSON file (null when missing), ctx.text(rel) a text file, ctx.write(rel,
|
|
122
|
+
// content) stages a write, ctx.expect(rel, key, value) refuses STATE_CHANGED when
|
|
123
|
+
// the file's field differs from what the caller prepared against, ctx.idle(id)
|
|
124
|
+
// refuses ATTEMPT_RUNNING for a change with a running attempt, ctx.refuse(code,
|
|
125
|
+
// message) aborts with nothing written. `hooks(point)` is a test seam invoked at
|
|
126
|
+
// 'validated', 'staged', 'manifest', 'rename:<i>' and 'cleanup'.
|
|
127
|
+
function run(root, { command = 'transaction', waitMs, hooks = null, log } = {}, fn) {
|
|
128
|
+
return state.withLock(root, () => {
|
|
129
|
+
// Before anything else: finish what a killed writer committed and drop what
|
|
130
|
+
// it only staged, so every reader inside the lock sees consistent state.
|
|
131
|
+
recoverPending(root);
|
|
132
|
+
const writes = [];
|
|
133
|
+
const targets = new Set();
|
|
134
|
+
const ctx = {
|
|
135
|
+
root,
|
|
136
|
+
read(rel) {
|
|
137
|
+
assertRelative(rel);
|
|
138
|
+
const read = readJson(path.join(root, rel));
|
|
139
|
+
if (read.error === 'missing') return null;
|
|
140
|
+
if (read.error) refuse('MALFORMED', `${rel}: ${read.error}`);
|
|
141
|
+
return read.data;
|
|
142
|
+
},
|
|
143
|
+
text(rel) {
|
|
144
|
+
assertRelative(rel);
|
|
145
|
+
try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch (error) { return error.code === 'ENOENT' ? null : refuse('INPUT_INVALID', `${rel}: ${error.message}`); }
|
|
146
|
+
},
|
|
147
|
+
exists(rel) { assertRelative(rel); return fs.existsSync(path.join(root, rel)); },
|
|
148
|
+
expect(rel, key, value) {
|
|
149
|
+
const doc = ctx.read(rel);
|
|
150
|
+
const actual = doc ? doc[key] : null;
|
|
151
|
+
if (actual !== value) refuse('STATE_CHANGED', `${rel}: ${key} is ${JSON.stringify(actual)}, expected ${JSON.stringify(value)}; the record changed since the operation was prepared — inspect it and repeat the command against the current state`);
|
|
152
|
+
return doc;
|
|
153
|
+
},
|
|
154
|
+
idle(changeId) { requireIdle(root, changeId); },
|
|
155
|
+
running(changeId) { return runningAttempts(root, changeId); },
|
|
156
|
+
refuse,
|
|
157
|
+
write(rel, content) {
|
|
158
|
+
assertRelative(rel);
|
|
159
|
+
if (rel.startsWith(`${state.RUNTIME_DIR}/journal/`) || rel.startsWith(`${state.RUNTIME_DIR}/lock`)) refuse('INPUT_INVALID', `${rel}: the journal and the lock are not transaction targets`);
|
|
160
|
+
if (targets.has(rel)) refuse('INPUT_INVALID', `${rel}: written twice in one transaction`);
|
|
161
|
+
targets.add(rel);
|
|
162
|
+
writes.push({ target: rel, content: serialize(content) });
|
|
163
|
+
},
|
|
164
|
+
now: nowIso(),
|
|
165
|
+
};
|
|
166
|
+
const result = fn(ctx);
|
|
167
|
+
if (hooks) hooks('validated');
|
|
168
|
+
if (!writes.length) return { result, writes: [], id: null };
|
|
169
|
+
const id = `${TXN_PREFIX}${compact()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
170
|
+
const staging = path.join(journalDir(root), id);
|
|
171
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
172
|
+
const manifest = { schema: MANIFEST_SCHEMA, id, command, started: ctx.now, writes: [] };
|
|
173
|
+
writes.forEach((w, i) => {
|
|
174
|
+
const staged = `${String(i + 1).padStart(2, '0')}-${path.basename(w.target)}`;
|
|
175
|
+
fs.writeFileSync(path.join(staging, staged), w.content);
|
|
176
|
+
manifest.writes.push({ target: w.target, staged });
|
|
177
|
+
});
|
|
178
|
+
if (hooks) hooks('staged');
|
|
179
|
+
// The manifest is the commit point: written to a temporary name and renamed.
|
|
180
|
+
const temp = path.join(staging, `.manifest.${process.pid}.tmp`);
|
|
181
|
+
fs.writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
182
|
+
fs.renameSync(temp, path.join(staging, 'manifest.json'));
|
|
183
|
+
if (hooks) hooks('manifest');
|
|
184
|
+
applyManifest(root, staging, manifest, hooks);
|
|
185
|
+
return { result, writes: manifest.writes.map(w => w.target), id };
|
|
186
|
+
}, { command, waitMs, log });
|
|
187
|
+
}
|
|
188
|
+
function assertRelative(rel) {
|
|
189
|
+
if (typeof rel !== 'string' || !SAFE_RELATIVE.test(rel) || path.isAbsolute(rel)) refuse('INPUT_INVALID', `${rel}: paths must be repository-relative without ".."`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Bounded text arguments stored verbatim in records (reasons, notes, references…).
|
|
193
|
+
function boundedText(value, name, { required = true } = {}) {
|
|
194
|
+
if (value === undefined || value === null) { if (required) refuse('INPUT_INVALID', `--${name} is required`); return null; }
|
|
195
|
+
if (typeof value !== 'string' || (required && !value.trim())) refuse('INPUT_INVALID', `--${name} must be a nonempty string`);
|
|
196
|
+
if (value.length > MAX_TEXT) refuse('INPUT_INVALID', `--${name} is longer than ${MAX_TEXT} characters; store a reference, not a transcript`);
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { Refusal, refuse, run, pending, recoverPending, runningAttempts, requireIdle, boundedText, MANIFEST_SCHEMA, MAX_TEXT, TXN_PREFIX };
|