pincer-workflow 0.7.0 → 1.0.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 +157 -193
- package/bin/pincer.js +189 -166
- package/package.json +4 -3
- package/template/.agents/skills/pincer-blueprint/SKILL.md +70 -0
- package/template/.agents/skills/pincer-status/SKILL.md +29 -55
- package/template/.claude/commands/pincer-blueprint.md +7 -0
- package/template/.claude/commands/pincer-status.md +4 -59
- package/template/.claude/hooks/hook-policy.cjs +60 -25
- package/template/.claude/hooks/ticket-guard.sh +6 -3
- package/template/.claude/pincer/library/agents/accessibility-reviewer.md +27 -0
- package/template/.claude/pincer/library/agents/code-quality-reviewer.md +25 -0
- package/template/.claude/pincer/library/agents/codebase-explorer.md +23 -0
- package/template/.claude/pincer/library/agents/fix-agent.md +23 -0
- package/template/.claude/pincer/library/agents/history-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents/implementer.md +24 -0
- package/template/.claude/pincer/library/agents/security-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents/silent-failure-hunter.md +27 -0
- package/template/.claude/pincer/library/agents/standards-reviewer.md +25 -0
- package/template/.claude/pincer/library/agents/test-coverage-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents-skeleton.md +89 -0
- package/template/.claude/pincer/library/agreement.md +46 -0
- package/template/.claude/pincer/library/models.json +6 -0
- package/template/.claude/pincer/library/stages/blueprint/SKILL.md +69 -0
- package/template/.claude/pincer/library/stages/blueprint/formats/harness.md +82 -0
- package/template/.claude/pincer/library/stages/blueprint/formats/roster-rules.md +61 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/confirm-kind.md +19 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/first-job.md +18 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-handoffs.md +21 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-models.md +20 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-pipeline.md +27 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-repo.md +21 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-responsibility.md +30 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-roster.md +20 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-simplify.md +26 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-systems.md +28 -0
- package/template/.claude/pincer/library/stages/code/SKILL.md +59 -0
- package/template/.claude/pincer/library/stages/code/formats/pr.md +55 -0
- package/template/.claude/pincer/library/stages/code/prompts/implement.md +25 -0
- package/template/.claude/pincer/library/stages/evaluate/SKILL.md +51 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/evidence.md +49 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/finding-report.md +72 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/finding.md +35 -0
- package/template/.claude/pincer/library/stages/evaluate/prompts/review.md +25 -0
- package/template/.claude/pincer/library/stages/narrow/SKILL.md +47 -0
- package/template/.claude/pincer/library/stages/narrow/formats/ticket.md +126 -0
- package/template/.claude/pincer/library/stages/narrow/prompts/explore.md +19 -0
- package/template/.claude/pincer/library/stages/plan/SKILL.md +46 -0
- package/template/.claude/pincer/library/stages/plan/formats/spec.md +100 -0
- package/template/.claude/pincer/library/stages/plan/prompts/explore.md +20 -0
- package/template/.claude/pincer/library/stages/release/SKILL.md +48 -0
- package/template/.claude/pincer/library/stages/status/SKILL.md +37 -0
- package/template/.claude/skills/pincer-blueprint/SKILL.md +69 -0
- package/template/.claude/skills/pincer-blueprint/formats/harness.md +82 -0
- package/template/.claude/skills/pincer-blueprint/formats/roster-rules.md +61 -0
- package/template/.claude/skills/pincer-blueprint/prompts/confirm-kind.md +19 -0
- package/template/.claude/skills/pincer-blueprint/prompts/first-job.md +18 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-handoffs.md +21 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-models.md +20 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-pipeline.md +27 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-repo.md +21 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-responsibility.md +30 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-roster.md +20 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-simplify.md +26 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-systems.md +28 -0
- package/template/.claude/skills/pincer-status/SKILL.md +37 -0
- package/template/.codex/README.md +39 -35
- package/template/.github/copilot-instructions.md +19 -14
- package/template/.github/prompts/pincer-blueprint.prompt.md +70 -0
- package/template/.github/prompts/pincer-status.prompt.md +29 -55
- package/template/AGENTS.md +34 -67
- package/template/docs/engine.md +236 -0
- package/template/docs/release-checklist.md +22 -28
- package/template/scripts/pincer/blueprint.cjs +137 -0
- package/template/scripts/pincer/cli.cjs +233 -0
- package/template/scripts/pincer/evidence.cjs +142 -0
- package/template/scripts/pincer/generate.cjs +373 -0
- package/template/scripts/pincer/harness.cjs +269 -0
- package/template/scripts/pincer/models.cjs +121 -0
- package/template/scripts/{pincer-runtime → pincer}/parse.cjs +83 -133
- package/template/scripts/pincer/recon.cjs +205 -0
- package/template/scripts/pincer/status.cjs +161 -0
- package/template/scripts/pincer/ticket.cjs +203 -0
- package/template/scripts/pincer/util.cjs +104 -0
- package/template/scripts/pincer-blueprint.cjs +7 -0
- package/template/scripts/pincer-evidence.cjs +5 -73
- package/template/scripts/pincer-status.sh +10 -12
- package/template/scripts/pincer-ticket.sh +15 -18
- package/template/scripts/sync-prompts.sh +2 -50
- package/template/.agents/skills/pincer-code/SKILL.md +0 -240
- package/template/.agents/skills/pincer-evaluate/SKILL.md +0 -178
- package/template/.agents/skills/pincer-narrow/SKILL.md +0 -148
- package/template/.agents/skills/pincer-plan/SKILL.md +0 -134
- package/template/.agents/skills/pincer-release/SKILL.md +0 -82
- package/template/.claude/agents/code-quality-reviewer.md +0 -38
- package/template/.claude/agents/codebase-explorer.md +0 -23
- package/template/.claude/commands/pincer-code.md +0 -238
- package/template/.claude/commands/pincer-evaluate.md +0 -176
- package/template/.claude/commands/pincer-narrow.md +0 -146
- package/template/.claude/commands/pincer-plan.md +0 -132
- package/template/.claude/commands/pincer-release.md +0 -80
- package/template/.claude/references/prd-template.md +0 -108
- package/template/.claude/references/ticket-template.md +0 -86
- package/template/.github/prompts/pincer-code.prompt.md +0 -240
- package/template/.github/prompts/pincer-evaluate.prompt.md +0 -178
- package/template/.github/prompts/pincer-narrow.prompt.md +0 -148
- package/template/.github/prompts/pincer-plan.prompt.md +0 -134
- package/template/.github/prompts/pincer-release.prompt.md +0 -82
- package/template/docs/dry-run-checklist.md +0 -264
- package/template/docs/runtime-contracts.md +0 -1769
- package/template/scripts/pincer-runtime/adopt.cjs +0 -132
- package/template/scripts/pincer-runtime/agreement.cjs +0 -240
- package/template/scripts/pincer-runtime/authorization.cjs +0 -167
- package/template/scripts/pincer-runtime/changes.cjs +0 -517
- package/template/scripts/pincer-runtime/checks.cjs +0 -48
- package/template/scripts/pincer-runtime/coverage.cjs +0 -361
- package/template/scripts/pincer-runtime/dispositions.cjs +0 -95
- package/template/scripts/pincer-runtime/evidence.cjs +0 -676
- package/template/scripts/pincer-runtime/fsutil.cjs +0 -37
- package/template/scripts/pincer-runtime/gates.cjs +0 -73
- package/template/scripts/pincer-runtime/identity.cjs +0 -163
- package/template/scripts/pincer-runtime/impact.cjs +0 -177
- package/template/scripts/pincer-runtime/io.cjs +0 -41
- package/template/scripts/pincer-runtime/lifecycle.cjs +0 -311
- package/template/scripts/pincer-runtime/locator.cjs +0 -158
- package/template/scripts/pincer-runtime/migrate.cjs +0 -204
- package/template/scripts/pincer-runtime/phases.cjs +0 -245
- package/template/scripts/pincer-runtime/readiness.cjs +0 -102
- package/template/scripts/pincer-runtime/requirements.cjs +0 -255
- package/template/scripts/pincer-runtime/resume.cjs +0 -273
- package/template/scripts/pincer-runtime/routing.cjs +0 -54
- package/template/scripts/pincer-runtime/runner.cjs +0 -242
- package/template/scripts/pincer-runtime/sanitize.cjs +0 -63
- package/template/scripts/pincer-runtime/scaffold.cjs +0 -254
- package/template/scripts/pincer-runtime/source.cjs +0 -129
- package/template/scripts/pincer-runtime/state.cjs +0 -314
- package/template/scripts/pincer-runtime/status.cjs +0 -514
- package/template/scripts/pincer-runtime/transaction.cjs +0 -200
- package/template/scripts/pincer-runtime/transitions.cjs +0 -134
- package/template/scripts/pincer-runtime.cjs +0 -686
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
// PINCER runtime — the attempt runner (docs/runtime-contracts.md, "Attempts",
|
|
3
|
-
// "Capture and sanitization"). Persists a `running` record before launch (which
|
|
4
|
-
// supersedes prior readiness for the context immediately), snapshots inputs
|
|
5
|
-
// before and after, executes the check through `bash -eo pipefail` in its own
|
|
6
|
-
// process group with bounded sanitized capture, enforces the timeout with
|
|
7
|
-
// escalation, handles signals, and finalizes the record from what actually
|
|
8
|
-
// happened. Nothing here decides readiness; readiness.cjs reads the records.
|
|
9
|
-
const fs = require('node:fs');
|
|
10
|
-
const os = require('node:os');
|
|
11
|
-
const path = require('node:path');
|
|
12
|
-
const { spawn, execFileSync } = require('node:child_process');
|
|
13
|
-
const parse = require('./parse.cjs');
|
|
14
|
-
const source = require('./source.cjs');
|
|
15
|
-
const state = require('./state.cjs');
|
|
16
|
-
const { sanitizeLine, sanitizeText } = require('./sanitize.cjs');
|
|
17
|
-
const { nowIso } = require('./fsutil.cjs');
|
|
18
|
-
|
|
19
|
-
const CAPTURE_LIMIT = 1024 * 1024;
|
|
20
|
-
const PARTIAL_LINE_LIMIT = 64 * 1024;
|
|
21
|
-
const GRACE_MS = 5000;
|
|
22
|
-
const DRAIN_MS = 2000;
|
|
23
|
-
const RUNNER_ARGS = ['-eo', 'pipefail', '-c'];
|
|
24
|
-
|
|
25
|
-
let bashInfo = null;
|
|
26
|
-
function runnerInfo() {
|
|
27
|
-
if (bashInfo) return bashInfo;
|
|
28
|
-
let shell = 'bash', version = 'unknown';
|
|
29
|
-
try { shell = execFileSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8' }).trim() || 'bash'; } catch { /* keep default */ }
|
|
30
|
-
try { version = execFileSync('bash', ['--version'], { encoding: 'utf8' }).split('\n')[0]; } catch { /* keep default */ }
|
|
31
|
-
bashInfo = { shell, args: RUNNER_ARGS, version };
|
|
32
|
-
return bashInfo;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// A bounded, sanitized capture of one stream into a file, echoed to `echo` when given.
|
|
36
|
-
class Capture {
|
|
37
|
-
constructor(file, echo) {
|
|
38
|
-
this.file = file; this.echo = echo;
|
|
39
|
-
this.fd = fs.openSync(file, 'w');
|
|
40
|
-
this.bytes = 0; this.dropped = 0; this.redactions = 0; this.truncated = false;
|
|
41
|
-
this.partial = ''; this.state = {}; this.failed = null;
|
|
42
|
-
}
|
|
43
|
-
write(chunk) {
|
|
44
|
-
this.partial += chunk.toString('utf8');
|
|
45
|
-
let index;
|
|
46
|
-
while ((index = this.partial.indexOf('\n')) !== -1) {
|
|
47
|
-
this.emit(this.partial.slice(0, index), true);
|
|
48
|
-
this.partial = this.partial.slice(index + 1);
|
|
49
|
-
}
|
|
50
|
-
if (this.partial.length > PARTIAL_LINE_LIMIT) { this.emit(this.partial, false); this.partial = ''; }
|
|
51
|
-
}
|
|
52
|
-
emit(line, newline) {
|
|
53
|
-
const { text, redactions } = sanitizeLine(line, this.state);
|
|
54
|
-
this.redactions += redactions;
|
|
55
|
-
const out = newline ? `${text}\n` : text;
|
|
56
|
-
if (this.echo) this.echo.write(out);
|
|
57
|
-
const buffer = Buffer.from(out, 'utf8');
|
|
58
|
-
if (this.bytes + buffer.length <= CAPTURE_LIMIT) {
|
|
59
|
-
try { fs.writeSync(this.fd, buffer); this.bytes += buffer.length; } catch (error) { this.failed = this.failed || error.message; }
|
|
60
|
-
} else {
|
|
61
|
-
if (!this.truncated) {
|
|
62
|
-
const room = CAPTURE_LIMIT - this.bytes;
|
|
63
|
-
if (room > 0) { try { fs.writeSync(this.fd, buffer.subarray(0, room)); this.bytes += room; } catch (error) { this.failed = this.failed || error.message; } }
|
|
64
|
-
this.truncated = true;
|
|
65
|
-
this.dropped += buffer.length - Math.max(room, 0);
|
|
66
|
-
} else this.dropped += buffer.length;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
close() {
|
|
70
|
-
if (this.partial) { this.emit(this.partial, false); this.partial = ''; }
|
|
71
|
-
if (this.truncated) {
|
|
72
|
-
try { fs.writeSync(this.fd, `\n[pincer: truncated, ${this.dropped} more bytes not stored]\n`); } catch (error) { this.failed = this.failed || error.message; }
|
|
73
|
-
}
|
|
74
|
-
fs.closeSync(this.fd);
|
|
75
|
-
return { bytes: this.bytes, truncated: this.truncated, redactions: this.redactions, failed: this.failed };
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Run one attempt. `context` is the attempt context (kind, change, prd, prd_revision,
|
|
80
|
-
// base, ticket/ticket_digest or candidate/check); `commands` the block lines;
|
|
81
|
-
// `timeoutSeconds` the effective timeout; `echo` when the output should also reach
|
|
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 }) {
|
|
92
|
-
const block = commands.length ? `${commands.join('\n')}\n` : '';
|
|
93
|
-
const checkDigest = parse.sha256(`${block}timeout=${timeoutSeconds}\n`);
|
|
94
|
-
const display = sanitizeText(block).text.slice(0, 2000);
|
|
95
|
-
const before = source.snapshot(root);
|
|
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 };
|
|
97
|
-
|
|
98
|
-
let attempt, logDir, relLogDir;
|
|
99
|
-
try {
|
|
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;
|
|
110
|
-
const read = state.readIndex(root);
|
|
111
|
-
if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
|
|
112
|
-
const index = read.index;
|
|
113
|
-
const sequence = index.sequence + 1;
|
|
114
|
-
const id = state.attemptId(sequence);
|
|
115
|
-
source.storeManifest(root, before);
|
|
116
|
-
relLogDir = `${state.RUNTIME_DIR}/attempts/${id}`;
|
|
117
|
-
logDir = path.join(root, relLogDir);
|
|
118
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
119
|
-
attempt = {
|
|
120
|
-
schema, runtime: schema, id, sequence, context: persisted,
|
|
121
|
-
check: { digest: checkDigest, display, timeout_seconds: timeoutSeconds },
|
|
122
|
-
outcome: 'running', exit_code: null, signal: null,
|
|
123
|
-
runner: runnerInfo(), cwd: cwd || '.',
|
|
124
|
-
environment: { os: `${os.platform()} ${os.release()}`, node: process.version, declared },
|
|
125
|
-
started: nowIso(), finished: null,
|
|
126
|
-
source: { before: before.digest, after: null, files: before.files.length, limitations: before.limitations },
|
|
127
|
-
artifacts: { stdout: { path: `${relLogDir}/stdout.log`, sha256: null, bytes: 0, truncated: false, redactions: 0 }, stderr: { path: `${relLogDir}/stderr.log`, sha256: null, bytes: 0, truncated: false, redactions: 0 } },
|
|
128
|
-
owner: { pid: process.pid, ppid: process.ppid, host: os.hostname() }, child: null,
|
|
129
|
-
limitations: [],
|
|
130
|
-
};
|
|
131
|
-
state.writeAttempt(root, attempt);
|
|
132
|
-
index.sequence = sequence;
|
|
133
|
-
index.current[key] = id;
|
|
134
|
-
index.running.push(id);
|
|
135
|
-
state.writeIndex(root, index);
|
|
136
|
-
}, { command });
|
|
137
|
-
} catch (error) {
|
|
138
|
-
if (error && error.refusal) return { code: error.code, problem: error.message, refused: true };
|
|
139
|
-
if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
|
|
140
|
-
if (error.code === 'INVALID') return { code: 'INPUT_INVALID', problem: error.message };
|
|
141
|
-
return { code: 'ATTEMPT_ERROR', problem: `cannot persist the attempt record: ${error.message}` };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (announce) announce(attempt);
|
|
145
|
-
// Execute in a fresh process group so timeouts and signals reach every descendant.
|
|
146
|
-
let child, launchError = null;
|
|
147
|
-
const stdout = new Capture(path.join(logDir, 'stdout.log'), echo ? process.stdout : null);
|
|
148
|
-
const stderr = new Capture(path.join(logDir, 'stderr.log'), echo ? process.stderr : null);
|
|
149
|
-
let timedOut = false, interruptedBy = null, abandoned = false, killTimer = null, graceTimer = null, drainTimer = null, settle = null;
|
|
150
|
-
const sent = [];
|
|
151
|
-
// Signal the whole group, whether or not the shell itself has exited: a
|
|
152
|
-
// background child that inherited the output pipes keeps the run alive and
|
|
153
|
-
// must be terminated the same way. The bare pid is a fallback only while the
|
|
154
|
-
// shell is known to be alive (after it is reaped the pid may be reused). The
|
|
155
|
-
// group id itself can be reused only once every member is gone; the window
|
|
156
|
-
// between the two attempts is a documented limit, not something detected here.
|
|
157
|
-
// `sent` records only signals a kill call delivered.
|
|
158
|
-
const signalGroup = signal => {
|
|
159
|
-
try { process.kill(-child.pid, signal); sent.push(signal); return; } catch { /* no group left */ }
|
|
160
|
-
if (child.exitCode === null && child.signalCode === null) { try { process.kill(child.pid, signal); sent.push(signal); } catch { /* gone */ } }
|
|
161
|
-
};
|
|
162
|
-
let terminating = false;
|
|
163
|
-
const terminate = () => {
|
|
164
|
-
if (!child || !child.pid || terminating) return;
|
|
165
|
-
terminating = true;
|
|
166
|
-
signalGroup('SIGTERM');
|
|
167
|
-
graceTimer = setTimeout(() => {
|
|
168
|
-
signalGroup('SIGKILL');
|
|
169
|
-
// Bound the wait for the pipes to close: a descendant that survives
|
|
170
|
-
// SIGKILL (or was never reachable) must not hold the run open forever.
|
|
171
|
-
drainTimer = setTimeout(() => {
|
|
172
|
-
abandoned = true;
|
|
173
|
-
try { child.stdout.destroy(); child.stderr.destroy(); child.unref(); } catch { /* best effort */ }
|
|
174
|
-
settle({ code: child.exitCode, signal: child.signalCode });
|
|
175
|
-
}, DRAIN_MS);
|
|
176
|
-
}, GRACE_MS);
|
|
177
|
-
};
|
|
178
|
-
const onSignal = signal => { interruptedBy = signal; terminate(); };
|
|
179
|
-
const exit = await new Promise(resolve => {
|
|
180
|
-
let settled = false;
|
|
181
|
-
settle = result => { if (!settled) { settled = true; resolve(result); } };
|
|
182
|
-
try {
|
|
183
|
-
child = spawn(runnerInfo().shell, [...RUNNER_ARGS, block], { cwd: cwd ? path.join(root, cwd) : root, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
184
|
-
} catch (error) { launchError = error.message; return settle({ code: null, signal: null }); }
|
|
185
|
-
child.on('error', error => { launchError = error.message; });
|
|
186
|
-
if (child.pid) {
|
|
187
|
-
attempt.child = { pid: child.pid };
|
|
188
|
-
try { state.withLock(root, () => state.writeAttempt(root, attempt), { command }); } catch { /* the final write records it */ }
|
|
189
|
-
}
|
|
190
|
-
child.stdout.on('data', chunk => stdout.write(chunk));
|
|
191
|
-
child.stderr.on('data', chunk => stderr.write(chunk));
|
|
192
|
-
process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal);
|
|
193
|
-
killTimer = setTimeout(() => { timedOut = true; terminate(); }, timeoutSeconds * 1000);
|
|
194
|
-
child.on('close', (code, signal) => settle({ code, signal }));
|
|
195
|
-
});
|
|
196
|
-
clearTimeout(killTimer); clearTimeout(graceTimer); clearTimeout(drainTimer);
|
|
197
|
-
process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal);
|
|
198
|
-
const outInfo = stdout.close(), errInfo = stderr.close();
|
|
199
|
-
const termination = `${sent.length ? `the child process group was sent ${sent.join(' then ')}` : 'no reachable process remained in the child\'s group (the shell had exited and its descendants left the group)'}${abandoned ? `; output capture was abandoned ${DRAIN_MS / 1000} s after the SIGKILL point and a descendant may still be running` : ''}`;
|
|
200
|
-
|
|
201
|
-
const after = source.snapshot(root);
|
|
202
|
-
const changed = after.digest && before.digest !== after.digest ? source.diffManifests(before, after) : [];
|
|
203
|
-
attempt.finished = nowIso();
|
|
204
|
-
attempt.exit_code = exit.code;
|
|
205
|
-
attempt.signal = exit.signal;
|
|
206
|
-
attempt.source.after = after.digest;
|
|
207
|
-
for (const [name, info] of [['stdout', outInfo], ['stderr', errInfo]]) {
|
|
208
|
-
const file = path.join(logDir, `${name}.log`);
|
|
209
|
-
let sha = null;
|
|
210
|
-
try { sha = parse.sha256(fs.readFileSync(file)); } catch { sha = null; }
|
|
211
|
-
attempt.artifacts[name] = { ...attempt.artifacts[name], sha256: sha, bytes: info.bytes, truncated: info.truncated, redactions: info.redactions };
|
|
212
|
-
}
|
|
213
|
-
const captureFailure = outInfo.failed || errInfo.failed;
|
|
214
|
-
if (launchError) { attempt.outcome = 'error'; attempt.error = `cannot launch the check: ${launchError}`; }
|
|
215
|
-
else if (interruptedBy) { attempt.outcome = 'interrupted'; attempt.limitations.push(`interrupted by ${interruptedBy}; ${termination}`); }
|
|
216
|
-
else if (timedOut) { attempt.outcome = 'timed_out'; attempt.limitations.push(`terminated after ${timeoutSeconds} s; ${termination}`); }
|
|
217
|
-
else if (captureFailure) { attempt.outcome = 'error'; attempt.error = `capture failed: ${captureFailure}`; }
|
|
218
|
-
else if (after.problems.length) { attempt.outcome = 'error'; attempt.error = `source view invalid after the run: ${after.problems[0].code} ${after.problems[0].detail}`; }
|
|
219
|
-
else if (exit.code === 0 && changed.length) { attempt.outcome = 'error'; attempt.error = `SOURCE_CHANGED: the check mutated source: ${changed.slice(0, 5).join(', ')}${changed.length > 5 ? ` (+${changed.length - 5})` : ''}`; }
|
|
220
|
-
else if (exit.code === 0) attempt.outcome = 'passed';
|
|
221
|
-
else attempt.outcome = 'failed';
|
|
222
|
-
if (changed.length && attempt.outcome !== 'error') attempt.limitations.push(`source changed during the run: ${changed.slice(0, 5).join(', ')}`);
|
|
223
|
-
if (after.digest) { try { source.storeManifest(root, after); } catch { /* best effort; the digest is recorded */ } }
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
state.withLock(root, () => {
|
|
227
|
-
const read = state.readIndex(root);
|
|
228
|
-
if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
|
|
229
|
-
const index = read.index;
|
|
230
|
-
state.writeAttempt(root, attempt);
|
|
231
|
-
index.running = index.running.filter(id => id !== attempt.id);
|
|
232
|
-
state.writeIndex(root, index);
|
|
233
|
-
}, { command });
|
|
234
|
-
} catch (error) {
|
|
235
|
-
return { code: 'ATTEMPT_ERROR', problem: `attempt ${attempt.id} finished ${attempt.outcome} but the record could not be finalized: ${error.message}`, attempt };
|
|
236
|
-
}
|
|
237
|
-
return { attempt, changed };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const exitFor = attempt => ({ passed: 0, failed: 1, timed_out: 124, interrupted: 130, error: 4 })[attempt.outcome] ?? 4;
|
|
241
|
-
|
|
242
|
-
module.exports = { runAttempt, exitFor, CAPTURE_LIMIT, GRACE_MS, DRAIN_MS, runnerInfo };
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
// PINCER runtime — output sanitizer (docs/runtime-contracts.md, "Capture and
|
|
3
|
-
// sanitization"). A safety net applied to every captured line and to command
|
|
4
|
-
// display text before anything is persisted: documented secret patterns are
|
|
5
|
-
// replaced by [redacted] and counted. It is not a guarantee; checks must avoid
|
|
6
|
-
// printing secrets, and no raw log is ever exported.
|
|
7
|
-
const PATTERNS = [
|
|
8
|
-
// Bearer tokens first, so an `Authorization: Bearer <token>` header keeps the
|
|
9
|
-
// token, not the word Bearer, as the redacted value.
|
|
10
|
-
{ re: /((?:bearer|basic|token|digest)\s+)[A-Za-z0-9\-._~+/=]+/gi, replace: '$1[redacted]' },
|
|
11
|
-
// key/secret/password/token/authorization assignments and JSON fields; a
|
|
12
|
-
// quoted value is redacted up to its closing quote, an unquoted one to the
|
|
13
|
-
// next whitespace or separator. Bounded quantifiers: an unbounded prefix would
|
|
14
|
-
// backtrack quadratically on long lines.
|
|
15
|
-
{ re: /((?:^|[^A-Za-z0-9_.-])[A-Za-z0-9_.-]{0,64}?(?:key|secret|password|passwd|token|authorization)[A-Za-z0-9_.-]{0,64}["']?[ \t]{0,8}[:=][ \t]{0,8})(?:"([^"\n]{0,512})"|'([^'\n]{0,512})'|([^\s"',;]+))/gi, replace: (m, head, dq, sq, bare) => `${head}${dq !== undefined ? '"[redacted]"' : sq !== undefined ? "'[redacted]'" : '[redacted]'}` },
|
|
16
|
-
{ re: /AKIA[0-9A-Z]{16}/g, replace: '[redacted]' },
|
|
17
|
-
{ re: /gh[pousr]_[A-Za-z0-9]{20,}/g, replace: '[redacted]' },
|
|
18
|
-
];
|
|
19
|
-
const PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
20
|
-
const PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
|
|
21
|
-
|
|
22
|
-
// Stateful: PEM private key blocks span lines. `state` is a plain object the
|
|
23
|
-
// caller keeps per stream.
|
|
24
|
-
function sanitizeLine(line, state = {}) {
|
|
25
|
-
let redactions = 0;
|
|
26
|
-
if (state.inPem) {
|
|
27
|
-
if (PEM_END.test(line)) state.inPem = false;
|
|
28
|
-
return { text: '[redacted]', redactions: 1 };
|
|
29
|
-
}
|
|
30
|
-
if (PEM_BEGIN.test(line)) {
|
|
31
|
-
state.inPem = !PEM_END.test(line);
|
|
32
|
-
return { text: '[redacted]', redactions: 1 };
|
|
33
|
-
}
|
|
34
|
-
let text = line;
|
|
35
|
-
for (const { re, replace } of PATTERNS) {
|
|
36
|
-
text = text.replace(re, (...args) => {
|
|
37
|
-
redactions++;
|
|
38
|
-
return typeof replace === 'function' ? replace(...args) : replace.replace(/\$(\d)/g, (_, n) => args[Number(n)]);
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
return { text, redactions };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function sanitizeText(text) {
|
|
45
|
-
const state = {};
|
|
46
|
-
let redactions = 0;
|
|
47
|
-
const lines = String(text).split('\n').map(line => { const r = sanitizeLine(line, state); redactions += r.redactions; return r.text; });
|
|
48
|
-
return { text: lines.join('\n'), redactions };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// A Verification block must not carry an inline secret literal: the block is
|
|
52
|
-
// recorded as display text and would persist the value. `$references` and
|
|
53
|
-
// empty values are fine.
|
|
54
|
-
// An assignment anywhere in the line (after a word boundary, so `env TOKEN=x cmd`
|
|
55
|
-
// and `true; TOKEN=x cmd` count); `$VAR`, `"$VAR"`, `$(…)` and backtick
|
|
56
|
-
// references are allowed, literals are not.
|
|
57
|
-
const INLINE_SECRET = /(?:^|[\s;&|(])(?:export\s+)?[A-Za-z0-9_]*(?:KEY|SECRET|PASSWORD|PASSWD|TOKEN)[A-Za-z0-9_]*=(?!["']?\$|["']?`|\s|$)\S/i;
|
|
58
|
-
function inlineSecretLine(commands) {
|
|
59
|
-
const index = commands.findIndex(line => INLINE_SECRET.test(line));
|
|
60
|
-
return index === -1 ? null : index + 1;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
module.exports = { sanitizeLine, sanitizeText, inlineSecretLine, PATTERNS };
|
|
@@ -1,254 +0,0 @@
|
|
|
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 };
|