pincer-workflow 0.5.0 → 0.6.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 +6 -6
- package/bin/pincer.js +17 -1
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +85 -14
- package/template/.agents/skills/pincer-evaluate/SKILL.md +42 -13
- package/template/.agents/skills/pincer-narrow/SKILL.md +43 -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 +12 -2
- package/template/.claude/commands/pincer-code.md +85 -14
- package/template/.claude/commands/pincer-evaluate.md +42 -13
- package/template/.claude/commands/pincer-narrow.md +43 -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 +12 -2
- 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 +85 -14
- package/template/.github/prompts/pincer-evaluate.prompt.md +42 -13
- package/template/.github/prompts/pincer-narrow.prompt.md +43 -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 +12 -2
- 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 +1342 -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 +9 -1
- package/template/scripts/pincer-runtime/requirements.cjs +255 -0
- package/template/scripts/pincer-runtime/resume.cjs +205 -0
- package/template/scripts/pincer-runtime/routing.cjs +54 -0
- package/template/scripts/pincer-runtime/runner.cjs +24 -6
- 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 +387 -76
- package/template/scripts/pincer-status.sh +1 -1
- package/template/scripts/pincer-ticket.sh +1 -1
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
const fs = require('node:fs');
|
|
23
23
|
const path = require('node:path');
|
|
24
24
|
const { HEX40, PRD_REF, validate, digestFile } = require('./pincer-runtime/evidence.cjs');
|
|
25
|
+
const io = require('./pincer-runtime/io.cjs');
|
|
25
26
|
|
|
26
27
|
function usage(message) {
|
|
27
|
-
if (message)
|
|
28
|
-
|
|
28
|
+
if (message) io.err(`pincer-evidence: ${message}\n`);
|
|
29
|
+
io.err('usage: pincer-evidence.cjs validate <manifest> [--candidate <sha>] [--base <sha>] [--prd .prd/prd-vN.md] [--files]\n pincer-evidence.cjs digest <file>...\n');
|
|
29
30
|
process.exit(2);
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -51,20 +52,21 @@ function main(argv) {
|
|
|
51
52
|
if (opts.prd !== undefined && !PRD_REF.test(opts.prd)) usage('--prd must be of the form .prd/prd-vN.md');
|
|
52
53
|
const problems = validate(manifest, opts);
|
|
53
54
|
if (problems.length > 0) {
|
|
54
|
-
for (const p of problems)
|
|
55
|
+
for (const p of problems) io.err(`evidence: ${manifest}: ${p}\n`);
|
|
55
56
|
process.exit(1);
|
|
56
57
|
}
|
|
57
58
|
const doc = JSON.parse(fs.readFileSync(path.resolve(manifest), 'utf8'));
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
io.out(`ok ${doc.candidate}${doc.schema >= 2 ? ` schema ${doc.schema}` : ''}\n`);
|
|
60
|
+
for (const l of opts.limitations || []) io.err(`evidence: limitation: ${l}\n`);
|
|
61
|
+
if (opts.files) for (const p of opts.list) io.out(`${p}\n`);
|
|
60
62
|
return;
|
|
61
63
|
}
|
|
62
64
|
if (command === 'digest') {
|
|
63
65
|
if (rest.length === 0) usage('digest requires at least one file');
|
|
64
66
|
for (const file of rest) {
|
|
65
67
|
let digest;
|
|
66
|
-
try { digest = digestFile(file); } catch (error) {
|
|
67
|
-
|
|
68
|
+
try { digest = digestFile(file); } catch (error) { io.err(`pincer-evidence: ${file}: ${error.code === 'ENOENT' ? 'missing' : error.message}\n`); process.exit(1); }
|
|
69
|
+
io.out(`${digest} ${file}\n`);
|
|
68
70
|
}
|
|
69
71
|
return;
|
|
70
72
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — strict coverage adoption (docs/runtime-contracts.md, "Strict
|
|
3
|
+
// coverage" → "Adoption and rollback"). `coverage adopt --preview` computes the plan
|
|
4
|
+
// and writes nothing; `--apply` is one transaction that backs up the schema 2
|
|
5
|
+
// record, records the adoption agreement (projection 2, snapshot schema 2) and
|
|
6
|
+
// rewrites the record as schema 3 with the retained capability and one `adopt`
|
|
7
|
+
// event. It validates the authored inputs (inventory, map, graph), refuses on a
|
|
8
|
+
// running attempt, a terminal state, an incomplete transaction or changed inputs,
|
|
9
|
+
// grants no authorization, and is idempotent. Migration never adopts.
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const changes = require('./changes.cjs');
|
|
13
|
+
const agreement = require('./agreement.cjs');
|
|
14
|
+
const authorization = require('./authorization.cjs');
|
|
15
|
+
const coverage = require('./coverage.cjs');
|
|
16
|
+
const dispositions = require('./dispositions.cjs');
|
|
17
|
+
const transaction = require('./transaction.cjs');
|
|
18
|
+
const state = require('./state.cjs');
|
|
19
|
+
const { nowIso } = require('./fsutil.cjs');
|
|
20
|
+
|
|
21
|
+
// Compute the plan. Returns { change, conflicts: [{ code, detail }], already, record,
|
|
22
|
+
// file, agreement: { id, digest }, inventory: { requirements, scenarios, digest },
|
|
23
|
+
// map: { path, digest }, historical, scope: [...], backup }.
|
|
24
|
+
function plan(root, { change } = {}) {
|
|
25
|
+
const conflicts = [];
|
|
26
|
+
const conflict = (code, detail) => conflicts.push({ code, detail });
|
|
27
|
+
const out = { change, conflicts, already: false, record: null, file: null };
|
|
28
|
+
if (typeof change !== 'string' || !changes.CHANGE_ID.test(change)) { conflict('INPUT_INVALID', `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${change}`); return out; }
|
|
29
|
+
const loaded = changes.loadRecords(root);
|
|
30
|
+
if (loaded.mode === 'legacy') { conflict('CHANGE_REQUIRED', `no change record under ${changes.CHANGES_DIR}/ — register the change first (node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md); migration and registration never adopt strict coverage`); return out; }
|
|
31
|
+
if (loaded.mode === 'migrated') { conflict('MIGRATION_REQUIRED', `${changes.CHANGES_DIR}/ holds a v0.5.0 binding; migrate to change records first (migrate --preview --prd <prd>); migration never adopts strict coverage`); return out; }
|
|
32
|
+
for (const p of loaded.problems) conflict(p.code, p.detail);
|
|
33
|
+
if (conflicts.length) return out;
|
|
34
|
+
const entry = loaded.records.get(change);
|
|
35
|
+
if (!entry) { conflict('INPUT_INVALID', `no change record ${changes.recordFile(change)} (retained: ${[...loaded.records.keys()].join(', ') || 'none'})`); return out; }
|
|
36
|
+
out.record = entry.record; out.file = entry.file;
|
|
37
|
+
if (changes.isStrict(entry.record)) { out.already = true; return out; }
|
|
38
|
+
const record = entry.record;
|
|
39
|
+
if (changes.TERMINAL.includes(record.lifecycle.state)) conflict('LIFECYCLE_BLOCKED', `change ${change} is ${record.lifecycle.state}; its history cannot adopt strict coverage — register a new change`);
|
|
40
|
+
const running = transaction.runningAttempts(root, change);
|
|
41
|
+
if (running.length) { const a = running[0]; 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'})`; conflict('ATTEMPT_RUNNING', `attempt ${a.id} of change ${change} is running${hint}; adoption waits for it`); }
|
|
42
|
+
const cov = coverage.load(root, record);
|
|
43
|
+
if (cov.code && cov.code !== 'COVERAGE_INCOMPLETE') { for (const p of cov.problems) conflict(cov.code, p); return out; }
|
|
44
|
+
if (cov.code === 'COVERAGE_INCOMPLETE') for (const p of cov.problems) conflict('COVERAGE_INCOMPLETE', p);
|
|
45
|
+
if (conflicts.length) return out;
|
|
46
|
+
const computed = agreement.compute(root, { ...record, schema: changes.SCHEMA_STRICT });
|
|
47
|
+
if (computed.code) { conflict(computed.code, computed.problem); return out; }
|
|
48
|
+
const gid = `G-${String(record.agreements.length + 1).padStart(2, '0')}`;
|
|
49
|
+
out.agreement = { id: gid, digest: computed.digest };
|
|
50
|
+
out.computed = computed;
|
|
51
|
+
out.inventory = { requirements: Object.keys(cov.inventory.requirements).length, scenarios: Object.keys(cov.inventory.scenarios).length, digest: cov.inventory.digest };
|
|
52
|
+
out.map = { path: cov.map.file, digest: cov.map.digest };
|
|
53
|
+
out.historical = state.exists(root) ? state.listAttempts(root).filter(a => a.context && a.context.change === change && a.schema !== 3).length : 0;
|
|
54
|
+
// Scope dispositions are reported for information: their authorization is recorded after adoption.
|
|
55
|
+
const verdict = authorization.verdict(root, record);
|
|
56
|
+
out.scope = Object.values(cov.graph.scope).map(s => ({ id: s.id, disposition: s.disposition, decision: s.decision }));
|
|
57
|
+
out.scopeProblems = dispositions.scopeProblems(record, cov.graph, verdict).problems.map(p => p.detail);
|
|
58
|
+
out.backup = `.pincer/backups/<UTC timestamp>/${entry.file}`;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderPlan(p) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
if (p.conflicts.length) {
|
|
65
|
+
for (const c of p.conflicts) lines.push(`conflict ${c.code}: ${c.detail}`);
|
|
66
|
+
lines.push('adoption refused: resolve the conflicts above; nothing was written');
|
|
67
|
+
return `${lines.join('\n')}\n`;
|
|
68
|
+
}
|
|
69
|
+
if (p.already) {
|
|
70
|
+
lines.push(`already adopted: change ${p.change} is strict since ${p.record.coverage.adopted} (map ${p.record.coverage.map}, adoption agreement ${p.record.coverage.agreement}); nothing to do`);
|
|
71
|
+
return `${lines.join('\n')}\n`;
|
|
72
|
+
}
|
|
73
|
+
lines.push(`adoption plan for change ${p.change} (${p.file}, ${p.record.lifecycle.state})`);
|
|
74
|
+
lines.push(` inventory ${p.inventory.requirements} requirement(s), ${p.inventory.scenarios} scenario(s) · digest ${p.inventory.digest.slice(0, 12)}`);
|
|
75
|
+
lines.push(` map ${p.map.path} · digest ${p.map.digest.slice(0, 12)} · structure complete`);
|
|
76
|
+
for (const s of p.scope) lines.push(` scope ${s.id} ${s.disposition} by decision ${s.decision}${p.scopeProblems.find(d => d.startsWith(`${s.id} (`)) ? ` — not yet authorized: ${p.scopeProblems.find(d => d.startsWith(`${s.id} (`)).replace(/^[^:]*: /, '')}` : ''}`);
|
|
77
|
+
lines.push(` agreement ${p.agreement.id} ${p.agreement.digest.slice(0, 12)} (projection 2: PRD revision, inventory, coverage map, tickets, resolved decisions) recorded with its snapshot`);
|
|
78
|
+
lines.push(` record ${p.file} → schema 3 with coverage { map, adopted, agreement ${p.agreement.id} } and one adopt event; ${p.record.agreements.length} earlier agreement(s) kept (inventory history unavailable before ${p.agreement.id})`);
|
|
79
|
+
lines.push(` history ${p.historical} existing attempt(s) of this change become HISTORICAL_EVIDENCE until verified again`);
|
|
80
|
+
lines.push(` backup ${p.backup}`);
|
|
81
|
+
lines.push(' note adoption grants no authorization: the agreement above needs `change authorize` (user, or --delegated --basis A-NN) before execution');
|
|
82
|
+
lines.push(`apply with: node scripts/pincer-runtime.cjs coverage adopt --apply --change ${p.change} --agreement ${p.agreement.digest}`);
|
|
83
|
+
return `${lines.join('\n')}\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Apply as one transaction. Returns { plan, applied, record, backup } | { plan, already } |
|
|
87
|
+
// { plan, applied: false } (conflicts) | { plan, error, code }.
|
|
88
|
+
function apply(root, { change, agreement: expected = null, hooks = null } = {}) {
|
|
89
|
+
const first = plan(root, { change });
|
|
90
|
+
if (first.conflicts.length) return { plan: first, applied: false };
|
|
91
|
+
if (first.already) return { plan: first, applied: false, already: true };
|
|
92
|
+
const stamp = nowIso().replace(/[-:]/g, '');
|
|
93
|
+
let backupRel = null;
|
|
94
|
+
try {
|
|
95
|
+
const out = transaction.run(root, { command: `coverage adopt ${change}`, hooks }, ctx => {
|
|
96
|
+
const p = plan(root, { change });
|
|
97
|
+
if (p.conflicts.length) ctx.refuse(p.conflicts[0].code, p.conflicts[0].detail);
|
|
98
|
+
if (p.already) return { plan: p, already: true };
|
|
99
|
+
if (expected !== null && expected !== p.agreement.digest) ctx.refuse('AGREEMENT_CHANGED', `--agreement ${expected.slice(0, 12)} is not the agreement adoption would record now (${p.agreement.digest.slice(0, 12)}); the inventory, the map, a ticket or a decision changed since the preview — preview again and adopt the current digest`);
|
|
100
|
+
ctx.idle(change);
|
|
101
|
+
const old = p.record;
|
|
102
|
+
// Backup before anything is staged (the copy is outside the transaction's targets).
|
|
103
|
+
const src = path.join(root, p.file);
|
|
104
|
+
const dest = path.join(root, '.pincer', 'backups', stamp, p.file);
|
|
105
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
106
|
+
fs.copyFileSync(src, dest);
|
|
107
|
+
backupRel = `.pincer/backups/${stamp}/${p.file}`;
|
|
108
|
+
const record = {};
|
|
109
|
+
for (const k of changes.RECORD_KEYS_STRICT) record[k] = k === 'coverage' ? null : JSON.parse(JSON.stringify(old[k]));
|
|
110
|
+
record.schema = changes.SCHEMA_STRICT; record.runtime = changes.RUNTIME_STRICT;
|
|
111
|
+
record.agreements = old.agreements.map(g => { const e = {}; for (const k of changes.AGREEMENT_KEYS_STRICT) e[k] = k === 'inventory' || k === 'coverage' ? null : g[k]; return e; });
|
|
112
|
+
record.coverage = { map: coverage.file(change), adopted: ctx.now, agreement: p.agreement.id };
|
|
113
|
+
const appended = agreement.appendAgreement(ctx, changes, record, p.file, p.computed, { stage: false, event: false });
|
|
114
|
+
if (appended.agreement.id !== p.agreement.id) ctx.refuse('STATE_CHANGED', `the record changed since the operation was prepared (agreement ${appended.agreement.id} instead of ${p.agreement.id})`);
|
|
115
|
+
const sequence = record.sequence + 1;
|
|
116
|
+
record.events.push({ sequence, kind: 'adopt', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: p.agreement.id, authorization: null, decision: null, replacement: null, note: null });
|
|
117
|
+
record.sequence = sequence;
|
|
118
|
+
const invalid = changes.validateRecord(record, p.file);
|
|
119
|
+
if (invalid) ctx.refuse(invalid.code, invalid.problem);
|
|
120
|
+
ctx.write(p.file, record);
|
|
121
|
+
return { plan: p, record };
|
|
122
|
+
});
|
|
123
|
+
if (out.result.already) return { plan: out.result.plan, applied: false, already: true };
|
|
124
|
+
return { plan: out.result.plan, applied: true, record: out.result.record, backup: backupRel };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error.refusal) return { plan: first, applied: false, error: error.message, code: error.code };
|
|
127
|
+
if (error.code === 'STATE_BUSY') return { plan: first, applied: false, error: error.message, code: 'STATE_BUSY' };
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { plan, renderPlan, apply };
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — agreements (docs/runtime-contracts.md, "Agreements and
|
|
3
|
+
// authorization"). An agreement is the reviewed content a user authorized: the
|
|
4
|
+
// change, the PRD's authored revision, the breakdown (every ticket of the PRD,
|
|
5
|
+
// normalized as in "Content revisions") and the resolved consequential
|
|
6
|
+
// decisions. Its digest is SHA-256 over the projection text below, version 1.
|
|
7
|
+
// Agreement entries carry a tracked snapshot of the normalized inputs so an old
|
|
8
|
+
// agreement stays reviewable and its digest recomputable from the file alone;
|
|
9
|
+
// the structural difference between two agreements is computed here too. Nothing
|
|
10
|
+
// here judges semantics or grants approval.
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const parse = require('./parse.cjs');
|
|
14
|
+
const transaction = require('./transaction.cjs');
|
|
15
|
+
const requirements = require('./requirements.cjs');
|
|
16
|
+
const coverage = require('./coverage.cjs');
|
|
17
|
+
const { readJson } = require('./fsutil.cjs');
|
|
18
|
+
|
|
19
|
+
const PROJECTION_VERSION = 1;
|
|
20
|
+
const SNAPSHOT_SCHEMA = 1;
|
|
21
|
+
// Strict coverage (PRD v6): projection version 2 adds the inventory and coverage
|
|
22
|
+
// map digests; snapshot schema 2 carries the normalized inventory and map text.
|
|
23
|
+
const PROJECTION_VERSION_STRICT = 2;
|
|
24
|
+
const SNAPSHOT_SCHEMA_STRICT = 2;
|
|
25
|
+
const SNAPSHOT_KEYS = ['schema', 'change', 'agreement', 'digest', 'projection', 'prd', 'tickets', 'decisions', 'recorded'];
|
|
26
|
+
const SNAPSHOT_KEYS_STRICT = ['schema', 'change', 'agreement', 'digest', 'projection', 'prd', 'inventory', 'coverage', 'tickets', 'decisions', 'recorded'];
|
|
27
|
+
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
const ticketNumber = id => Number(id.slice(2));
|
|
29
|
+
|
|
30
|
+
const decisionDigest = d => parse.sha256(`${d.id}\n${d.summary}\n${d.reference}\n${d.excerpt}\n`);
|
|
31
|
+
|
|
32
|
+
// The exact projection text (each line terminated by \n).
|
|
33
|
+
function projectionText({ change, prd, tickets, decisions, inventory = null, coverage: map = null }) {
|
|
34
|
+
const strict = Boolean(inventory && map);
|
|
35
|
+
const lines = [`pincer agreement ${strict ? PROJECTION_VERSION_STRICT : PROJECTION_VERSION}`, `change ${change}`, `prd ${prd.path} ${prd.revision}`];
|
|
36
|
+
if (strict) { lines.push(`inventory ${inventory.digest}`); lines.push(`coverage ${map.path} ${map.digest}`); }
|
|
37
|
+
for (const id of Object.keys(tickets).sort((a, b) => ticketNumber(a) - ticketNumber(b))) lines.push(`ticket ${id} ${tickets[id].digest}`);
|
|
38
|
+
for (const id of Object.keys(decisions).sort()) lines.push(`decision ${id} ${decisionDigest({ id, ...decisions[id] })}`);
|
|
39
|
+
return `${lines.join('\n')}\n`;
|
|
40
|
+
}
|
|
41
|
+
const breakdownDigest = tickets => parse.sha256(Object.keys(tickets).sort((a, b) => ticketNumber(a) - ticketNumber(b)).map(id => `ticket ${id} ${tickets[id].digest}\n`).join(''));
|
|
42
|
+
|
|
43
|
+
// Compute the current agreement of a record from the files on disk. Returns
|
|
44
|
+
// { digest, projection, prd, tickets, decisions, breakdown } or { code, problem }.
|
|
45
|
+
function compute(root, record) {
|
|
46
|
+
const prdResult = parse.validatePrd(root, record.prd);
|
|
47
|
+
if (!prdResult.ok) return { code: 'INPUT_INVALID', problem: `${record.prd}: ${prdResult.problems[0]}` };
|
|
48
|
+
const set = parse.validateTicketSet(root);
|
|
49
|
+
if (!set.ok) return { code: 'INPUT_INVALID', problem: `pincer-ticket: ${set.file ? `${set.file}: ` : ''}${set.problems[0]}` };
|
|
50
|
+
const statusModule = require('./status.cjs');
|
|
51
|
+
const tickets = {};
|
|
52
|
+
for (const file of set.files) {
|
|
53
|
+
const text = fs.readFileSync(path.join(root, file), 'utf8');
|
|
54
|
+
const v = parse.validateTicket(file, text);
|
|
55
|
+
const assoc = statusModule.ticketPrd(root, file, v.fields);
|
|
56
|
+
if (assoc.problem) return { code: 'INPUT_INVALID', problem: assoc.problem };
|
|
57
|
+
if (assoc.prd !== record.prd) continue;
|
|
58
|
+
tickets[v.fields.ticket] = { file, digest: parse.ticketDigest(text), text: parse.normalizeTicket(text) };
|
|
59
|
+
}
|
|
60
|
+
const decisions = {};
|
|
61
|
+
for (const d of record.decisions) if (d.status === 'resolved') decisions[d.id] = { summary: d.summary, reference: d.reference, excerpt: d.excerpt };
|
|
62
|
+
const prd = { path: record.prd, revision: parse.prdDigest(prdResult.text), text: parse.normalizePrd(prdResult.text) };
|
|
63
|
+
const inputs = { change: record.change, prd, tickets, decisions };
|
|
64
|
+
if (record.schema === 3) {
|
|
65
|
+
// A strict change binds its inventory and its coverage map (docs/runtime-contracts.md,
|
|
66
|
+
// "Strict change records"); when either cannot be read the agreement cannot be computed.
|
|
67
|
+
const inv = requirements.readInventory(root, record.prd);
|
|
68
|
+
if (!inv.ok) return { code: inv.code, problem: inv.problems[0] };
|
|
69
|
+
const m = coverage.readMap(root, record);
|
|
70
|
+
if (!m.ok) return { code: m.code, problem: m.problems[0] };
|
|
71
|
+
inputs.inventory = requirements.snapshotOf(inv.inventory);
|
|
72
|
+
inputs.coverage = { path: m.file, digest: m.digest, text: m.normalized };
|
|
73
|
+
inputs.graphInputs = { inventory: inv.inventory, map: m };
|
|
74
|
+
}
|
|
75
|
+
const projection = projectionText(inputs);
|
|
76
|
+
return { ...inputs, projection, digest: parse.sha256(projection), breakdown: breakdownDigest(tickets) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Snapshots -----------------------------------------------------------------------
|
|
80
|
+
function snapshotDoc(record, agreementId, computed, recorded) {
|
|
81
|
+
if (computed.inventory && computed.coverage) return { schema: SNAPSHOT_SCHEMA_STRICT, change: record.change, agreement: agreementId, digest: computed.digest, projection: computed.projection, prd: computed.prd, inventory: computed.inventory, coverage: computed.coverage, tickets: computed.tickets, decisions: computed.decisions, recorded };
|
|
82
|
+
return { schema: SNAPSHOT_SCHEMA, change: record.change, agreement: agreementId, digest: computed.digest, projection: computed.projection, prd: computed.prd, tickets: computed.tickets, decisions: computed.decisions, recorded };
|
|
83
|
+
}
|
|
84
|
+
// Read and verify one agreement's snapshot: the file exists, has the documented
|
|
85
|
+
// shape, its projection recomputes from its own inputs, and the digest is the
|
|
86
|
+
// SHA-256 of that projection and equals the entry's. Returns { snapshot } or
|
|
87
|
+
// { code: 'HISTORY_INVALID', problem }.
|
|
88
|
+
function readSnapshot(root, record, agreement) {
|
|
89
|
+
const rel = agreement.snapshot;
|
|
90
|
+
const invalid = p => ({ code: 'HISTORY_INVALID', problem: `${rel}: ${p} — the agreement ${agreement.id} of change "${record.change}" cannot be reviewed from a digest alone` });
|
|
91
|
+
const read = readJson(path.join(root, rel));
|
|
92
|
+
if (read.error === 'missing') return invalid('snapshot missing');
|
|
93
|
+
if (read.error) return invalid(read.error);
|
|
94
|
+
const s = read.data;
|
|
95
|
+
if (!isObject(s) || ![SNAPSHOT_SCHEMA, SNAPSHOT_SCHEMA_STRICT].includes(s.schema)) return invalid('not a schema 1 or 2 agreement snapshot');
|
|
96
|
+
const keys = s.schema === SNAPSHOT_SCHEMA_STRICT ? SNAPSHOT_KEYS_STRICT : SNAPSHOT_KEYS;
|
|
97
|
+
if (Object.keys(s).some(k => !keys.includes(k)) || !keys.every(k => k in s)) return invalid(`not a schema ${s.schema} agreement snapshot (keys)`);
|
|
98
|
+
if (s.schema === SNAPSHOT_SCHEMA_STRICT) {
|
|
99
|
+
if (!isObject(s.prd) || typeof s.prd.path !== 'string') return invalid('snapshot prd must be { path, revision, text }');
|
|
100
|
+
const inv = requirements.validateSnapshot(s.inventory, s.prd.path);
|
|
101
|
+
if (inv) return invalid(`snapshot inventory: ${inv}`);
|
|
102
|
+
if (!isObject(s.coverage) || typeof s.coverage.path !== 'string' || typeof s.coverage.digest !== 'string' || typeof s.coverage.text !== 'string') return invalid('snapshot coverage must be { path, digest, text }');
|
|
103
|
+
if (s.coverage.path !== coverage.file(record.change)) return invalid(`snapshot coverage path must be ${coverage.file(record.change)}`);
|
|
104
|
+
const m = coverage.validateText(s.coverage.text, { change: record.change, prd: s.prd.path });
|
|
105
|
+
if (m.problem) return invalid(`snapshot coverage map: ${m.problem}`);
|
|
106
|
+
if (m.digest !== s.coverage.digest || parse.sha256(s.coverage.text) !== s.coverage.digest) return invalid('the snapshot coverage map text does not hash to its recorded digest');
|
|
107
|
+
if (agreement.inventory !== undefined && (agreement.inventory !== s.inventory.digest || agreement.coverage !== s.coverage.digest)) return invalid('the snapshot inventory and coverage digests do not match the record entry');
|
|
108
|
+
} else if (agreement.inventory !== undefined && (agreement.inventory !== null || agreement.coverage !== null)) return invalid('a schema 1 snapshot carries no inventory or coverage digest, but the record entry does');
|
|
109
|
+
if (s.change !== record.change || s.agreement !== agreement.id) return invalid(`snapshot belongs to ${s.change}/${s.agreement}`);
|
|
110
|
+
if (!isObject(s.prd) || typeof s.prd.path !== 'string' || typeof s.prd.revision !== 'string' || typeof s.prd.text !== 'string') return invalid('snapshot prd must be { path, revision, text }');
|
|
111
|
+
if (!isObject(s.tickets) || !Object.values(s.tickets).every(t => isObject(t) && typeof t.file === 'string' && typeof t.digest === 'string' && typeof t.text === 'string')) return invalid('snapshot tickets must map IDs to { file, digest, text }');
|
|
112
|
+
if (!isObject(s.decisions)) return invalid('snapshot decisions must be an object');
|
|
113
|
+
if (parse.sha256(s.prd.text) !== s.prd.revision) return invalid('the snapshot PRD text does not hash to its recorded revision');
|
|
114
|
+
for (const [id, t] of Object.entries(s.tickets)) if (parse.sha256(t.text) !== t.digest) return invalid(`the snapshot text of ${id} does not hash to its recorded digest`);
|
|
115
|
+
const recomputed = projectionText({ change: s.change, prd: s.prd, tickets: s.tickets, decisions: s.decisions, inventory: s.inventory || null, coverage: s.coverage || null });
|
|
116
|
+
if (recomputed !== s.projection) return invalid('the snapshot projection does not recompute from its inputs');
|
|
117
|
+
if (parse.sha256(s.projection) !== s.digest || s.digest !== agreement.digest) return invalid('the snapshot digest does not match the projection or the record entry');
|
|
118
|
+
return { snapshot: s };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Structural difference ------------------------------------------------------------
|
|
122
|
+
// Which parts of two normalized ticket texts differ: frontmatter, acceptance,
|
|
123
|
+
// verification, other. Never a semantic judgment.
|
|
124
|
+
function ticketParts(a, b) {
|
|
125
|
+
const sa = parse.ticketSections(a), sb = parse.ticketSections(b);
|
|
126
|
+
return ['frontmatter', 'acceptance', 'verification', 'other'].filter(k => sa[k] !== sb[k]);
|
|
127
|
+
}
|
|
128
|
+
// difference(from, to): `from` is a snapshot (or computed inputs) and `to` the
|
|
129
|
+
// current computed inputs.
|
|
130
|
+
function difference(from, to) {
|
|
131
|
+
const before = new Set(Object.keys(from.tickets)), after = new Set(Object.keys(to.tickets));
|
|
132
|
+
const byNumber = (a, b) => ticketNumber(a) - ticketNumber(b);
|
|
133
|
+
const added = [...after].filter(id => !before.has(id)).sort(byNumber);
|
|
134
|
+
const removed = [...before].filter(id => !after.has(id)).sort(byNumber);
|
|
135
|
+
const changed = [...after].filter(id => before.has(id) && from.tickets[id].digest !== to.tickets[id].digest).sort(byNumber).map(id => ({ id, parts: ticketParts(from.tickets[id].text, to.tickets[id].text) }));
|
|
136
|
+
const decisionsBefore = Object.keys(from.decisions), decisionsAfter = Object.keys(to.decisions);
|
|
137
|
+
const both = from.inventory && to.inventory && from.coverage && to.coverage;
|
|
138
|
+
const out = {
|
|
139
|
+
same: from.digest === to.digest,
|
|
140
|
+
prd_changed: from.prd.revision !== to.prd.revision,
|
|
141
|
+
tickets_added: added, tickets_removed: removed, tickets_changed: changed,
|
|
142
|
+
decisions_added: decisionsAfter.filter(id => !decisionsBefore.includes(id)).sort(),
|
|
143
|
+
decisions_removed: decisionsBefore.filter(id => !decisionsAfter.includes(id)).sort(),
|
|
144
|
+
};
|
|
145
|
+
// Strict coverage (either side strict): the inventory difference, or null with
|
|
146
|
+
// strict_history false when one side has no inventory (history unavailable).
|
|
147
|
+
// A difference between two non-strict agreements keeps the v5 shape exactly.
|
|
148
|
+
if (from.inventory || to.inventory) {
|
|
149
|
+
out.inventory = both ? requirements.difference(from.inventory, to.inventory) : null;
|
|
150
|
+
out.coverage_changed = both ? from.coverage.digest !== to.coverage.digest : null;
|
|
151
|
+
out.strict_history = Boolean(both);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
function renderDifference(d) {
|
|
156
|
+
if (d.same) return 'unchanged';
|
|
157
|
+
const bits = [];
|
|
158
|
+
if (d.prd_changed) bits.push('PRD body changed');
|
|
159
|
+
if (d.tickets_added.length) bits.push(`tickets added: ${d.tickets_added.join(', ')}`);
|
|
160
|
+
if (d.tickets_removed.length) bits.push(`tickets removed: ${d.tickets_removed.join(', ')}`);
|
|
161
|
+
if (d.tickets_changed.length) bits.push(`tickets changed: ${d.tickets_changed.map(t => `${t.id} (${t.parts.join(', ') || 'content'})`).join(', ')}`);
|
|
162
|
+
if (d.decisions_added.length) bits.push(`decisions resolved: ${d.decisions_added.join(', ')}`);
|
|
163
|
+
if (d.decisions_removed.length) bits.push(`decisions no longer recorded: ${d.decisions_removed.join(', ')}`);
|
|
164
|
+
if (d.inventory && !d.inventory.same) {
|
|
165
|
+
const inv = d.inventory, parts = [];
|
|
166
|
+
if (inv.requirements.added.length) parts.push(`requirements added: ${inv.requirements.added.join(', ')}`);
|
|
167
|
+
if (inv.requirements.removed.length) parts.push(`requirements removed: ${inv.requirements.removed.join(', ')}`);
|
|
168
|
+
if (inv.requirements.changed.length) parts.push(`requirements changed: ${inv.requirements.changed.map(r => `${r.id} (${r.parts.join(', ')})`).join(', ')}`);
|
|
169
|
+
if (inv.scenarios.added.length) parts.push(`scenarios added: ${inv.scenarios.added.join(', ')}`);
|
|
170
|
+
if (inv.scenarios.removed.length) parts.push(`scenarios removed: ${inv.scenarios.removed.join(', ')}`);
|
|
171
|
+
if (inv.scenarios.changed.length) parts.push(`scenarios changed: ${inv.scenarios.changed.map(r => `${r.id} (${r.parts.join(', ')})`).join(', ')}`);
|
|
172
|
+
bits.push(`inventory changed (${parts.join('; ') || 'digest'})`);
|
|
173
|
+
}
|
|
174
|
+
if (d.coverage_changed) bits.push('coverage map changed');
|
|
175
|
+
if (d.strict_history === false) bits.push('inventory history unavailable for one side');
|
|
176
|
+
return bits.join('; ') || 'digest differs';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The latest recorded agreement entry, and whether it matches the current digest.
|
|
180
|
+
const latestEntry = record => (record.agreements.length ? record.agreements[record.agreements.length - 1] : null);
|
|
181
|
+
const entryFor = (record, digest) => record.agreements.find(g => g.digest === digest) || null;
|
|
182
|
+
|
|
183
|
+
// --- change revise ---------------------------------------------------------------------
|
|
184
|
+
// Record the current agreement as G-NN with its snapshot when it differs from the
|
|
185
|
+
// latest recorded one; a no-op otherwise. Authorizes nothing. `expect` is the
|
|
186
|
+
// record sequence the caller prepared against. Returns { action: 'recorded' |
|
|
187
|
+
// 'unchanged', agreement, record, difference } or { code, problem }.
|
|
188
|
+
function revise(root, id, { expect = null, hooks = null } = {}) {
|
|
189
|
+
const changes = require('./changes.cjs');
|
|
190
|
+
try {
|
|
191
|
+
const out = transaction.run(root, { command: `change revise ${id}`, hooks }, ctx => {
|
|
192
|
+
const resolved = changes.resolveSelected(root, { change: id });
|
|
193
|
+
if (resolved.code) ctx.refuse(resolved.code, resolved.problem);
|
|
194
|
+
if (expect !== null) ctx.expect(resolved.file, 'sequence', expect);
|
|
195
|
+
const record = resolved.record;
|
|
196
|
+
if (changes.TERMINAL.includes(record.lifecycle.state)) ctx.refuse('LIFECYCLE_BLOCKED', `change ${id} is ${record.lifecycle.state}; its agreements are history and cannot be revised — register a new change`);
|
|
197
|
+
const computed = compute(root, record);
|
|
198
|
+
if (computed.code) ctx.refuse(computed.code, computed.problem);
|
|
199
|
+
const latest = latestEntry(record);
|
|
200
|
+
if (latest && latest.digest === computed.digest) return { action: 'unchanged', agreement: latest, record, difference: null };
|
|
201
|
+
const previous = latest ? readSnapshot(root, record, latest) : null;
|
|
202
|
+
if (previous && previous.code) ctx.refuse(previous.code, previous.problem);
|
|
203
|
+
return appendAgreement(ctx, changes, record, resolved.file, computed, { previous: previous ? previous.snapshot : null });
|
|
204
|
+
});
|
|
205
|
+
return out.result;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error.refusal) return { code: error.code, problem: error.message };
|
|
208
|
+
if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Stage a new agreement entry, its snapshot and the `agreement` event on an
|
|
213
|
+
// in-memory record; shared with `change authorize` (which records the agreement
|
|
214
|
+
// it binds when it is not recorded yet). Mutates `record`; the caller must stage
|
|
215
|
+
// the record write when it adds more.
|
|
216
|
+
function appendAgreement(ctx, changes, record, file, computed, { previous = null, stage = true, event = true } = {}) {
|
|
217
|
+
const gid = `G-${String(record.agreements.length + 1).padStart(2, '0')}`;
|
|
218
|
+
const snapshotRel = changes.snapshotFile(record.change, gid);
|
|
219
|
+
const entry = record.schema === 3
|
|
220
|
+
? { id: gid, digest: computed.digest, prd_revision: computed.prd.revision, breakdown: computed.breakdown, inventory: computed.inventory ? computed.inventory.digest : null, coverage: computed.coverage ? computed.coverage.digest : null, tickets: Object.keys(computed.tickets).sort((a, b) => ticketNumber(a) - ticketNumber(b)), decisions: Object.keys(computed.decisions).sort(), snapshot: snapshotRel, recorded: ctx.now }
|
|
221
|
+
: { id: gid, digest: computed.digest, prd_revision: computed.prd.revision, breakdown: computed.breakdown, tickets: Object.keys(computed.tickets).sort((a, b) => ticketNumber(a) - ticketNumber(b)), decisions: Object.keys(computed.decisions).sort(), snapshot: snapshotRel, recorded: ctx.now };
|
|
222
|
+
record.agreements.push(entry);
|
|
223
|
+
if (event) {
|
|
224
|
+
// `change authorize` records the entry inside its own event instead.
|
|
225
|
+
const sequence = record.sequence + 1;
|
|
226
|
+
record.events.push({ sequence, kind: 'agreement', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: gid, authorization: null, decision: null, replacement: null, note: null });
|
|
227
|
+
record.sequence = sequence;
|
|
228
|
+
}
|
|
229
|
+
ctx.write(snapshotRel, snapshotDoc(record, gid, computed, ctx.now));
|
|
230
|
+
if (stage) ctx.write(file, record);
|
|
231
|
+
return { action: 'recorded', agreement: entry, record, difference: previous ? difference(previous, computed) : null };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The inventory snapshot of an agreement entry (null when the entry predates strict coverage or is unreadable).
|
|
235
|
+
function inventoryOf(root, record, entry) {
|
|
236
|
+
if (!entry || entry.inventory === null || entry.inventory === undefined) return null;
|
|
237
|
+
const snap = readSnapshot(root, record, entry);
|
|
238
|
+
return snap.code ? null : snap.snapshot.inventory;
|
|
239
|
+
}
|
|
240
|
+
module.exports = { PROJECTION_VERSION, PROJECTION_VERSION_STRICT, SNAPSHOT_SCHEMA, SNAPSHOT_SCHEMA_STRICT, inventoryOf, projectionText, decisionDigest, breakdownDigest, compute, snapshotDoc, readSnapshot, difference, renderDifference, latestEntry, entryFor, revise, appendAgreement };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — authorization and decisions (docs/runtime-contracts.md,
|
|
3
|
+
// "Agreements and authorization"). An authorization record binds one agreement
|
|
4
|
+
// digest to the user's actual instruction (disposition `user`) or to an earlier
|
|
5
|
+
// authorization it stays within (disposition `delegated`, a reviewer's judgment
|
|
6
|
+
// recorded with its explanation). Decisions are the consequential choices that
|
|
7
|
+
// need the user: raised as `open`, which blocks execution, and resolved with the
|
|
8
|
+
// user's decision, which changes the agreement. The verdict computed here is the
|
|
9
|
+
// one every gate, status and resume consume. Nothing here infers approval from a
|
|
10
|
+
// status, a passing check, a registration or the v0.5.0 free text.
|
|
11
|
+
const transaction = require('./transaction.cjs');
|
|
12
|
+
const agreement = require('./agreement.cjs');
|
|
13
|
+
const { inlineSecretLine } = require('./sanitize.cjs');
|
|
14
|
+
|
|
15
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
16
|
+
const DECISION_ID = /^D-[0-9]{2,6}$/;
|
|
17
|
+
const AUTHORIZATION_ID = /^A-[0-9]{2,6}$/;
|
|
18
|
+
const nextId = (prefix, list) => `${prefix}-${String(list.length + 1).padStart(2, '0')}`;
|
|
19
|
+
|
|
20
|
+
// --- Verdict (pure over the record and the computed agreement) -------------------------
|
|
21
|
+
// { verdict: 'current' | 'DECISION_REQUIRED' | 'AUTHORIZATION_REQUIRED' | 'AGREEMENT_CHANGED',
|
|
22
|
+
// detail, authorized, current, latest, open, difference } — or, when the agreement
|
|
23
|
+
// cannot be computed, { verdict: <input code>, detail }.
|
|
24
|
+
function verdict(root, record, computed = null) {
|
|
25
|
+
const now = computed || agreement.compute(root, record);
|
|
26
|
+
if (now.code) return { verdict: now.code, detail: now.problem, authorized: null, current: null, latest: null, open: [], difference: null };
|
|
27
|
+
const open = record.decisions.filter(d => d.status === 'open');
|
|
28
|
+
const authorized = record.authorizations.find(a => a.digest === now.digest) || null;
|
|
29
|
+
const latest = record.authorizations.length ? record.authorizations[record.authorizations.length - 1] : null;
|
|
30
|
+
let difference = null;
|
|
31
|
+
if (!authorized && latest) {
|
|
32
|
+
const entry = record.agreements.find(g => g.id === latest.agreement);
|
|
33
|
+
const snap = entry ? agreement.readSnapshot(root, record, entry) : { code: 'HISTORY_INVALID' };
|
|
34
|
+
if (!snap.code) difference = agreement.difference(snap.snapshot, now);
|
|
35
|
+
}
|
|
36
|
+
const base = { authorized, current: now.digest, latest, open: open.map(d => d.id), difference };
|
|
37
|
+
if (open.length) return { ...base, verdict: 'DECISION_REQUIRED', detail: `decision ${open[0].id} is open (${open[0].summary}); record the user's decision with: node scripts/pincer-runtime.cjs change decide ${record.change} --resolve ${open[0].id} --reference <text> --excerpt <text>` };
|
|
38
|
+
if (!record.authorizations.length) return { ...base, verdict: 'AUTHORIZATION_REQUIRED', detail: `change ${record.change} has no authorization record${record.legacy.authorization_text ? ' (the v0.5.0 free text is retained as history only)' : ''}; record the user's instruction with: node scripts/pincer-runtime.cjs change authorize ${record.change} --agreement ${now.digest} --reference <text> --excerpt <text>` };
|
|
39
|
+
if (!authorized) return { ...base, verdict: 'AGREEMENT_CHANGED', detail: `the latest authorization ${latest.id} covers agreement ${latest.agreement} ${latest.digest.slice(0, 12)}, the current agreement is ${now.digest.slice(0, 12)}${difference ? ` (${agreement.renderDifference(difference)})` : ''}; record the disposition with: node scripts/pincer-runtime.cjs change authorize ${record.change} --agreement ${now.digest} … (user) or --delegated --basis ${latest.id} --explanation <text>` };
|
|
40
|
+
return { ...base, verdict: 'current', detail: `${authorized.id} (${authorized.disposition}) covers the current agreement ${now.digest.slice(0, 12)}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Shared validation of authored texts ----------------------------------------------------
|
|
44
|
+
function text(value, name, options) {
|
|
45
|
+
const v = transaction.boundedText(value, name, options);
|
|
46
|
+
if (v !== null && inlineSecretLine([v])) transaction.refuse('INPUT_INVALID', `--${name} assigns a secret-like literal; reference secrets by name, never by value`);
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- change authorize ---------------------------------------------------------------------------
|
|
51
|
+
// Returns { action: 'recorded' | 'unchanged', authorization, agreement, record } or { code, problem }.
|
|
52
|
+
function authorize(root, id, opts = {}) {
|
|
53
|
+
const changes = require('./changes.cjs');
|
|
54
|
+
try {
|
|
55
|
+
const out = transaction.run(root, { command: `change authorize ${id}`, hooks: opts.hooks || null }, ctx => {
|
|
56
|
+
const resolved = changes.resolveSelected(root, { change: id });
|
|
57
|
+
if (resolved.code) ctx.refuse(resolved.code, resolved.problem);
|
|
58
|
+
if (opts.expect !== undefined && opts.expect !== null) ctx.expect(resolved.file, 'sequence', opts.expect);
|
|
59
|
+
const record = resolved.record;
|
|
60
|
+
if (changes.TERMINAL.includes(record.lifecycle.state)) ctx.refuse('LIFECYCLE_BLOCKED', `change ${id} is ${record.lifecycle.state}; it cannot be authorized — register a new change and reference this record`);
|
|
61
|
+
if (typeof opts.agreement !== 'string' || !SHA256.test(opts.agreement)) ctx.refuse('INPUT_INVALID', '--agreement must be the 64-hex agreement digest (shown by change show and status)');
|
|
62
|
+
const disposition = opts.delegated ? 'delegated' : 'user';
|
|
63
|
+
const reference = disposition === 'user' ? text(opts.reference, 'reference') : (opts.reference !== undefined && opts.reference !== null ? ctx.refuse('INPUT_INVALID', 'a delegated authorization carries no --reference/--excerpt; name its basis and explanation instead') : null);
|
|
64
|
+
const excerpt = disposition === 'user' ? text(opts.excerpt, 'excerpt') : (opts.excerpt !== undefined && opts.excerpt !== null ? ctx.refuse('INPUT_INVALID', 'a delegated authorization carries no --reference/--excerpt; name its basis and explanation instead') : null);
|
|
65
|
+
const constraints = text(opts.constraints, 'constraints', { required: false });
|
|
66
|
+
let basis = null, explanation = null;
|
|
67
|
+
if (disposition === 'delegated') {
|
|
68
|
+
if (typeof opts.basis !== 'string' || !AUTHORIZATION_ID.test(opts.basis)) ctx.refuse('INPUT_INVALID', '--delegated requires --basis A-NN (an earlier authorization of this change)');
|
|
69
|
+
if (!record.authorizations.some(a => a.id === opts.basis)) ctx.refuse('INPUT_INVALID', `--basis ${opts.basis} is not an authorization of change ${id} (recorded: ${record.authorizations.map(a => a.id).join(', ') || 'none'})`);
|
|
70
|
+
basis = opts.basis;
|
|
71
|
+
explanation = text(opts.explanation, 'explanation');
|
|
72
|
+
} else if (opts.basis || opts.explanation) ctx.refuse('INPUT_INVALID', '--basis and --explanation belong to --delegated authorizations');
|
|
73
|
+
const decisions = [...new Set(opts.decisions || [])].sort();
|
|
74
|
+
for (const d of decisions) {
|
|
75
|
+
if (!DECISION_ID.test(d)) ctx.refuse('INPUT_INVALID', `--decision must name a decision such as D-01 (got ${d})`);
|
|
76
|
+
const found = record.decisions.find(x => x.id === d);
|
|
77
|
+
if (!found) ctx.refuse('INPUT_INVALID', `--decision ${d} is not a decision of change ${id} (recorded: ${record.decisions.map(x => x.id).join(', ') || 'none'})`);
|
|
78
|
+
if (found.status !== 'resolved') ctx.refuse('DECISION_REQUIRED', `decision ${d} is still open; resolve it first with: node scripts/pincer-runtime.cjs change decide ${id} --resolve ${d} --reference <text> --excerpt <text>`);
|
|
79
|
+
}
|
|
80
|
+
const computed = agreement.compute(root, record);
|
|
81
|
+
if (computed.code) ctx.refuse(computed.code, computed.problem);
|
|
82
|
+
if (computed.digest !== opts.agreement) ctx.refuse('AGREEMENT_CHANGED', `--agreement ${opts.agreement.slice(0, 12)} is not the current agreement of ${id} (${computed.digest.slice(0, 12)}); the authored inputs changed since it was prepared — review them (change show ${id}) and authorize the current digest`);
|
|
83
|
+
const same = record.authorizations.find(a => a.digest === computed.digest && a.disposition === disposition && a.reference === reference && a.excerpt === excerpt && a.constraints === constraints && a.basis === basis && a.explanation === explanation && JSON.stringify(a.decisions) === JSON.stringify(decisions));
|
|
84
|
+
if (same) return { action: 'unchanged', authorization: same, agreement: record.agreements.find(g => g.id === same.agreement), record };
|
|
85
|
+
let entry = agreement.entryFor(record, computed.digest);
|
|
86
|
+
if (!entry) {
|
|
87
|
+
const latest = agreement.latestEntry(record);
|
|
88
|
+
const previous = latest ? agreement.readSnapshot(root, record, latest) : null;
|
|
89
|
+
if (previous && previous.code) ctx.refuse(previous.code, previous.problem);
|
|
90
|
+
entry = agreement.appendAgreement(ctx, changes, record, resolved.file, computed, { stage: false, event: false }).agreement;
|
|
91
|
+
}
|
|
92
|
+
const auth = { id: nextId('A', record.authorizations), agreement: entry.id, digest: computed.digest, disposition, reference, excerpt, constraints, basis, explanation, decisions, recorded: ctx.now };
|
|
93
|
+
record.authorizations.push(auth);
|
|
94
|
+
const sequence = record.sequence + 1;
|
|
95
|
+
record.events.push({ sequence, kind: 'authorize', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: entry.id, authorization: auth.id, decision: decisions[0] || null, replacement: null, note: null });
|
|
96
|
+
record.sequence = sequence;
|
|
97
|
+
ctx.write(resolved.file, record);
|
|
98
|
+
return { action: 'recorded', authorization: auth, agreement: entry, record };
|
|
99
|
+
});
|
|
100
|
+
return out.result;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.refusal) return { code: error.code, problem: error.message };
|
|
103
|
+
if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- change decide ---------------------------------------------------------------------------
|
|
109
|
+
// Raise: { action: 'raised' | 'unchanged', decision, record }; resolve: { action:
|
|
110
|
+
// 'resolved' | 'unchanged', decision, record, agreement: <new digest> }.
|
|
111
|
+
function decide(root, id, opts = {}) {
|
|
112
|
+
const changes = require('./changes.cjs');
|
|
113
|
+
try {
|
|
114
|
+
const out = transaction.run(root, { command: `change decide ${id}`, hooks: opts.hooks || null }, ctx => {
|
|
115
|
+
const resolved = changes.resolveSelected(root, { change: id });
|
|
116
|
+
if (resolved.code) ctx.refuse(resolved.code, resolved.problem);
|
|
117
|
+
if (opts.expect !== undefined && opts.expect !== null) ctx.expect(resolved.file, 'sequence', opts.expect);
|
|
118
|
+
const record = resolved.record;
|
|
119
|
+
if (changes.TERMINAL.includes(record.lifecycle.state)) ctx.refuse('LIFECYCLE_BLOCKED', `change ${id} is ${record.lifecycle.state}; decisions belong to a new change`);
|
|
120
|
+
if (opts.resolve) {
|
|
121
|
+
if (!DECISION_ID.test(opts.resolve)) ctx.refuse('INPUT_INVALID', '--resolve must name a decision such as D-01');
|
|
122
|
+
const decision = record.decisions.find(d => d.id === opts.resolve);
|
|
123
|
+
if (!decision) ctx.refuse('INPUT_INVALID', `no decision ${opts.resolve} on change ${id} (recorded: ${record.decisions.map(d => d.id).join(', ') || 'none'})`);
|
|
124
|
+
const reference = text(opts.reference, 'reference');
|
|
125
|
+
const excerpt = text(opts.excerpt, 'excerpt');
|
|
126
|
+
if (decision.status === 'resolved') {
|
|
127
|
+
if (decision.reference === reference && decision.excerpt === excerpt) return { action: 'unchanged', decision, record, agreement: agreement.compute(root, record).digest || null };
|
|
128
|
+
ctx.refuse('INPUT_INVALID', `${decision.id} is already resolved ("${decision.excerpt}", ${decision.reference}); a different decision needs a new decision record`);
|
|
129
|
+
}
|
|
130
|
+
decision.status = 'resolved'; decision.reference = reference; decision.excerpt = excerpt; decision.resolved = ctx.now;
|
|
131
|
+
const sequence = record.sequence + 1;
|
|
132
|
+
record.events.push({ sequence, kind: 'resolve', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: null, authorization: null, decision: decision.id, replacement: null, note: null });
|
|
133
|
+
record.sequence = sequence;
|
|
134
|
+
ctx.write(resolved.file, record);
|
|
135
|
+
const after = agreement.compute(root, record);
|
|
136
|
+
return { action: 'resolved', decision, record, agreement: after.code ? null : after.digest };
|
|
137
|
+
}
|
|
138
|
+
const summary = text(opts.summary, 'summary');
|
|
139
|
+
const expectedId = nextId('D', record.decisions);
|
|
140
|
+
if (opts.id !== undefined && opts.id !== null) {
|
|
141
|
+
if (!DECISION_ID.test(opts.id)) ctx.refuse('INPUT_INVALID', '--id must be a decision ID such as D-01');
|
|
142
|
+
const existing = record.decisions.find(d => d.id === opts.id);
|
|
143
|
+
if (existing) {
|
|
144
|
+
if (existing.status === 'open' && existing.summary === summary) return { action: 'unchanged', decision: existing, record };
|
|
145
|
+
ctx.refuse('INPUT_INVALID', `${opts.id} already exists on change ${id} (${existing.status}: ${existing.summary}); decisions are never rewritten — raise a new one`);
|
|
146
|
+
}
|
|
147
|
+
if (opts.id !== expectedId) ctx.refuse('INPUT_INVALID', `the next decision of change ${id} is ${expectedId}, not ${opts.id}`);
|
|
148
|
+
}
|
|
149
|
+
const same = record.decisions.find(d => d.status === 'open' && d.summary === summary);
|
|
150
|
+
if (same) return { action: 'unchanged', decision: same, record };
|
|
151
|
+
const decision = { id: expectedId, status: 'open', summary, reference: null, excerpt: null, raised: ctx.now, resolved: null };
|
|
152
|
+
record.decisions.push(decision);
|
|
153
|
+
const sequence = record.sequence + 1;
|
|
154
|
+
record.events.push({ sequence, kind: 'decide', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: null, authorization: null, decision: decision.id, replacement: null, note: null });
|
|
155
|
+
record.sequence = sequence;
|
|
156
|
+
ctx.write(resolved.file, record);
|
|
157
|
+
return { action: 'raised', decision, record };
|
|
158
|
+
});
|
|
159
|
+
return out.result;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error.refusal) return { code: error.code, problem: error.message };
|
|
162
|
+
if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { verdict, authorize, decide, DECISION_ID, AUTHORIZATION_ID };
|