pincer-workflow 0.7.0 → 1.0.1

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.
Files changed (139) hide show
  1. package/README.md +161 -193
  2. package/bin/pincer.js +189 -166
  3. package/package.json +4 -3
  4. package/template/.agents/skills/pincer-blueprint/SKILL.md +70 -0
  5. package/template/.agents/skills/pincer-status/SKILL.md +29 -55
  6. package/template/.claude/commands/pincer-blueprint.md +7 -0
  7. package/template/.claude/commands/pincer-status.md +4 -59
  8. package/template/.claude/hooks/hook-policy.cjs +60 -25
  9. package/template/.claude/hooks/ticket-guard.sh +6 -3
  10. package/template/.claude/pincer/library/agents/accessibility-reviewer.md +27 -0
  11. package/template/.claude/pincer/library/agents/code-quality-reviewer.md +25 -0
  12. package/template/.claude/pincer/library/agents/codebase-explorer.md +23 -0
  13. package/template/.claude/pincer/library/agents/fix-agent.md +23 -0
  14. package/template/.claude/pincer/library/agents/history-reviewer.md +26 -0
  15. package/template/.claude/pincer/library/agents/implementer.md +24 -0
  16. package/template/.claude/pincer/library/agents/security-reviewer.md +26 -0
  17. package/template/.claude/pincer/library/agents/silent-failure-hunter.md +27 -0
  18. package/template/.claude/pincer/library/agents/standards-reviewer.md +25 -0
  19. package/template/.claude/pincer/library/agents/test-coverage-reviewer.md +26 -0
  20. package/template/.claude/pincer/library/agents-skeleton.md +89 -0
  21. package/template/.claude/pincer/library/agreement.md +46 -0
  22. package/template/.claude/pincer/library/models.json +6 -0
  23. package/template/.claude/pincer/library/stages/blueprint/SKILL.md +69 -0
  24. package/template/.claude/pincer/library/stages/blueprint/formats/harness.md +82 -0
  25. package/template/.claude/pincer/library/stages/blueprint/formats/roster-rules.md +61 -0
  26. package/template/.claude/pincer/library/stages/blueprint/prompts/confirm-kind.md +19 -0
  27. package/template/.claude/pincer/library/stages/blueprint/prompts/first-job.md +18 -0
  28. package/template/.claude/pincer/library/stages/blueprint/prompts/q-handoffs.md +21 -0
  29. package/template/.claude/pincer/library/stages/blueprint/prompts/q-models.md +20 -0
  30. package/template/.claude/pincer/library/stages/blueprint/prompts/q-pipeline.md +27 -0
  31. package/template/.claude/pincer/library/stages/blueprint/prompts/q-repo.md +21 -0
  32. package/template/.claude/pincer/library/stages/blueprint/prompts/q-responsibility.md +30 -0
  33. package/template/.claude/pincer/library/stages/blueprint/prompts/q-roster.md +20 -0
  34. package/template/.claude/pincer/library/stages/blueprint/prompts/q-simplify.md +26 -0
  35. package/template/.claude/pincer/library/stages/blueprint/prompts/q-systems.md +28 -0
  36. package/template/.claude/pincer/library/stages/code/SKILL.md +59 -0
  37. package/template/.claude/pincer/library/stages/code/formats/pr.md +55 -0
  38. package/template/.claude/pincer/library/stages/code/prompts/implement.md +25 -0
  39. package/template/.claude/pincer/library/stages/evaluate/SKILL.md +51 -0
  40. package/template/.claude/pincer/library/stages/evaluate/formats/evidence.md +49 -0
  41. package/template/.claude/pincer/library/stages/evaluate/formats/finding-report.md +72 -0
  42. package/template/.claude/pincer/library/stages/evaluate/formats/finding.md +35 -0
  43. package/template/.claude/pincer/library/stages/evaluate/prompts/review.md +25 -0
  44. package/template/.claude/pincer/library/stages/narrow/SKILL.md +47 -0
  45. package/template/.claude/pincer/library/stages/narrow/formats/ticket.md +126 -0
  46. package/template/.claude/pincer/library/stages/narrow/prompts/explore.md +19 -0
  47. package/template/.claude/pincer/library/stages/plan/SKILL.md +46 -0
  48. package/template/.claude/pincer/library/stages/plan/formats/spec.md +100 -0
  49. package/template/.claude/pincer/library/stages/plan/prompts/explore.md +20 -0
  50. package/template/.claude/pincer/library/stages/release/SKILL.md +51 -0
  51. package/template/.claude/pincer/library/stages/status/SKILL.md +37 -0
  52. package/template/.claude/skills/pincer-blueprint/SKILL.md +69 -0
  53. package/template/.claude/skills/pincer-blueprint/formats/harness.md +82 -0
  54. package/template/.claude/skills/pincer-blueprint/formats/roster-rules.md +61 -0
  55. package/template/.claude/skills/pincer-blueprint/prompts/confirm-kind.md +19 -0
  56. package/template/.claude/skills/pincer-blueprint/prompts/first-job.md +18 -0
  57. package/template/.claude/skills/pincer-blueprint/prompts/q-handoffs.md +21 -0
  58. package/template/.claude/skills/pincer-blueprint/prompts/q-models.md +20 -0
  59. package/template/.claude/skills/pincer-blueprint/prompts/q-pipeline.md +27 -0
  60. package/template/.claude/skills/pincer-blueprint/prompts/q-repo.md +21 -0
  61. package/template/.claude/skills/pincer-blueprint/prompts/q-responsibility.md +30 -0
  62. package/template/.claude/skills/pincer-blueprint/prompts/q-roster.md +20 -0
  63. package/template/.claude/skills/pincer-blueprint/prompts/q-simplify.md +26 -0
  64. package/template/.claude/skills/pincer-blueprint/prompts/q-systems.md +28 -0
  65. package/template/.claude/skills/pincer-status/SKILL.md +37 -0
  66. package/template/.codex/README.md +39 -35
  67. package/template/.github/copilot-instructions.md +19 -14
  68. package/template/.github/prompts/pincer-blueprint.prompt.md +70 -0
  69. package/template/.github/prompts/pincer-status.prompt.md +29 -55
  70. package/template/AGENTS.md +34 -67
  71. package/template/docs/engine.md +249 -0
  72. package/template/docs/release-checklist.md +27 -28
  73. package/template/scripts/pincer/blueprint.cjs +137 -0
  74. package/template/scripts/pincer/cli.cjs +233 -0
  75. package/template/scripts/pincer/evidence.cjs +142 -0
  76. package/template/scripts/pincer/generate.cjs +375 -0
  77. package/template/scripts/pincer/harness.cjs +269 -0
  78. package/template/scripts/pincer/models.cjs +121 -0
  79. package/template/scripts/{pincer-runtime → pincer}/parse.cjs +83 -133
  80. package/template/scripts/pincer/recon.cjs +205 -0
  81. package/template/scripts/pincer/status.cjs +173 -0
  82. package/template/scripts/pincer/ticket.cjs +204 -0
  83. package/template/scripts/pincer/util.cjs +104 -0
  84. package/template/scripts/pincer-blueprint.cjs +7 -0
  85. package/template/scripts/pincer-evidence.cjs +5 -73
  86. package/template/scripts/pincer-status.sh +10 -12
  87. package/template/scripts/pincer-ticket.sh +15 -18
  88. package/template/scripts/sync-prompts.sh +2 -50
  89. package/template/.agents/skills/pincer-code/SKILL.md +0 -240
  90. package/template/.agents/skills/pincer-evaluate/SKILL.md +0 -178
  91. package/template/.agents/skills/pincer-narrow/SKILL.md +0 -148
  92. package/template/.agents/skills/pincer-plan/SKILL.md +0 -134
  93. package/template/.agents/skills/pincer-release/SKILL.md +0 -82
  94. package/template/.claude/agents/code-quality-reviewer.md +0 -38
  95. package/template/.claude/agents/codebase-explorer.md +0 -23
  96. package/template/.claude/commands/pincer-code.md +0 -238
  97. package/template/.claude/commands/pincer-evaluate.md +0 -176
  98. package/template/.claude/commands/pincer-narrow.md +0 -146
  99. package/template/.claude/commands/pincer-plan.md +0 -132
  100. package/template/.claude/commands/pincer-release.md +0 -80
  101. package/template/.claude/references/prd-template.md +0 -108
  102. package/template/.claude/references/ticket-template.md +0 -86
  103. package/template/.github/prompts/pincer-code.prompt.md +0 -240
  104. package/template/.github/prompts/pincer-evaluate.prompt.md +0 -178
  105. package/template/.github/prompts/pincer-narrow.prompt.md +0 -148
  106. package/template/.github/prompts/pincer-plan.prompt.md +0 -134
  107. package/template/.github/prompts/pincer-release.prompt.md +0 -82
  108. package/template/docs/dry-run-checklist.md +0 -264
  109. package/template/docs/runtime-contracts.md +0 -1769
  110. package/template/scripts/pincer-runtime/adopt.cjs +0 -132
  111. package/template/scripts/pincer-runtime/agreement.cjs +0 -240
  112. package/template/scripts/pincer-runtime/authorization.cjs +0 -167
  113. package/template/scripts/pincer-runtime/changes.cjs +0 -517
  114. package/template/scripts/pincer-runtime/checks.cjs +0 -48
  115. package/template/scripts/pincer-runtime/coverage.cjs +0 -361
  116. package/template/scripts/pincer-runtime/dispositions.cjs +0 -95
  117. package/template/scripts/pincer-runtime/evidence.cjs +0 -676
  118. package/template/scripts/pincer-runtime/fsutil.cjs +0 -37
  119. package/template/scripts/pincer-runtime/gates.cjs +0 -73
  120. package/template/scripts/pincer-runtime/identity.cjs +0 -163
  121. package/template/scripts/pincer-runtime/impact.cjs +0 -177
  122. package/template/scripts/pincer-runtime/io.cjs +0 -41
  123. package/template/scripts/pincer-runtime/lifecycle.cjs +0 -311
  124. package/template/scripts/pincer-runtime/locator.cjs +0 -158
  125. package/template/scripts/pincer-runtime/migrate.cjs +0 -204
  126. package/template/scripts/pincer-runtime/phases.cjs +0 -245
  127. package/template/scripts/pincer-runtime/readiness.cjs +0 -102
  128. package/template/scripts/pincer-runtime/requirements.cjs +0 -255
  129. package/template/scripts/pincer-runtime/resume.cjs +0 -273
  130. package/template/scripts/pincer-runtime/routing.cjs +0 -54
  131. package/template/scripts/pincer-runtime/runner.cjs +0 -242
  132. package/template/scripts/pincer-runtime/sanitize.cjs +0 -63
  133. package/template/scripts/pincer-runtime/scaffold.cjs +0 -254
  134. package/template/scripts/pincer-runtime/source.cjs +0 -129
  135. package/template/scripts/pincer-runtime/state.cjs +0 -314
  136. package/template/scripts/pincer-runtime/status.cjs +0 -514
  137. package/template/scripts/pincer-runtime/transaction.cjs +0 -200
  138. package/template/scripts/pincer-runtime/transitions.cjs +0 -134
  139. 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 };