pincer-workflow 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -19
- package/bin/pincer.js +17 -1
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +88 -14
- package/template/.agents/skills/pincer-evaluate/SKILL.md +42 -13
- package/template/.agents/skills/pincer-narrow/SKILL.md +53 -12
- package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
- package/template/.agents/skills/pincer-release/SKILL.md +18 -0
- package/template/.agents/skills/pincer-status/SKILL.md +29 -5
- package/template/.claude/commands/pincer-code.md +88 -14
- package/template/.claude/commands/pincer-evaluate.md +42 -13
- package/template/.claude/commands/pincer-narrow.md +53 -12
- package/template/.claude/commands/pincer-plan.md +12 -4
- package/template/.claude/commands/pincer-release.md +18 -0
- package/template/.claude/commands/pincer-status.md +29 -5
- package/template/.claude/hooks/hook-policy.cjs +13 -6
- package/template/.claude/references/prd-template.md +11 -4
- package/template/.codex/README.md +1 -1
- package/template/.github/prompts/pincer-code.prompt.md +88 -14
- package/template/.github/prompts/pincer-evaluate.prompt.md +42 -13
- package/template/.github/prompts/pincer-narrow.prompt.md +53 -12
- package/template/.github/prompts/pincer-plan.prompt.md +12 -4
- package/template/.github/prompts/pincer-release.prompt.md +18 -0
- package/template/.github/prompts/pincer-status.prompt.md +29 -5
- package/template/AGENTS.md +17 -1
- package/template/docs/dry-run-checklist.md +30 -3
- package/template/docs/release-checklist.md +3 -1
- package/template/docs/runtime-contracts.md +1428 -96
- package/template/scripts/pincer-evidence.cjs +9 -7
- package/template/scripts/pincer-runtime/adopt.cjs +132 -0
- package/template/scripts/pincer-runtime/agreement.cjs +240 -0
- package/template/scripts/pincer-runtime/authorization.cjs +167 -0
- package/template/scripts/pincer-runtime/changes.cjs +517 -0
- package/template/scripts/pincer-runtime/checks.cjs +48 -0
- package/template/scripts/pincer-runtime/coverage.cjs +361 -0
- package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
- package/template/scripts/pincer-runtime/evidence.cjs +303 -18
- package/template/scripts/pincer-runtime/gates.cjs +73 -0
- package/template/scripts/pincer-runtime/identity.cjs +21 -4
- package/template/scripts/pincer-runtime/impact.cjs +177 -0
- package/template/scripts/pincer-runtime/io.cjs +41 -0
- package/template/scripts/pincer-runtime/lifecycle.cjs +34 -12
- package/template/scripts/pincer-runtime/locator.cjs +158 -0
- package/template/scripts/pincer-runtime/migrate.cjs +140 -63
- package/template/scripts/pincer-runtime/parse.cjs +20 -1
- package/template/scripts/pincer-runtime/phases.cjs +245 -0
- package/template/scripts/pincer-runtime/readiness.cjs +15 -2
- package/template/scripts/pincer-runtime/requirements.cjs +255 -0
- package/template/scripts/pincer-runtime/resume.cjs +273 -0
- package/template/scripts/pincer-runtime/routing.cjs +54 -0
- package/template/scripts/pincer-runtime/runner.cjs +24 -6
- package/template/scripts/pincer-runtime/scaffold.cjs +254 -0
- package/template/scripts/pincer-runtime/state.cjs +29 -7
- package/template/scripts/pincer-runtime/status.cjs +178 -22
- package/template/scripts/pincer-runtime/transaction.cjs +200 -0
- package/template/scripts/pincer-runtime/transitions.cjs +134 -0
- package/template/scripts/pincer-runtime.cjs +412 -76
- package/template/scripts/pincer-status.sh +1 -1
- package/template/scripts/pincer-ticket.sh +1 -1
|
@@ -1,45 +1,68 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// PINCER runtime — migration (docs/runtime-contracts.md, "Migration and
|
|
3
|
-
// rollback"). Preview is read-only; apply backs up every
|
|
4
|
-
// changes, strips legacy receipts into the
|
|
5
|
-
// never runtime evidence),
|
|
6
|
-
//
|
|
3
|
+
// rollback"). Preview is read-only; apply is one transaction that backs up every
|
|
4
|
+
// authored file it changes, strips legacy receipts into the change record's
|
|
5
|
+
// `legacy.receipts` (history, never runtime evidence), converts a v0.5.0 schema 1
|
|
6
|
+
// binding into a schema 2 record (same id, base and registration; the free-text
|
|
7
|
+
// authorization retained as unvalidated history), rewrites the local candidate
|
|
8
|
+
// pointers that no longer resolve, ignores .pincer/, and selects the change in
|
|
9
|
+
// this worktree. The migrated change is planned with no authorization; existing
|
|
10
|
+
// attempts and evaluations stay history until verified again. Conflicts fail
|
|
11
|
+
// closed before the first write; repeated apply is a no-op.
|
|
7
12
|
const fs = require('node:fs');
|
|
8
13
|
const path = require('node:path');
|
|
9
14
|
const parse = require('./parse.cjs');
|
|
10
15
|
const identity = require('./identity.cjs');
|
|
16
|
+
const changes = require('./changes.cjs');
|
|
11
17
|
const statusModule = require('./status.cjs');
|
|
18
|
+
const state = require('./state.cjs');
|
|
19
|
+
const transaction = require('./transaction.cjs');
|
|
12
20
|
const { fmUnset } = require('./lifecycle.cjs');
|
|
13
|
-
const { nowIso,
|
|
21
|
+
const { nowIso, readJson } = require('./fsutil.cjs');
|
|
14
22
|
|
|
15
23
|
const IGNORE_LINE = '.pincer/';
|
|
24
|
+
const gitignoreHas = root => changes.gitignoreHas(fs.existsSync(path.join(root, '.gitignore')) ? fs.readFileSync(path.join(root, '.gitignore'), 'utf8') : '');
|
|
25
|
+
const OLD_CANDIDATE_KEY = /^candidate:([0-9a-f]{40}):(C-[0-9]{2,6})$/;
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).some(l => l === IGNORE_LINE || l === '/.pincer/' || l === '.pincer');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Compute the plan. Returns { prd, change, conflicts: [{code, detail}], binding:
|
|
24
|
-
// { file, exists, action }, tickets: [{file, id, verified, last_check}],
|
|
25
|
-
// gitignore: boolean (needs the line), alreadyMigrated }.
|
|
27
|
+
// Compute the plan. Returns { prd, change, source: 'legacy' | 'binding' | 'record',
|
|
28
|
+
// conflicts, binding: { file, existing, action }, tickets, gitignore, index,
|
|
29
|
+
// selection, alreadyMigrated, partial, authorization }.
|
|
26
30
|
function plan(root, { prd, change, authorization = null } = {}) {
|
|
27
31
|
const conflicts = [];
|
|
28
32
|
const conflict = (code, detail) => conflicts.push({ code, detail });
|
|
29
33
|
const prdResult = parse.validatePrd(root, prd);
|
|
30
34
|
if (!prdResult.ok) { conflict('INPUT_INVALID', `${prdResult.file || prd}: ${prdResult.problems[0]}`); return { prd, conflicts }; }
|
|
31
|
-
|
|
32
|
-
if (!
|
|
35
|
+
let id = change || `prd-v${prd.match(parse.PRD_REF)[1]}`;
|
|
36
|
+
if (!changes.CHANGE_ID.test(id)) conflict('INPUT_INVALID', `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}`);
|
|
33
37
|
if (!identity.head(root)) conflict('UNSUPPORTED_INPUT', 'migration needs a git repository with at least one commit');
|
|
34
|
-
const
|
|
35
|
-
let existing = null;
|
|
36
|
-
if (
|
|
37
|
-
else if (
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else
|
|
42
|
-
|
|
38
|
+
const scan = changes.scan(root);
|
|
39
|
+
let source = 'legacy', binding = null, existing = null, bindingFile = null;
|
|
40
|
+
if (scan.mode === 'invalid') for (const p of scan.problems) conflict(p.code, p.detail);
|
|
41
|
+
else if (scan.mode === 'migrated') {
|
|
42
|
+
const bindings = scan.entries.filter(e => e.schema === 1);
|
|
43
|
+
const bad = scan.entries.filter(e => e.code);
|
|
44
|
+
if (bindings.length > 1 || bad.length) conflict('AMBIGUOUS', `several files under .prd/changes/ (${scan.entries.map(e => path.basename(e.file)).join(', ')}); keep exactly one v0.5.0 binding`);
|
|
45
|
+
else {
|
|
46
|
+
const b = bindings[0].doc;
|
|
47
|
+
const invalid = identity.validateBinding(b);
|
|
48
|
+
if (invalid) conflict(/schema|runtime contract/.test(invalid) ? 'UNSUPPORTED_SCHEMA' : 'INPUT_INVALID', `${bindings[0].file}: ${invalid}`);
|
|
49
|
+
else if (path.basename(bindings[0].file, '.json') !== b.change) conflict('INPUT_INVALID', `${bindings[0].file}: filename does not match change "${b.change}"`);
|
|
50
|
+
else if (b.prd !== prd) conflict('AMBIGUOUS', `${bindings[0].file} binds ${b.prd}, not ${prd}; a v0.5.0 binding is converted first — migrate it with: node scripts/pincer-runtime.cjs migrate --preview --prd ${b.prd}, then register ${prd}`);
|
|
51
|
+
else if (change && change !== b.change) conflict('AMBIGUOUS', `${bindings[0].file} already binds ${prd} as change "${b.change}"; pass --change ${b.change}`);
|
|
52
|
+
else { id = b.change; source = 'binding'; binding = b; bindingFile = bindings[0].file; }
|
|
53
|
+
}
|
|
54
|
+
} else if (scan.mode === 'changes') {
|
|
55
|
+
const loaded = changes.loadRecords(root);
|
|
56
|
+
for (const p of loaded.problems) conflict(p.code, p.detail);
|
|
57
|
+
if (!loaded.problems.length) {
|
|
58
|
+
const owner = changes.ownerOf(loaded, prd);
|
|
59
|
+
if (owner && owner[0] !== id) conflict('INPUT_INVALID', `${prd} is owned by change "${owner[0]}" (${owner[1].file}); pass --change ${owner[0]}`);
|
|
60
|
+
else {
|
|
61
|
+
const rec = loaded.records.get(id);
|
|
62
|
+
if (rec && rec.record.prd !== prd) conflict('INPUT_INVALID', `${rec.file} names change "${id}" for ${rec.record.prd}; choose another --change ID for ${prd}`);
|
|
63
|
+
else if (rec) { existing = rec; source = 'record'; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
43
66
|
}
|
|
44
67
|
const set = parse.validateTicketSet(root);
|
|
45
68
|
if (!set.ok) conflict('INPUT_INVALID', `pincer-ticket: ${set.file ? `${set.file}: ` : ''}${set.problems[0]}`);
|
|
@@ -55,9 +78,26 @@ function plan(root, { prd, change, authorization = null } = {}) {
|
|
|
55
78
|
}
|
|
56
79
|
}
|
|
57
80
|
const gitignore = !gitignoreHas(root);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
// Local pointers of a converted binding: candidate keys gain the change segment.
|
|
82
|
+
let index = null;
|
|
83
|
+
if (source === 'binding' && state.exists(root)) {
|
|
84
|
+
const read = state.readIndex(root);
|
|
85
|
+
if (read.error) conflict('INPUT_INVALID', read.error);
|
|
86
|
+
else {
|
|
87
|
+
const stale = Object.keys(read.index.current).filter(k => OLD_CANDIDATE_KEY.test(k));
|
|
88
|
+
if (stale.length) index = { stale, doc: read.index };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const sel = changes.readSelection(root);
|
|
92
|
+
const selection = sel.code || sel.change !== id ? id : null;
|
|
93
|
+
const alreadyMigrated = source === 'record' && tickets.length === 0 && !gitignore;
|
|
94
|
+
const partial = source === 'record' && tickets.length > 0;
|
|
95
|
+
return {
|
|
96
|
+
prd, change: id, source, authorization, conflicts, bindingFile,
|
|
97
|
+
binding: { file: changes.recordFile(id), existing: existing ? existing.record : binding, bindingFile, action: source === 'binding' ? 'convert' : source === 'record' ? (partial ? 'complete' : 'keep') : 'register' },
|
|
98
|
+
tickets, gitignore, index, selection, alreadyMigrated, partial,
|
|
99
|
+
historical: { attempts: state.exists(root) ? state.listAttempts(root).filter(a => a.schema === 1).length : 0 },
|
|
100
|
+
};
|
|
61
101
|
}
|
|
62
102
|
|
|
63
103
|
function renderPlan(p) {
|
|
@@ -68,23 +108,33 @@ function renderPlan(p) {
|
|
|
68
108
|
return lines.join('\n') + '\n';
|
|
69
109
|
}
|
|
70
110
|
lines.push(`migration plan for ${p.prd} (change ${p.change})`);
|
|
71
|
-
if (p.alreadyMigrated) { lines.push(' already migrated:
|
|
72
|
-
if (p.
|
|
73
|
-
|
|
74
|
-
|
|
111
|
+
if (p.alreadyMigrated) { lines.push(' already migrated: change record present, no legacy receipts remain, .pincer/ ignored'); if (p.selection) lines.push(` selection .pincer/runtime/selection.json → ${p.change} is not set in this worktree; select with: node scripts/pincer-runtime.cjs change select ${p.change}`); return lines.join('\n') + '\n'; }
|
|
112
|
+
if (p.source === 'binding') {
|
|
113
|
+
const b = p.binding.existing;
|
|
114
|
+
lines.push(` binding ${p.bindingFile || p.binding.file} (v0.5.0, schema 1) → change record (schema 2) at the same path: change ${b.change}, base ${b.base.slice(0, 7)} and registration ${b.registered} kept; ${Object.keys(b.legacy_receipts).length} receipt(s) already imported carried over`);
|
|
115
|
+
if (b.authorization) lines.push(` note the binding's authorization text "${b.authorization}" is retained as legacy.authorization_text (unvalidated history); it never authorizes execution`);
|
|
116
|
+
} else if (p.source === 'record') lines.push(` record ${p.binding.file} (present; ${p.tickets.length} receipt(s) imported into legacy.receipts by a migrate event)`);
|
|
117
|
+
else lines.push(` record ${p.binding.file} (new schema 2 record; base = HEAD; planned)`);
|
|
118
|
+
if (p.partial) lines.push(' note an earlier migration was partially applied (record present, receipts remain); apply completes it');
|
|
119
|
+
for (const t of p.tickets) lines.push(` ticket ${t.file}: remove ${[t.verified ? 'verified' : null, t.last_check ? 'last_check' : null].filter(Boolean).join(', ')} → legacy.receipts[${t.id}] (history, not runtime evidence)`);
|
|
75
120
|
if (!p.tickets.length) lines.push(' tickets no legacy receipts to import');
|
|
76
121
|
lines.push(p.gitignore ? ` gitignore add \`${IGNORE_LINE}\`` : ' gitignore already ignores .pincer/');
|
|
77
|
-
lines.push(`
|
|
78
|
-
|
|
122
|
+
if (p.index) lines.push(` index .pincer/runtime/index.json: ${p.index.stale.length} candidate pointer(s) dropped — a v0.5.0 attempt record cannot satisfy the change-scoped key, so its check is run again (attempt records untouched, kept as history)`);
|
|
123
|
+
lines.push(` history ${p.historical.attempts} existing attempt(s) and any saved evaluation stay history (HISTORICAL_EVIDENCE) until verified again; the change is planned with no authorization`);
|
|
124
|
+
if (p.selection) lines.push(` selection .pincer/runtime/selection.json → ${p.change} (this worktree only; a fresh clone selects explicitly)`);
|
|
125
|
+
lines.push(' backups .pincer/backups/<timestamp>/ for every changed authored file (tickets, .gitignore, the binding, the local index)');
|
|
126
|
+
if (p.authorization) lines.push(` note --authorization "${p.authorization}" is retained as legacy.authorization_text (unvalidated history); record the user's actual instruction afterwards with: node scripts/pincer-runtime.cjs change authorize ${p.change} --agreement <digest> --reference <text> --excerpt <text>`);
|
|
127
|
+
else if (!(p.source === 'binding' && p.binding.existing.authorization)) lines.push(` note no --authorization given; migration grants no authorization — record the user's instruction afterwards with: node scripts/pincer-runtime.cjs change authorize ${p.change} …`);
|
|
79
128
|
lines.push(`apply with: node scripts/pincer-runtime.cjs migrate --apply --prd ${p.prd}${p.change !== `prd-v${p.prd.match(parse.PRD_REF)[1]}` ? ` --change ${p.change}` : ''}`);
|
|
80
129
|
return lines.join('\n') + '\n';
|
|
81
130
|
}
|
|
82
131
|
|
|
83
|
-
// Apply the plan: backups
|
|
132
|
+
// Apply the plan as one transaction: backups first (copies of the originals),
|
|
133
|
+
// then tickets, .gitignore, the record, the index and the selection together.
|
|
84
134
|
function apply(root, options) {
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
if (
|
|
135
|
+
const first = plan(root, options);
|
|
136
|
+
if (first.conflicts.length) return { plan: first, applied: false };
|
|
137
|
+
if (first.alreadyMigrated) return { plan: first, applied: false, already: true };
|
|
88
138
|
const stamp = nowIso().replace(/[-:]/g, '');
|
|
89
139
|
const backupDir = path.join(root, '.pincer', 'backups', stamp);
|
|
90
140
|
const backups = [];
|
|
@@ -96,32 +146,59 @@ function apply(root, options) {
|
|
|
96
146
|
fs.copyFileSync(src, dest);
|
|
97
147
|
backups.push(`.pincer/backups/${stamp}/${rel}`);
|
|
98
148
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
149
|
+
try {
|
|
150
|
+
const out = transaction.run(root, { command: `migrate --apply ${options.prd}`, hooks: options.hooks || null }, ctx => {
|
|
151
|
+
const p = plan(root, options);
|
|
152
|
+
if (p.conflicts.length) ctx.refuse(p.conflicts[0].code, p.conflicts[0].detail);
|
|
153
|
+
if (p.alreadyMigrated) return { plan: p, already: true };
|
|
154
|
+
for (const t of p.tickets) backup(t.file);
|
|
155
|
+
if (p.gitignore) backup('.gitignore');
|
|
156
|
+
if (p.source === 'binding') backup(p.bindingFile);
|
|
157
|
+
if (p.index) backup(`${state.RUNTIME_DIR}/index.json`);
|
|
158
|
+
const receipts = {};
|
|
159
|
+
for (const t of p.tickets) {
|
|
160
|
+
receipts[t.id] = { verified: t.verified, last_check: t.last_check };
|
|
161
|
+
ctx.write(t.file, fmUnset(fmUnset(t.text, 'verified'), 'last_check'));
|
|
162
|
+
}
|
|
163
|
+
if (p.gitignore) {
|
|
164
|
+
const existing = ctx.text('.gitignore') || '';
|
|
165
|
+
const lead = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
166
|
+
ctx.write('.gitignore', `${existing}${lead}${existing ? '\n' : ''}# pincer runtime state (added by migrate)\n${IGNORE_LINE}\n`);
|
|
167
|
+
}
|
|
168
|
+
let record;
|
|
169
|
+
if (p.source === 'record') {
|
|
170
|
+
record = JSON.parse(JSON.stringify(p.binding.existing));
|
|
171
|
+
record.legacy.receipts = { ...record.legacy.receipts, ...receipts };
|
|
172
|
+
if (options.authorization) record.legacy.authorization_text = options.authorization;
|
|
173
|
+
const sequence = record.sequence + 1;
|
|
174
|
+
record.events.push({ sequence, kind: 'migrate', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: null, authorization: null, decision: null, replacement: null, note: `${Object.keys(receipts).length} legacy receipt(s) imported` });
|
|
175
|
+
record.sequence = sequence;
|
|
176
|
+
} else if (p.source === 'binding') {
|
|
177
|
+
const b = p.binding.existing;
|
|
178
|
+
record = changes.newRecord({ change: b.change, prd: b.prd, base: b.base, now: ctx.now, kind: 'migrate', legacy: { receipts: { ...b.legacy_receipts, ...receipts }, authorization_text: options.authorization || b.authorization || null, migrated_from: 'binding', migrated: ctx.now } });
|
|
179
|
+
record.registered = b.registered;
|
|
180
|
+
record.events[0].note = `converted from the v0.5.0 binding (registered ${b.registered})`;
|
|
181
|
+
} else {
|
|
182
|
+
record = changes.newRecord({ change: p.change, prd: p.prd, base: identity.head(root), now: ctx.now, kind: 'migrate', legacy: { receipts, authorization_text: options.authorization || null, migrated_from: 'legacy', migrated: ctx.now } });
|
|
183
|
+
}
|
|
184
|
+
const invalid = changes.validateRecord(record, p.binding.file);
|
|
185
|
+
if (invalid) ctx.refuse(invalid.code, invalid.problem);
|
|
186
|
+
ctx.write(p.binding.file, record);
|
|
187
|
+
if (p.index) {
|
|
188
|
+
const doc = p.index.doc;
|
|
189
|
+
for (const key of p.index.stale) delete doc.current[key];
|
|
190
|
+
ctx.write(`${state.RUNTIME_DIR}/index.json`, doc);
|
|
191
|
+
}
|
|
192
|
+
if (p.selection) ctx.write(changes.SELECTION_FILE, { schema: 1, change: p.change, selected: ctx.now });
|
|
193
|
+
return { plan: p, record };
|
|
194
|
+
});
|
|
195
|
+
if (out.result.already) return { plan: out.result.plan, applied: false, already: true };
|
|
196
|
+
return { plan: out.result.plan, applied: true, record: out.result.record, backups, backupDir: backups.length ? `.pincer/backups/${stamp}/` : null };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.refusal) return { plan: first, applied: false, error: error.message, code: error.code, backups };
|
|
199
|
+
if (error.code === 'STATE_BUSY') return { plan: first, applied: false, error: error.message, code: 'STATE_BUSY', backups };
|
|
200
|
+
throw error;
|
|
122
201
|
}
|
|
123
|
-
identity.writeBinding(root, binding);
|
|
124
|
-
return { plan: p, applied: true, binding, backups, backupDir: backups.length ? `.pincer/backups/${stamp}/` : null };
|
|
125
202
|
}
|
|
126
203
|
|
|
127
204
|
module.exports = { plan, renderPlan, apply, IGNORE_LINE, gitignoreHas };
|
|
@@ -252,6 +252,25 @@ function normalizePrd(text) {
|
|
|
252
252
|
}
|
|
253
253
|
return `${out.join('\n')}\n`;
|
|
254
254
|
}
|
|
255
|
+
// Split a ticket text into the parts the structural agreement difference names:
|
|
256
|
+
// the frontmatter, the Acceptance Criteria section, the Verification section and
|
|
257
|
+
// everything else (headings inside fenced blocks are ignored, as elsewhere).
|
|
258
|
+
function ticketSections(text) {
|
|
259
|
+
const rows = lines(text);
|
|
260
|
+
const parts = { frontmatter: [], acceptance: [], verification: [], other: [] };
|
|
261
|
+
let inFront = rows[0] === '---', section = 'other', fence = false;
|
|
262
|
+
for (let i = 0; i < rows.length; i++) {
|
|
263
|
+
const line = rows[i];
|
|
264
|
+
if (inFront) { parts.frontmatter.push(line); if (i > 0 && line === '---') inFront = false; continue; }
|
|
265
|
+
if (fence) { parts[section].push(line); if (/^[ \t]*```/.test(line)) fence = false; continue; }
|
|
266
|
+
if (/^[ \t]*```/.test(line)) { fence = true; parts[section].push(line); continue; }
|
|
267
|
+
if (/^## Acceptance Criteria[ \t]*$/.test(line)) section = 'acceptance';
|
|
268
|
+
else if (/^## Verification[ \t]*$/.test(line)) section = 'verification';
|
|
269
|
+
else if (/^## /.test(line)) section = 'other';
|
|
270
|
+
parts[section].push(line);
|
|
271
|
+
}
|
|
272
|
+
return Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, v.join('\n')]));
|
|
273
|
+
}
|
|
255
274
|
const ticketDigest = text => sha256(normalizeTicket(text));
|
|
256
275
|
const prdDigest = text => sha256(normalizePrd(text));
|
|
257
276
|
const checkDigest = (text, timeoutSeconds = DEFAULT_TIMEOUT) => sha256(`${blockText(text)}timeout=${timeoutSeconds}\n`);
|
|
@@ -292,6 +311,6 @@ module.exports = {
|
|
|
292
311
|
sha256, lines, normalizeId, canonicalId, parseFrontmatter, frontmatterField,
|
|
293
312
|
verificationCommands, blockText, legacyBlockHash, unticked, effectiveTimeout,
|
|
294
313
|
validateTicket, dependencies, validateMetadata, validatePrd,
|
|
295
|
-
normalizeTicket, normalizePrd, ticketDigest, prdDigest, checkDigest,
|
|
314
|
+
normalizeTicket, normalizePrd, ticketDigest, prdDigest, checkDigest, ticketSections,
|
|
296
315
|
ticketFiles, ticketFile, validateTicketSet,
|
|
297
316
|
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — phase-specific coverage (docs/runtime-contracts.md, "Strict
|
|
3
|
+
// coverage" → "Phase-specific coverage"). One pure computation over the validated
|
|
4
|
+
// graph, consumed by `coverage`, `change complete`, `evidence export`, `ready`,
|
|
5
|
+
// status and resume: `structure` (every obligation linked or authorized as not
|
|
6
|
+
// delivered, nothing missing from the baseline), `implementation` (structure plus
|
|
7
|
+
// every ticket done and ready under the v5 rules) and `candidate` (the evaluated
|
|
8
|
+
// candidate's reconciled evidence). The three are separate fields; none implies
|
|
9
|
+
// another, and a linked check, a done ticket or a passing syntax check never
|
|
10
|
+
// becomes a delivery or adequacy verdict. A change without the capability is
|
|
11
|
+
// labeled `unverified`. Nothing here writes, launches or judges semantics.
|
|
12
|
+
const changes = require('./changes.cjs');
|
|
13
|
+
const routing = require('./routing.cjs');
|
|
14
|
+
const agreement = require('./agreement.cjs');
|
|
15
|
+
const authorization = require('./authorization.cjs');
|
|
16
|
+
const coverage = require('./coverage.cjs');
|
|
17
|
+
const dispositions = require('./dispositions.cjs');
|
|
18
|
+
const requirements = require('./requirements.cjs');
|
|
19
|
+
const locator = require('./locator.cjs');
|
|
20
|
+
const state = require('./state.cjs');
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const STRUCTURE_ORDER = ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'COVERAGE_INCOMPLETE', 'SCOPE_UNAUTHORIZED', 'OBLIGATION_MISSING'];
|
|
25
|
+
const byOrder = (a, b) => STRUCTURE_ORDER.indexOf(a.code) - STRUCTURE_ORDER.indexOf(b.code);
|
|
26
|
+
|
|
27
|
+
const unverified = reason => ({ strict: false, label: 'unverified', reason, inventory: null, map: null, graph: null, scope: [], structure: null, implementation: null, candidate: null, blockers: [] });
|
|
28
|
+
|
|
29
|
+
// compute(root, record, { gathered, verdict }) — `gathered` is status.render(...).gathered
|
|
30
|
+
// for the record (computed here when absent); `verdict` the authorization verdict.
|
|
31
|
+
function compute(root, record, { gathered = null, verdict = null } = {}) {
|
|
32
|
+
if (!record) return unverified('no change record');
|
|
33
|
+
if (!changes.isStrict(record)) return unverified(`strict coverage not adopted (node scripts/pincer-runtime.cjs coverage adopt --preview --change ${record.change})`);
|
|
34
|
+
const out = { strict: true, label: 'strict', reason: null, inventory: null, map: null, graph: null, scope: [], structure: { complete: false, problems: [] }, implementation: { complete: false, scenarios: {}, problems: [] }, candidate: { evaluated: false, candidate: null, manifest: null, delivery: null, adequacy: null, scenarios: {}, problems: [] }, blockers: [] };
|
|
35
|
+
const priorInventory = gid => agreement.inventoryOf(root, record, record.agreements.find(g => g.id === gid) || null);
|
|
36
|
+
const cov = coverage.load(root, record, { priorInventory });
|
|
37
|
+
out.inventory = cov.inventory ? { digest: cov.inventory.digest, requirements: Object.fromEntries(Object.entries(cov.inventory.requirements).map(([id, r]) => [id, { title: r.title, line: r.line, end: r.end, scenarios: r.scenarios }])), scenarios: Object.fromEntries(Object.entries(cov.inventory.scenarios).map(([id, s]) => [id, { requirement: s.requirement, line: s.line, end: s.end }])) } : null;
|
|
38
|
+
out.map = cov.map ? { path: cov.map.file, digest: cov.map.digest, checks: Object.fromEntries(Object.entries(cov.map.map.checks).map(([id, c]) => [id, { kind: c.kind, required: c.required, declared: coverage.definitionDigest(c) }])) } : null;
|
|
39
|
+
if (cov.code && cov.code !== 'COVERAGE_INCOMPLETE') {
|
|
40
|
+
out.structure.problems = cov.problems.map(detail => ({ code: cov.code, detail, ids: [] }));
|
|
41
|
+
out.blockers = out.structure.problems.map(p => ({ code: p.code, detail: p.detail }));
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
out.graph = cov.graph;
|
|
45
|
+
const v = verdict || authorization.verdict(root, record);
|
|
46
|
+
const scope = dispositions.scopeProblems(record, cov.graph, v);
|
|
47
|
+
out.scope = scope.resolved;
|
|
48
|
+
const problems = [...cov.graph.problems, ...scope.problems, ...dispositions.obligationProblems(dispositions.baseline(root, record), cov.inventory, cov.map.map)].sort(byOrder);
|
|
49
|
+
out.structure = { complete: problems.length === 0, problems };
|
|
50
|
+
// Implementation: every in-scope scenario's tickets done and ready; every ticket of the change done and ready.
|
|
51
|
+
const g = gathered || require('./status.cjs').render(root, { change: record.change }).gathered;
|
|
52
|
+
// A fresh clone (no local attempt history) of a completed change validates the saved
|
|
53
|
+
// candidate record only: a done ticket's missing local attempt is its stated limit, not
|
|
54
|
+
// unfinished work. Before completion the v5 rule holds: a fresh clone must verify.
|
|
55
|
+
const localUnavailable = !state.hasIndex(root) && record.lifecycle.state === 'completed';
|
|
56
|
+
out.implementation.limitations = localUnavailable ? ['local verification history unavailable; done tickets rely on the saved candidate evidence until verified here'] : [];
|
|
57
|
+
const ticketState = id => {
|
|
58
|
+
const t = g && g.tickets ? g.tickets.find(x => x.fields.ticket === id) : null;
|
|
59
|
+
if (!t) return { id, status: null, ready: false, code: 'EVIDENCE_MISSING', detail: 'no ticket file' };
|
|
60
|
+
const r = g.computeReadiness(t);
|
|
61
|
+
if (localUnavailable && t.fields.status === 'done' && !r.ready && r.reasons[0].code === 'EVIDENCE_MISSING') return { id, status: 'done', ready: true, code: null, detail: null, limitation: 'no local attempt history' };
|
|
62
|
+
return { id, status: t.fields.status, ready: r.ready, code: r.ready ? null : r.reasons[0].code, detail: r.ready ? null : `${r.reasons[0].detail} — ${r.reasons[0].next}` };
|
|
63
|
+
};
|
|
64
|
+
const implProblems = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const id of requirements.sortIds(Object.keys(cov.graph.scenarios))) {
|
|
67
|
+
const s = cov.graph.scenarios[id];
|
|
68
|
+
const tickets = s.tickets.map(ticketState);
|
|
69
|
+
const unfinished = tickets.find(t => t.status !== 'done');
|
|
70
|
+
const unverifiedT = tickets.find(t => t.status === 'done' && !t.ready);
|
|
71
|
+
const state = unfinished ? 'unfinished' : unverifiedT ? 'unverified' : 'complete';
|
|
72
|
+
const detail = unfinished ? `${unfinished.id} is ${unfinished.status || 'missing'}, not done` : unverifiedT ? `${unverifiedT.id} ${unverifiedT.code}: ${unverifiedT.detail}` : null;
|
|
73
|
+
out.implementation.scenarios[id] = { scope: 'in-scope', requirement: s.requirement, implementation: state, tickets, checks: s.checks, detail };
|
|
74
|
+
if (state !== 'complete' && !seen.has(detail)) { seen.add(detail); implProblems.push({ code: unfinished ? 'LIFECYCLE_BLOCKED' : unverifiedT.code, detail: `${id}: ${detail}`, ids: [id, (unfinished || unverifiedT).id] }); }
|
|
75
|
+
}
|
|
76
|
+
for (const id of requirements.sortIds(Object.keys(cov.graph.scope))) {
|
|
77
|
+
const s = cov.graph.scope[id];
|
|
78
|
+
out.implementation.scenarios[id] = { scope: s.disposition, requirement: s.requirement, implementation: 'not applicable', tickets: [], checks: [], detail: `${s.disposition} by decision ${s.decision}` };
|
|
79
|
+
}
|
|
80
|
+
// Enabling tickets and any other ticket of the change must be done and ready too.
|
|
81
|
+
for (const id of Object.keys(cov.tickets || {})) {
|
|
82
|
+
const t = ticketState(id);
|
|
83
|
+
if (t.status === 'done' && t.ready) continue;
|
|
84
|
+
const detail = t.status !== 'done' ? `${id} is ${t.status}, not done` : `${id} ${t.code}: ${t.detail}`;
|
|
85
|
+
if (!implProblems.some(p => p.ids.includes(id))) implProblems.push({ code: t.status !== 'done' ? 'LIFECYCLE_BLOCKED' : t.code, detail, ids: [id] });
|
|
86
|
+
}
|
|
87
|
+
out.implementation.problems = implProblems;
|
|
88
|
+
out.implementation.complete = out.structure.complete && implProblems.length === 0;
|
|
89
|
+
candidateCoverage(root, record, out.candidate);
|
|
90
|
+
// A missing or stale evaluation blocks only a completed change: before completion no candidate is expected.
|
|
91
|
+
out.blockers = [...out.structure.problems, ...implProblems, ...(record.lifecycle.state === 'completed' ? out.candidate.problems : [])].map(p => ({ code: p.code, detail: p.detail }));
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Candidate coverage: the change's latest evaluation (schema 3) reconciled by the
|
|
96
|
+
// locator, with per-scenario dispositions and the codes that block release.
|
|
97
|
+
function candidateCoverage(root, record, out) {
|
|
98
|
+
const loc = locator.current(root, record);
|
|
99
|
+
if (loc.state === 'missing') { out.problems = [{ code: 'EVIDENCE_MISSING', detail: 'candidate: not evaluated', ids: [] }]; return; }
|
|
100
|
+
out.evaluated = true; out.candidate = loc.candidate || null; out.manifest = loc.manifest || null;
|
|
101
|
+
let m = null;
|
|
102
|
+
try { m = JSON.parse(fs.readFileSync(path.join(root, loc.manifest), 'utf8')); } catch { m = null; }
|
|
103
|
+
const problems = [];
|
|
104
|
+
if (loc.state === 'stale') problems.push({ code: 'CANDIDATE_STALE', detail: loc.text, ids: [] });
|
|
105
|
+
if (!m || m.schema !== 3) { problems.push({ code: 'EVIDENCE_MISSING', detail: `candidate evidence is ${m && m.schema ? `schema ${m.schema}` : 'unreadable'}, not the strict schema 3; evaluate again`, ids: [] }); out.problems = problems; return; }
|
|
106
|
+
const checkOf = Object.fromEntries((m.checks || []).filter(c => c && c.id).map(c => [c.id, c]));
|
|
107
|
+
for (const row of m.scenarios || []) {
|
|
108
|
+
if (!row || !row.id) continue;
|
|
109
|
+
const checks = (row.checks || []).map(id => { const c = checkOf[id] || {}; return { id, kind: c.kind || null, required: c.required ?? null, result: c.result || null }; });
|
|
110
|
+
const failing = checks.filter(c => c.required && c.result !== 'passed');
|
|
111
|
+
out.scenarios[row.id] = { disposition: row.disposition, checks, detail: row.disposition === 'blocked' ? `blocked by ${failing.map(c => `${c.id} (${c.kind} ${c.result || 'missing'})`).join(', ') || 'a missing check'}` : row.disposition === 'delivered' ? 'every required check passed' : `${row.disposition} by decision ${row.decision} (${row.authorization || 'unauthorized'})` };
|
|
112
|
+
for (const c of failing) problems.push({ code: c.kind === 'command' ? ({ failed: 'CHECK_FAILED', unverified: 'ATTEMPT_ERROR' }[c.result] || 'CHECK_FAILED') : 'REVIEW_MISSING', detail: `${row.id}: ${c.id} (${c.kind}) is ${c.result || 'missing'}`, ids: [row.id, c.id] });
|
|
113
|
+
}
|
|
114
|
+
out.delivery = m.delivery || null;
|
|
115
|
+
out.adequacy = m.adequacy || null;
|
|
116
|
+
if (!m.adequacy || m.adequacy.verdict !== 'adequate') problems.push({ code: 'ADEQUACY_REQUIRED', detail: m.adequacy ? `the reviewer judged the checks inadequate: ${m.adequacy.note}` : 'no adequacy judgment recorded', ids: [] });
|
|
117
|
+
out.problems = problems;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The first blocking problem for a phase, in the contract's order, or null.
|
|
121
|
+
function firstBlocker(report, phase) {
|
|
122
|
+
if (!report.strict) return null;
|
|
123
|
+
if (report.structure.problems.length) return report.structure.problems[0];
|
|
124
|
+
if (phase === 'structure') return null;
|
|
125
|
+
if (report.implementation.problems.length) return report.implementation.problems[0];
|
|
126
|
+
if (phase === 'implementation') return null;
|
|
127
|
+
return report.candidate.problems[0] || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- The coverage report (docs/runtime-contracts.md, "Coverage and impact commands") ------
|
|
131
|
+
const RUNTIME_CMD = 'node scripts/pincer-runtime.cjs';
|
|
132
|
+
// One ordered next action for a strict change: the first blocking code, else the
|
|
133
|
+
// phase that is due (verify/start/complete/evaluate/release), or nothing to do.
|
|
134
|
+
function nextAction(report, record, { verdict = null, running = [], selected = true } = {}) {
|
|
135
|
+
const cmd = (action, command, extra = {}) => ({ action, command, ticket: null, check: null, decision: null, ...extra });
|
|
136
|
+
if (!report.strict) return cmd('adopt strict coverage when wanted', `${RUNTIME_CMD} coverage adopt --preview --change ${record.change}`);
|
|
137
|
+
const id = record.change;
|
|
138
|
+
// Rules 2 and 3 outrank everything below, including the agreement verdict: a
|
|
139
|
+
// historical change is never routed to execution or to authorization, and a
|
|
140
|
+
// running attempt is waited for rather than raced.
|
|
141
|
+
const ahead = routing.preface({ id, lifecycle: record.lifecycle, base: record.base, running });
|
|
142
|
+
if (ahead) return { ...cmd(ahead.action, ahead.command), ...ahead };
|
|
143
|
+
if (verdict && verdict.verdict !== 'current') {
|
|
144
|
+
if (verdict.verdict === 'DECISION_REQUIRED') return cmd("record the user's decision", `${RUNTIME_CMD} change decide ${id} --resolve ${verdict.open[0]} --reference <text> --excerpt <text>`, { decision: verdict.open[0] });
|
|
145
|
+
if (['AUTHORIZATION_REQUIRED', 'AGREEMENT_CHANGED'].includes(verdict.verdict)) return cmd("record the user's authorization of the current agreement", `${RUNTIME_CMD} change authorize ${id} --agreement ${verdict.current} --reference <text> --excerpt <text>`);
|
|
146
|
+
if (verdict.verdict === 'COVERAGE_INVALID') return cmd('repair the coverage map', verdict.detail);
|
|
147
|
+
if (verdict.verdict === 'INVENTORY_INVALID') return cmd('repair the PRD definitions', verdict.detail);
|
|
148
|
+
return cmd('repair the agreement inputs', `${verdict.verdict}: ${verdict.detail}`);
|
|
149
|
+
}
|
|
150
|
+
const p = report.structure.problems[0];
|
|
151
|
+
if (p) {
|
|
152
|
+
if (p.code === 'INVENTORY_INVALID') return cmd('repair the PRD definitions', p.detail);
|
|
153
|
+
if (p.code === 'COVERAGE_INVALID') return cmd('repair the coverage map', p.detail);
|
|
154
|
+
if (p.code === 'COVERAGE_INCOMPLETE') return cmd('author the missing coverage rows, then authorize', `edit .prd/coverage/${id}.json: ${p.detail}; then ${RUNTIME_CMD} change authorize ${id} --agreement <digest> …`);
|
|
155
|
+
if (p.code === 'SCOPE_UNAUTHORIZED') return cmd('record the scope decision and its user authorization', `${RUNTIME_CMD} change decide ${id} --summary <text> / --resolve D-NN …, then change authorize ${id} --agreement <digest> --decision D-NN …`, { decision: p.decision || null });
|
|
156
|
+
if (p.code === 'OBLIGATION_MISSING') return cmd('restore the obligation, or record the decision and the removed tombstone', p.detail);
|
|
157
|
+
return cmd('repair the coverage inputs', `${p.code}: ${p.detail}`);
|
|
158
|
+
}
|
|
159
|
+
const staged = routing.lifecycleAction({ id, lifecycle: record.lifecycle });
|
|
160
|
+
if (staged) return { ...cmd(staged.action, staged.command), ...staged };
|
|
161
|
+
const i = report.implementation.problems[0];
|
|
162
|
+
if (i) {
|
|
163
|
+
const ticket = i.ids.find(x => /^T-/.test(x)) || null;
|
|
164
|
+
if (i.code === 'LIFECYCLE_BLOCKED' && ticket) return cmd(`finish ${ticket}`, `scripts/pincer-ticket.sh start ${ticket} → verify ${ticket} → done ${ticket}`, { ticket });
|
|
165
|
+
if (ticket) return cmd(`re-verify ${ticket} (${i.code})`, `scripts/pincer-ticket.sh verify ${ticket}`, { ticket });
|
|
166
|
+
return cmd('finish the mapped work', i.detail);
|
|
167
|
+
}
|
|
168
|
+
if (record.lifecycle.state === 'active') return cmd('complete the change', `${RUNTIME_CMD} change complete ${id}`);
|
|
169
|
+
const c = report.candidate.problems[0];
|
|
170
|
+
if (c) {
|
|
171
|
+
const check = c.ids.find(x => /^C-/.test(x)) || null;
|
|
172
|
+
if (c.code === 'EVIDENCE_MISSING' && !report.candidate.evaluated) return cmd('evaluate the candidate', '/pincer-evaluate (declare the candidate, run every declared check, record the reviews and the adequacy judgment, export)');
|
|
173
|
+
if (['CHECK_FAILED', 'ATTEMPT_ERROR'].includes(c.code) && check) return cmd(`re-run ${check} and re-evaluate`, `${RUNTIME_CMD} check ${check} --candidate <sha>, then /pincer-evaluate`, { check });
|
|
174
|
+
if (c.code === 'REVIEW_MISSING' && check) return cmd(`perform and record the review ${check}`, `record ${check} with its candidate-bound artifact in the draft, then /pincer-evaluate`, { check });
|
|
175
|
+
if (c.code === 'ADEQUACY_REQUIRED') return cmd("record the reviewer's adequacy judgment", '/pincer-evaluate with adequacy { verdict, note } in the draft');
|
|
176
|
+
return cmd('evaluate the candidate again', `${c.code}: ${c.detail}`);
|
|
177
|
+
}
|
|
178
|
+
return cmd('read-only release audit', '/pincer-release');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// report(root, record, { gathered, verdict, generated }) → coverage JSON schema 1.
|
|
182
|
+
function report(root, record, { gathered = null, verdict = null, generated = null, selected = true } = {}) {
|
|
183
|
+
const authorization = require('./authorization.cjs');
|
|
184
|
+
const running = record ? require('./transaction.cjs').runningAttempts(root, record.change) : [];
|
|
185
|
+
const v = verdict || (record ? authorization.verdict(root, record) : null);
|
|
186
|
+
const r = compute(root, record, { gathered, verdict: v });
|
|
187
|
+
const reviewed = v && v.authorized && record ? (() => { const g = record.agreements.find(x => x.id === v.authorized.agreement); return g ? { agreement: g.id, authorization: v.authorized.id, digest: g.digest } : null; })() : null;
|
|
188
|
+
const base = record && r.strict ? require('./dispositions.cjs').baseline(root, record) : null;
|
|
189
|
+
const out = {
|
|
190
|
+
schema: 1, runtime: changes.RUNTIME_STRICT, generated: generated || require('./fsutil.cjs').nowIso(), root, mode: 'changes', change: record ? record.change : null,
|
|
191
|
+
strict: r.strict, label: r.label, reason: r.reason,
|
|
192
|
+
inventory: r.inventory ? { digest: r.inventory.digest, requirements: Object.entries(r.inventory.requirements).map(([id, x]) => ({ id, title: x.title, line: x.line, end: x.end, scenarios: x.scenarios })), scenarios: Object.entries(r.inventory.scenarios).map(([id, x]) => ({ id, requirement: x.requirement, line: x.line, end: x.end })) } : null,
|
|
193
|
+
map: r.map ? { path: r.map.path, digest: r.map.digest, checks: Object.entries(r.map.checks).map(([id, c]) => ({ id, ...c })) } : null,
|
|
194
|
+
agreement: { current: v && v.current ? v.current : null, reviewed, verdict: v ? v.verdict : null },
|
|
195
|
+
baseline: base ? { agreements: base.agreements, scenarios: Object.keys(base.scenarios) } : null,
|
|
196
|
+
structure: r.structure, implementation: r.implementation, candidate: r.candidate, scope: r.scope,
|
|
197
|
+
blockers: [
|
|
198
|
+
...(running.length ? [{ code: 'ATTEMPT_RUNNING', detail: `attempt ${running[0].id} of change ${record.change} is running${running[0].alive === false ? ' (its owner is no longer running)' : ''}` }] : []),
|
|
199
|
+
...(v && v.verdict !== 'current' ? [{ code: v.verdict, detail: v.detail }] : []), ...r.blockers],
|
|
200
|
+
next: routing.qualify(nextAction(r, record, { verdict: v, running, selected }), { id: record ? record.change : null, selected }),
|
|
201
|
+
};
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function render(j) {
|
|
206
|
+
const lines = [];
|
|
207
|
+
const short = s => (typeof s === 'string' ? s.slice(0, 12) : '—');
|
|
208
|
+
lines.push(`PINCER coverage · ${j.generated} · ${j.root}`);
|
|
209
|
+
lines.push(`Change ${j.change || 'none'} · coverage ${j.label}${j.reason ? ` (${j.reason})` : ''}`);
|
|
210
|
+
if (j.strict) {
|
|
211
|
+
lines.push(`Agreement current ${short(j.agreement.current)} · reviewed ${j.agreement.reviewed ? `${j.agreement.reviewed.agreement} (${j.agreement.reviewed.authorization}, ${short(j.agreement.reviewed.digest)})` : 'none'} · verdict ${j.agreement.verdict}`);
|
|
212
|
+
lines.push(`Inventory ${j.inventory ? `${j.inventory.requirements.length} requirement(s), ${j.inventory.scenarios.length} scenario(s) · ${short(j.inventory.digest)}` : 'unreadable'}`);
|
|
213
|
+
lines.push(`Map ${j.map ? `${j.map.path} · ${short(j.map.digest)} · checks ${j.map.checks.map(c => `${c.id} (${c.kind}${c.required ? ', required' : ''})`).join(', ')}` : 'unreadable'}`);
|
|
214
|
+
lines.push(`Structure ${j.structure.complete ? 'complete' : `incomplete: ${j.structure.problems.map(p => `${p.code} ${p.detail}`).join('; ')}`}`);
|
|
215
|
+
const sc = Object.entries(j.implementation.scenarios);
|
|
216
|
+
lines.push(`Implementation ${j.implementation.complete ? 'complete' : 'incomplete'} · ${sc.filter(([, s]) => s.implementation === 'complete').length}/${sc.filter(([, s]) => s.scope === 'in-scope').length} in-scope scenario(s) complete${sc.some(([, s]) => s.scope !== 'in-scope') ? ` · ${sc.filter(([, s]) => s.scope !== 'in-scope').map(([id, s]) => `${id} ${s.scope}`).join(', ')}` : ''}`);
|
|
217
|
+
for (const [id, s] of sc) lines.push(` ${id.padEnd(6)} ${s.scope === 'in-scope' ? s.implementation.padEnd(14) : s.scope.padEnd(14)} ${s.scope === 'in-scope' ? `tickets ${s.tickets.map(t => `${t.id} ${t.status || 'missing'}${t.ready ? '' : t.code ? ` (${t.code})` : ''}`).join(', ')} · checks ${s.checks.join(', ')}` : s.detail}${j.candidate.scenarios[id] ? ` · candidate ${j.candidate.scenarios[id].disposition}` : ''}`);
|
|
218
|
+
const c = j.candidate;
|
|
219
|
+
lines.push(`Candidate ${c.evaluated ? `${c.candidate ? c.candidate.slice(0, 7) : '?'} · delivery original ${c.delivery ? c.delivery.original : '?'}, agreed ${c.delivery ? c.delivery.agreed : '?'} · adequacy ${c.adequacy ? `${c.adequacy.verdict} ("${c.adequacy.note}")` : 'not recorded'}` : 'not evaluated · adequacy: not recorded'}${c.problems.length ? ` · ${c.problems.map(p => `${p.code} ${p.detail}`).join('; ')}` : ''}`);
|
|
220
|
+
if (j.scope.length) lines.push(`Scope ${j.scope.map(s => `${s.id} ${s.disposition} by ${s.decision} (${s.authorization}; reviewer judgment: "${s.excerpt}" must support it)`).join('; ')}`);
|
|
221
|
+
}
|
|
222
|
+
lines.push(`Blockers ${j.blockers.length ? j.blockers.map(b => `${b.code} ${b.detail}`).join('\n ') : 'none'}`);
|
|
223
|
+
lines.push(`Next ${j.next.action}: ${j.next.command}`);
|
|
224
|
+
return `${lines.join('\n')}\n`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A one-line summary for status and resume.
|
|
228
|
+
function summary(r) {
|
|
229
|
+
if (!r.strict) return { strict: false, label: 'unverified', reason: r.reason, structure: null, implementation: null, candidate: null };
|
|
230
|
+
const sc = Object.values(r.implementation.scenarios);
|
|
231
|
+
return {
|
|
232
|
+
strict: true, label: 'strict', reason: null,
|
|
233
|
+
structure: { complete: r.structure.complete, problems: r.structure.problems.map(p => ({ code: p.code, detail: p.detail })) },
|
|
234
|
+
implementation: { complete: r.implementation.complete, scenarios: { total: sc.length, complete: sc.filter(s => s.implementation === 'complete').length, unfinished: sc.filter(s => s.implementation === 'unfinished').length, unverified: sc.filter(s => s.implementation === 'unverified').length, dispositioned: sc.filter(s => s.scope !== 'in-scope').length } },
|
|
235
|
+
candidate: r.candidate.evaluated ? { evaluated: true, delivery: r.candidate.delivery, adequacy: r.candidate.adequacy ? r.candidate.adequacy.verdict : null } : { evaluated: false, delivery: null, adequacy: null },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function summaryLine(r, record) {
|
|
239
|
+
if (!r.strict) return `Coverage unverified · ${r.reason}`;
|
|
240
|
+
const s = summary(r);
|
|
241
|
+
const reviewed = record && record.authorizations.length ? `${record.authorizations.at(-1).agreement} (${record.authorizations.at(-1).id})` : 'none';
|
|
242
|
+
return `Coverage strict · agreement ${reviewed} · structure ${s.structure.complete ? 'complete' : `incomplete (${s.structure.problems[0].code})`} · implementation ${s.implementation.scenarios.complete}/${s.implementation.scenarios.total - s.implementation.scenarios.dispositioned} scenarios${s.implementation.scenarios.dispositioned ? ` (${s.implementation.scenarios.dispositioned} dispositioned)` : ''} · candidate ${s.candidate.evaluated ? `${s.candidate.delivery ? `delivery original ${s.candidate.delivery.original}, agreed ${s.candidate.delivery.agreed}` : 'delivery not recorded (evidence predates strict coverage)'}, adequacy ${s.candidate.adequacy || 'not recorded'}` : 'not evaluated'}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { STRUCTURE_ORDER, compute, firstBlocker, nextAction, report, render, summary, summaryLine };
|
|
@@ -39,7 +39,7 @@ function legacyTicketReadiness(text, fields) {
|
|
|
39
39
|
// `contextKey` is the key the attempt was read for and `pointedId` the id the
|
|
40
40
|
// index names for it (the record must match both); the attempt's artifacts
|
|
41
41
|
// carry `missing`/`altered` from state.inspectArtifacts.
|
|
42
|
-
function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt, current, sourceProblems = [], changedPaths = [], contextKey = null, pointedId = null }) {
|
|
42
|
+
function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt, current, sourceProblems = [], changedPaths = [], contextKey = null, pointedId = null, mode = 'migrated', strict = false }) {
|
|
43
43
|
const reasons = [];
|
|
44
44
|
for (const p of sourceProblems) reasons.push(reason(p.code, p.detail, p.code === 'SECRET_PATH' ? 'remove or ignore the secret file' : 'remove the input or change the configuration'));
|
|
45
45
|
if (reasons.length) return { ready: false, reasons };
|
|
@@ -52,6 +52,14 @@ function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt
|
|
|
52
52
|
// outcome is honored: a stripped or foreign record is never a pass.
|
|
53
53
|
const invalid = validateAttempt(attempt, contextKey, pointedId);
|
|
54
54
|
if (invalid) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} ${invalid}`, 'verify')] };
|
|
55
|
+
// A record written before the migration to change records keeps its identity
|
|
56
|
+
// as history; it never becomes current evidence for the new change context.
|
|
57
|
+
if (mode === 'changes' && attempt.schema === 1) return { ready: false, reasons: [reason('HISTORICAL_EVIDENCE', `attempt ${id} was recorded under schema ${attempt.schema} (before this project used change records) and is history, not current evidence`, 'verify')] };
|
|
58
|
+
// Strict coverage (schema 3 attempts): an attempt recorded before adoption is history;
|
|
59
|
+
// a strict attempt read for a change without the capability is never evidence.
|
|
60
|
+
if (mode === 'changes' && strict && attempt.schema !== 3) return { ready: false, reasons: [reason('HISTORICAL_EVIDENCE', `attempt ${id} was recorded under schema ${attempt.schema} (before this change adopted strict coverage) and is history, not current evidence`, 'verify')] };
|
|
61
|
+
if (mode === 'changes' && !strict && attempt.schema === 3) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} is a strict-coverage (schema 3) attempt; this change has not adopted strict coverage`, 'verify')] };
|
|
62
|
+
if (mode !== 'changes' && attempt.schema !== 1) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} is a change-record (schema ${attempt.schema}) attempt; this project is not in changes mode`, 'verify')] };
|
|
55
63
|
switch (attempt.outcome) {
|
|
56
64
|
case 'running': return { ready: false, reasons: [reason('ATTEMPT_RUNNING', `attempt ${id} is running`, 'wait, or run recover if its owner died')] };
|
|
57
65
|
case 'interrupted': return { ready: false, reasons: [reason('ATTEMPT_INTERRUPTED', `attempt ${id} was interrupted`, 'verify')] };
|
|
@@ -62,7 +70,12 @@ function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt
|
|
|
62
70
|
default: return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} has unknown outcome ${JSON.stringify(attempt.outcome)}`, 'verify')] };
|
|
63
71
|
}
|
|
64
72
|
if (current.prdRevision && attempt.context && attempt.context.prd_revision !== current.prdRevision) {
|
|
65
|
-
|
|
73
|
+
// The remedy is mode-specific. `register --rebind` is the v0.4.1/v0.5.0 repair and
|
|
74
|
+
// changes mode refuses it outright (AGREEMENT_CHANGED: "--rebind is not supported
|
|
75
|
+
// for change records"), so naming it there sends the reader to a command the
|
|
76
|
+
// runtime will not run — the defect class T-80 closed for the coverage report.
|
|
77
|
+
reasons.push(reason('REVISION_CHANGED', 'the PRD revision changed since the passing attempt',
|
|
78
|
+
mode === 'changes' ? 'change revise, record its authorization, then verify' : 'register --rebind, then verify'));
|
|
66
79
|
}
|
|
67
80
|
if (attempt.check && attempt.check.digest !== parse.checkDigest(text, timeout)) {
|
|
68
81
|
reasons.push(reason('CHECK_CHANGED', 'the Verification block or timeout changed since the passing attempt', 'verify'));
|