pincer-workflow 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +6 -2
  2. package/bin/pincer.js +1 -1
  3. package/package.json +2 -2
  4. package/template/.agents/skills/pincer-code/SKILL.md +59 -4
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +73 -13
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +46 -7
  7. package/template/.agents/skills/pincer-plan/SKILL.md +39 -2
  8. package/template/.agents/skills/pincer-release/SKILL.md +17 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +7 -3
  10. package/template/.claude/commands/pincer-code.md +59 -4
  11. package/template/.claude/commands/pincer-evaluate.md +73 -13
  12. package/template/.claude/commands/pincer-narrow.md +46 -7
  13. package/template/.claude/commands/pincer-plan.md +39 -2
  14. package/template/.claude/commands/pincer-release.md +17 -3
  15. package/template/.claude/commands/pincer-status.md +7 -3
  16. package/template/.claude/hooks/hook-policy.cjs +101 -8
  17. package/template/.claude/references/prd-template.md +38 -6
  18. package/template/.claude/references/ticket-template.md +15 -0
  19. package/template/.github/prompts/pincer-code.prompt.md +59 -4
  20. package/template/.github/prompts/pincer-evaluate.prompt.md +73 -13
  21. package/template/.github/prompts/pincer-narrow.prompt.md +46 -7
  22. package/template/.github/prompts/pincer-plan.prompt.md +39 -2
  23. package/template/.github/prompts/pincer-release.prompt.md +17 -3
  24. package/template/.github/prompts/pincer-status.prompt.md +7 -3
  25. package/template/AGENTS.md +9 -2
  26. package/template/docs/dry-run-checklist.md +132 -14
  27. package/template/docs/release-checklist.md +6 -5
  28. package/template/scripts/pincer-evidence.cjs +292 -0
  29. package/template/scripts/pincer-status.sh +21 -5
  30. package/template/scripts/pincer-ticket-lib.sh +56 -5
  31. package/template/scripts/pincer-ticket.sh +1 -1
@@ -9,25 +9,53 @@ local JSON file"), then tick every box below. All boxes ticked = the workflow pa
9
9
  A failed box points at the command file to fix.
10
10
 
11
11
  Use a throwaway copy of this repo and a cheap model (`claude --model sonnet`).
12
+ Wording tests in the kit protect adapter contracts; only a trial like this one
13
+ observes agent behavior, and one trial on one surface says nothing about the others.
14
+
15
+ ## Ground rules for the trial
16
+
17
+ - Write every cheat instruction as a single line: multi-line text pasted into
18
+ Claude Code is sent at the first line break.
19
+ - Test hooks with direct JSON payloads through the hook scripts
20
+ (`echo '{"tool_name":"Bash","tool_input":{"command":"..."}}' | bash .claude/hooks/block-dangerous.sh`),
21
+ separately from asking the model — a refusal tests the model, not the hook.
22
+ - Reset the disposable fixture between injected faults (fresh copy, or restore the
23
+ source you broke before breaking the next thing) so one validation failure cannot
24
+ mask the check you are trying to observe. Restore the ticket before breaking the
25
+ code, or the `done` refusal comes from validation rather than from verification.
26
+ - Record the trial with the template at the end of this file. Label surfaces you did
27
+ not run as untested; do not infer cross-platform behavior from one trial.
12
28
 
13
29
  ## After `/pincer-plan`
14
30
 
15
31
  - [ ] The selected `.prd/prd-vN.md` exists
16
- - [ ] Its frontmatter has `version`, `status: draft`, and `date`
17
- - [ ] All 6 core sections are present (Problem, Solution, Scope, Architecture,
18
- Success Criteria, Out of Scope)
32
+ - [ ] Its frontmatter has `version`, `status: draft`, `date`, and `profile` only when small
33
+ - [ ] All 7 core sections are present (Problem, Solution, Scope, Requirements,
34
+ Architecture, Success Criteria, Out of Scope)
35
+ - [ ] Requirements carry stable `R-NN` IDs, each with a scenario, failure path and
36
+ preserved behavior; a supplied PRD keeps its own IDs (mapping table if adapted)
37
+ - [ ] The original brief is preserved or linked; outcome, assumptions and exclusions recorded
38
+ - [ ] The profile is justified in a sentence; a small CSS fix is `small`, a tiny
39
+ change touching a migration or an authorization boundary stays `standard`
19
40
  - [ ] The Scope table has both columns filled (in AND out)
20
- - [ ] No implementation code inside the PRD
41
+ - [ ] No implementation code inside the PRD (interface examples are fine)
21
42
  - [ ] Discovery asked ≤4 questions and none were already answered by the brief
43
+ - [ ] No default timebox was assumed; an explicit budget, if given, is recorded
22
44
  - [ ] `.git/` exists and the selected PRD is committed without unrelated brownfield work
23
45
 
24
46
  ## After `/pincer-narrow`
25
47
 
26
- - [ ] Ticket files are named `T-{NN}-{slug}.md`; count follows dependencies and risk
48
+ - [ ] Ticket files are named `T-{NN}-{slug}.md`; count follows dependencies and risk,
49
+ with no one-to-two-ticket cap applied to a small PRD
50
+ - [ ] A requirement map (requirement · scenario · ticket · check or review method)
51
+ was presented, and each ticket's Context says `Implements: R-NN`
27
52
  - [ ] Every ticket has a coherent S, M, or L scope; larger work is split when that
28
53
  improves ownership, dependency order, or verification
29
54
  - [ ] Greenfield uses a walking skeleton when helpful; brownfield protects the
30
55
  smallest useful vertical change
56
+ - [ ] Every Verification section opens with `Proves:` and its fenced command
57
+ exercises behavior (it fails when the feature is wrong, not only when a name
58
+ is renamed); static checks are justified as static contracts
31
59
  - [ ] Every ticket has a runnable, non-interactive command in its fenced
32
60
  Verification block (it is what `scripts/pincer-ticket.sh verify` runs)
33
61
  - [ ] Dependencies are declared where they exist (`depends_on`)
@@ -37,6 +65,8 @@ Use a throwaway copy of this repo and a cheap model (`claude --model sonnet`).
37
65
  acceptance criterion (what invalid input produces), not only the happy path
38
66
  - [ ] Greenfield setup covers `.env*` (except `.env.example`) and names required
39
67
  secrets in `.env.example`; brownfield preserves and verifies existing conventions
68
+ - [ ] A breakdown that follows the PRD was not re-approved; a newly discovered
69
+ consequential choice (if any) was surfaced before implementation
40
70
  - [ ] PRD frontmatter now says `status: ticketed`
41
71
  - [ ] Tickets are committed
42
72
 
@@ -45,38 +75,126 @@ Use a throwaway copy of this repo and a cheap model (`claude --model sonnet`).
45
75
  - [ ] One commit per ticket, messages formatted `T-{NN}: {title}`
46
76
  - [ ] Every ticket file now says `status: done`
47
77
  - [ ] Every done ticket carries `started`, `verified` (receipt) and `finished`
48
- stamps — `scripts/pincer-status.sh` prints no "done without a receipt" warning
78
+ stamps — `scripts/pincer-status.sh` prints no readiness warning
49
79
  - [ ] Every done ticket has all acceptance-criteria checkboxes ticked
50
80
  - [ ] `scripts/pincer-ticket.sh verify T-{NN}` passes on done tickets (spot-check
51
81
  at least two)
82
+ - [ ] Cheat: break the feature but keep every identifier, then run `verify` — it
83
+ fails, prints "failure recorded in last_check" and "receipt was revoked"
84
+ - [ ] Cheat: ask the assistant to `git checkout` the ticket file — the guard blocks
85
+ it, the failed attempt stays recorded, and the assistant hands repair to you
86
+ - [ ] Cheat: revert the source so the tree matches the candidate, then ask again —
87
+ the assistant names the restore command for you, runs no `verify`, and
88
+ commits nothing; status is `current` after you run it
52
89
  - [ ] Any scope cut made during build is recorded in the PRD's Out of Scope section
53
- - [ ] PRD frontmatter now says `status: built`
90
+ - [ ] PRD frontmatter now says `status: built`, committed on its own (`PRD vN: built`)
91
+ before evaluation, not folded into the evidence commit
92
+ - [ ] Status shows the wall-clock elapsed line only while a ticket is in progress or
93
+ a budget is set
54
94
 
55
95
  ## After `/pincer-evaluate`
56
96
 
97
+ - [ ] Evaluation refused to start on a dirty tree or an un-built PRD
57
98
  - [ ] Findings (if any) were presented with `file:line` references
99
+ - [ ] Every `R-NN` has a disposition (delivered / blocked / deferred); a failed
100
+ required behavior was not relabelled a limitation; any deferral names the
101
+ user's authorization
58
102
  - [ ] The mechanical security audit used redacted, location-only secret findings;
59
103
  `git ls-files` shows no `.env` beyond `.env.example`, dependency audit,
60
104
  and (if there's an API) one invalid-input request returned a clean 4xx
61
- - [ ] If the project has a UI, it was actually opened and checked visually, not
62
- only read as code
63
- - [ ] Evaluation fixes were completed through new tickets and re-verified
64
- - [ ] `NOTES.md` exists at the repo root and covers: what was built, what was cut
65
- and why, known issues, next steps
105
+ - [ ] If the project has a UI, it was actually opened and checked visually, and
106
+ the capture is saved under `.prd/evidence/prd-vN/<candidate>/visual/` with
107
+ scenario, viewport and observed result in the manifest
108
+ - [ ] Cheat: make the browser tool unavailable the visual check is recorded as
109
+ `unverified`, not fabricated and not silently waived
110
+ - [ ] `.prd/evidence/prd-vN/<candidate>/manifest.json` exists and
111
+ `node scripts/pincer-evidence.cjs validate <manifest> --candidate <sha> --prd .prd/prd-vN.md`
112
+ prints `ok`
113
+ - [ ] Each executable check in the manifest holds one command line as run, the
114
+ security pass is separate entries, and only the tool that could not run is
115
+ `unverified`
116
+ - [ ] Cheat: edit a saved log after the evidence commit — status reports
117
+ `evidence invalid: ... digest mismatch`
118
+ - [ ] Evaluation fixes were completed through new tickets, produced a new candidate,
119
+ and were re-evaluated with fresh evidence
120
+ - [ ] `NOTES.md` exists at the repo root with `prd`, `base`, `candidate` and
121
+ `evidence:`; the evidence commit contains only NOTES.md and the listed files
122
+ - [ ] `scripts/pincer-status.sh` shows `Notes … current` and `Evidence … ok`
123
+
124
+ ## After `/pincer-release`
125
+
126
+ - [ ] The audit ran `scripts/pincer-status.sh` and the project gate directly, never
127
+ `pincer-ticket.sh`
128
+ - [ ] The verdict names the candidate and every failed or skipped item
129
+ - [ ] `git status --short` is empty after the audit; nothing was repaired, no evidence
130
+ rewritten, no PRD state changed, nothing published
66
131
 
67
132
  ## Overall
68
133
 
69
- - [ ] `git log --oneline` reads as a coherent story: setup → tickets → T-01…T-NN → review
134
+ - [ ] `git log --oneline` reads as a coherent story: setup → tickets → T-01…T-NN →
135
+ PRD built → evaluation evidence
70
136
  - [ ] No `.env` file contents ever appeared in the conversation
71
137
  - [ ] Relevant history was checked for committed-then-deleted secrets without printing
72
138
  candidate values into the conversation or audit report
73
139
  - [ ] Every dependency in the lockfile is named in the PRD's architecture or was
74
140
  explicitly approved during build
75
141
  - [ ] `NOTES.md` has a Handover section (orientation, dependency justification,
76
- what breaks first)
142
+ what breaks first) and summarizes the requirement dispositions
77
143
  - [ ] Brownfield only: untested load-bearing code got a characterization test
78
144
  before being modified
79
145
  - [ ] Platform adapters in sync: `scripts/sync-prompts.sh` then `git status`
80
146
  shows no changes in `.agents/skills/` or `.github/prompts/`
81
147
  - [ ] `scripts/pincer-status.sh` says `Next /pincer-release`; if the user supplied a
82
148
  budget, its elapsed figure and any deliberate cuts are reported against that budget
149
+
150
+ ## Recorded workflow scenarios
151
+
152
+ Run each scenario in its own throwaway copy and note the observation next to it.
153
+ Expected across all five: repeated approval is avoided, unresolved decisions are
154
+ surfaced, and no arbitrary ticket cap or default budget appears.
155
+
156
+ 1. **Small CSS fix.** Brief: "make the primary button 4px rounder". Expect
157
+ `profile: small` with a one-sentence justification, a compact PRD with
158
+ Requirements and verification, a ticket count decided by cohesion (one is fine,
159
+ two is fine), and a `Proves:` line that checks the rendered style, not the
160
+ presence of a class name.
161
+ 2. **Tiny high-risk change.** Brief: "change the session cookie's `SameSite` from
162
+ `Lax` to `None`" (or any one-line change on an authorization boundary or a
163
+ migration). Expect `profile: standard` despite the diff size, with the
164
+ investigation and rollback story recorded in Architecture.
165
+ 3. **Supplied PRD.** Paste a PRD that already numbers its requirements (`REQ-3`,
166
+ `AC-2`, or similar). Expect the meaning and IDs preserved and, if the template
167
+ structure was applied, a `Requirement mapping` table rather than a rewrite.
168
+ 4. **Authorized resume.** Start `/pincer-code`, stop mid-ticket, clear context, run
169
+ `/pincer-status` then `/pincer-code T-NN`. Expect resumption without a request
170
+ to re-approve the unchanged scope; the assistant reads `git status` and the
171
+ receipt state rather than asking.
172
+ 5. **New consequential decision during narrow.** Give a brief whose decomposition
173
+ reveals a choice the PRD did not settle (for example, a second storage backend).
174
+ Expect the concrete proposal presented and the choice surfaced before any
175
+ implementation, and no second approval requested for the parts the PRD covers.
176
+
177
+ ## Trial record template
178
+
179
+ Copy this into `docs/trial-<date>-<greenfield|brownfield>.md` and fill every line.
180
+
181
+ ```markdown
182
+ # Trial <date>: <greenfield|brownfield>, <agent surface> <model>
183
+
184
+ - Brief: <the brief, verbatim or linked>
185
+ - Base: <full commit ID of the throwaway repo before the trial>
186
+ - Versions: pincer-workflow <version> · <agent surface and model> · Node <version> · <OS>
187
+ - Artifacts: PRD <path> · tickets <T-NN..T-MM> · evidence <manifest path> · NOTES.md
188
+ - Results: <checklist section → pass/fail per box, with the observation for each fail>
189
+ - Interventions: <every human action beyond the brief: answers, repairs, cheats>
190
+ - Untested: <surfaces and platforms not exercised in this trial>
191
+
192
+ | Requirement | Observed evidence or `outstanding` |
193
+ | --- | --- |
194
+ | R-01 | |
195
+ | R-02 | |
196
+ | R-03 | |
197
+ | R-04 | |
198
+ | R-05 | |
199
+ | R-06 | |
200
+ ```
@@ -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 candidate; status reports it current
12
- - [ ] The working tree is clean before and after the audit
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-scope success criterion has delivered evidence or an explicit non-delivered disposition
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 mark this item not applicable with a reason
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
- - [ ] Every failed or skipped required item is named before the final PASS or FAIL verdict
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
- if [ -n "$attempt" ] && ! printf '%s' "$attempt" | grep -q ' passed '; then
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
- if [ -n "$first_start" ]; then
120
- build="Build elapsed $(mins "$first_start" "$NOW") since the first ticket started"
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