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.
- package/README.md +157 -106
- 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 -41
- package/template/.claude/commands/pincer-blueprint.md +7 -0
- package/template/.claude/commands/pincer-status.md +4 -45
- 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 -41
- 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 -237
- package/template/.agents/skills/pincer-evaluate/SKILL.md +0 -178
- package/template/.agents/skills/pincer-narrow/SKILL.md +0 -138
- 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 -235
- package/template/.claude/commands/pincer-evaluate.md +0 -176
- package/template/.claude/commands/pincer-narrow.md +0 -136
- 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 -237
- package/template/.github/prompts/pincer-evaluate.prompt.md +0 -178
- package/template/.github/prompts/pincer-narrow.prompt.md +0 -138
- 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 -1683
- 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 -97
- package/template/scripts/pincer-runtime/requirements.cjs +0 -255
- package/template/scripts/pincer-runtime/resume.cjs +0 -205
- 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/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 -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 };
|