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
|
@@ -79,18 +79,34 @@ class Capture {
|
|
|
79
79
|
// Run one attempt. `context` is the attempt context (kind, change, prd, prd_revision,
|
|
80
80
|
// base, ticket/ticket_digest or candidate/check); `commands` the block lines;
|
|
81
81
|
// `timeoutSeconds` the effective timeout; `echo` when the output should also reach
|
|
82
|
-
// the terminal.
|
|
83
|
-
|
|
82
|
+
// the terminal. `revalidate`, when given, is called under the worktree lock
|
|
83
|
+
// immediately before the `running` record is written and returns the context to
|
|
84
|
+
// record (docs/runtime-contracts.md, "Command gates"): the gates the caller passed
|
|
85
|
+
// before the lock are evaluated again there, so a transition, revision or
|
|
86
|
+
// authorization committed in between is seen and the attempt is refused (a thrown
|
|
87
|
+
// transaction Refusal becomes { code, problem, refused: true }) or recorded against
|
|
88
|
+
// the current agreement. `announce` is called once the record is registered and
|
|
89
|
+
// before the launch, so a refusal prints nothing. Returns { attempt } or { code,
|
|
90
|
+
// problem } for refusals before launch; nothing is written for a refusal.
|
|
91
|
+
async function runAttempt({ root, context, commands, timeoutSeconds, command = 'verify', echo = true, declared = {}, revalidate = null, announce = null, cwd = null }) {
|
|
84
92
|
const block = commands.length ? `${commands.join('\n')}\n` : '';
|
|
85
93
|
const checkDigest = parse.sha256(`${block}timeout=${timeoutSeconds}\n`);
|
|
86
94
|
const display = sanitizeText(block).text.slice(0, 2000);
|
|
87
95
|
const before = source.snapshot(root);
|
|
88
96
|
if (before.problems.length) return { code: before.problems[0].code, problem: before.problems.map(p => `${p.code}: ${p.detail}`).join('\n'), problems: before.problems };
|
|
89
97
|
|
|
90
|
-
const key = state.contextKey(context);
|
|
91
98
|
let attempt, logDir, relLogDir;
|
|
92
99
|
try {
|
|
93
100
|
state.withLock(root, () => {
|
|
101
|
+
// Finish any committed transaction before writing: its staged files would
|
|
102
|
+
// otherwise be renamed over this attempt and its index pointer later.
|
|
103
|
+
require('./transaction.cjs').recoverPending(root);
|
|
104
|
+
if (revalidate) context = revalidate();
|
|
105
|
+
const key = state.contextKey(context);
|
|
106
|
+
// Changes mode records (schema 2) carry the agreement digest; `mode` only
|
|
107
|
+
// selects the key format and is not part of the record.
|
|
108
|
+
const { mode, ...persisted } = context;
|
|
109
|
+
const schema = persisted.coverage ? 3 : persisted.agreement ? 2 : 1;
|
|
94
110
|
const read = state.readIndex(root);
|
|
95
111
|
if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
|
|
96
112
|
const index = read.index;
|
|
@@ -101,10 +117,10 @@ async function runAttempt({ root, context, commands, timeoutSeconds, command = '
|
|
|
101
117
|
logDir = path.join(root, relLogDir);
|
|
102
118
|
fs.mkdirSync(logDir, { recursive: true });
|
|
103
119
|
attempt = {
|
|
104
|
-
schema
|
|
120
|
+
schema, runtime: schema, id, sequence, context: persisted,
|
|
105
121
|
check: { digest: checkDigest, display, timeout_seconds: timeoutSeconds },
|
|
106
122
|
outcome: 'running', exit_code: null, signal: null,
|
|
107
|
-
runner: runnerInfo(), cwd: '.',
|
|
123
|
+
runner: runnerInfo(), cwd: cwd || '.',
|
|
108
124
|
environment: { os: `${os.platform()} ${os.release()}`, node: process.version, declared },
|
|
109
125
|
started: nowIso(), finished: null,
|
|
110
126
|
source: { before: before.digest, after: null, files: before.files.length, limitations: before.limitations },
|
|
@@ -119,11 +135,13 @@ async function runAttempt({ root, context, commands, timeoutSeconds, command = '
|
|
|
119
135
|
state.writeIndex(root, index);
|
|
120
136
|
}, { command });
|
|
121
137
|
} catch (error) {
|
|
138
|
+
if (error && error.refusal) return { code: error.code, problem: error.message, refused: true };
|
|
122
139
|
if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
|
|
123
140
|
if (error.code === 'INVALID') return { code: 'INPUT_INVALID', problem: error.message };
|
|
124
141
|
return { code: 'ATTEMPT_ERROR', problem: `cannot persist the attempt record: ${error.message}` };
|
|
125
142
|
}
|
|
126
143
|
|
|
144
|
+
if (announce) announce(attempt);
|
|
127
145
|
// Execute in a fresh process group so timeouts and signals reach every descendant.
|
|
128
146
|
let child, launchError = null;
|
|
129
147
|
const stdout = new Capture(path.join(logDir, 'stdout.log'), echo ? process.stdout : null);
|
|
@@ -162,7 +180,7 @@ async function runAttempt({ root, context, commands, timeoutSeconds, command = '
|
|
|
162
180
|
let settled = false;
|
|
163
181
|
settle = result => { if (!settled) { settled = true; resolve(result); } };
|
|
164
182
|
try {
|
|
165
|
-
child = spawn(runnerInfo().shell, [...RUNNER_ARGS, block], { cwd: root, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
183
|
+
child = spawn(runnerInfo().shell, [...RUNNER_ARGS, block], { cwd: cwd ? path.join(root, cwd) : root, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
166
184
|
} catch (error) { launchError = error.message; return settle({ code: null, signal: null }); }
|
|
167
185
|
child.on('error', error => { launchError = error.message; });
|
|
168
186
|
if (child.pid) {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — the coverage draft (docs/runtime-contracts.md, "Coverage draft").
|
|
3
|
+
// `coverage scaffold --change <id>` projects the validated PRD inventory, the change's
|
|
4
|
+
// tickets and any authored map into one reviewable draft. It exists because authoring
|
|
5
|
+
// `.prd/coverage/<id>.json` means transcribing every live scenario, every ticket role
|
|
6
|
+
// and every check declaration out of documents the runtime has already parsed; that
|
|
7
|
+
// transcription is the measured cost this removes (docs/prd-v7-pilots.md).
|
|
8
|
+
//
|
|
9
|
+
// What it must never do is decide. The draft carries no semantic judgment: an
|
|
10
|
+
// unresolved scenario stays unresolved, a check command is never invented, a ticket
|
|
11
|
+
// role is never guessed, and a scope disposition is never chosen. Candidate tickets
|
|
12
|
+
// and their existing verification text are listed as *material to read*, with their
|
|
13
|
+
// file provenance, never promoted into a link or a declaration.
|
|
14
|
+
//
|
|
15
|
+
// The envelope is `draft: 1` with no `schema` key, so `coverage.validateMap` refuses
|
|
16
|
+
// it: a draft written to disk is not a coverage map and cannot become one by being
|
|
17
|
+
// saved. The real map is authored by hand, validated by `coverage`, adopted by
|
|
18
|
+
// `coverage adopt` and authorized by `change authorize`, exactly as before.
|
|
19
|
+
//
|
|
20
|
+
// Pure and read-only: nothing here writes a file, launches a check, changes a
|
|
21
|
+
// selection or records an approval. The draft body carries no timestamp, so two calls
|
|
22
|
+
// on identical authored inputs produce byte-identical output.
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const parse = require('./parse.cjs');
|
|
26
|
+
const requirements = require('./requirements.cjs');
|
|
27
|
+
const coverage = require('./coverage.cjs');
|
|
28
|
+
|
|
29
|
+
const DRAFT = 1;
|
|
30
|
+
const KIND = 'coverage-draft';
|
|
31
|
+
const RUNTIME = 'node scripts/pincer-runtime.cjs';
|
|
32
|
+
// Unresolved codes, in report order. These describe the draft, not the runtime state:
|
|
33
|
+
// they say what a human still has to author, never what a gate will refuse.
|
|
34
|
+
const UNRESOLVED_ORDER = ['SCENARIO_UNLINKED', 'CHECK_UNDECLARED', 'TICKET_UNCLASSIFIED', 'SCENARIO_STALE', 'TICKET_FOREIGN'];
|
|
35
|
+
|
|
36
|
+
const AUTHORED = 'authored';
|
|
37
|
+
const UNRESOLVED = 'unresolved';
|
|
38
|
+
|
|
39
|
+
// The first line under a ticket heading, used as the objective a reviewer reads.
|
|
40
|
+
function firstLine(text, heading) {
|
|
41
|
+
const rows = parse.lines(text);
|
|
42
|
+
const i = rows.findIndex(l => l.trim() === heading);
|
|
43
|
+
if (i === -1) return null;
|
|
44
|
+
const next = rows.slice(i + 1).find(l => l.trim() !== '');
|
|
45
|
+
return next ? next.trim() : null;
|
|
46
|
+
}
|
|
47
|
+
// The IDs a ticket's own Context section claims ("- Implements: R-04." and
|
|
48
|
+
// "- Scenarios: S-10, S-11."). Classified against the live inventory rather than by
|
|
49
|
+
// prefix, because tickets name other tickets on the same lines — T-03 of the strict
|
|
50
|
+
// fixture says "Implements: none (enables T-01, T-02)", and reading that as two
|
|
51
|
+
// scenario claims would be the draft inventing a link out of prose. An ID the
|
|
52
|
+
// inventory does not define is not reported at all.
|
|
53
|
+
const CLAIM_LINE = /^[ \t]*[-+*][ \t]+(?:Implements|Scenarios|Requirements):/;
|
|
54
|
+
function claimsOf(text, inventory) {
|
|
55
|
+
const ids = new Set();
|
|
56
|
+
for (const row of parse.lines(text)) {
|
|
57
|
+
if (!CLAIM_LINE.test(row)) continue;
|
|
58
|
+
for (const id of row.match(new RegExp(requirements.ID, 'g')) || []) ids.add(id);
|
|
59
|
+
}
|
|
60
|
+
const claimed = [...ids];
|
|
61
|
+
return {
|
|
62
|
+
requirements: requirements.sortIds(claimed.filter(id => inventory.requirements[id])),
|
|
63
|
+
scenarios: requirements.sortIds(claimed.filter(id => inventory.scenarios[id])),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// The ticket's own Verification fence, verbatim, as provenance for a reviewer. It is
|
|
67
|
+
// quoted, never turned into a check declaration: whether it proves the scenario is a
|
|
68
|
+
// judgment the draft does not make.
|
|
69
|
+
function verificationOf(text) {
|
|
70
|
+
const commands = parse.verificationCommands(text);
|
|
71
|
+
return commands && commands.length ? commands.join('\n') : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// build(root, record) → { ok, code, problems, draft }
|
|
75
|
+
// `code` is set only when the inputs cannot be read; a missing map is the normal case
|
|
76
|
+
// this command exists for, not an error.
|
|
77
|
+
function build(root, record) {
|
|
78
|
+
const fail = (code, problems) => ({ ok: false, code, problems, draft: null });
|
|
79
|
+
const inv = requirements.readInventory(root, record.prd);
|
|
80
|
+
if (!inv.ok) return fail(inv.code, inv.problems);
|
|
81
|
+
const inventory = inv.inventory;
|
|
82
|
+
|
|
83
|
+
// An absent map is expected; an unreadable one is not — silently dropping authored
|
|
84
|
+
// content would be the one way this command could destroy work.
|
|
85
|
+
const rel = coverage.file(record.change);
|
|
86
|
+
const real = coverage.realFile(root, rel);
|
|
87
|
+
let authored = null;
|
|
88
|
+
if (!real) {
|
|
89
|
+
const m = coverage.readMap(root, record);
|
|
90
|
+
if (!m.ok) return fail(m.code, m.problems);
|
|
91
|
+
authored = m;
|
|
92
|
+
} else if (!real.endsWith(': missing')) {
|
|
93
|
+
return fail('COVERAGE_INVALID', [`${rel}: ${real.slice(rel.length + 2)}`]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const t = coverage.ticketsOf(root, record);
|
|
97
|
+
if (!t.ok) return fail(t.code, t.problems);
|
|
98
|
+
|
|
99
|
+
const map = authored ? authored.map : null;
|
|
100
|
+
const unresolved = [];
|
|
101
|
+
const note = (code, id, detail) => unresolved.push({ code, id, detail });
|
|
102
|
+
|
|
103
|
+
// --- scenarios: every live scenario exactly once, authored rows preserved ----------
|
|
104
|
+
const scenarios = {};
|
|
105
|
+
const scope = {};
|
|
106
|
+
for (const id of requirements.sortIds(Object.keys(inventory.scenarios))) {
|
|
107
|
+
const live = inventory.scenarios[id];
|
|
108
|
+
const authoredScope = map && map.scope ? map.scope[id] : null;
|
|
109
|
+
if (authoredScope) {
|
|
110
|
+
scope[id] = { ...authoredScope, state: AUTHORED };
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const row = map && map.scenarios ? map.scenarios[id] : null;
|
|
114
|
+
const tickets = row ? [...row.tickets] : [];
|
|
115
|
+
const checks = row ? [...row.checks] : [];
|
|
116
|
+
const resolved = Boolean(row) && tickets.length > 0 && checks.length > 0;
|
|
117
|
+
scenarios[id] = { requirement: live.requirement, state: resolved ? AUTHORED : UNRESOLVED, tickets, checks };
|
|
118
|
+
if (!row) note('SCENARIO_UNLINKED', id, `${id} (${live.requirement}) has no row in scenarios or scope`);
|
|
119
|
+
else if (!tickets.length) note('SCENARIO_UNLINKED', id, `${id} is linked to no ticket`);
|
|
120
|
+
else if (!checks.length) note('CHECK_UNDECLARED', id, `${id} is linked to no check`);
|
|
121
|
+
}
|
|
122
|
+
// A map row naming a scenario the inventory no longer defines is authored content:
|
|
123
|
+
// it is preserved and flagged, never deleted, because removing an obligation is a
|
|
124
|
+
// decision with its own disposition and authorization.
|
|
125
|
+
if (map) {
|
|
126
|
+
for (const id of requirements.sortIds(Object.keys(map.scenarios))) {
|
|
127
|
+
if (scenarios[id] || scope[id]) continue;
|
|
128
|
+
scenarios[id] = { requirement: null, state: UNRESOLVED, tickets: [...map.scenarios[id].tickets], checks: [...map.scenarios[id].checks] };
|
|
129
|
+
note('SCENARIO_STALE', id, `${id} has a row but is not a scenario of ${record.prd}`);
|
|
130
|
+
}
|
|
131
|
+
for (const id of requirements.sortIds(Object.keys(map.scope))) {
|
|
132
|
+
if (scope[id] || inventory.scenarios[id]) continue;
|
|
133
|
+
scope[id] = { ...map.scope[id], state: UNRESOLVED };
|
|
134
|
+
note('SCENARIO_STALE', id, `${id} has a scope row but is not a scenario of ${record.prd}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- tickets: authored roles preserved; unclassified tickets named, not classified --
|
|
139
|
+
const tickets = {};
|
|
140
|
+
for (const id of requirements.sortIds(Object.keys(t.tickets))) {
|
|
141
|
+
const row = map && map.tickets ? map.tickets[id] : null;
|
|
142
|
+
if (row) tickets[id] = { role: row.role, rationale: row.rationale, state: AUTHORED };
|
|
143
|
+
else {
|
|
144
|
+
tickets[id] = { role: null, rationale: null, state: UNRESOLVED };
|
|
145
|
+
note('TICKET_UNCLASSIFIED', id, `${id} has no role; classify it as implements or enables (an enabling ticket needs a rationale)`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (map) {
|
|
149
|
+
for (const id of requirements.sortIds(Object.keys(map.tickets))) {
|
|
150
|
+
if (tickets[id]) continue;
|
|
151
|
+
tickets[id] = { role: map.tickets[id].role, rationale: map.tickets[id].rationale, state: UNRESOLVED };
|
|
152
|
+
note(t.others[id] ? 'TICKET_FOREIGN' : 'SCENARIO_STALE', id,
|
|
153
|
+
t.others[id] ? `${id} is a ticket of ${t.others[id]}, not of ${record.prd}` : `${id} has a row but no ticket file is associated with ${record.prd}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- checks: authored declarations preserved verbatim; none invented ---------------
|
|
158
|
+
const checks = {};
|
|
159
|
+
if (map) for (const id of Object.keys(map.checks).sort()) checks[id] = { ...map.checks[id], state: AUTHORED };
|
|
160
|
+
// Every check a scenario links to must be declared; an undeclared one is named.
|
|
161
|
+
for (const [sid, row] of Object.entries(scenarios)) {
|
|
162
|
+
for (const c of row.checks) {
|
|
163
|
+
if (checks[c]) continue;
|
|
164
|
+
note('CHECK_UNDECLARED', c, `${c} is linked from ${sid} but has no declaration`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- candidates: material to read, with provenance. Never a link. -----------------
|
|
169
|
+
const candidates = { tickets: {} };
|
|
170
|
+
for (const id of requirements.sortIds(Object.keys(t.tickets))) {
|
|
171
|
+
const entry = t.tickets[id];
|
|
172
|
+
const claims = claimsOf(entry.text, inventory);
|
|
173
|
+
candidates.tickets[id] = {
|
|
174
|
+
file: entry.file,
|
|
175
|
+
objective: firstLine(entry.text, '## Objective'),
|
|
176
|
+
implements: claims.requirements,
|
|
177
|
+
scenarios: claims.scenarios,
|
|
178
|
+
verification: verificationOf(entry.text),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
unresolved.sort((a, b) => (UNRESOLVED_ORDER.indexOf(a.code) - UNRESOLVED_ORDER.indexOf(b.code)) || requirements.compareIds(a.id, b.id));
|
|
183
|
+
const draft = {
|
|
184
|
+
draft: DRAFT,
|
|
185
|
+
kind: KIND,
|
|
186
|
+
change: record.change,
|
|
187
|
+
prd: record.prd,
|
|
188
|
+
inventory: { digest: inventory.digest, requirements: Object.keys(inventory.requirements).length, scenarios: Object.keys(inventory.scenarios).length },
|
|
189
|
+
authored: { map: authored ? authored.file : null, digest: authored ? authored.digest : null },
|
|
190
|
+
scenarios, scope, tickets, checks, candidates, unresolved,
|
|
191
|
+
next: nextAction(record, unresolved, authored),
|
|
192
|
+
};
|
|
193
|
+
return { ok: true, code: null, problems: [], draft };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// What the reader does next. A complete draft does not adopt anything: it says the map
|
|
197
|
+
// is ready to be reviewed and validated, which is a different claim from "correct".
|
|
198
|
+
function nextAction(record, unresolved, authored) {
|
|
199
|
+
if (unresolved.length) {
|
|
200
|
+
const first = unresolved[0];
|
|
201
|
+
return { action: `author the unresolved entries (${unresolved.length}), starting with ${first.id}`, command: `edit ${coverage.file(record.change)} — ${first.detail}` };
|
|
202
|
+
}
|
|
203
|
+
if (!authored) return { action: 'author the map from this draft, then validate it', command: `edit ${coverage.file(record.change)}, then ${RUNTIME} coverage --change ${record.change}` };
|
|
204
|
+
return { action: 'review the authored map, then preview adoption', command: `${RUNTIME} coverage adopt --preview --change ${record.change}` };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function render(d) {
|
|
208
|
+
const lines = [];
|
|
209
|
+
const count = o => Object.keys(o).length;
|
|
210
|
+
lines.push(`PINCER coverage draft · change ${d.change} · ${d.prd}`);
|
|
211
|
+
lines.push(`Inventory ${d.inventory.requirements} requirement(s), ${d.inventory.scenarios} scenario(s) · digest ${d.inventory.digest.slice(0, 12)}`);
|
|
212
|
+
lines.push(`Authored ${d.authored.map ? `${d.authored.map} · digest ${d.authored.digest.slice(0, 12)}` : 'no map yet'}`);
|
|
213
|
+
lines.push('');
|
|
214
|
+
lines.push(`Scenarios ${count(d.scenarios)} linked row(s), ${count(d.scope)} scope row(s)`);
|
|
215
|
+
for (const [id, s] of Object.entries(d.scenarios)) {
|
|
216
|
+
const marker = s.state === AUTHORED ? ' ' : '?';
|
|
217
|
+
lines.push(` ${marker} ${id} ${s.requirement || '(not in the inventory)'} tickets ${s.tickets.length ? s.tickets.join(', ') : '—'} checks ${s.checks.length ? s.checks.join(', ') : '—'}`);
|
|
218
|
+
}
|
|
219
|
+
for (const [id, s] of Object.entries(d.scope)) {
|
|
220
|
+
lines.push(` ${s.state === AUTHORED ? ' ' : '?'} ${id} scope: ${s.disposition}${s.decision ? ` (${s.decision})` : ''}${s.note ? ` — ${s.note}` : ''}`);
|
|
221
|
+
}
|
|
222
|
+
lines.push('');
|
|
223
|
+
lines.push(`Tickets ${count(d.tickets)}`);
|
|
224
|
+
for (const [id, t] of Object.entries(d.tickets)) {
|
|
225
|
+
const c = d.candidates.tickets[id];
|
|
226
|
+
lines.push(` ${t.state === AUTHORED ? ' ' : '?'} ${id} role ${t.role || '—'}${t.rationale ? ` (${t.rationale})` : ''}${c && c.objective ? ` · ${c.objective}` : ''}`);
|
|
227
|
+
if (t.state !== AUTHORED && c) {
|
|
228
|
+
const claimed = [...c.implements, ...c.scenarios];
|
|
229
|
+
if (claimed.length) lines.push(` the ticket says it implements ${claimed.join(', ')} (${c.file}) — a statement to check, not a link`);
|
|
230
|
+
if (c.verification) lines.push(` its Verification block runs: ${c.verification.split('\n')[0]}${c.verification.includes('\n') ? ' …' : ''}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
lines.push('');
|
|
234
|
+
lines.push(`Checks ${count(d.checks)}`);
|
|
235
|
+
for (const [id, c] of Object.entries(d.checks)) {
|
|
236
|
+
lines.push(` ${c.state === AUTHORED ? ' ' : '?'} ${id} ${c.kind}${c.required ? ', required' : ''} ${c.kind === 'command' ? c.command : c.obligation}`);
|
|
237
|
+
}
|
|
238
|
+
if (!count(d.checks)) lines.push(' none declared — a check is authored, never derived from a ticket');
|
|
239
|
+
lines.push('');
|
|
240
|
+
if (d.unresolved.length) {
|
|
241
|
+
lines.push(`Unresolved ${d.unresolved.length}`);
|
|
242
|
+
for (const u of d.unresolved) lines.push(` ${u.code} ${u.detail}`);
|
|
243
|
+
} else {
|
|
244
|
+
lines.push('Unresolved none — every scenario has a row, every ticket a role, every linked check a declaration');
|
|
245
|
+
}
|
|
246
|
+
lines.push('');
|
|
247
|
+
lines.push('This is a draft, not a coverage map: it carries no schema, and `coverage`,');
|
|
248
|
+
lines.push('`coverage adopt` and the agreement digest do not accept it. Author');
|
|
249
|
+
lines.push(`${coverage.file(d.change)} yourself, review the links, then validate and adopt it.`);
|
|
250
|
+
lines.push(`Next ${d.next.action}: ${d.next.command}`);
|
|
251
|
+
return `${lines.join('\n')}\n`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = { DRAFT, KIND, UNRESOLVED_ORDER, AUTHORED, UNRESOLVED, build, render, nextAction };
|
|
@@ -9,6 +9,7 @@ const os = require('node:os');
|
|
|
9
9
|
const path = require('node:path');
|
|
10
10
|
const crypto = require('node:crypto');
|
|
11
11
|
const { nowIso, atomicWrite, readJson } = require('./fsutil.cjs');
|
|
12
|
+
const io = require('./io.cjs');
|
|
12
13
|
|
|
13
14
|
const RUNTIME_DIR = '.pincer/runtime';
|
|
14
15
|
const INDEX_SCHEMA = 1;
|
|
@@ -34,6 +35,9 @@ function ensureLayout(root) {
|
|
|
34
35
|
return p;
|
|
35
36
|
}
|
|
36
37
|
const exists = root => fs.existsSync(paths(root).dir);
|
|
38
|
+
// Local attempt history exists once an index was written; a selection alone (a fresh
|
|
39
|
+
// clone that ran `change select`) is not attempt history.
|
|
40
|
+
const hasIndex = root => fs.existsSync(paths(root).index);
|
|
37
41
|
|
|
38
42
|
const emptyIndex = () => ({ schema: INDEX_SCHEMA, sequence: 0, current: {}, running: [] });
|
|
39
43
|
function validateIndex(doc) {
|
|
@@ -73,7 +77,7 @@ class StateBusy extends Error {
|
|
|
73
77
|
// Acquire the exclusive lock: mkdir is atomic; a lock whose owner pid is dead on
|
|
74
78
|
// this host is reclaimed with a diagnostic; a live or foreign-host owner is never
|
|
75
79
|
// stolen. Returns a release function.
|
|
76
|
-
function acquireLock(root, { waitMs, command = 'runtime', log = message =>
|
|
80
|
+
function acquireLock(root, { waitMs, command = 'runtime', log = message => io.err(`${message}\n`) } = {}) {
|
|
77
81
|
const p = ensureLayout(root);
|
|
78
82
|
const bound = waitMs ?? (Number(process.env.PINCER_LOCK_WAIT_MS) > 0 ? Number(process.env.PINCER_LOCK_WAIT_MS) : LOCK_WAIT_MS);
|
|
79
83
|
const deadline = Date.now() + bound;
|
|
@@ -124,7 +128,12 @@ function withLock(root, fn, options) {
|
|
|
124
128
|
|
|
125
129
|
const compactTimestamp = () => nowIso().replace(/[-:]/g, '');
|
|
126
130
|
const attemptId = sequence => `${String(sequence).padStart(6, '0')}-${compactTimestamp()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
127
|
-
|
|
131
|
+
// Context keys (docs/runtime-contracts.md, "Attempts"): ticket keys are change-scoped
|
|
132
|
+
// in every runtime mode; candidate keys gain the change in changes mode (schema 2
|
|
133
|
+
// records) so two changes sharing a check ID and a candidate never share a pointer.
|
|
134
|
+
const contextKey = context => (context.kind === 'candidate'
|
|
135
|
+
? (context.mode === 'changes' || context.agreement ? `candidate:${context.change}:${context.candidate}:${context.check}` : `candidate:${context.candidate}:${context.check}`)
|
|
136
|
+
: `ticket:${context.change}:${context.ticket}`);
|
|
128
137
|
|
|
129
138
|
const OUTCOMES = ['running', 'passed', 'failed', 'interrupted', 'timed_out', 'error'];
|
|
130
139
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
@@ -140,13 +149,19 @@ function validateAttempt(a, key, pointedId) {
|
|
|
140
149
|
const digestOrNull = v => v === null || (typeof v === 'string' && SHA256.test(v));
|
|
141
150
|
if (!obj(a)) return 'record is not a JSON object';
|
|
142
151
|
const bad = [];
|
|
143
|
-
|
|
144
|
-
|
|
152
|
+
// Schema 1 (migrated mode), schema 2 (changes mode: the agreement digest is part
|
|
153
|
+
// of the context) and schema 3 (strict coverage: inventory and coverage digests
|
|
154
|
+
// too) share every other field.
|
|
155
|
+
if (![1, 2, 3].includes(a.schema)) bad.push('schema');
|
|
156
|
+
if (a.runtime !== a.schema) bad.push('runtime');
|
|
145
157
|
if (!str(a.id)) bad.push('id');
|
|
146
158
|
if (!Number.isInteger(a.sequence) || a.sequence < 1) bad.push('sequence');
|
|
147
159
|
const c = a.context;
|
|
148
160
|
if (!obj(c) || !['ticket', 'candidate'].includes(c.kind) || !str(c.change) || !str(c.prd) || !str(c.prd_revision) || !str(c.base)
|
|
149
|
-
|| (c.kind === 'ticket' ? !str(c.ticket) || !str(c.ticket_digest) : !str(c.candidate) || !str(c.check))
|
|
161
|
+
|| (c.kind === 'ticket' ? !str(c.ticket) || !str(c.ticket_digest) : !str(c.candidate) || !str(c.check))
|
|
162
|
+
|| (a.schema === 1 ? 'agreement' in c || 'inventory' in c || 'coverage' in c
|
|
163
|
+
: !(typeof c.agreement === 'string' && SHA256.test(c.agreement))
|
|
164
|
+
|| (a.schema === 2 ? 'inventory' in c || 'coverage' in c : !(typeof c.inventory === 'string' && SHA256.test(c.inventory) && typeof c.coverage === 'string' && SHA256.test(c.coverage))))) bad.push('context');
|
|
150
165
|
if (!obj(a.check) || typeof a.check.digest !== 'string' || !SHA256.test(a.check.digest) || typeof a.check.display !== 'string'
|
|
151
166
|
|| !Number.isInteger(a.check.timeout_seconds) || a.check.timeout_seconds <= 0) bad.push('check');
|
|
152
167
|
if (!OUTCOMES.includes(a.outcome)) bad.push('outcome');
|
|
@@ -231,10 +246,14 @@ function latestAttempt(root, key, index) {
|
|
|
231
246
|
function recover(root, options = {}) {
|
|
232
247
|
return withLock(root, () => {
|
|
233
248
|
const p = paths(root);
|
|
249
|
+
// Committed-but-unapplied transactions are completed and uncommitted staging
|
|
250
|
+
// is discarded before anything else is read (docs/runtime-contracts.md,
|
|
251
|
+
// "Transactions and recovery"); required lazily to avoid a module cycle.
|
|
252
|
+
const transactions = require('./transaction.cjs').recoverPending(root);
|
|
234
253
|
const read = readIndex(root);
|
|
235
254
|
if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
|
|
236
255
|
const index = read.index;
|
|
237
|
-
const report = { finalized: [], live: [], foreign: [], missing: [], journal: [] };
|
|
256
|
+
const report = { finalized: [], live: [], foreign: [], missing: [], journal: [], transactions };
|
|
238
257
|
const stillRunning = [];
|
|
239
258
|
for (const id of index.running) {
|
|
240
259
|
const attempt = readAttempt(root, id).attempt;
|
|
@@ -274,6 +293,9 @@ function recover(root, options = {}) {
|
|
|
274
293
|
if (fs.existsSync(p.journal)) {
|
|
275
294
|
for (const name of fs.readdirSync(p.journal)) {
|
|
276
295
|
const file = path.join(p.journal, name);
|
|
296
|
+
let stat = null;
|
|
297
|
+
try { stat = fs.lstatSync(file); } catch { continue; }
|
|
298
|
+
if (stat.isDirectory()) continue; // transaction staging is handled above; an unreadable manifest stays for inspection
|
|
277
299
|
try { fs.rmSync(file, { force: true }); report.journal.push(`${RUNTIME_DIR}/journal/${name}`); } catch { /* ignore */ }
|
|
278
300
|
}
|
|
279
301
|
}
|
|
@@ -287,6 +309,6 @@ function recover(root, options = {}) {
|
|
|
287
309
|
|
|
288
310
|
module.exports = {
|
|
289
311
|
RUNTIME_DIR, INDEX_SCHEMA, LOCK_WAIT_MS, StateBusy,
|
|
290
|
-
paths, ensureLayout, exists, emptyIndex, readIndex, writeIndex, isAlive,
|
|
312
|
+
paths, ensureLayout, exists, hasIndex, emptyIndex, readIndex, writeIndex, isAlive,
|
|
291
313
|
acquireLock, withLock, attemptId, contextKey, validateAttempt, inspectArtifacts, writeAttempt, readAttempt, listAttempts, latestAttempt, recover,
|
|
292
314
|
};
|