pincer-workflow 0.6.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.
Files changed (138) hide show
  1. package/README.md +157 -106
  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 -41
  6. package/template/.claude/commands/pincer-blueprint.md +7 -0
  7. package/template/.claude/commands/pincer-status.md +4 -45
  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 +48 -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 -41
  70. package/template/AGENTS.md +34 -67
  71. package/template/docs/engine.md +236 -0
  72. package/template/docs/release-checklist.md +22 -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 +373 -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 +161 -0
  82. package/template/scripts/pincer/ticket.cjs +203 -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 -237
  90. package/template/.agents/skills/pincer-evaluate/SKILL.md +0 -178
  91. package/template/.agents/skills/pincer-narrow/SKILL.md +0 -138
  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 -235
  97. package/template/.claude/commands/pincer-evaluate.md +0 -176
  98. package/template/.claude/commands/pincer-narrow.md +0 -136
  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 -237
  104. package/template/.github/prompts/pincer-evaluate.prompt.md +0 -178
  105. package/template/.github/prompts/pincer-narrow.prompt.md +0 -138
  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 -1683
  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 -97
  128. package/template/scripts/pincer-runtime/requirements.cjs +0 -255
  129. package/template/scripts/pincer-runtime/resume.cjs +0 -205
  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/source.cjs +0 -129
  134. package/template/scripts/pincer-runtime/state.cjs +0 -314
  135. package/template/scripts/pincer-runtime/status.cjs +0 -514
  136. package/template/scripts/pincer-runtime/transaction.cjs +0 -200
  137. package/template/scripts/pincer-runtime/transitions.cjs +0 -134
  138. package/template/scripts/pincer-runtime.cjs +0 -661
@@ -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,129 +0,0 @@
1
- 'use strict';
2
- // PINCER runtime — the source manifest (docs/runtime-contracts.md, "Source
3
- // manifest"): a versioned SHA-256 identity of the inputs a verification ran
4
- // against. Tracked plus untracked non-ignored files, modes, deletions; tickets
5
- // and PRDs normalized; fixed and configured exclusions; secret paths, symlinks
6
- // and submodules refused rather than silently omitted.
7
- const fs = require('node:fs');
8
- const path = require('node:path');
9
- const parse = require('./parse.cjs');
10
- const { atomicWrite, tryGit } = require('./fsutil.cjs');
11
-
12
- const SCHEMA = 1;
13
- const FIXED_EXCLUDES = ['.git/', '.pincer/', 'NOTES.md', '.prd/evidence/', '.prd/changes/'];
14
- const EXCLUDE_FILE = '.prd/source-exclude';
15
- const PROTECTED_PREFIXES = ['tickets/'];
16
- const NUL = String.fromCharCode(0);
17
-
18
- const isTicket = p => /^tickets\/T-[0-9]+.*\.md$/.test(p);
19
- const isPrd = p => parse.PRD_REF.test(p);
20
- const isSecret = p => { const b = path.posix.basename(p); return b === '.env' || (b.startsWith('.env.') && b !== '.env.example'); };
21
- const fixedExcluded = p => FIXED_EXCLUDES.some(rule => (rule.endsWith('/') ? p.startsWith(rule) : p === rule));
22
-
23
- // Glob -> RegExp: `**` spans directories, `*` stays within a segment, `?` is one
24
- // character; a pattern without `/` matches a basename anywhere; a trailing `/`
25
- // matches a directory prefix.
26
- function compilePattern(pattern) {
27
- const escape = s => s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
28
- const glob = s => escape(s).replace(/\*\*/g, 'DOUBLESTAR').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/DOUBLESTAR/g, '.*');
29
- if (pattern.endsWith('/')) return new RegExp(`^${glob(pattern.slice(0, -1))}/`);
30
- if (!pattern.includes('/')) return new RegExp(`(^|/)${glob(pattern)}$`);
31
- return new RegExp(`^${glob(pattern.replace(/^\//, ''))}$`);
32
- }
33
- function readExcludeFile(root) {
34
- const file = path.join(root, EXCLUDE_FILE);
35
- if (!fs.existsSync(file)) return [];
36
- return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
37
- }
38
-
39
- const splitZ = buffer => (buffer ? buffer.toString('utf8').split(NUL).filter(Boolean) : []);
40
-
41
- // Compute the manifest. Returns { schema, digest, files, excluded, limitations, problems }.
42
- function snapshot(root) {
43
- const problems = [];
44
- const limitations = [];
45
- const excluded = [];
46
- const toplevel = tryGit(root, ['rev-parse', '--show-toplevel']);
47
- if (toplevel.error) {
48
- return { schema: SCHEMA, digest: null, files: [], excluded, limitations, problems: [{ code: 'UNSUPPORTED_INPUT', detail: 'not inside a git repository; the source identity needs git' }] };
49
- }
50
- const tracked = new Map(); // path -> mode from the index
51
- const staged = tryGit(root, ['ls-files', '-z', '-s'], { buffer: true });
52
- if (staged.error) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `git ls-files failed: ${staged.error}` });
53
- else {
54
- for (const entry of splitZ(staged.out)) {
55
- const match = entry.match(/^(\d{6}) [0-9a-f]{40} \d\t([\s\S]*)$/);
56
- if (match) tracked.set(match[2], match[1]);
57
- }
58
- }
59
- const listed = tryGit(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { buffer: true });
60
- const paths = listed.error ? [] : [...new Set(splitZ(listed.out))];
61
- const deleted = new Set(splitZ(tryGit(root, ['ls-files', '-z', '--deleted'], { buffer: true }).out));
62
- const ignoredDirs = splitZ(tryGit(root, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'], { buffer: true }).out)
63
- .filter(p => !p.startsWith('.pincer/') && p !== '.pincer/');
64
- if (ignoredDirs.length) {
65
- const shown = ignoredDirs.slice(0, 10).join(', ') + (ignoredDirs.length > 10 ? `, ... (${ignoredDirs.length} ignored paths)` : '');
66
- limitations.push(`ignored paths are not part of the source identity: ${shown}`);
67
- }
68
- limitations.push('external services and installed toolchains are not part of the source identity');
69
-
70
- const patterns = readExcludeFile(root).map(pattern => ({ pattern, regex: compilePattern(pattern) }));
71
- const entries = [];
72
- for (const p of paths) {
73
- if (fixedExcluded(p)) { excluded.push(p); continue; }
74
- if (isSecret(p)) { problems.push({ code: 'SECRET_PATH', detail: `${p}: secret file in the source view; remove it or ignore it (its contents were not read)` }); continue; }
75
- const mode = tracked.get(p);
76
- if (mode === '160000') { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: submodules are not supported` }); continue; }
77
- const abs = path.join(root, p);
78
- let stat = null;
79
- try { stat = fs.lstatSync(abs); } catch { stat = null; }
80
- if (mode === '120000' || (stat && stat.isSymbolicLink())) { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: symbolic links are not supported` }); continue; }
81
- const rule = patterns.find(({ regex }) => regex.test(p));
82
- if (rule) {
83
- if (PROTECTED_PREFIXES.some(prefix => p.startsWith(prefix)) || isPrd(p) || p === EXCLUDE_FILE) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${EXCLUDE_FILE}: pattern "${rule.pattern}" matches protected path ${p}` });
84
- else if (tracked.has(p)) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${EXCLUDE_FILE}: pattern "${rule.pattern}" matches tracked file ${p}; exclusions may cover untracked paths only` });
85
- else excluded.push(p);
86
- continue;
87
- }
88
- if (deleted.has(p) || !stat) { entries.push({ path: p, deleted: true }); continue; }
89
- if (stat.isDirectory()) continue;
90
- let content;
91
- try { content = fs.readFileSync(abs); } catch (error) { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: cannot read (${error.code || error.message})` }); continue; }
92
- let sha;
93
- if (isTicket(p)) sha = parse.sha256(parse.normalizeTicket(content.toString('utf8')));
94
- else if (isPrd(p)) sha = parse.sha256(parse.normalizePrd(content.toString('utf8')));
95
- else sha = parse.sha256(content);
96
- entries.push({ path: p, sha256: sha, mode: (stat.mode & 0o111) ? '100755' : '100644' });
97
- }
98
- entries.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)));
99
- const digestInput = entries.map(e => `${e.deleted ? 'deleted' : e.mode} ${e.deleted ? '-' : e.sha256} ${e.path}\n`).join('');
100
- const digest = problems.length ? null : parse.sha256(digestInput);
101
- return { schema: SCHEMA, digest, files: entries, excluded, limitations, problems };
102
- }
103
-
104
- function storeManifest(root, manifest) {
105
- if (!manifest.digest) throw new Error('cannot store a manifest with problems');
106
- const file = path.join(root, '.pincer', 'runtime', 'manifests', `${manifest.digest}.json`);
107
- if (!fs.existsSync(file)) atomicWrite(file, `${JSON.stringify(manifest, null, 2)}\n`, { journalDir: path.join(root, '.pincer', 'runtime', 'journal') });
108
- return `.pincer/runtime/manifests/${manifest.digest}.json`;
109
- }
110
- function readManifest(root, digest) {
111
- const file = path.join(root, '.pincer', 'runtime', 'manifests', `${digest}.json`);
112
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
113
- }
114
-
115
- // Paths that differ between two manifests, for diagnostics.
116
- function diffManifests(before, after) {
117
- const a = new Map(before.files.map(e => [e.path, e]));
118
- const b = new Map(after.files.map(e => [e.path, e]));
119
- const changed = [];
120
- for (const [p, e] of b) {
121
- const prior = a.get(p);
122
- if (!prior) changed.push(`${p} (added)`);
123
- else if (JSON.stringify(prior) !== JSON.stringify(e)) changed.push(`${p}${e.deleted ? ' (deleted)' : prior.mode !== e.mode ? ' (mode)' : ''}`);
124
- }
125
- for (const p of a.keys()) if (!b.has(p)) changed.push(`${p} (removed)`);
126
- return changed;
127
- }
128
-
129
- module.exports = { SCHEMA, FIXED_EXCLUDES, EXCLUDE_FILE, snapshot, storeManifest, readManifest, diffManifests, compilePattern, isSecret };