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.
- package/README.md +161 -193
- package/bin/pincer.js +189 -166
- package/package.json +4 -3
- package/template/.agents/skills/pincer-blueprint/SKILL.md +70 -0
- package/template/.agents/skills/pincer-status/SKILL.md +29 -55
- package/template/.claude/commands/pincer-blueprint.md +7 -0
- package/template/.claude/commands/pincer-status.md +4 -59
- package/template/.claude/hooks/hook-policy.cjs +60 -25
- package/template/.claude/hooks/ticket-guard.sh +6 -3
- package/template/.claude/pincer/library/agents/accessibility-reviewer.md +27 -0
- package/template/.claude/pincer/library/agents/code-quality-reviewer.md +25 -0
- package/template/.claude/pincer/library/agents/codebase-explorer.md +23 -0
- package/template/.claude/pincer/library/agents/fix-agent.md +23 -0
- package/template/.claude/pincer/library/agents/history-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents/implementer.md +24 -0
- package/template/.claude/pincer/library/agents/security-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents/silent-failure-hunter.md +27 -0
- package/template/.claude/pincer/library/agents/standards-reviewer.md +25 -0
- package/template/.claude/pincer/library/agents/test-coverage-reviewer.md +26 -0
- package/template/.claude/pincer/library/agents-skeleton.md +89 -0
- package/template/.claude/pincer/library/agreement.md +46 -0
- package/template/.claude/pincer/library/models.json +6 -0
- package/template/.claude/pincer/library/stages/blueprint/SKILL.md +69 -0
- package/template/.claude/pincer/library/stages/blueprint/formats/harness.md +82 -0
- package/template/.claude/pincer/library/stages/blueprint/formats/roster-rules.md +61 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/confirm-kind.md +19 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/first-job.md +18 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-handoffs.md +21 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-models.md +20 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-pipeline.md +27 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-repo.md +21 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-responsibility.md +30 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-roster.md +20 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-simplify.md +26 -0
- package/template/.claude/pincer/library/stages/blueprint/prompts/q-systems.md +28 -0
- package/template/.claude/pincer/library/stages/code/SKILL.md +59 -0
- package/template/.claude/pincer/library/stages/code/formats/pr.md +55 -0
- package/template/.claude/pincer/library/stages/code/prompts/implement.md +25 -0
- package/template/.claude/pincer/library/stages/evaluate/SKILL.md +51 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/evidence.md +49 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/finding-report.md +72 -0
- package/template/.claude/pincer/library/stages/evaluate/formats/finding.md +35 -0
- package/template/.claude/pincer/library/stages/evaluate/prompts/review.md +25 -0
- package/template/.claude/pincer/library/stages/narrow/SKILL.md +47 -0
- package/template/.claude/pincer/library/stages/narrow/formats/ticket.md +126 -0
- package/template/.claude/pincer/library/stages/narrow/prompts/explore.md +19 -0
- package/template/.claude/pincer/library/stages/plan/SKILL.md +46 -0
- package/template/.claude/pincer/library/stages/plan/formats/spec.md +100 -0
- package/template/.claude/pincer/library/stages/plan/prompts/explore.md +20 -0
- package/template/.claude/pincer/library/stages/release/SKILL.md +51 -0
- package/template/.claude/pincer/library/stages/status/SKILL.md +37 -0
- package/template/.claude/skills/pincer-blueprint/SKILL.md +69 -0
- package/template/.claude/skills/pincer-blueprint/formats/harness.md +82 -0
- package/template/.claude/skills/pincer-blueprint/formats/roster-rules.md +61 -0
- package/template/.claude/skills/pincer-blueprint/prompts/confirm-kind.md +19 -0
- package/template/.claude/skills/pincer-blueprint/prompts/first-job.md +18 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-handoffs.md +21 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-models.md +20 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-pipeline.md +27 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-repo.md +21 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-responsibility.md +30 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-roster.md +20 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-simplify.md +26 -0
- package/template/.claude/skills/pincer-blueprint/prompts/q-systems.md +28 -0
- package/template/.claude/skills/pincer-status/SKILL.md +37 -0
- package/template/.codex/README.md +39 -35
- package/template/.github/copilot-instructions.md +19 -14
- package/template/.github/prompts/pincer-blueprint.prompt.md +70 -0
- package/template/.github/prompts/pincer-status.prompt.md +29 -55
- package/template/AGENTS.md +34 -67
- package/template/docs/engine.md +249 -0
- package/template/docs/release-checklist.md +27 -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 +375 -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 +173 -0
- package/template/scripts/pincer/ticket.cjs +204 -0
- package/template/scripts/pincer/util.cjs +104 -0
- package/template/scripts/pincer-blueprint.cjs +7 -0
- package/template/scripts/pincer-evidence.cjs +5 -73
- package/template/scripts/pincer-status.sh +10 -12
- package/template/scripts/pincer-ticket.sh +15 -18
- package/template/scripts/sync-prompts.sh +2 -50
- package/template/.agents/skills/pincer-code/SKILL.md +0 -240
- package/template/.agents/skills/pincer-evaluate/SKILL.md +0 -178
- package/template/.agents/skills/pincer-narrow/SKILL.md +0 -148
- package/template/.agents/skills/pincer-plan/SKILL.md +0 -134
- package/template/.agents/skills/pincer-release/SKILL.md +0 -82
- package/template/.claude/agents/code-quality-reviewer.md +0 -38
- package/template/.claude/agents/codebase-explorer.md +0 -23
- package/template/.claude/commands/pincer-code.md +0 -238
- package/template/.claude/commands/pincer-evaluate.md +0 -176
- package/template/.claude/commands/pincer-narrow.md +0 -146
- package/template/.claude/commands/pincer-plan.md +0 -132
- package/template/.claude/commands/pincer-release.md +0 -80
- package/template/.claude/references/prd-template.md +0 -108
- package/template/.claude/references/ticket-template.md +0 -86
- package/template/.github/prompts/pincer-code.prompt.md +0 -240
- package/template/.github/prompts/pincer-evaluate.prompt.md +0 -178
- package/template/.github/prompts/pincer-narrow.prompt.md +0 -148
- package/template/.github/prompts/pincer-plan.prompt.md +0 -134
- package/template/.github/prompts/pincer-release.prompt.md +0 -82
- package/template/docs/dry-run-checklist.md +0 -264
- package/template/docs/runtime-contracts.md +0 -1769
- package/template/scripts/pincer-runtime/adopt.cjs +0 -132
- package/template/scripts/pincer-runtime/agreement.cjs +0 -240
- package/template/scripts/pincer-runtime/authorization.cjs +0 -167
- package/template/scripts/pincer-runtime/changes.cjs +0 -517
- package/template/scripts/pincer-runtime/checks.cjs +0 -48
- package/template/scripts/pincer-runtime/coverage.cjs +0 -361
- package/template/scripts/pincer-runtime/dispositions.cjs +0 -95
- package/template/scripts/pincer-runtime/evidence.cjs +0 -676
- package/template/scripts/pincer-runtime/fsutil.cjs +0 -37
- package/template/scripts/pincer-runtime/gates.cjs +0 -73
- package/template/scripts/pincer-runtime/identity.cjs +0 -163
- package/template/scripts/pincer-runtime/impact.cjs +0 -177
- package/template/scripts/pincer-runtime/io.cjs +0 -41
- package/template/scripts/pincer-runtime/lifecycle.cjs +0 -311
- package/template/scripts/pincer-runtime/locator.cjs +0 -158
- package/template/scripts/pincer-runtime/migrate.cjs +0 -204
- package/template/scripts/pincer-runtime/phases.cjs +0 -245
- package/template/scripts/pincer-runtime/readiness.cjs +0 -102
- package/template/scripts/pincer-runtime/requirements.cjs +0 -255
- package/template/scripts/pincer-runtime/resume.cjs +0 -273
- package/template/scripts/pincer-runtime/routing.cjs +0 -54
- package/template/scripts/pincer-runtime/runner.cjs +0 -242
- package/template/scripts/pincer-runtime/sanitize.cjs +0 -63
- package/template/scripts/pincer-runtime/scaffold.cjs +0 -254
- package/template/scripts/pincer-runtime/source.cjs +0 -129
- package/template/scripts/pincer-runtime/state.cjs +0 -314
- package/template/scripts/pincer-runtime/status.cjs +0 -514
- package/template/scripts/pincer-runtime/transaction.cjs +0 -200
- package/template/scripts/pincer-runtime/transitions.cjs +0 -134
- package/template/scripts/pincer-runtime.cjs +0 -686
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER engine — where the work stands (docs/engine.md, "Stage gate"), read
|
|
3
|
+
// from harness.json and the artifacts on disk. Read-only: nothing here writes
|
|
4
|
+
// a file or runs a check. Every stage skill runs the gate first.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const parse = require('./parse.cjs');
|
|
8
|
+
const harnessModule = require('./harness.cjs');
|
|
9
|
+
const models = require('./models.cjs');
|
|
10
|
+
const generate = require('./generate.cjs');
|
|
11
|
+
|
|
12
|
+
const SCRIPT = 'scripts/pincer-ticket.sh';
|
|
13
|
+
const { STAGES, ALWAYS_ON, HARNESS_FILE } = harnessModule;
|
|
14
|
+
|
|
15
|
+
// Only a Codex-only install unambiguously chooses dollar-style skill mentions.
|
|
16
|
+
// Mixed installs keep the shared slash spelling; shell paths are never rewritten.
|
|
17
|
+
function commandMentions(root, text) {
|
|
18
|
+
const platforms = generate.manifestPlatforms(root);
|
|
19
|
+
return platforms.length === 1 && platforms[0] === 'codex' ? generate.codexMentions(text) : text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Is a ticket's receipt current? Lifted from 0.7: the wording is what a done
|
|
23
|
+
// ticket that needs attention has always shown.
|
|
24
|
+
function readiness(text, fields) {
|
|
25
|
+
const receipt = fields.verified || '';
|
|
26
|
+
const attempt = fields.last_check || '';
|
|
27
|
+
const hash = parse.blockHash(text);
|
|
28
|
+
const fail = (message) => ({ ready: false, message });
|
|
29
|
+
if (!attempt) return fail('missing latest verification outcome — re-run verify');
|
|
30
|
+
const parts = attempt.split(/[ \t]+/);
|
|
31
|
+
if (!(parts.length === 3 && parse.TIMESTAMP.test(parts[0]) && parts[1] === 'passed' && /^[a-f0-9]{12}$/.test(parts[2])) || parts[2] !== hash) {
|
|
32
|
+
return fail(`latest verification: ${attempt} — re-run verify`);
|
|
33
|
+
}
|
|
34
|
+
if (!receipt) return fail('done without a verification receipt — re-run verify');
|
|
35
|
+
const rparts = receipt.split(/[ \t]+/);
|
|
36
|
+
if (!(rparts.length === 2 && parse.TIMESTAMP.test(rparts[0]) && /^[a-f0-9]{12}$/.test(rparts[1])) || rparts[1] !== hash) {
|
|
37
|
+
return fail('stale or malformed verification receipt — re-run verify');
|
|
38
|
+
}
|
|
39
|
+
if (parse.unticked(text).length) return fail('unticked acceptance criteria — complete and re-run verify');
|
|
40
|
+
return { ready: true, message: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const idOf = (file) => (path.basename(file).match(/^T-[0-9]+/) || [''])[0];
|
|
44
|
+
const readText = (root, file) => { try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch (error) { return null; } };
|
|
45
|
+
|
|
46
|
+
function specEntries(root, specsDir) {
|
|
47
|
+
const dir = path.join(root, specsDir);
|
|
48
|
+
if (!fs.existsSync(dir)) return [];
|
|
49
|
+
return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).sort().map((name) => {
|
|
50
|
+
const file = path.posix.join(specsDir, name);
|
|
51
|
+
const text = readText(root, file) || '';
|
|
52
|
+
return { file, status: parse.frontmatterField(text, 'status') || null };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function ticketEntry(root, file) {
|
|
56
|
+
const text = readText(root, file);
|
|
57
|
+
const entry = { id: idOf(file), file, status: null, started: null, verified: null, lastCheck: null, accepted: null, acceptedBy: null, ready: false, problem: null, depends: [] };
|
|
58
|
+
if (text === null) { entry.problem = 'cannot read the file'; return entry; }
|
|
59
|
+
const v = parse.validateTicket(file, text);
|
|
60
|
+
const fields = v.fields;
|
|
61
|
+
entry.status = fields.status || null;
|
|
62
|
+
entry.started = fields.started || null;
|
|
63
|
+
entry.verified = fields.verified || null;
|
|
64
|
+
entry.lastCheck = fields.last_check || null;
|
|
65
|
+
entry.accepted = fields.accepted || null;
|
|
66
|
+
entry.acceptedBy = fields.accepted_by || null;
|
|
67
|
+
entry.depends = parse.dependencies(fields);
|
|
68
|
+
if (!v.ok) { entry.problem = v.problems.join('; '); return entry; }
|
|
69
|
+
entry.id = fields.ticket;
|
|
70
|
+
const r = readiness(text, fields);
|
|
71
|
+
entry.ready = r.ready;
|
|
72
|
+
entry.readiness = r.message;
|
|
73
|
+
return entry;
|
|
74
|
+
}
|
|
75
|
+
function manifests(root, evidenceDir) {
|
|
76
|
+
const dir = path.join(root, evidenceDir);
|
|
77
|
+
if (!fs.existsSync(dir)) return [];
|
|
78
|
+
return fs.readdirSync(dir).sort()
|
|
79
|
+
.map((name) => path.posix.join(evidenceDir, name, 'manifest.json'))
|
|
80
|
+
.filter((file) => fs.existsSync(path.join(root, file)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The whole picture, as data. `harness` is null without a valid agreement;
|
|
84
|
+
// `harnessProblem` says why when the file exists but does not validate.
|
|
85
|
+
function gather(root) {
|
|
86
|
+
const report = { name: path.basename(root), harness: null, harnessProblem: null, stages: {}, models: null, specs: [], tickets: [], evidence: [], warnings: [], next: '' };
|
|
87
|
+
const loaded = harnessModule.load(root);
|
|
88
|
+
let homes = harnessModule.homes(null);
|
|
89
|
+
if (loaded.ok) {
|
|
90
|
+
report.harness = loaded.harness;
|
|
91
|
+
if (loaded.harness.project.name) report.name = loaded.harness.project.name;
|
|
92
|
+
for (const name of STAGES) report.stages[name] = harnessModule.stageOn(loaded.harness, name) ? 'on' : 'off';
|
|
93
|
+
report.models = models.effective(loaded.harness, generate.libraryDir(root));
|
|
94
|
+
homes = harnessModule.homes(loaded.harness);
|
|
95
|
+
} else if (!loaded.missing) {
|
|
96
|
+
report.harnessProblem = loaded.message;
|
|
97
|
+
report.warnings.push(`${HARNESS_FILE}: ${loaded.message}`);
|
|
98
|
+
}
|
|
99
|
+
report.specs = specEntries(root, homes.specs);
|
|
100
|
+
report.tickets = parse.ticketFiles(root, homes.tickets).map((file) => ticketEntry(root, file));
|
|
101
|
+
report.evidence = manifests(root, homes.evidence);
|
|
102
|
+
for (const t of report.tickets) {
|
|
103
|
+
if (t.problem) { report.warnings.push(`${t.file}: ${t.problem}`); continue; }
|
|
104
|
+
if (t.status === 'done' && !t.ready) report.warnings.push(`${t.id} done: ${t.readiness}`);
|
|
105
|
+
if (t.status === 'done' && !t.accepted) report.warnings.push(`${t.id} done without acceptance`);
|
|
106
|
+
}
|
|
107
|
+
report.next = commandMentions(root, nextCommand(report));
|
|
108
|
+
return report;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The one thing to do next, first rule that matches.
|
|
112
|
+
function nextCommand(report) {
|
|
113
|
+
if (!report.harness) return '/pincer-blueprint';
|
|
114
|
+
const tickets = report.tickets;
|
|
115
|
+
const broken = tickets.find((t) => t.problem);
|
|
116
|
+
if (broken) return `fix ${broken.file}`;
|
|
117
|
+
const draft = report.stages.plan === 'on' && report.specs.find((s) => !['approved', 'built'].includes(s.status));
|
|
118
|
+
if (report.stages.plan === 'on' && report.specs.length === 0 && tickets.length === 0) return '/pincer-plan <brief>';
|
|
119
|
+
if (tickets.length === 0) {
|
|
120
|
+
if (draft) return `/pincer-plan ${draft.file} (awaiting approval)`;
|
|
121
|
+
return report.stages.narrow === 'on' ? '/pincer-narrow' : '/pincer-code';
|
|
122
|
+
}
|
|
123
|
+
const inProgress = tickets.find((t) => t.status === 'in_progress');
|
|
124
|
+
if (inProgress) return `${SCRIPT} verify ${inProgress.id}`;
|
|
125
|
+
if (tickets.some((t) => t.status === 'open')) return '/pincer-code';
|
|
126
|
+
const stale = tickets.find((t) => t.status === 'done' && !t.ready);
|
|
127
|
+
if (stale) return `${SCRIPT} verify ${stale.id}`;
|
|
128
|
+
if (draft) return `/pincer-plan ${draft.file} (awaiting approval)`;
|
|
129
|
+
if (report.stages.evaluate === 'on' && report.harness.stages.evaluate.evidence && report.evidence.length === 0) return '/pincer-evaluate';
|
|
130
|
+
const unaccepted = tickets.find((t) => t.status === 'done' && !t.accepted);
|
|
131
|
+
if (unaccepted) {
|
|
132
|
+
// Acceptance is the floor even with release off: then the next step is the accept call itself.
|
|
133
|
+
if (harnessModule.stageOn(report.harness, 'release')) return '/pincer-release';
|
|
134
|
+
return `${SCRIPT} accept ${unaccepted.id} --by "<name>" --words "<their words>"`;
|
|
135
|
+
}
|
|
136
|
+
return 'all tickets done and accepted';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ticketLine(t) {
|
|
140
|
+
if (t.problem) return `${t.id || t.file} invalid: ${t.problem}`;
|
|
141
|
+
let line = `${t.id} ${t.status}`;
|
|
142
|
+
if (t.status === 'in_progress' && t.started) line += ` started ${t.started}`;
|
|
143
|
+
if (t.verified) line += ` verified ${t.verified.split(/\s+/)[0]}`;
|
|
144
|
+
if (t.acceptedBy) line += ` accepted by ${t.acceptedBy}`;
|
|
145
|
+
return line;
|
|
146
|
+
}
|
|
147
|
+
function render(report) {
|
|
148
|
+
const lines = [];
|
|
149
|
+
const h = report.harness;
|
|
150
|
+
lines.push(h ? `PINCER — ${report.name} (${h.project.kind})` : 'PINCER — no agreement yet');
|
|
151
|
+
if (h) lines.push(`Stages: ${STAGES.map((name) => `${name} ${report.stages[name]}`).join(', ')}`);
|
|
152
|
+
const tiers = report.models ? models.statusLine(report.models) : '';
|
|
153
|
+
if (tiers) lines.push(`Models: ${tiers}`);
|
|
154
|
+
if (report.specs.length) lines.push(`Specs: ${report.specs.map((s) => `${s.file} (${s.status || 'no status'})`).join(', ')}`);
|
|
155
|
+
for (const t of report.tickets) lines.push(ticketLine(t));
|
|
156
|
+
if (report.warnings.length) { lines.push('Warnings:'); for (const w of report.warnings) lines.push(` ${w}`); }
|
|
157
|
+
lines.push(`Next: ${report.next}`);
|
|
158
|
+
return `${lines.join('\n')}\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The gate every stage runs first. Exit 0 and "on", or exit 3 and the reason;
|
|
162
|
+
// exit 2 for a name that is not a stage. The always-on stages pass without an
|
|
163
|
+
// agreement, because the blueprint is what writes it.
|
|
164
|
+
function stageGate(root, name) {
|
|
165
|
+
if (!STAGES.includes(name)) return { exit: 2, message: `${name} is not a stage (${STAGES.join(', ')})` };
|
|
166
|
+
if (ALWAYS_ON.includes(name)) return { exit: 0, message: 'on' };
|
|
167
|
+
const loaded = harnessModule.load(root);
|
|
168
|
+
if (!loaded.ok) return { exit: 3, message: loaded.missing ? commandMentions(root, loaded.message) : `${HARNESS_FILE}: ${loaded.message}` };
|
|
169
|
+
if (!harnessModule.stageOn(loaded.harness, name)) return { exit: 3, message: `stage ${name} is off in ${HARNESS_FILE}` };
|
|
170
|
+
return { exit: 0, message: 'on' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { readiness, gather, nextCommand, render, stageGate, commandMentions };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER engine — the ticket lifecycle (docs/engine.md, "Ticket fields and
|
|
3
|
+
// receipts"): start, verify, done, accept. The only writer of a ticket's
|
|
4
|
+
// lifecycle fields. `verify` runs the Verification block and stamps a receipt
|
|
5
|
+
// only on exit 0; `done` re-runs the check and refuses on unticked criteria or
|
|
6
|
+
// a stale receipt; `accept` records a named person's words on a done ticket.
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { spawn } = require('node:child_process');
|
|
10
|
+
const parse = require('./parse.cjs');
|
|
11
|
+
const harnessModule = require('./harness.cjs');
|
|
12
|
+
const { commandMentions } = require('./status.cjs');
|
|
13
|
+
const { nowIso, atomicWrite } = require('./util.cjs');
|
|
14
|
+
|
|
15
|
+
const EXIT = { OK: 0, REFUSED: 1, USAGE: 2, HARNESS: 3, INTERRUPTED: 130 };
|
|
16
|
+
const GRACE_MS = 5000;
|
|
17
|
+
const SCRIPT = 'scripts/pincer-ticket.sh';
|
|
18
|
+
|
|
19
|
+
class Refusal extends Error {
|
|
20
|
+
constructor(message, { exit = EXIT.REFUSED, prefix = 'pincer-ticket' } = {}) { super(message); this.exit = exit; this.prefix = prefix; }
|
|
21
|
+
}
|
|
22
|
+
const refuse = (message, options) => { throw new Refusal(message, options); };
|
|
23
|
+
|
|
24
|
+
// --- The agreement ---------------------------------------------------------
|
|
25
|
+
// Every verb needs an agreement with the code stage on; tickets live in its home.
|
|
26
|
+
function agreement(root) {
|
|
27
|
+
const loaded = harnessModule.load(root);
|
|
28
|
+
if (!loaded.ok) refuse(`${harnessModule.HARNESS_FILE}: ${loaded.missing ? commandMentions(root, loaded.message) : loaded.message}`, { exit: EXIT.HARNESS, prefix: '' });
|
|
29
|
+
if (!harnessModule.stageOn(loaded.harness, 'code')) refuse(`stage code is off in ${harnessModule.HARNESS_FILE}`, { exit: EXIT.HARNESS, prefix: '' });
|
|
30
|
+
return { harness: loaded.harness, tickets: harnessModule.homes(loaded.harness).tickets };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Tickets ---------------------------------------------------------------
|
|
34
|
+
const readTicket = (root, file) => fs.readFileSync(path.join(root, file), 'utf8');
|
|
35
|
+
const writeTicket = (root, file, text) => atomicWrite(path.join(root, file), text);
|
|
36
|
+
|
|
37
|
+
function loadTicket(root, file, id) {
|
|
38
|
+
const text = readTicket(root, file);
|
|
39
|
+
const v = parse.validateTicket(file, text);
|
|
40
|
+
if (!v.ok) refuse(v.problems.map((p) => `${file}: ${p}`).join('\n'));
|
|
41
|
+
return { id: id || v.fields.ticket, file, text, fields: v.fields, timeout: v.timeout };
|
|
42
|
+
}
|
|
43
|
+
function resolve(root, ticketsDir, input) {
|
|
44
|
+
const tf = parse.ticketFile(root, ticketsDir, input);
|
|
45
|
+
if (tf.problem) refuse(tf.problem);
|
|
46
|
+
return loadTicket(root, tf.file, tf.id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Why a ticket's receipt is not current, or null when it is: a receipt exists,
|
|
50
|
+
// its hash matches the Verification block as it stands, and the latest check
|
|
51
|
+
// of that block passed.
|
|
52
|
+
function receiptProblem(text, fields) {
|
|
53
|
+
const receipt = fields.verified || '';
|
|
54
|
+
if (!receipt) return 'no verification receipt';
|
|
55
|
+
const hash = parse.blockHash(text);
|
|
56
|
+
const receiptHash = receipt.split(/\s+/).pop();
|
|
57
|
+
if (receiptHash !== hash) return `receipt hash ${receiptHash} does not match the current Verification block (${hash})`;
|
|
58
|
+
const last = (fields.last_check || '').split(/\s+/);
|
|
59
|
+
if (last.length === 3 && last[2] === hash && last[1] !== 'passed') return `last check ${last[1]}`;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- start -----------------------------------------------------------------
|
|
64
|
+
function startLoaded(root, ticketsDir, t) {
|
|
65
|
+
if (t.fields.status === 'in_progress') return { exit: EXIT.OK, out: `${t.id} already in progress (started ${t.fields.started || ''})\n` };
|
|
66
|
+
if (t.fields.status === 'done') refuse(`${t.id} is already done`);
|
|
67
|
+
for (const dep of parse.dependencies(t.fields)) {
|
|
68
|
+
const df = parse.ticketFile(root, ticketsDir, dep);
|
|
69
|
+
if (df.problem) refuse(`${t.id} depends on ${dep}: ${df.problem}`);
|
|
70
|
+
const d = loadTicket(root, df.file, dep);
|
|
71
|
+
if (d.fields.status !== 'done') refuse(`${t.id} depends on ${dep}, which is '${d.fields.status}' — finish ${dep} first (or fix depends_on in ${t.file})`);
|
|
72
|
+
const problem = receiptProblem(d.text, d.fields);
|
|
73
|
+
if (problem) refuse(`${t.id} depends on ${dep}: ${problem} — run '${SCRIPT} verify ${dep}' again`);
|
|
74
|
+
}
|
|
75
|
+
let text = parse.fmSet(t.text, 'status', 'in_progress');
|
|
76
|
+
if (!t.fields.started) text = parse.fmSet(text, 'started', nowIso());
|
|
77
|
+
writeTicket(root, t.file, text);
|
|
78
|
+
return { exit: EXIT.OK, out: `▶ ${t.id} started ${parse.frontmatterField(text, 'started')} — ${t.file}\n` };
|
|
79
|
+
}
|
|
80
|
+
function start(root, input) {
|
|
81
|
+
const { tickets } = agreement(root);
|
|
82
|
+
return startLoaded(root, tickets, resolve(root, tickets, input));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- verify ----------------------------------------------------------------
|
|
86
|
+
// The block runs under bash with inherited stdio in its own process group;
|
|
87
|
+
// SIGINT/SIGTERM to the engine terminate it and are reported as interrupted, and
|
|
88
|
+
// so is the ticket's timeout (seconds) when the block outlives it.
|
|
89
|
+
function execute(root, block, timeoutSeconds) {
|
|
90
|
+
return new Promise((done) => {
|
|
91
|
+
const child = spawn('bash', ['-eo', 'pipefail', '-c', block], { cwd: root, detached: true, stdio: 'inherit', env: process.env });
|
|
92
|
+
let interrupted = null, timedOut = false;
|
|
93
|
+
const terminate = () => {
|
|
94
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } }
|
|
95
|
+
setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, GRACE_MS).unref();
|
|
96
|
+
};
|
|
97
|
+
const onSignal = (signal) => { interrupted = signal; terminate(); };
|
|
98
|
+
const timer = timeoutSeconds > 0 ? setTimeout(() => { timedOut = true; terminate(); }, timeoutSeconds * 1000) : null;
|
|
99
|
+
const release = () => { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); if (timer) clearTimeout(timer); };
|
|
100
|
+
process.on('SIGINT', onSignal);
|
|
101
|
+
process.on('SIGTERM', onSignal);
|
|
102
|
+
child.on('error', (error) => { release(); done({ code: 127, signal: null, interrupted, timedOut, error: error.message }); });
|
|
103
|
+
child.on('exit', (code, signal) => { release(); done({ code, signal, interrupted, timedOut }); });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runCheck(root, t, io) {
|
|
108
|
+
const { id, file } = t;
|
|
109
|
+
if (t.fields.status === 'done') io.out(`${id} is done — re-running its check and updating the latest outcome\n`);
|
|
110
|
+
const commands = parse.verificationCommands(t.text);
|
|
111
|
+
const block = parse.blockText(t.text);
|
|
112
|
+
const hash = parse.blockHash(t.text);
|
|
113
|
+
let text = parse.fmUnset(t.text, 'verified');
|
|
114
|
+
text = parse.fmSet(text, 'last_check', `${nowIso()} running ${hash}`);
|
|
115
|
+
writeTicket(root, file, text);
|
|
116
|
+
if (!commands.some((c) => !/^[ \t]*(#.*)?$/.test(c))) refuse(`no runnable command in the Verification block of ${file}`);
|
|
117
|
+
io.out(`── ${id} verification ──\n`);
|
|
118
|
+
for (const c of commands) io.out(` $ ${c}\n`);
|
|
119
|
+
const timeout = parse.effectiveTimeout(t.fields);
|
|
120
|
+
const result = await execute(root, block, timeout);
|
|
121
|
+
const stamp = (outcome) => writeTicket(root, file, parse.fmSet(readTicket(root, file), 'last_check', `${nowIso()} ${outcome} ${hash}`));
|
|
122
|
+
if (result.timedOut) {
|
|
123
|
+
stamp('interrupted');
|
|
124
|
+
io.err(`✗ ${id} verification TIMED OUT after ${timeout}s — recorded as interrupted in last_check of ${file}. Raise timeout: in the frontmatter or fix the check, then re-run verify.\n`);
|
|
125
|
+
return { exit: EXIT.INTERRUPTED };
|
|
126
|
+
}
|
|
127
|
+
if (result.interrupted) { stamp('interrupted'); return { exit: EXIT.INTERRUPTED }; }
|
|
128
|
+
const rc = result.code === null ? 1 : result.code;
|
|
129
|
+
if (rc !== 0) {
|
|
130
|
+
stamp('failed');
|
|
131
|
+
io.err(`✗ ${id} verification FAILED (exit ${rc}) — failure recorded in last_check of ${file}; any prior successful receipt was revoked. Fix, then re-run verify.\n`);
|
|
132
|
+
return { exit: EXIT.REFUSED };
|
|
133
|
+
}
|
|
134
|
+
const after = readTicket(root, file);
|
|
135
|
+
if (parse.blockHash(after) !== hash) { stamp('failed'); refuse('Verification block changed during execution — re-run verify'); }
|
|
136
|
+
if (!parse.validateTicket(file, after).ok) { stamp('failed'); refuse('ticket became invalid during verification — fix it and re-run verify'); }
|
|
137
|
+
let passed = parse.fmSet(after, 'last_check', `${nowIso()} passed ${hash}`);
|
|
138
|
+
passed = parse.fmSet(passed, 'verified', `${nowIso()} ${hash}`);
|
|
139
|
+
writeTicket(root, file, passed);
|
|
140
|
+
io.out(`✓ ${id} verified — receipt: ${parse.frontmatterField(passed, 'verified')}\n`);
|
|
141
|
+
return { exit: EXIT.OK };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const defaultIo = () => ({ out: (text) => process.stdout.write(text), err: (text) => process.stderr.write(text) });
|
|
145
|
+
|
|
146
|
+
async function verify(root, input, io = defaultIo()) {
|
|
147
|
+
const { tickets } = agreement(root);
|
|
148
|
+
const t0 = resolve(root, tickets, input);
|
|
149
|
+
if (t0.fields.status === 'open') io.out(startLoaded(root, tickets, t0).out);
|
|
150
|
+
return runCheck(root, loadTicket(root, t0.file, t0.id), io);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- done ------------------------------------------------------------------
|
|
154
|
+
const slugWords = (file) => path.basename(file, '.md').replace(/^T-[0-9]+-/, '').replace(/-/g, ' ');
|
|
155
|
+
|
|
156
|
+
async function done(root, input, io = defaultIo()) {
|
|
157
|
+
const { tickets } = agreement(root);
|
|
158
|
+
const t = resolve(root, tickets, input);
|
|
159
|
+
const { id, file } = t;
|
|
160
|
+
const status = t.fields.status;
|
|
161
|
+
if (status !== 'in_progress' && status !== 'done') refuse(`${id} is '${status}' — run '${SCRIPT} verify ${id}' first`);
|
|
162
|
+
const receipt = t.fields.verified || '';
|
|
163
|
+
if (!receipt) refuse(`no verification receipt on ${id} — run '${SCRIPT} verify ${id}' and get a green check first`);
|
|
164
|
+
const current = parse.blockHash(t.text);
|
|
165
|
+
const receiptHash = receipt.split(/\s+/).pop();
|
|
166
|
+
if (receiptHash !== current) refuse(`receipt hash ${receiptHash} does not match the current Verification block (${current}): the check changed after it passed — run '${SCRIPT} verify ${id}' again`);
|
|
167
|
+
const unticked = parse.unticked(t.text);
|
|
168
|
+
if (unticked.length) refuse(`unticked acceptance criteria on ${id}:\n${unticked.join('\n')}\nTick each verified criterion; a criterion that was cut is a scope change to record in the spec, not a box to skip.`);
|
|
169
|
+
const result = await runCheck(root, t, io);
|
|
170
|
+
if (result.exit !== EXIT.OK) return result;
|
|
171
|
+
const after = loadTicket(root, file, id);
|
|
172
|
+
const still = parse.unticked(after.text);
|
|
173
|
+
if (still.length) refuse(`unticked acceptance criteria on ${id} after verification:\n${still.join('\n')}`);
|
|
174
|
+
if (status === 'done') { io.out(`${id} already done — current check passed\n`); return { exit: EXIT.OK }; }
|
|
175
|
+
let text = parse.fmSet(after.text, 'status', 'done');
|
|
176
|
+
text = parse.fmSet(text, 'finished', nowIso());
|
|
177
|
+
writeTicket(root, file, text);
|
|
178
|
+
io.out(`✓ ${id} done. Inspect staged work, stage only this ticket's paths, review git diff --cached, then commit: ${id}: ${slugWords(file)}\n`);
|
|
179
|
+
return { exit: EXIT.OK };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- accept ----------------------------------------------------------------
|
|
183
|
+
// The floor's second half: a named person, from the agreement's acceptors,
|
|
184
|
+
// and their words, on one line. Run only after the person said so.
|
|
185
|
+
function accept(root, input, { by, words } = {}) {
|
|
186
|
+
const { harness, tickets } = agreement(root);
|
|
187
|
+
const t = resolve(root, tickets, input);
|
|
188
|
+
if (t.fields.status !== 'done') refuse(`${t.id} is '${t.fields.status}' — acceptance is recorded on a done ticket; run verify and done first`);
|
|
189
|
+
const stale = receiptProblem(t.text, t.fields);
|
|
190
|
+
if (stale) refuse(`${t.id}: ${stale} — run '${SCRIPT} verify ${t.id}' and get a green check before recording acceptance`);
|
|
191
|
+
const people = harnessModule.acceptors(harness);
|
|
192
|
+
const name = String(by || '').trim();
|
|
193
|
+
if (!people.includes(name)) refuse(`${name ? `'${name}'` : 'nobody'} is not an acceptor in ${harnessModule.HARNESS_FILE} — the people who accept are: ${people.join(', ')}`);
|
|
194
|
+
const said = String(words || '').replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
195
|
+
if (!said) refuse(`${t.id}: --words must quote what ${name} said when accepting`);
|
|
196
|
+
const previous = t.fields.accepted ? `${t.id} already accepted by ${t.fields.accepted_by} at ${t.fields.accepted} — overwriting\n` : '';
|
|
197
|
+
let text = parse.fmSet(t.text, 'accepted', nowIso());
|
|
198
|
+
text = parse.fmSet(text, 'accepted_by', name);
|
|
199
|
+
text = parse.fmSet(text, 'accepted_words', said);
|
|
200
|
+
writeTicket(root, t.file, text);
|
|
201
|
+
return { exit: EXIT.OK, out: `${previous}✓ ${t.id} accepted by ${name}: ${said}\n` };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { EXIT, Refusal, receiptProblem, start, verify, done, accept };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER engine — small helpers shared by the engine modules: time, atomic
|
|
3
|
+
// writes, JSON, git, and the output sanitizer. Dependency-free.
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { execFileSync } = require('node:child_process');
|
|
8
|
+
|
|
9
|
+
// UTC to the second, the timestamp format every ticket field uses.
|
|
10
|
+
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
11
|
+
|
|
12
|
+
// Write via a temporary file in the same directory and rename into place, so a
|
|
13
|
+
// reader never sees a partial file and a crash leaves the old file or the new one.
|
|
14
|
+
function atomicWrite(file, text) {
|
|
15
|
+
const dir = path.dirname(file);
|
|
16
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
17
|
+
const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
18
|
+
fs.writeFileSync(temp, text);
|
|
19
|
+
fs.renameSync(temp, file);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The parsed file, or null when it is missing or not JSON. Callers that need
|
|
23
|
+
// to tell the two apart read the file themselves.
|
|
24
|
+
function readJson(file) {
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Run git in `root`; stdout without its trailing newline. Throws on a non-zero
|
|
29
|
+
// exit (the error carries stderr).
|
|
30
|
+
function git(root, args) {
|
|
31
|
+
return execFileSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], {
|
|
32
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024,
|
|
33
|
+
}).trimEnd();
|
|
34
|
+
}
|
|
35
|
+
function tryGit(root, args) {
|
|
36
|
+
try { return git(root, args); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Output sanitizer: documented secret patterns are replaced by [redacted]
|
|
40
|
+
// before anything captured is persisted. A safety net, not a guarantee.
|
|
41
|
+
const PATTERNS = [
|
|
42
|
+
// Bearer tokens first, so an `Authorization: Bearer <token>` header keeps the
|
|
43
|
+
// token, not the word Bearer, as the redacted value.
|
|
44
|
+
{ re: /((?:bearer|basic|token|digest)\s+)[A-Za-z0-9\-._~+/=]+/gi, replace: '$1[redacted]' },
|
|
45
|
+
// key/secret/password/token/authorization assignments and JSON fields; a
|
|
46
|
+
// quoted value is redacted up to its closing quote, an unquoted one to the
|
|
47
|
+
// next whitespace or separator. Bounded quantifiers: an unbounded prefix would
|
|
48
|
+
// backtrack quadratically on long lines.
|
|
49
|
+
{ 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) => `${head}${dq !== undefined ? '"[redacted]"' : sq !== undefined ? "'[redacted]'" : '[redacted]'}` },
|
|
50
|
+
{ re: /AKIA[0-9A-Z]{16}/g, replace: '[redacted]' },
|
|
51
|
+
{ re: /gh[pousr]_[A-Za-z0-9]{20,}/g, replace: '[redacted]' },
|
|
52
|
+
];
|
|
53
|
+
const PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
54
|
+
const PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
|
|
55
|
+
|
|
56
|
+
// Stateful: PEM private key blocks span lines. `state` is a plain object the
|
|
57
|
+
// caller keeps per stream.
|
|
58
|
+
function sanitizeLine(line, state = {}) {
|
|
59
|
+
if (state.inPem) {
|
|
60
|
+
if (PEM_END.test(line)) state.inPem = false;
|
|
61
|
+
return '[redacted]';
|
|
62
|
+
}
|
|
63
|
+
if (PEM_BEGIN.test(line)) {
|
|
64
|
+
state.inPem = !PEM_END.test(line);
|
|
65
|
+
return '[redacted]';
|
|
66
|
+
}
|
|
67
|
+
let text = line;
|
|
68
|
+
for (const { re, replace } of PATTERNS) {
|
|
69
|
+
text = text.replace(re, (...args) => (typeof replace === 'function' ? replace(...args) : replace.replace(/\$(\d)/g, (_, n) => args[Number(n)])));
|
|
70
|
+
}
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
function sanitizeText(text) {
|
|
74
|
+
const state = {};
|
|
75
|
+
return String(text).split('\n').map((line) => sanitizeLine(line, state)).join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Complete output on every exit path. process.stdout is asynchronous when it
|
|
79
|
+
// is a pipe, so a report larger than the pipe buffer is cut short by an early
|
|
80
|
+
// exit; writing the descriptor synchronously puts the bytes in the pipe before
|
|
81
|
+
// the call returns. A reader that went away (EPIPE) or a closed descriptor
|
|
82
|
+
// (EBADF) stays silent and keeps the command's own exit code.
|
|
83
|
+
const IDLE = new Int32Array(new SharedArrayBuffer(4));
|
|
84
|
+
function writeFd(fd, text) {
|
|
85
|
+
if (!text) return;
|
|
86
|
+
const buffer = Buffer.from(String(text), 'utf8');
|
|
87
|
+
let offset = 0;
|
|
88
|
+
while (offset < buffer.length) {
|
|
89
|
+
try {
|
|
90
|
+
offset += fs.writeSync(fd, buffer, offset, buffer.length - offset);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code === 'EAGAIN' || error.code === 'EINTR') { Atomics.wait(IDLE, 0, 0, 1); continue; }
|
|
93
|
+
if (error.code === 'EPIPE' || error.code === 'EBADF') return;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const out = (text) => writeFd(1, text);
|
|
99
|
+
const err = (text) => writeFd(2, text);
|
|
100
|
+
|
|
101
|
+
// A URL without the credentials that may sit before its host (https://user:token@host/...).
|
|
102
|
+
const stripUserinfo = (url) => (typeof url === 'string' ? url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1') : url);
|
|
103
|
+
|
|
104
|
+
module.exports = { nowIso, atomicWrite, readJson, git, tryGit, sanitizeLine, sanitizeText, stripUserinfo, writeFd, out, err };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point for the blueprint stage's scripts: recon prints the classification
|
|
3
|
+
// with its reasons and the answers it could pre-fill (offline unless --network);
|
|
4
|
+
// write turns an answers file into harness.json, the agreement in AGENTS.md and
|
|
5
|
+
// the project copies.
|
|
6
|
+
'use strict';
|
|
7
|
+
require('./pincer/cli.cjs').blueprint(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
@@ -1,76 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
// PINCER evidence —
|
|
4
|
-
//
|
|
5
|
-
// in scripts/pincer-runtime/evidence.cjs (shared with status and release).
|
|
3
|
+
// PINCER evidence — validate an evidence manifest, or digest files for one
|
|
4
|
+
// (docs/engine.md, "Evidence manifest schema 1"). Read-only.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
// `validate` exits 0 and prints `ok <candidate>` when the manifest is
|
|
12
|
-
// consistent; with --files it also lists the manifest and every artifact path
|
|
13
|
-
// (repository-relative, one per line). Otherwise it prints one
|
|
14
|
-
// `evidence: <manifest>: <reason>` line per problem to stderr and exits 1.
|
|
15
|
-
// Usage errors exit 2. Nothing is ever written.
|
|
16
|
-
//
|
|
17
|
-
// What validation establishes: that the locally authored record is internally
|
|
18
|
-
// consistent — schema, references, candidate association, required results,
|
|
19
|
-
// artifact existence and digests, repository containment. It is NOT independent
|
|
20
|
-
// attestation that the recorded commands ran or that images depict the stated
|
|
21
|
-
// application; that judgment stays with the reviewer.
|
|
22
|
-
const fs = require('node:fs');
|
|
23
|
-
const path = require('node:path');
|
|
24
|
-
const { HEX40, PRD_REF, validate, digestFile } = require('./pincer-runtime/evidence.cjs');
|
|
25
|
-
const io = require('./pincer-runtime/io.cjs');
|
|
26
|
-
|
|
27
|
-
function usage(message) {
|
|
28
|
-
if (message) io.err(`pincer-evidence: ${message}\n`);
|
|
29
|
-
io.err('usage: pincer-evidence.cjs validate <manifest> [--candidate <sha>] [--base <sha>] [--prd .prd/prd-vN.md] [--files]\n pincer-evidence.cjs digest <file>...\n');
|
|
30
|
-
process.exit(2);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function main(argv) {
|
|
34
|
-
const [command, ...rest] = argv;
|
|
35
|
-
if (command === 'validate') {
|
|
36
|
-
const opts = { files: false };
|
|
37
|
-
let manifest = null;
|
|
38
|
-
for (let i = 0; i < rest.length; i++) {
|
|
39
|
-
const arg = rest[i];
|
|
40
|
-
if (arg === '--files') opts.files = true;
|
|
41
|
-
else if (arg === '--candidate' || arg === '--base' || arg === '--prd') {
|
|
42
|
-
const value = rest[++i];
|
|
43
|
-
if (value === undefined) usage(`${arg} requires a value`);
|
|
44
|
-
opts[arg.slice(2)] = value;
|
|
45
|
-
} else if (arg.startsWith('--')) usage(`unknown option ${arg}`);
|
|
46
|
-
else if (manifest === null) manifest = arg;
|
|
47
|
-
else usage('validate takes exactly one manifest path');
|
|
48
|
-
}
|
|
49
|
-
if (manifest === null) usage('validate requires a manifest path');
|
|
50
|
-
if (opts.candidate !== undefined && !HEX40.test(opts.candidate)) usage('--candidate must be a full 40-hex commit ID');
|
|
51
|
-
if (opts.base !== undefined && !HEX40.test(opts.base)) usage('--base must be a full 40-hex commit ID');
|
|
52
|
-
if (opts.prd !== undefined && !PRD_REF.test(opts.prd)) usage('--prd must be of the form .prd/prd-vN.md');
|
|
53
|
-
const problems = validate(manifest, opts);
|
|
54
|
-
if (problems.length > 0) {
|
|
55
|
-
for (const p of problems) io.err(`evidence: ${manifest}: ${p}\n`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
const doc = JSON.parse(fs.readFileSync(path.resolve(manifest), 'utf8'));
|
|
59
|
-
io.out(`ok ${doc.candidate}${doc.schema >= 2 ? ` schema ${doc.schema}` : ''}\n`);
|
|
60
|
-
for (const l of opts.limitations || []) io.err(`evidence: limitation: ${l}\n`);
|
|
61
|
-
if (opts.files) for (const p of opts.list) io.out(`${p}\n`);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (command === 'digest') {
|
|
65
|
-
if (rest.length === 0) usage('digest requires at least one file');
|
|
66
|
-
for (const file of rest) {
|
|
67
|
-
let digest;
|
|
68
|
-
try { digest = digestFile(file); } catch (error) { io.err(`pincer-evidence: ${file}: ${error.code === 'ENOENT' ? 'missing' : error.message}\n`); process.exit(1); }
|
|
69
|
-
io.out(`${digest} ${file}\n`);
|
|
70
|
-
}
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
usage(command ? `unknown command ${command}` : undefined);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
main(process.argv.slice(2));
|
|
6
|
+
// scripts/pincer-evidence.cjs validate <manifest> [--candidate <sha>] prints "ok <candidate>", or one problem per line and exits 1
|
|
7
|
+
// scripts/pincer-evidence.cjs digest <file>... prints "<sha256> <file>" per line
|
|
8
|
+
require('./pincer/cli.cjs').main(['evidence', ...process.argv.slice(2)]).then((code) => { process.exitCode = code; });
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# PINCER status — where the
|
|
3
|
-
#
|
|
2
|
+
# PINCER status — where the work stands, read from harness.json and the
|
|
3
|
+
# artifacts on disk (docs/engine.md, "Stage gate"). Read-only. Every stage
|
|
4
|
+
# skill runs the gate first; /pincer-status wraps the report.
|
|
4
5
|
#
|
|
5
|
-
# scripts/pincer-status.sh
|
|
6
|
+
# scripts/pincer-status.sh stages, specs, every ticket, warnings, and the next command
|
|
7
|
+
# scripts/pincer-status.sh --json the same report as JSON
|
|
8
|
+
# scripts/pincer-status.sh --stage <name> the gate: prints "on" and exits 0, or the reason and exits 3
|
|
6
9
|
#
|
|
7
|
-
#
|
|
8
|
-
# (`status`), which prints the PRD state and profile, every ticket with its
|
|
9
|
-
# state and clock-based elapsed time, the wall-clock elapsed build line while a
|
|
10
|
-
# ticket is in progress or PINCER_BUILD_BUDGET_MIN is set, the evidence verdict,
|
|
11
|
-
# readiness warnings, and the next command. Exit codes follow
|
|
12
|
-
# docs/runtime-contracts.md (0 inspected, 4 invalid input).
|
|
10
|
+
# Exit codes: 0 ok, 2 usage, 3 no agreement, invalid harness.json or stage off.
|
|
13
11
|
command -v node >/dev/null 2>&1 || {
|
|
14
|
-
echo 'pincer-status: Node.js 22+ is required — the
|
|
15
|
-
exit
|
|
12
|
+
echo 'pincer-status: Node.js 22+ is required — the engine is scripts/pincer/cli.cjs' >&2
|
|
13
|
+
exit 1
|
|
16
14
|
}
|
|
17
|
-
exec node "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer
|
|
15
|
+
exec node "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer/cli.cjs" status "$@"
|
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# PINCER ticket
|
|
3
|
-
#
|
|
4
|
-
# (
|
|
5
|
-
#
|
|
6
|
-
#
|
|
2
|
+
# PINCER ticket lifecycle — the ONLY writer of a ticket's lifecycle fields
|
|
3
|
+
# (docs/engine.md, "Ticket fields and receipts"). On Claude Code a PreToolUse
|
|
4
|
+
# hook (.claude/hooks/ticket-guard.sh) blocks hand edits of those fields, so
|
|
5
|
+
# "done" is reached only through a passing check and acceptance only through
|
|
6
|
+
# a named person's words.
|
|
7
7
|
#
|
|
8
|
-
# scripts/pincer-ticket.sh start T-03 open -> in_progress; refuses while a depends_on ticket
|
|
9
|
-
# scripts/pincer-ticket.sh verify T-03 runs the ticket's Verification block and
|
|
10
|
-
# scripts/pincer-ticket.sh done T-03 closes the ticket on a current passing check with
|
|
11
|
-
# scripts/pincer-ticket.sh
|
|
8
|
+
# scripts/pincer-ticket.sh start T-03 open -> in_progress; refuses while a depends_on ticket is not done and verified
|
|
9
|
+
# scripts/pincer-ticket.sh verify T-03 runs the ticket's Verification block and stamps the receipt on exit 0
|
|
10
|
+
# scripts/pincer-ticket.sh done T-03 closes the ticket on a current passing check with every criterion ticked
|
|
11
|
+
# scripts/pincer-ticket.sh accept T-03 --by "<name>" --words "<what they said>" records the person's acceptance
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
# `last_check` and `verified` into the ticket and `done` re-runs the check.
|
|
15
|
-
# Migrated projects record attempts under .pincer/runtime/ and `done` consumes
|
|
16
|
-
# the current passing attempt. Exit codes follow docs/runtime-contracts.md.
|
|
13
|
+
# Exit codes: 0 ok, 1 refused, 2 usage, 3 no agreement or stage off, 130 interrupted.
|
|
17
14
|
command -v node >/dev/null 2>&1 || {
|
|
18
|
-
echo 'pincer-ticket: Node.js 22+ is required — the
|
|
19
|
-
exit
|
|
15
|
+
echo 'pincer-ticket: Node.js 22+ is required — the engine is scripts/pincer/cli.cjs' >&2
|
|
16
|
+
exit 1
|
|
20
17
|
}
|
|
21
18
|
case "${1:-}" in
|
|
22
|
-
start|verify|done|
|
|
23
|
-
*) sed -n '8,11p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
|
|
19
|
+
start|verify|done|accept) ;;
|
|
20
|
+
*) sed -n '8,11p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2 ;;
|
|
24
21
|
esac
|
|
25
|
-
exec node "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer
|
|
22
|
+
exec node "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer/cli.cjs" ticket "$@"
|