pincer-workflow 0.3.0 → 0.4.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 +6 -2
- package/bin/pincer.js +1 -1
- package/package.json +2 -2
- package/template/.agents/skills/pincer-code/SKILL.md +46 -4
- package/template/.agents/skills/pincer-evaluate/SKILL.md +64 -13
- package/template/.agents/skills/pincer-narrow/SKILL.md +46 -7
- package/template/.agents/skills/pincer-plan/SKILL.md +30 -1
- package/template/.agents/skills/pincer-release/SKILL.md +17 -3
- package/template/.agents/skills/pincer-status/SKILL.md +5 -3
- package/template/.claude/commands/pincer-code.md +46 -4
- package/template/.claude/commands/pincer-evaluate.md +64 -13
- package/template/.claude/commands/pincer-narrow.md +46 -7
- package/template/.claude/commands/pincer-plan.md +30 -1
- package/template/.claude/commands/pincer-release.md +17 -3
- package/template/.claude/commands/pincer-status.md +5 -3
- package/template/.claude/hooks/hook-policy.cjs +101 -8
- package/template/.claude/references/prd-template.md +38 -6
- package/template/.claude/references/ticket-template.md +15 -0
- package/template/.github/prompts/pincer-code.prompt.md +46 -4
- package/template/.github/prompts/pincer-evaluate.prompt.md +64 -13
- package/template/.github/prompts/pincer-narrow.prompt.md +46 -7
- package/template/.github/prompts/pincer-plan.prompt.md +30 -1
- package/template/.github/prompts/pincer-release.prompt.md +17 -3
- package/template/.github/prompts/pincer-status.prompt.md +5 -3
- package/template/AGENTS.md +9 -2
- package/template/docs/dry-run-checklist.md +126 -14
- package/template/docs/release-checklist.md +6 -5
- package/template/scripts/pincer-evidence.cjs +292 -0
- package/template/scripts/pincer-status.sh +21 -5
- package/template/scripts/pincer-ticket-lib.sh +56 -5
- package/template/scripts/pincer-ticket.sh +1 -1
|
@@ -8,12 +8,12 @@ to the owning stage or a new ticket.
|
|
|
8
8
|
|
|
9
9
|
- [ ] `scripts/pincer-status.sh` selects the intended PRD with `status: built` and no warnings
|
|
10
10
|
- [ ] Every ticket associated with that PRD is done with current `last_check` and `verified` evidence
|
|
11
|
-
- [ ] `NOTES.md` names the selected PRD, reviewed base, and
|
|
12
|
-
- [ ]
|
|
11
|
+
- [ ] `NOTES.md` names the selected PRD, reviewed base, candidate, and `evidence:` manifest; status reports the notes current and the evidence `ok`
|
|
12
|
+
- [ ] Every file the evidence manifest lists is tracked; the working tree is clean before and after the audit
|
|
13
13
|
|
|
14
14
|
## Scope and evidence
|
|
15
15
|
|
|
16
|
-
- [ ] Every in
|
|
16
|
+
- [ ] Every requirement in the PRD has a disposition in the evidence manifest: delivered with passing checks, or deferred with recorded user authorization; none is blocked
|
|
17
17
|
- [ ] Ticket dependencies, acceptance criteria, and verification commands match the implemented change
|
|
18
18
|
- [ ] The repository's candidate-wide release gate passes when run directly, without invoking the ticket state writer
|
|
19
19
|
- [ ] Scope cuts and known limitations are explicit in the PRD or evaluation notes
|
|
@@ -22,7 +22,7 @@ to the owning stage or a new ticket.
|
|
|
22
22
|
|
|
23
23
|
- [ ] High-confidence review findings have file and line evidence and a disposition
|
|
24
24
|
- [ ] Fixes made after evaluation use a new ticket, current verification, and a scoped commit
|
|
25
|
-
- [ ] UI changes have visual evidence; non-UI changes
|
|
25
|
+
- [ ] UI changes have visual checks with saved images in the evidence manifest (scenario, viewport, observed result); non-UI changes record `visual_review.applicable: false` with a reason
|
|
26
26
|
- [ ] Security review reports locations and remediation without printing candidate secret values
|
|
27
27
|
- [ ] No secret environment file is tracked; dependency and invalid-input checks run when applicable
|
|
28
28
|
|
|
@@ -31,4 +31,5 @@ to the owning stage or a new ticket.
|
|
|
31
31
|
- [ ] Commit history identifies the plan, ticket work, evaluation, and any review-fix tickets
|
|
32
32
|
- [ ] `NOTES.md` covers what shipped, cuts, limitations, next steps, dependencies, and the riskiest aging assumption
|
|
33
33
|
- [ ] Platform or environment limits are stated without claiming untested support
|
|
34
|
-
- [ ]
|
|
34
|
+
- [ ] The audit repaired no ticket, rewrote no evidence, changed no PRD state, and published nothing
|
|
35
|
+
- [ ] Every failed or skipped required item is named before the final PASS or FAIL verdict, which names the candidate
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// PINCER evidence — read-only validator for candidate evidence manifests
|
|
4
|
+
// (evidence schema 1). Dependency-free; runs under `node` on any platform.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/pincer-evidence.cjs validate .prd/evidence/prd-vN/<candidate>/manifest.json \
|
|
7
|
+
// [--candidate <sha>] [--base <sha>] [--prd .prd/prd-vN.md] [--files]
|
|
8
|
+
// node scripts/pincer-evidence.cjs digest <file>...
|
|
9
|
+
//
|
|
10
|
+
// `validate` exits 0 and prints `ok <candidate>` when the manifest is
|
|
11
|
+
// consistent; with --files it also lists the manifest and every artifact path
|
|
12
|
+
// (repository-relative, one per line). Otherwise it prints one
|
|
13
|
+
// `evidence: <manifest>: <reason>` line per problem to stderr and exits 1.
|
|
14
|
+
// Usage errors exit 2. Nothing is ever written.
|
|
15
|
+
//
|
|
16
|
+
// What validation establishes: that the locally authored record is internally
|
|
17
|
+
// consistent — schema, references, candidate association, required results,
|
|
18
|
+
// artifact existence and digests, repository containment. It is NOT independent
|
|
19
|
+
// attestation that the recorded commands ran or that images depict the stated
|
|
20
|
+
// application; that judgment stays with the reviewer.
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const crypto = require('node:crypto');
|
|
24
|
+
const { execFileSync } = require('node:child_process');
|
|
25
|
+
|
|
26
|
+
const SCHEMA = 1;
|
|
27
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
28
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
29
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
30
|
+
const PRD_REF = /^\.prd\/prd-v([1-9][0-9]{0,8})\.md$/;
|
|
31
|
+
const MANIFEST_AT = /^\.prd\/evidence\/prd-v([1-9][0-9]{0,8})\/([0-9a-f]{40})\/manifest\.json$/;
|
|
32
|
+
// Requirement IDs are the PRD's own: R-01 from the template, or a supplied PRD's
|
|
33
|
+
// REQ-1 / AC-12 style, kept verbatim rather than renamed.
|
|
34
|
+
const REQ_ID = /^[A-Z][A-Z0-9]{0,7}-[0-9]{1,6}$/;
|
|
35
|
+
const TICKET_ID = /^T-[0-9]{2,6}$/;
|
|
36
|
+
const CHECK_ID = /^C-[0-9]{2,6}$/;
|
|
37
|
+
const IMAGE = /\.(png|jpe?g|webp)$/i;
|
|
38
|
+
const MAX_TEXT = 2000; // guard against pasted environment dumps
|
|
39
|
+
const TOP_KEYS = ['schema', 'prd', 'base', 'candidate', 'created', 'environment', 'coverage_review', 'requirements', 'checks', 'visual_review', 'artifacts'];
|
|
40
|
+
const ENV_KEYS = ['os', 'node', 'tools', 'limitations'];
|
|
41
|
+
const REQ_KEYS = ['id', 'disposition', 'tickets', 'checks', 'note', 'authorized_by'];
|
|
42
|
+
const CHECK_KEYS = ['id', 'kind', 'required', 'result', 'command', 'timestamp', 'artifacts', 'scenario', 'viewport', 'observed', 'note'];
|
|
43
|
+
const DISPOSITIONS = ['delivered', 'blocked', 'deferred'];
|
|
44
|
+
const KINDS = ['command', 'visual', 'review'];
|
|
45
|
+
const RESULTS = ['passed', 'failed', 'unverified'];
|
|
46
|
+
|
|
47
|
+
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
48
|
+
const shortText = v => typeof v === 'string' && v.length <= MAX_TEXT;
|
|
49
|
+
const nonempty = v => shortText(v) && v.trim() !== '';
|
|
50
|
+
const toPosix = p => p.split(path.sep).join('/');
|
|
51
|
+
|
|
52
|
+
function repoRoot() {
|
|
53
|
+
if (process.env.CLAUDE_PROJECT_DIR) return path.resolve(process.env.CLAUDE_PROJECT_DIR);
|
|
54
|
+
try {
|
|
55
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
56
|
+
} catch {
|
|
57
|
+
return process.cwd();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Resolve through symlinks above the repository (macOS /var -> /private/var)
|
|
62
|
+
// without requiring the leaf to exist yet.
|
|
63
|
+
function realpathDeep(p) {
|
|
64
|
+
try { return fs.realpathSync(p); } catch {
|
|
65
|
+
const parent = path.dirname(p);
|
|
66
|
+
return parent === p ? p : path.join(realpathDeep(parent), path.basename(p));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function digestFile(file) {
|
|
71
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Why a repository-relative path is unsafe, or null when it is acceptable.
|
|
75
|
+
function unsafePath(p) {
|
|
76
|
+
if (p.startsWith('/')) return 'absolute paths are not allowed';
|
|
77
|
+
if (/^[A-Za-z]:/.test(p)) return 'drive-letter paths are not allowed';
|
|
78
|
+
if (p.includes('\\')) return 'backslashes are not allowed; use repository-relative POSIX paths';
|
|
79
|
+
if (p.split('/').some(s => s === '' || s === '.' || s === '..')) return 'path must be normalized and repository-relative (no "..", "." or empty segments)';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validate(manifestArg, opts) {
|
|
84
|
+
const root = realpathDeep(repoRoot());
|
|
85
|
+
const abs = realpathDeep(path.resolve(manifestArg));
|
|
86
|
+
const rel = toPosix(path.relative(root, abs));
|
|
87
|
+
const at = rel.match(MANIFEST_AT);
|
|
88
|
+
if (!at) return [`manifest must live at .prd/evidence/prd-vN/<candidate>/manifest.json inside the repository (got ${rel})`];
|
|
89
|
+
const [, dirVersion, dirCandidate] = at;
|
|
90
|
+
const dirRel = path.posix.dirname(rel);
|
|
91
|
+
|
|
92
|
+
let raw;
|
|
93
|
+
try { raw = fs.readFileSync(abs, 'utf8'); } catch { return ['missing — run /pincer-evaluate to write evidence for this candidate']; }
|
|
94
|
+
let doc;
|
|
95
|
+
try { doc = JSON.parse(raw); } catch (error) { return [`malformed JSON (${error.message})`]; }
|
|
96
|
+
if (!isObject(doc)) return ['malformed: the manifest must be a JSON object'];
|
|
97
|
+
if (doc.schema !== SCHEMA) return [`unknown evidence schema ${JSON.stringify(doc.schema)} — this runtime validates schema ${SCHEMA}`];
|
|
98
|
+
|
|
99
|
+
const problems = [];
|
|
100
|
+
const problem = message => problems.push(message);
|
|
101
|
+
for (const key of Object.keys(doc)) if (!TOP_KEYS.includes(key)) problem(`unknown top-level key "${key}"`);
|
|
102
|
+
for (const key of TOP_KEYS) if (!(key in doc)) problem(`missing "${key}"`);
|
|
103
|
+
|
|
104
|
+
const prd = typeof doc.prd === 'string' ? doc.prd.match(PRD_REF) : null;
|
|
105
|
+
if (!prd) problem('prd must be a reference of the form .prd/prd-vN.md');
|
|
106
|
+
else if (prd[1] !== dirVersion) problem(`wrong PRD: manifest names ${doc.prd} but lives under prd-v${dirVersion}`);
|
|
107
|
+
if (opts.prd && doc.prd !== opts.prd) problem(`wrong PRD: manifest is for ${doc.prd}, expected ${opts.prd}`);
|
|
108
|
+
|
|
109
|
+
for (const key of ['base', 'candidate']) {
|
|
110
|
+
if (typeof doc[key] !== 'string' || !HEX40.test(doc[key])) problem(`${key} must be a full 40-hex commit ID`);
|
|
111
|
+
}
|
|
112
|
+
if (typeof doc.candidate === 'string' && HEX40.test(doc.candidate) && doc.candidate !== dirCandidate) {
|
|
113
|
+
problem(`wrong candidate: manifest names ${doc.candidate} but lives under ${dirCandidate}`);
|
|
114
|
+
}
|
|
115
|
+
if (opts.candidate && doc.candidate !== opts.candidate) problem(`wrong candidate: manifest is for ${doc.candidate}, expected ${opts.candidate}`);
|
|
116
|
+
if (opts.base && doc.base !== opts.base) problem(`wrong base: manifest records ${doc.base}, expected ${opts.base}`);
|
|
117
|
+
if (typeof doc.created !== 'string' || !ISO_UTC.test(doc.created)) problem('created must be an ISO-8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ)');
|
|
118
|
+
|
|
119
|
+
const env = doc.environment;
|
|
120
|
+
if (!isObject(env)) problem('environment must be an object with os, node, tools and limitations');
|
|
121
|
+
else {
|
|
122
|
+
for (const key of ['os', 'node']) if (!nonempty(env[key])) problem(`environment.${key} must be a short nonempty string (redacted summary, not a dump)`);
|
|
123
|
+
for (const key of ['tools', 'limitations']) {
|
|
124
|
+
if (!Array.isArray(env[key]) || !env[key].every(nonempty)) problem(`environment.${key} must be an array of short strings`);
|
|
125
|
+
}
|
|
126
|
+
for (const key of Object.keys(env)) if (!ENV_KEYS.includes(key)) problem(`environment.${key} is not allowed — persist redacted summaries only`);
|
|
127
|
+
}
|
|
128
|
+
if (!nonempty(doc.coverage_review)) problem('coverage_review must be a nonempty string recording the reviewer judgment on requirement coverage');
|
|
129
|
+
|
|
130
|
+
// Artifacts: repository-contained regular files with matching digests.
|
|
131
|
+
const artifacts = new Map();
|
|
132
|
+
if (!Array.isArray(doc.artifacts)) problem('artifacts must be an array of {path, sha256}');
|
|
133
|
+
else doc.artifacts.forEach((entry, index) => {
|
|
134
|
+
const label = `artifacts[${index}]`;
|
|
135
|
+
if (!isObject(entry)) { problem(`${label} must be an object {path, sha256}`); return; }
|
|
136
|
+
for (const key of Object.keys(entry)) if (!['path', 'sha256'].includes(key)) problem(`${label}.${key} is not allowed`);
|
|
137
|
+
const p = entry.path;
|
|
138
|
+
if (typeof p !== 'string' || p === '') { problem(`${label}.path must be a nonempty string`); return; }
|
|
139
|
+
if (artifacts.has(p)) problem(`duplicate artifact path ${p}`);
|
|
140
|
+
artifacts.set(p, false);
|
|
141
|
+
const why = unsafePath(p);
|
|
142
|
+
if (why) { problem(`artifact ${p}: ${why}`); return; }
|
|
143
|
+
if (!p.startsWith(`${dirRel}/`)) { problem(`artifact ${p}: outside the evidence directory ${dirRel}/`); return; }
|
|
144
|
+
const digestOk = typeof entry.sha256 === 'string' && SHA256.test(entry.sha256);
|
|
145
|
+
if (!digestOk) problem(`artifact ${p}: sha256 must be a full 64-hex digest`);
|
|
146
|
+
const segments = p.split('/');
|
|
147
|
+
let current = root;
|
|
148
|
+
for (let i = 0; i < segments.length; i++) {
|
|
149
|
+
current = path.join(current, segments[i]);
|
|
150
|
+
let stat;
|
|
151
|
+
try { stat = fs.lstatSync(current); } catch { problem(`artifact ${p}: missing`); return; }
|
|
152
|
+
const last = i === segments.length - 1;
|
|
153
|
+
if (stat.isSymbolicLink()) {
|
|
154
|
+
problem(last ? `artifact ${p}: is a symlink (only regular files inside the repository are accepted)` : `artifact ${p}: path component ${segments.slice(0, i + 1).join('/')} is a symlink`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!last && !stat.isDirectory()) { problem(`artifact ${p}: ${segments.slice(0, i + 1).join('/')} is not a directory`); return; }
|
|
158
|
+
if (last && !stat.isFile()) { problem(`artifact ${p}: not a regular file`); return; }
|
|
159
|
+
}
|
|
160
|
+
if (digestOk && digestFile(current) !== entry.sha256) problem(`artifact ${p}: digest mismatch — the file changed after the evidence was recorded`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Checks: what was run or judged, with results and artifact references.
|
|
164
|
+
const checks = new Map();
|
|
165
|
+
let visualChecks = 0;
|
|
166
|
+
if (!Array.isArray(doc.checks)) problem('checks must be an array');
|
|
167
|
+
else doc.checks.forEach((check, index) => {
|
|
168
|
+
const label = `checks[${index}]`;
|
|
169
|
+
if (!isObject(check)) { problem(`${label} must be an object`); return; }
|
|
170
|
+
const id = typeof check.id === 'string' && CHECK_ID.test(check.id) ? check.id : null;
|
|
171
|
+
if (!id) problem(`${label}.id must be a check ID such as C-01`);
|
|
172
|
+
else if (checks.has(id)) problem(`duplicate check ID ${id}`);
|
|
173
|
+
else checks.set(id, check);
|
|
174
|
+
const name = id || label;
|
|
175
|
+
for (const key of Object.keys(check)) if (!CHECK_KEYS.includes(key)) problem(`check ${name}: unknown key "${key}"`);
|
|
176
|
+
if (!KINDS.includes(check.kind)) problem(`check ${name}: kind must be one of ${KINDS.join(', ')}`);
|
|
177
|
+
if (typeof check.required !== 'boolean') problem(`check ${name}: required must be true or false`);
|
|
178
|
+
if (!RESULTS.includes(check.result)) problem(`check ${name}: result must be one of ${RESULTS.join(', ')}`);
|
|
179
|
+
if (typeof check.timestamp !== 'string' || !ISO_UTC.test(check.timestamp)) problem(`check ${name}: timestamp must be an ISO-8601 UTC timestamp`);
|
|
180
|
+
if (check.kind === 'command' && !nonempty(check.command)) problem(`check ${name}: command kind requires the command that was run`);
|
|
181
|
+
for (const key of ['command', 'scenario', 'viewport', 'observed', 'note']) {
|
|
182
|
+
if (key in check && !shortText(check[key])) problem(`check ${name}: ${key} must be a short string`);
|
|
183
|
+
}
|
|
184
|
+
let images = 0;
|
|
185
|
+
if (!Array.isArray(check.artifacts)) problem(`check ${name}: artifacts must be an array of repository-relative paths`);
|
|
186
|
+
else for (const p of check.artifacts) {
|
|
187
|
+
if (typeof p !== 'string') { problem(`check ${name}: artifact reference must be a string`); continue; }
|
|
188
|
+
if (!artifacts.has(p)) problem(`check ${name}: references unlisted artifact ${p} (dangling reference)`);
|
|
189
|
+
else artifacts.set(p, true);
|
|
190
|
+
if (IMAGE.test(p)) images++;
|
|
191
|
+
}
|
|
192
|
+
if (check.kind === 'visual') {
|
|
193
|
+
visualChecks++;
|
|
194
|
+
for (const key of ['scenario', 'viewport', 'observed']) if (!nonempty(check[key])) problem(`check ${name}: visual check requires ${key}`);
|
|
195
|
+
// A passed visual check must show its image; an unverified one (tool
|
|
196
|
+
// unavailable) is recorded honestly without one and, when required, blocks.
|
|
197
|
+
if (check.result === 'passed' && images === 0) problem(`check ${name}: a passed visual check requires a saved image artifact (.png, .jpg or .webp)`);
|
|
198
|
+
}
|
|
199
|
+
if (check.required === true && check.result !== 'passed') {
|
|
200
|
+
problem(`required check ${name} is ${check.result} — readiness is blocked until it passes on a new candidate or the requirement is deferred with authorization`);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
for (const [p, referenced] of artifacts) if (!referenced) problem(`artifact ${p}: not referenced by any check`);
|
|
204
|
+
|
|
205
|
+
// Requirements: every ID dispositioned; deferrals authorized; checks resolve.
|
|
206
|
+
const requirements = new Set();
|
|
207
|
+
if (!Array.isArray(doc.requirements) || doc.requirements.length === 0) problem('requirements must be a nonempty array — every PRD requirement needs a disposition');
|
|
208
|
+
else doc.requirements.forEach((req, index) => {
|
|
209
|
+
const label = `requirements[${index}]`;
|
|
210
|
+
if (!isObject(req)) { problem(`${label} must be an object`); return; }
|
|
211
|
+
const id = typeof req.id === 'string' && REQ_ID.test(req.id) ? req.id : null;
|
|
212
|
+
if (!id) problem(`${label}.id must be a requirement ID such as R-01 or the PRD's own REQ-1 (uppercase prefix, dash, digits)`);
|
|
213
|
+
else if (requirements.has(id)) problem(`duplicate requirement ID ${id}`);
|
|
214
|
+
else requirements.add(id);
|
|
215
|
+
const name = id || label;
|
|
216
|
+
for (const key of Object.keys(req)) if (!REQ_KEYS.includes(key)) problem(`requirement ${name}: unknown key "${key}"`);
|
|
217
|
+
if (!DISPOSITIONS.includes(req.disposition)) problem(`requirement ${name}: disposition must be one of ${DISPOSITIONS.join(', ')}`);
|
|
218
|
+
if (!Array.isArray(req.tickets) || !req.tickets.every(t => typeof t === 'string' && TICKET_ID.test(t))) problem(`requirement ${name}: tickets must be an array of ticket IDs such as T-01`);
|
|
219
|
+
if (!Array.isArray(req.checks)) problem(`requirement ${name}: checks must be an array of check IDs`);
|
|
220
|
+
else for (const c of req.checks) {
|
|
221
|
+
if (typeof c !== 'string' || !checks.has(c)) problem(`requirement ${name}: references unknown check ${JSON.stringify(c)} (dangling reference)`);
|
|
222
|
+
}
|
|
223
|
+
for (const key of ['note', 'authorized_by']) if (key in req && !shortText(req[key])) problem(`requirement ${name}: ${key} must be a short string`);
|
|
224
|
+
if (req.disposition === 'deferred' && !nonempty(req.authorized_by)) problem(`requirement ${name}: deferred requires authorized_by naming the explicit user authorization`);
|
|
225
|
+
if (req.disposition === 'delivered' && Array.isArray(req.checks) && req.checks.length === 0) problem(`requirement ${name}: delivered requires at least one check`);
|
|
226
|
+
if (req.disposition === 'blocked') problem(`requirement ${name} is blocked — readiness is blocked until it is delivered on a new candidate or deferred with authorization`);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const visual = doc.visual_review;
|
|
230
|
+
if (!isObject(visual) || typeof visual.applicable !== 'boolean') problem('visual_review must be {applicable: boolean, reason?: string}');
|
|
231
|
+
else {
|
|
232
|
+
for (const key of Object.keys(visual)) if (!['applicable', 'reason'].includes(key)) problem(`visual_review.${key} is not allowed`);
|
|
233
|
+
if (visual.applicable === false && !nonempty(visual.reason)) problem('visual_review.reason is required when visual review is not applicable (say why)');
|
|
234
|
+
if (visual.applicable === true && visualChecks === 0) problem('visual_review.applicable is true but no visual check is recorded');
|
|
235
|
+
if ('reason' in visual && !shortText(visual.reason)) problem('visual_review.reason must be a short string');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (problems.length === 0 && opts.files) {
|
|
239
|
+
opts.list = [rel, ...doc.artifacts.map(a => a.path)];
|
|
240
|
+
}
|
|
241
|
+
return problems;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function usage(message) {
|
|
245
|
+
if (message) process.stderr.write(`pincer-evidence: ${message}\n`);
|
|
246
|
+
process.stderr.write('usage: pincer-evidence.cjs validate <manifest> [--candidate <sha>] [--base <sha>] [--prd .prd/prd-vN.md] [--files]\n pincer-evidence.cjs digest <file>...\n');
|
|
247
|
+
process.exit(2);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function main(argv) {
|
|
251
|
+
const [command, ...rest] = argv;
|
|
252
|
+
if (command === 'validate') {
|
|
253
|
+
const opts = { files: false };
|
|
254
|
+
let manifest = null;
|
|
255
|
+
for (let i = 0; i < rest.length; i++) {
|
|
256
|
+
const arg = rest[i];
|
|
257
|
+
if (arg === '--files') opts.files = true;
|
|
258
|
+
else if (arg === '--candidate' || arg === '--base' || arg === '--prd') {
|
|
259
|
+
const value = rest[++i];
|
|
260
|
+
if (value === undefined) usage(`${arg} requires a value`);
|
|
261
|
+
opts[arg.slice(2)] = value;
|
|
262
|
+
} else if (arg.startsWith('--')) usage(`unknown option ${arg}`);
|
|
263
|
+
else if (manifest === null) manifest = arg;
|
|
264
|
+
else usage('validate takes exactly one manifest path');
|
|
265
|
+
}
|
|
266
|
+
if (manifest === null) usage('validate requires a manifest path');
|
|
267
|
+
if (opts.candidate !== undefined && !HEX40.test(opts.candidate)) usage('--candidate must be a full 40-hex commit ID');
|
|
268
|
+
if (opts.base !== undefined && !HEX40.test(opts.base)) usage('--base must be a full 40-hex commit ID');
|
|
269
|
+
if (opts.prd !== undefined && !PRD_REF.test(opts.prd)) usage('--prd must be of the form .prd/prd-vN.md');
|
|
270
|
+
const problems = validate(manifest, opts);
|
|
271
|
+
if (problems.length > 0) {
|
|
272
|
+
for (const p of problems) process.stderr.write(`evidence: ${manifest}: ${p}\n`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
const doc = JSON.parse(fs.readFileSync(path.resolve(manifest), 'utf8'));
|
|
276
|
+
process.stdout.write(`ok ${doc.candidate}\n`);
|
|
277
|
+
if (opts.files) for (const p of opts.list) process.stdout.write(`${p}\n`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (command === 'digest') {
|
|
281
|
+
if (rest.length === 0) usage('digest requires at least one file');
|
|
282
|
+
for (const file of rest) {
|
|
283
|
+
let digest;
|
|
284
|
+
try { digest = digestFile(file); } catch (error) { process.stderr.write(`pincer-evidence: ${file}: ${error.code === 'ENOENT' ? 'missing' : error.message}\n`); process.exit(1); }
|
|
285
|
+
process.stdout.write(`${digest} ${file}\n`);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
usage(command ? `unknown command ${command}` : undefined);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
main(process.argv.slice(2));
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
# scripts/pincer-status.sh
|
|
6
6
|
#
|
|
7
7
|
# Elapsed times come from the `started` / `finished` stamps that
|
|
8
|
-
# scripts/pincer-ticket.sh writes, i.e. from the clock — never estimated
|
|
8
|
+
# scripts/pincer-ticket.sh writes, i.e. from the wall clock — never estimated
|
|
9
|
+
# and never a measure of active execution time. The build-wide elapsed line is
|
|
10
|
+
# printed only while a ticket is in progress or an explicit budget is set.
|
|
9
11
|
# Optional build budget: PINCER_BUILD_BUDGET_MIN.
|
|
10
12
|
set -uo pipefail
|
|
11
13
|
|
|
@@ -34,7 +36,7 @@ if [ -z "$prd" ]; then
|
|
|
34
36
|
echo "PRD none"
|
|
35
37
|
else
|
|
36
38
|
prd_status=$(fm_get "$prd" status)
|
|
37
|
-
echo "PRD $prd · status: ${prd_status:-?} · date: $(fm_get "$prd" date)"
|
|
39
|
+
echo "PRD $prd · status: ${prd_status:-?} · profile: $(prd_profile "$prd") · date: $(fm_get "$prd" date)"
|
|
38
40
|
fi
|
|
39
41
|
|
|
40
42
|
# Reject malformed/ambiguous tickets instead of treating unknown states as open.
|
|
@@ -71,7 +73,8 @@ else
|
|
|
71
73
|
st=$(fm_get "$f" status); size=$(fm_get "$f" size)
|
|
72
74
|
started=$(fm_get "$f" started); verified=$(fm_get "$f" verified); finished=$(fm_get "$f" finished)
|
|
73
75
|
attempt=$(fm_get "$f" last_check)
|
|
74
|
-
|
|
76
|
+
# Done tickets report through ticket_readiness below, so each problem is printed once.
|
|
77
|
+
if [ "$st" != done ] && [ -n "$attempt" ] && ! printf '%s' "$attempt" | grep -q ' passed '; then
|
|
75
78
|
warn="$warn WARN $id latest verification: $attempt — re-run verify\n"
|
|
76
79
|
fi
|
|
77
80
|
deps=$(fm_get "$f" depends_on | grep -oE 'T-[0-9]+' | tr '\n' ' ' || true)
|
|
@@ -116,8 +119,10 @@ else
|
|
|
116
119
|
echo "Tickets $((n_open + n_prog + n_done)) total · $n_done done · $n_prog in progress · $n_open open"
|
|
117
120
|
printf '%b' "$rows"
|
|
118
121
|
printf '%b' "$warn"
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
# Wall-clock elapsed is shown only while work is active or against an explicit
|
|
123
|
+
# budget; on a finished build it is noise and it never measures execution time.
|
|
124
|
+
if [ -n "$first_start" ] && { [ "$n_prog" -gt 0 ] || [ -n "$BUDGET" ]; }; then
|
|
125
|
+
build="Build wall-clock elapsed $(mins "$first_start" "$NOW") since the first ticket started (not active execution time)"
|
|
121
126
|
[ -z "$BUDGET" ] || build="$build · budget ${BUDGET}m"
|
|
122
127
|
echo "$build"
|
|
123
128
|
fi
|
|
@@ -126,6 +131,17 @@ fi
|
|
|
126
131
|
notes_valid=no
|
|
127
132
|
if notes=$(notes_current "$prd"); then notes_valid=yes; fi
|
|
128
133
|
echo "Notes NOTES.md: $notes"
|
|
134
|
+
# The evidence line reports the shared validator's verdict for the manifest the
|
|
135
|
+
# notes name, independent of whether the candidate is still current.
|
|
136
|
+
manifest=""
|
|
137
|
+
[ -f NOTES.md ] && validate_metadata NOTES.md >/dev/null 2>&1 && manifest=$(fm_get NOTES.md evidence)
|
|
138
|
+
if [ -n "$manifest" ]; then
|
|
139
|
+
if ev=$(evidence_validate "$manifest" "$(fm_get NOTES.md candidate)" "$(fm_get NOTES.md base)" "$prd"); then
|
|
140
|
+
echo "Evidence $manifest · ok"
|
|
141
|
+
else
|
|
142
|
+
echo "Evidence $manifest · $(evidence_reason "$ev")"
|
|
143
|
+
fi
|
|
144
|
+
fi
|
|
129
145
|
|
|
130
146
|
# ── Next action ──
|
|
131
147
|
if [ -z "$prd" ]; then
|
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
# Shared, read-only parsing for the supported Pincer ticket format.
|
|
3
3
|
# Validators return nonzero with a diagnostic; callers decide how to report it.
|
|
4
4
|
|
|
5
|
+
PINCER_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
6
|
+
PINCER_EVIDENCE="$PINCER_LIB_DIR/pincer-evidence.cjs"
|
|
7
|
+
|
|
8
|
+
# Run the shared evidence validator; prints its combined output, returns its status.
|
|
9
|
+
# Usage: evidence_validate <manifest> <candidate> <base> <prd> [--files]
|
|
10
|
+
# Absent or malformed candidate/base/prd values are simply not checked, so a bad
|
|
11
|
+
# NOTES.md field is reported by notes_current, never as a manifest verdict.
|
|
12
|
+
evidence_validate() {
|
|
13
|
+
local manifest=$1 candidate=$2 base=$3 prd=$4; shift 4
|
|
14
|
+
command -v node >/dev/null 2>&1 || { printf 'evidence: %s: Node.js 18+ is required to validate evidence\n' "$manifest"; return 1; }
|
|
15
|
+
local -a args=(validate "$manifest")
|
|
16
|
+
if [[ $candidate =~ ^[a-f0-9]{40}$ ]]; then args+=(--candidate "$candidate"); fi
|
|
17
|
+
if [[ $base =~ ^[a-f0-9]{40}$ ]]; then args+=(--base "$base"); fi
|
|
18
|
+
if [[ $prd =~ ^\.prd/prd-v[1-9][0-9]{0,8}\.md$ ]]; then args+=(--prd "$prd"); fi
|
|
19
|
+
node "$PINCER_EVIDENCE" "${args[@]}" "$@" 2>&1
|
|
20
|
+
}
|
|
21
|
+
evidence_reason() { # first diagnostic line without the manifest prefix; usage errors are labelled
|
|
22
|
+
printf '%s\n' "$1" | head -1 | sed -e 's/^evidence: [^:]*: //' -e 's/^pincer-evidence: /validator usage: /'
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
normalize() { # CLI shorthand -> canonical ID; bound arithmetic before conversion.
|
|
6
26
|
local n=${1#T-}; n=${n#t-}
|
|
7
27
|
if ! [[ $n =~ ^[0-9]{1,6}$ ]] || [ "$((10#$n))" -eq 0 ]; then
|
|
@@ -185,6 +205,12 @@ validate_prd() {
|
|
|
185
205
|
validate_metadata "$ref" || return 1
|
|
186
206
|
[ "$(fm_get "$ref" version)" = "$version" ] || { printf 'pincer: %s: version must match filename (%s)\n' "$ref" "$version" >&2; return 1; }
|
|
187
207
|
case "$(fm_get "$ref" status)" in draft|ticketed|built) ;; *) printf 'pincer: %s: PRD status must be draft, ticketed, or built\n' "$ref" >&2; return 1 ;; esac
|
|
208
|
+
case "$(fm_get "$ref" profile)" in ''|small|standard) ;; *) printf 'pincer: %s: profile must be small or standard (omit for standard)\n' "$ref" >&2; return 1 ;; esac
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
prd_profile() { # effective planning profile; older PRDs without the field are standard
|
|
212
|
+
local profile; profile=$(fm_get "$1" profile)
|
|
213
|
+
printf '%s' "${profile:-standard}"
|
|
188
214
|
}
|
|
189
215
|
|
|
190
216
|
latest_prd() {
|
|
@@ -247,14 +273,19 @@ ticket_readiness() { # explain why a done ticket needs attention, without mutati
|
|
|
247
273
|
if [ -n "$(unticked "$file")" ]; then printf 'unticked acceptance criteria — complete and re-run verify'; return 1; fi
|
|
248
274
|
}
|
|
249
275
|
|
|
276
|
+
# An evaluation is current when NOTES.md names this PRD, a reviewed base and
|
|
277
|
+
# candidate that are ancestors of HEAD, and an evidence manifest that validates
|
|
278
|
+
# for that candidate; after the candidate, only NOTES.md and the files the
|
|
279
|
+
# manifest lists may have changed, all of them tracked, and the working tree is
|
|
280
|
+
# clean apart from NOTES.md. Legacy notes without a manifest never grant readiness.
|
|
250
281
|
notes_current() {
|
|
251
|
-
local prd=$1 candidate base changes
|
|
282
|
+
local prd=$1 candidate base changes manifest output listed file stored canonical offending
|
|
252
283
|
[ -f NOTES.md ] || { printf 'missing'; return 1; }
|
|
253
284
|
validate_metadata NOTES.md >/dev/null 2>&1 || { printf 'stale: invalid or missing evaluation metadata'; return 1; }
|
|
254
285
|
[ "$(fm_get NOTES.md prd)" = "$prd" ] || { printf 'stale: evaluation PRD does not match'; return 1; }
|
|
255
286
|
candidate=$(fm_get NOTES.md candidate); base=$(fm_get NOTES.md base)
|
|
256
|
-
if ! [[ $candidate =~ ^
|
|
257
|
-
printf 'stale: candidate and base must be full commit IDs'; return 1
|
|
287
|
+
if ! [[ $candidate =~ ^[a-f0-9]{40}$ ]] || ! [[ $base =~ ^[a-f0-9]{40}$ ]]; then
|
|
288
|
+
printf 'stale: candidate and base must be full 40-hex commit IDs'; return 1
|
|
258
289
|
fi
|
|
259
290
|
if ! git rev-parse --verify "$candidate^{commit}" >/dev/null 2>&1 ||
|
|
260
291
|
! git rev-parse --verify "$base^{commit}" >/dev/null 2>&1 ||
|
|
@@ -262,8 +293,28 @@ notes_current() {
|
|
|
262
293
|
! git merge-base --is-ancestor "$candidate" HEAD 2>/dev/null; then
|
|
263
294
|
printf 'stale: evaluation commits or ancestry unavailable'; return 1
|
|
264
295
|
fi
|
|
265
|
-
|
|
266
|
-
if [ -
|
|
296
|
+
manifest=$(fm_get NOTES.md evidence)
|
|
297
|
+
if [ -z "$manifest" ]; then
|
|
298
|
+
printf 'stale: legacy evaluation without evidence manifest — re-run /pincer-evaluate for evidence schema 1'; return 1
|
|
299
|
+
fi
|
|
300
|
+
if ! output=$(evidence_validate "$manifest" "$candidate" "$base" "$prd" --files); then
|
|
301
|
+
printf 'stale: evidence invalid: %s' "$(evidence_reason "$output")"; return 1
|
|
302
|
+
fi
|
|
303
|
+
listed=$(printf '%s\n' "$output" | tail -n +2)
|
|
304
|
+
# Every listed file must be tracked; keep git's own spelling of each path
|
|
305
|
+
# (unquoted, relative to this directory, NFC where git normalises) so the set
|
|
306
|
+
# compares byte-for-byte with the diff below.
|
|
307
|
+
canonical=""
|
|
308
|
+
while IFS= read -r file; do
|
|
309
|
+
[ -n "$file" ] || continue
|
|
310
|
+
stored=$(git -c core.quotePath=false ls-files --error-unmatch -- "$file" 2>/dev/null) || { printf 'stale: evidence not tracked: %s' "$file"; return 1; }
|
|
311
|
+
canonical="$canonical$stored
|
|
312
|
+
"
|
|
313
|
+
done <<< "$listed"
|
|
314
|
+
# Paths relative to the project root (which may be below the git toplevel).
|
|
315
|
+
changes=$(git -c core.quotePath=false diff --name-only --relative "$candidate" HEAD -- . ':(exclude)NOTES.md') || return 1
|
|
316
|
+
offending=$(printf '%s\n' "$changes" | grep -vxF -f <(printf '%s' "$canonical") | grep -v '^$' | head -1)
|
|
317
|
+
if [ -n "$offending" ]; then printf 'stale: candidate changed after evaluation: %s' "$offending"; return 1; fi
|
|
267
318
|
changes=$(git status --porcelain --untracked-files=all -- . ':(exclude)NOTES.md') || return 1
|
|
268
319
|
if [ -n "$changes" ]; then printf 'stale: working tree has changes outside NOTES.md'; return 1; fi
|
|
269
320
|
printf 'current (%s)' "$candidate"
|
|
@@ -95,7 +95,7 @@ cmd_verify() {
|
|
|
95
95
|
trap - INT TERM
|
|
96
96
|
if [ "$rc" -ne 0 ]; then
|
|
97
97
|
fm_set "$f" last_check "$(now) failed $hash"
|
|
98
|
-
echo "✗ $id verification FAILED (exit $rc) —
|
|
98
|
+
echo "✗ $id verification FAILED (exit $rc) — failure recorded in last_check of $f; any prior successful receipt was revoked. Fix, then re-run verify." >&2
|
|
99
99
|
exit "$rc"
|
|
100
100
|
fi
|
|
101
101
|
if [ "$hash" != "$(verify_hash "$f")" ]; then
|