pincer-workflow 0.4.1 → 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 +9 -7
- package/bin/pincer.js +58 -5
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +117 -12
- package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
- package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
- package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
- package/template/.agents/skills/pincer-release/SKILL.md +32 -3
- package/template/.agents/skills/pincer-status/SKILL.md +25 -2
- package/template/.claude/commands/pincer-code.md +117 -12
- package/template/.claude/commands/pincer-evaluate.md +57 -10
- package/template/.claude/commands/pincer-narrow.md +48 -8
- package/template/.claude/commands/pincer-plan.md +12 -4
- package/template/.claude/commands/pincer-release.md +32 -3
- package/template/.claude/commands/pincer-status.md +25 -2
- package/template/.claude/hooks/hook-policy.cjs +24 -3
- package/template/.claude/references/prd-template.md +11 -4
- package/template/.claude/references/ticket-template.md +4 -0
- package/template/.codex/README.md +3 -2
- package/template/.github/prompts/pincer-code.prompt.md +117 -12
- package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
- package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
- package/template/.github/prompts/pincer-plan.prompt.md +12 -4
- package/template/.github/prompts/pincer-release.prompt.md +32 -3
- package/template/.github/prompts/pincer-status.prompt.md +25 -2
- package/template/AGENTS.md +22 -0
- package/template/docs/dry-run-checklist.md +70 -6
- package/template/docs/release-checklist.md +5 -2
- package/template/docs/runtime-contracts.md +1683 -0
- package/template/scripts/pincer-evidence.cjs +13 -229
- 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 +676 -0
- package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
- package/template/scripts/pincer-runtime/gates.cjs +73 -0
- package/template/scripts/pincer-runtime/identity.cjs +163 -0
- 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 +311 -0
- package/template/scripts/pincer-runtime/locator.cjs +158 -0
- package/template/scripts/pincer-runtime/migrate.cjs +204 -0
- package/template/scripts/pincer-runtime/parse.cjs +316 -0
- package/template/scripts/pincer-runtime/phases.cjs +245 -0
- package/template/scripts/pincer-runtime/readiness.cjs +97 -0
- 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 +242 -0
- package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
- package/template/scripts/pincer-runtime/source.cjs +129 -0
- package/template/scripts/pincer-runtime/state.cjs +314 -0
- package/template/scripts/pincer-runtime/status.cjs +514 -0
- package/template/scripts/pincer-runtime/transaction.cjs +200 -0
- package/template/scripts/pincer-runtime/transitions.cjs +134 -0
- package/template/scripts/pincer-runtime.cjs +661 -0
- package/template/scripts/pincer-status.sh +11 -162
- package/template/scripts/pincer-ticket.sh +19 -139
- package/template/scripts/pincer-ticket-lib.sh +0 -321
|
@@ -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 };
|