pincer-workflow 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +7 -5
  2. package/bin/pincer.js +42 -5
  3. package/package.json +2 -2
  4. package/template/.agents/skills/pincer-code/SKILL.md +51 -4
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +29 -2
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +11 -2
  7. package/template/.agents/skills/pincer-plan/SKILL.md +9 -1
  8. package/template/.agents/skills/pincer-release/SKILL.md +14 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +17 -2
  10. package/template/.claude/commands/pincer-code.md +51 -4
  11. package/template/.claude/commands/pincer-evaluate.md +29 -2
  12. package/template/.claude/commands/pincer-narrow.md +11 -2
  13. package/template/.claude/commands/pincer-plan.md +9 -1
  14. package/template/.claude/commands/pincer-release.md +14 -3
  15. package/template/.claude/commands/pincer-status.md +17 -2
  16. package/template/.claude/hooks/hook-policy.cjs +17 -3
  17. package/template/.claude/references/ticket-template.md +4 -0
  18. package/template/.codex/README.md +3 -2
  19. package/template/.github/prompts/pincer-code.prompt.md +51 -4
  20. package/template/.github/prompts/pincer-evaluate.prompt.md +29 -2
  21. package/template/.github/prompts/pincer-narrow.prompt.md +11 -2
  22. package/template/.github/prompts/pincer-plan.prompt.md +9 -1
  23. package/template/.github/prompts/pincer-release.prompt.md +14 -3
  24. package/template/.github/prompts/pincer-status.prompt.md +17 -2
  25. package/template/AGENTS.md +6 -0
  26. package/template/docs/dry-run-checklist.md +46 -3
  27. package/template/docs/release-checklist.md +2 -1
  28. package/template/docs/runtime-contracts.md +437 -0
  29. package/template/scripts/pincer-evidence.cjs +5 -223
  30. package/template/scripts/pincer-runtime/evidence.cjs +391 -0
  31. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  32. package/template/scripts/pincer-runtime/identity.cjs +146 -0
  33. package/template/scripts/pincer-runtime/lifecycle.cjs +289 -0
  34. package/template/scripts/pincer-runtime/migrate.cjs +127 -0
  35. package/template/scripts/pincer-runtime/parse.cjs +297 -0
  36. package/template/scripts/pincer-runtime/readiness.cjs +89 -0
  37. package/template/scripts/pincer-runtime/runner.cjs +224 -0
  38. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  39. package/template/scripts/pincer-runtime/source.cjs +129 -0
  40. package/template/scripts/pincer-runtime/state.cjs +292 -0
  41. package/template/scripts/pincer-runtime/status.cjs +358 -0
  42. package/template/scripts/pincer-runtime.cjs +350 -0
  43. package/template/scripts/pincer-status.sh +11 -162
  44. package/template/scripts/pincer-ticket.sh +19 -139
  45. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -0,0 +1,437 @@
1
+ # PINCER Runtime Contracts
2
+
3
+ The runtime is `scripts/pincer-runtime.cjs` with its modules under
4
+ `scripts/pincer-runtime/`. It is dependency-free CommonJS for Node.js 18+ and is the
5
+ only writer of ticket lifecycle state, verification attempts, change bindings and
6
+ exported candidate evidence. The shell entry points `scripts/pincer-ticket.sh` and
7
+ `scripts/pincer-status.sh` are compatibility wrappers that delegate to it. This
8
+ document is the contract the runtime implements; tests pin it, and a change to a
9
+ contract updates this file in the same commit as the code.
10
+
11
+ Schema numbers in this document (`schema: 1` on bindings, attempts, manifests and
12
+ status; evidence `schema: 2`) are independent of the package version. The runtime
13
+ records its contract version as `runtime: 1`.
14
+
15
+ ## Modes
16
+
17
+ A project is in one of two modes per PRD, decided by the presence of a change binding
18
+ (`.prd/changes/<change-id>.json`, see below) whose `prd` names that PRD.
19
+
20
+ | | Legacy (no binding) | Migrated (binding present) |
21
+ | --- | --- | --- |
22
+ | How recognized | no `.prd/changes/*.json` names the PRD | exactly one binding names it |
23
+ | Selected PRD for status | the highest-numbered valid `.prd/prd-vN.md` | the binding's PRD; a newer unregistered PRD is reported, never selected |
24
+ | `start` | as v0.4.1: dependency and readiness checks, writes `status: in_progress` and `started` | same checks; a dependency is ready when its latest attempt passed against current inputs |
25
+ | `verify` | runs the block, writes `last_check` and `verified` into the ticket | runs the block as an attempt under `.pincer/runtime/`; writes nothing to tracked files |
26
+ | `done` | re-runs the check, then writes `status: done` and `finished` | consumes the latest passing attempt whose inputs are current; writes `status: done` and `finished` once; read-only and idempotent afterwards |
27
+ | Readiness authority | `last_check`/`verified` receipts in the ticket | attempt records; `verified`/`last_check` are ignored and reported as `LEGACY_RECEIPT` |
28
+ | Local state | none; `.pincer/` is never written | `.pincer/runtime/` (ignored) |
29
+ | Evidence | schema 1, authored | schema 2, exported from attempts (schema 1 still validates with a legacy label) |
30
+ | Status line | `Runtime legacy · no change binding · migrate with node scripts/pincer-runtime.cjs migrate --preview --prd <prd>` | `Runtime change <id> · revision <12 hex> · base <short sha>` |
31
+
32
+ Legacy mode is recognizable in every output; nothing switches modes implicitly.
33
+ Migration (`migrate --apply`) is the only transition.
34
+
35
+ ## Commands and exit codes
36
+
37
+ All commands: `node scripts/pincer-runtime.cjs <command> [arguments]`. The project
38
+ root is `CLAUDE_PROJECT_DIR`, else `git rev-parse --show-toplevel`, else the working
39
+ directory. Diagnostics go to stderr, prefixed `pincer-ticket: ` for ticket input,
40
+ `pincer: ` for PRD, NOTES and runtime state, `evidence: ` for manifests.
41
+
42
+ | Command | Arguments | Writes | Notes |
43
+ | --- | --- | --- | --- |
44
+ | `validate` | `<file>... [--digests]` | nothing | validates tickets, PRDs and NOTES; `--digests` prints `ticket`, `check` and `prd` digests |
45
+ | `register` | `--prd .prd/prd-vN.md [--change <id>] [--authorization <text>] [--replace] [--rebind]` | `.prd/changes/<id>.json` | requires a git HEAD; `--replace` allows a binding for another PRD to be replaced; `--rebind` updates `prd_revision` after PRD content changed |
46
+ | `snapshot` | `[--json] [--store]` | `.pincer/runtime/manifests/<digest>.json` only with `--store` | prints the source digest and file count, or the manifest |
47
+ | `status` | `[--json]` | nothing | human report, or one status JSON object on stdout |
48
+ | `ready` | `[T-NN]` | nothing | read-only gate: exit 0 when ready, 1 when not, with reason codes |
49
+ | `start` | `T-NN` | ticket `status`, `started`, `prd` | both modes |
50
+ | `verify` | `T-NN` | legacy: ticket receipts; migrated: an attempt | always creates a new attempt in migrated mode |
51
+ | `done` | `T-NN` | ticket `status`, `finished` (once) | legacy re-runs the check; migrated consumes the current pass |
52
+ | `bind` | `T-NN .prd/prd-vN.md` | ticket `prd` | legacy association repair |
53
+ | `recover` | | attempts, lock, journal | finalizes dead-owner `running` attempts as `interrupted`; never promotes to `passed` |
54
+ | `migrate` | `--preview | --apply --prd .prd/prd-vN.md [--change <id>] [--authorization <text>]` | apply: backups, ticket receipt lines, `.gitignore`, binding | preview writes nothing |
55
+ | `check` | `C-NN --candidate <sha> [--timeout <seconds>] -- <command...>` | an attempt | candidate-context check on a clean view of the committed candidate |
56
+ | `evidence export` | `--candidate <sha> --base <sha> --prd .prd/prd-vN.md --draft <file>` | `.prd/evidence/prd-vN/<candidate>/` | schema 2 manifest and logs populated from attempts |
57
+
58
+ Exit codes:
59
+
60
+ | Code | Meaning |
61
+ | --- | --- |
62
+ | 0 | success; for `status`, inspection succeeded even when work is not ready |
63
+ | 1 | the check failed, the readiness gate is not ready, or a transition was refused |
64
+ | 2 | usage error (unknown command or option, missing argument) |
65
+ | 3 | state busy: the lock is held by a live owner after the bounded wait |
66
+ | 4 | invalid input or unreadable state (malformed ticket, PRD, binding, index, manifest) |
67
+ | 124 | the check timed out |
68
+ | 130 | the check was interrupted (SIGINT or SIGTERM received by the runtime) |
69
+
70
+ Wrapper mappings: `scripts/pincer-ticket.sh <start|verify|done|bind> …` calls the
71
+ command of the same name and returns its exit code; `scripts/pincer-status.sh` calls
72
+ `status` (reading `PINCER_BUILD_BUDGET_MIN` from the environment) and returns its exit
73
+ code. Both print a Node.js 18+ requirement message and exit 4 when `node` is absent.
74
+
75
+ ## Supported grammar
76
+
77
+ The grammar is deliberately restricted; unsupported syntax is rejected before any
78
+ mutation, with the diagnostics listed in `scripts/pincer-runtime/parse.cjs`.
79
+
80
+ - **Frontmatter:** the file begins with `---`; each field is an unindented
81
+ `key: value` line (`[a-z_][a-z0-9_]*`), unique, optionally followed by a `#` comment;
82
+ blank and comment lines are allowed; a `---` line closes it. Tilde fences are
83
+ unsupported everywhere.
84
+ - **Ticket:** required `ticket` (`T-01`..`T-999999`, matching the filename
85
+ `T-NN-slug.md`), `status` (`open|in_progress|done`), `size` (`S|M|L`),
86
+ `depends_on` (inline list `[T-01, T-02]`, no duplicates, no self reference). Optional
87
+ `prd` (`.prd/prd-vN.md`), `started`/`finished` (ISO UTC `YYYY-MM-DDTHH:MM:SSZ`),
88
+ legacy `verified` (`<timestamp> <12 hex>`), legacy `last_check`
89
+ (`<timestamp> running|passed|failed|interrupted <12 hex>`), and `timeout` (a positive
90
+ integer number of seconds, at most 2147483; default 600). Exactly one `## Acceptance Criteria` section
91
+ with at least one checkbox (`- [ ] text`, `- [x] text`, `- [X] text`; `-`, `+`, `*`
92
+ or numbered markers, indentation allowed; no fences inside). Exactly one
93
+ `## Verification` section containing exactly one closed fenced `bash` block with at
94
+ least one runnable line and valid Bash syntax (`bash -n`). Headings inside other
95
+ fenced blocks are ignored.
96
+ - **PRD:** frontmatter `version` equal to the filename number, `status`
97
+ (`draft|ticketed|built`), optional `profile` (`small|standard`).
98
+ - **NOTES.md:** frontmatter `prd`, `base` and `candidate` (full 40-hex commit IDs),
99
+ `evidence` (manifest path); NOTES without `evidence` is a legacy evaluation.
100
+ - **Change ID:** `[a-z0-9][a-z0-9-]{0,63}`.
101
+
102
+ ## Change binding
103
+
104
+ `.prd/changes/<change-id>.json`, written only by `register` and `migrate --apply`,
105
+ tracked in git, schema 1:
106
+
107
+ | Field | Value |
108
+ | --- | --- |
109
+ | `schema` | `1` |
110
+ | `change` | the change ID (default `prd-vN`) |
111
+ | `prd` | `.prd/prd-vN.md` |
112
+ | `prd_revision` | the PRD content digest (below) |
113
+ | `base` | HEAD at registration, 40 hex |
114
+ | `registered` | ISO UTC timestamp |
115
+ | `authorization` | the recorded authorization reference when supplied, else `null`; registration never infers approval from PRD status |
116
+ | `runtime` | `1` |
117
+ | `legacy_receipts` | `{ "T-NN": { "verified": "...", "last_check": "..." } }` imported by migration, else `{}` |
118
+
119
+ Registration (and migration) adds `.pincer/` to `.gitignore` before writing the
120
+ binding, and the installer adds it on `init` and `update`, so local runtime state is
121
+ never an untracked change. Exactly one binding may exist per worktree in this increment. A second file, a file
122
+ for another PRD (without `--replace`), malformed JSON, an unsupported `schema`, or a
123
+ `prd_revision` that no longer matches the PRD content are diagnosed before any child
124
+ process starts (`AMBIGUOUS`, `CHANGE_REQUIRED`, `MALFORMED`, `UNSUPPORTED_SCHEMA`,
125
+ `REVISION_CHANGED`). Copying a binding into another repository requires its `base`
126
+ to exist there and its `prd` path to resolve; otherwise it is `UNSUPPORTED_INPUT`.
127
+ Binding fields are recorded on every attempt separately; a binding is never hashed
128
+ into the source manifest.
129
+
130
+ ## Content revisions
131
+
132
+ Digests are SHA-256, hex, shown shortened to 12 characters in human output and in
133
+ full in records. Line endings are not normalized.
134
+
135
+ | Digest | Input |
136
+ | --- | --- |
137
+ | PRD revision (`prd_revision`, `prdDigest`) | the PRD file with the frontmatter `status` line removed |
138
+ | Ticket authored digest (`ticket_digest`) | the ticket file with the frontmatter `status`, `started`, `finished`, `verified` and `last_check` lines removed and every Acceptance Criteria checkbox mark normalized to `[ ]` |
139
+ | Check digest (`check_digest`) | the Verification block text (the lines between the fences) followed by `\ntimeout=<effective seconds>` |
140
+ | Source digest | the source manifest (next section) |
141
+
142
+ These are the only normalization exceptions. Acceptance text, `depends_on`, `size`,
143
+ `timeout`, the Verification block and the PRD body always contribute. Validation of
144
+ the normalized fields is separate: normalization never makes an invalid ticket
145
+ eligible.
146
+
147
+ ## Source manifest
148
+
149
+ Source manifest schema 1 is a JSON object `{ schema: 1, digest, files: [], excluded: [],
150
+ limitations: [] }`. Each `files` entry is `{ path, sha256, mode }` or
151
+ `{ path, deleted: true }`; `mode` is `100644` or `100755` (the executable bit is part
152
+ of identity). The digest is SHA-256 over the lines `<mode or deleted> <sha256 or ->
153
+ <path>\n` sorted by path in byte order.
154
+
155
+ Inclusion: every path from `git ls-files -z --cached --others --exclude-standard`
156
+ relative to the project root (tracked files, plus untracked files that are not
157
+ ignored), so tests, configuration, lockfiles and new files all count. A tracked file
158
+ missing from the working tree is a `deleted` entry. Ticket files (`tickets/T-*.md`) and
159
+ PRDs (`.prd/prd-v*.md`) are hashed after the normalization above, so ticking a box or
160
+ a lifecycle transition does not change the digest.
161
+
162
+ Fixed exclusions (never inputs): `.git/`, `.pincer/`, `NOTES.md`, `.prd/evidence/`,
163
+ `.prd/changes/`. This is why a new evidence artifact or NOTES edit cannot invalidate
164
+ the snapshot that produced it; the separate post-candidate commit policy (only
165
+ NOTES.md and the manifest's listed files may follow the candidate) is unchanged.
166
+
167
+ Configured exclusions: the optional tracked file `.prd/source-exclude`, one pattern
168
+ per line (`#` comments; `dir/` prefixes; `*`, `**` and `?` globs; a pattern without
169
+ `/` matches a basename anywhere). The file is itself an input. A pattern may exclude
170
+ only untracked paths; a pattern that matches any tracked file, anything under
171
+ `tickets/`, any `.prd/prd-v*.md` or the exclude file itself is refused
172
+ (`UNSUPPORTED_INPUT`) with the offending pattern and path named.
173
+
174
+ Secret paths: a path whose basename is `.env` or starts with `.env.` (except
175
+ `.env.example`) blocks with `SECRET_PATH` naming only the path; its contents are never
176
+ read or hashed. `.env.example` is ordinary input.
177
+
178
+ Unsupported inputs, refused explicitly rather than silently omitted: symbolic links
179
+ (git mode `120000` or a symlink on disk), submodules (mode `160000`), and a project
180
+ that is not inside a git repository.
181
+
182
+ Limitations recorded on every manifest and attempt: ignored paths (for example
183
+ `node_modules/`, `dist/`) are listed by name as not part of the identity; external
184
+ services and installed toolchains are not part of the identity; source equality does
185
+ not prove the environment stayed healthy.
186
+
187
+ ## Attempts
188
+
189
+ Local state lives under `<root>/.pincer/runtime/` and belongs to the worktree; it is
190
+ ignored by git (`.pincer/` in `.gitignore`, added by migration) and never edited by
191
+ hand.
192
+
193
+ | Path | Content |
194
+ | --- | --- |
195
+ | `index.json` | `{ schema: 1, sequence: <last allocated>, current: { "<context key>": "<attempt id>" }, running: ["<attempt id>"] }` |
196
+ | `attempts/<attempt id>.json` | one attempt record |
197
+ | `attempts/<attempt id>/stdout.log`, `stderr.log` | sanitized captured output |
198
+ | `manifests/<digest>.json` | content-addressed source manifests |
199
+ | `lock/` | the exclusive lock directory; `lock/owner.json` is `{ pid, ppid, host, started, command }` |
200
+ | `journal/` | temporary files for atomic replacement |
201
+
202
+ Context keys: `ticket:<change>:<T-NN>` and `candidate:<40 hex>:<C-NN>`. Attempt IDs
203
+ are `<sequence, 6 digits>-<UTC compact timestamp>-<6 hex>`; the `sequence` in
204
+ `index.json` is the authority for ordering (timestamps alone never order attempts), and
205
+ `index.current[key]` is the authority for the latest attempt: when the record it names
206
+ is missing or unreadable, readiness is `EVIDENCE_MISSING`, never an older record.
207
+
208
+ Attempt record schema 1:
209
+
210
+ | Field | Value |
211
+ | --- | --- |
212
+ | `schema`, `runtime` | `1`, `1` |
213
+ | `id`, `sequence` | as above |
214
+ | `context` | `{ kind: "ticket" \| "candidate", change, prd, prd_revision, base, ticket?, ticket_digest?, candidate?, check? }` |
215
+ | `check` | `{ digest, display, timeout_seconds }` (display is the sanitized command text) |
216
+ | `outcome` | `running \| passed \| failed \| interrupted \| timed_out \| error` |
217
+ | `exit_code`, `signal` | the child's exit code or signal, else `null` |
218
+ | `runner` | `{ shell: "<bash path>", args: ["-eo", "pipefail", "-c"], version: "<first line of bash --version>" }` |
219
+ | `cwd` | the working directory relative to the root (`.`) |
220
+ | `environment` | `{ os, node, declared: {} }`; declared nonsecret context only, never a dump |
221
+ | `started`, `finished` | ISO UTC; `finished` is `null` while running |
222
+ | `source` | `{ before: <digest>, after: <digest or null>, files: <count>, limitations: [] }` |
223
+ | `artifacts` | `{ stdout: { path, sha256, bytes, truncated, redactions }, stderr: { ... } }` |
224
+ | `owner` | `{ pid, ppid, host }` (the runtime process) |
225
+ | `child` | `{ pid }` of the launched process group, else `null` |
226
+ | `limitations` | strings |
227
+ | `error` | message when `outcome` is `error`, else absent |
228
+
229
+ Rules: the `running` record is written and `index.current[key]` points at it before
230
+ the child starts, so prior readiness for the context is superseded immediately; the
231
+ outcome is `passed` only when the exit code is 0, the check digest is unchanged, and
232
+ `source.before` equals `source.after`; a nonzero exit is `failed`; exceeding the
233
+ timeout is `timed_out` (exit 124); SIGINT/SIGTERM to the runtime is `interrupted`
234
+ (exit 130); a launch failure, an unwritable state directory, a sanitizer failure or a
235
+ source mutation during the run is `error` (a mutation names the first changed path in
236
+ `error`). An attempt is never left `running` on an exit path the runtime controls; a
237
+ forced kill leaves `running`, which status reports as non-ready and `recover`
238
+ finalizes as `interrupted` after verifying the owner pid is dead on this host,
239
+ terminating the orphaned child process group when it is still alive (SIGTERM, then
240
+ SIGKILL when it is still running after the 5 s grace period; `recover` waits for that
241
+ before returning, records what it sent, and records the digests of the logs the dead
242
+ runner captured).
243
+
244
+ A record is evidence only when it is complete and was written for the context it is
245
+ read for: readiness validates the record against this schema (every field above
246
+ through `artifacts`; `owner`, `child`, `limitations` and `error` are not checked; `finished`
247
+ and the artifact digests must be present once the outcome is not `running`, except
248
+ that an `interrupted` record may carry `null` digests when a `recover` older than their
249
+ recording finalized it), that its context key equals the key the index was read for,
250
+ and that its `id` is the one `index.current[key]` names. A record that is incomplete,
251
+ malformed, written for another ticket or check, or copied over the pointed-at record
252
+ is `ATTEMPT_ERROR` (never ready) and `evidence export` refuses it; export also refuses
253
+ an `interrupted` record without log digests. Readiness and export
254
+ also compare each captured log with the digest the record carries: a log that was
255
+ altered after the run is `EVIDENCE_MISSING` (the reason names the stream) and export
256
+ refuses it; a missing log is `EVIDENCE_MISSING` as before. This detects mistakes and
257
+ stale copies, not a deliberate rewrite of both the log and its record.
258
+
259
+ Lock: acquired by building `lock/` with its `owner.json` in a staging directory and
260
+ renaming it into place (atomic; a waiter never sees an owner-less lock); waiters poll
261
+ every 100 ms for up to 10 s (`PINCER_LOCK_WAIT_MS` overrides the bound), then fail with
262
+ exit 3 naming the owner. A lock whose owner pid is on this host and no longer alive is
263
+ reclaimed with a diagnostic: the waiter renames the stale directory to a private name
264
+ first, re-reads its owner, and only the process that won the rename removes it, so two
265
+ waiters cannot both acquire; a live owner is never stolen; a foreign host is never
266
+ reclaimed automatically. The lock is held while records are written and
267
+ released while the child runs. Every write goes to `journal/` on the same filesystem
268
+ and is renamed into place; a stray journal file is ignored on read and reported by
269
+ `recover`. A malformed `index.json` is exit 4 and is never overwritten by inspection.
270
+
271
+ Timeout: the effective timeout is the ticket's `timeout` field or 600 s, or `--timeout`
272
+ for `check`; it is part of the check digest. On expiry (and on SIGINT/SIGTERM) the
273
+ runtime sends SIGTERM to the child's process group, waits 5 s, then sends SIGKILL,
274
+ whether or not the shell itself has already exited: a background child that inherited
275
+ the output pipes keeps the attempt open and is terminated the same way, so a check that
276
+ exits 0 leaving one behind is `timed_out`. If the pipes are still open 2 s after
277
+ SIGKILL, capture is abandoned and the record's limitation says a descendant may still
278
+ be running; the attempt never waits for a descendant's own schedule. The limitation
279
+ names the signals a kill call delivered, or says that no reachable process remained
280
+ in the group (a descendant that left the group with `setsid` holds the pipes and
281
+ survives; see "Platform limits"). Signalling the group after the shell was reaped
282
+ relies on the group id not being reused while any member is alive; between the
283
+ SIGTERM and SIGKILL attempts an empty group's id could in principle be reused by an
284
+ unrelated process, which is accepted as a limit.
285
+
286
+ ## Capture and sanitization
287
+
288
+ stdout and stderr are captured separately as streams, each stored up to 1 MiB
289
+ (1,048,576 bytes); beyond that the runtime keeps counting and appends
290
+ `[pincer: truncated, <N> more bytes not stored]` and records `truncated: true`. Each
291
+ line is sanitized before it is persisted: values matched by the documented patterns
292
+ are replaced with `[redacted]` and counted in `redactions`. Patterns: assignments or
293
+ JSON fields whose key contains `key`, `secret`, `password`, `passwd`, `token` or
294
+ `authorization` (case-insensitive) followed by `:` or `=`; `Bearer <token>`; PEM
295
+ private key blocks; AWS access key IDs (`AKIA` + 16 characters); GitHub tokens
296
+ (`gh[pousr]_` + 20 or more characters). The sanitizer is a safety net, not a
297
+ guarantee: checks must avoid printing secrets, no raw unredacted log is exported, and
298
+ the runtime never persists the environment. A Verification block that assigns a
299
+ secret-like variable inline (`TOKEN=literal`, not a `$reference`) is refused before
300
+ launch with the line number. If capture or sanitization fails, the attempt is `error`,
301
+ never a pass claiming complete output.
302
+
303
+ ## Readiness and reason codes
304
+
305
+ Readiness is one pure computation (`scripts/pincer-runtime/readiness.cjs`) consumed
306
+ by the human status, the JSON status, `ready`, `done`, `start` (for dependencies) and
307
+ release. Lifecycle `done` and verification readiness are distinct: an old done ticket
308
+ can be stale or failed without its `finished` date changing.
309
+
310
+ | Code | Meaning | Next action |
311
+ | --- | --- | --- |
312
+ | `CHANGE_REQUIRED` | no change binding names this PRD | `register` or `migrate` |
313
+ | `MIGRATION_REQUIRED` | legacy receipts exist and the command needs runtime state | `migrate --preview` |
314
+ | `REVISION_CHANGED` | PRD content differs from the bound revision | `register --rebind` |
315
+ | `CHECK_CHANGED` | the Verification block or timeout changed since the latest pass | `verify` |
316
+ | `SOURCE_CHANGED` | the source digest differs from the verified one (paths named) | `verify` |
317
+ | `CHECK_FAILED` | the latest attempt failed | fix, then `verify` |
318
+ | `ATTEMPT_RUNNING` | an attempt is `running` | wait, or `recover` if its owner died |
319
+ | `ATTEMPT_INTERRUPTED` | the latest attempt was interrupted | `verify` |
320
+ | `ATTEMPT_TIMED_OUT` | the latest attempt timed out | fix or raise `timeout`, then `verify` |
321
+ | `ATTEMPT_ERROR` | the latest attempt could not be recorded, is incomplete or malformed, belongs to another context, or mutated source | inspect the record, then `verify` |
322
+ | `EVIDENCE_MISSING` | no attempt, missing or altered log, or missing local state | `verify` |
323
+ | `LEGACY_RECEIPT` | only a migrated legacy receipt exists | `verify` |
324
+ | `CRITERIA_UNTICKED` | unticked acceptance criteria | tick verified criteria |
325
+ | `DEPENDENCY_BLOCKED` | a `depends_on` ticket is not ready | finish the dependency |
326
+ | `INPUT_INVALID` | malformed ticket, PRD or NOTES | repair the input |
327
+ | `CANDIDATE_STALE` | NOTES/evidence do not describe HEAD (reason quoted) | `/pincer-evaluate` |
328
+ | `STATE_BUSY` | the lock is held | retry, or `recover` |
329
+ | `SECRET_PATH` | a secret file is in the source view | remove or ignore it |
330
+ | `UNSUPPORTED_INPUT` | symlink, submodule, no git, or a refused exclusion | remove the input or change the configuration |
331
+
332
+ Status JSON schema 1 (one object on stdout; diagnostics on stderr; no progress text,
333
+ no secret values):
334
+
335
+ ```
336
+ { schema: 1, runtime: 1, generated, root, mode: "legacy" | "migrated",
337
+ change: { id, prd, prd_revision, base } | null,
338
+ prd: { path, status, profile, date } | null,
339
+ tickets: [ { id, file, status, size, depends_on, started, finished,
340
+ readiness: { ready, reasons: [ { code, detail } ], next },
341
+ latest_attempt: { id, sequence, outcome, started, finished } | null,
342
+ legacy_receipt: { verified, last_check } | null } ],
343
+ history: <tickets of other PRDs>,
344
+ candidate: { notes: "current" | "stale" | "missing", reason, candidate, base,
345
+ evidence: { manifest, schema, provenance: "runtime" | "legacy" | null, verdict },
346
+ local_attempts: "available" | "unavailable",
347
+ reasons: [ { code, detail } ] },
348
+ reasons: [ { code, detail } ], next }
349
+ ```
350
+
351
+ `status` exits 0 when inspection succeeded; `ready` exits 1 for non-ready work; both
352
+ exit 4 on invalid input.
353
+
354
+ ## Evidence schema 2
355
+
356
+ Schema 2 keeps every schema 1 field and rule and adds:
357
+
358
+ - top-level `change`: `{ id, prd_revision, base }` copied from the binding; its `base`
359
+ is HEAD at registration and may precede the evaluation `base` the manifest records;
360
+ - `provenance` on every check: `runtime` for command checks populated from attempts,
361
+ `authored` for review and visual checks and for a command check recorded as
362
+ `unverified` because its tool could not run; a `passed` or `failed` command check
363
+ must be `runtime`;
364
+ - `attempt` on runtime command checks: `{ id, sequence, outcome, exit_code, started,
365
+ finished, source_before, source_after, check_digest, runner, cwd, log_sha256,
366
+ truncated }`; the check's log artifact digest must equal `log_sha256`, `result` must
367
+ agree with `outcome` (`passed` → `passed`; `failed`, `timed_out`, `interrupted` →
368
+ `failed`; `error` → `unverified`), and `command` is the attempt's display text.
369
+
370
+ `evidence export` reads a draft JSON containing the authored fields (`environment.tools`,
371
+ `environment.limitations`, `coverage_review`, `requirements`, review and visual checks
372
+ with artifact paths, `visual_review`, and command check stubs `{ id, kind: "command",
373
+ required }`), refuses when any command stub has no attempt for the candidate, writes
374
+ `checks/C-NN.log` (the command line, then stdout, then stderr, with truncation notes),
375
+ fills the runtime fields, computes `artifacts` digests, writes `manifest.json` and
376
+ validates it. It never invents a review transcript or converts a review judgment into a
377
+ command result.
378
+
379
+ `check` refuses unless HEAD is `--candidate` (or a descendant that differs from it
380
+ only in `NOTES.md` and `.prd/evidence/prd-vN/<candidate>/`, such as the evaluate
381
+ commit), the binding is current, and `git status --porcelain --untracked-files=all`
382
+ lists nothing outside those same paths; it never stashes, resets or commits.
383
+
384
+ Validation: `node scripts/pincer-evidence.cjs validate <manifest> …` accepts schema 1
385
+ and 2 and prints `ok <candidate>` for schema 1 (unchanged) and `ok <candidate>
386
+ schema 2` for schema 2. The draft for `evidence export` should live outside the
387
+ evidence directory and the source view, for example `.pincer/drafts/<candidate>.json`. Status labels schema 1 `provenance:
388
+ legacy (schema 1, authored command results)` and schema 2 `provenance: runtime`. A
389
+ schema 1 manifest cannot satisfy a requirement for runtime evidence. Release
390
+ readiness additionally requires that no newer local attempt for the same candidate and
391
+ check with the same source digest is nonpassing; a fresh clone without
392
+ `.pincer/runtime/` reports `local verification history unavailable; saved candidate
393
+ evidence validated only`. Local capture establishes provenance and mistake detection,
394
+ not tamper-proof attestation.
395
+
396
+ ## Migration and rollback
397
+
398
+ `migrate --preview --prd .prd/prd-vN.md` prints the plan and writes nothing: the
399
+ binding it would write, each ticket of that PRD whose `verified`/`last_check` lines
400
+ would be removed and recorded as `legacy_receipts`, the `.gitignore` line it would
401
+ add, and every conflict. It exits 0 when apply would proceed and 1 when a conflict
402
+ would stop it. Conflicts (all fail closed, before the first write): a binding for
403
+ another PRD, malformed tickets, an unsupported binding schema, a ticket of another PRD
404
+ with the same ID. A partially applied earlier migration (binding present, receipts
405
+ remaining) is not a conflict: preview names it and apply completes it, extending the
406
+ existing binding's `legacy_receipts`.
407
+
408
+ `migrate --apply` backs up every authored file it changes under
409
+ `.pincer/backups/<UTC timestamp>/<original path>`, rewrites the tickets, adds
410
+ `.pincer/` to `.gitignore`, then writes the binding last. Repeated apply reports
411
+ `already migrated` and changes nothing. Imported receipts are history: a migrated done
412
+ ticket reports `LEGACY_RECEIPT` until a runtime attempt exists, without changing
413
+ `finished`. Installation and update deploy the runtime files and `doctor` reports when
414
+ a migration is available; neither migrates.
415
+
416
+ Rollback: restore the files from the backup directory (they are byte-identical
417
+ originals), delete `.prd/changes/<id>.json`, and remove `.pincer/`. The project is
418
+ then in legacy mode with its original receipts. An older runtime does not enforce the
419
+ runtime guarantees: it will accept the restored receipts as it did before.
420
+
421
+ ## Legacy compatibility
422
+
423
+ For an unmigrated project nothing changes: the ticket and status commands keep their
424
+ names, arguments, output lines and diagnostics; receipts stay in the ticket; `done`
425
+ re-runs the check; `.pincer/` is never created; schema 1 evidence validates. The only
426
+ additions are the `Runtime legacy …` status line, the exit code 4 for invalid input,
427
+ and the `--json` form of status. A project that already carries `.pincer/` from a
428
+ migrated worktree is migrated; it cannot be half in each mode.
429
+
430
+ ## Platform limits
431
+
432
+ The check runner is a POSIX contract: `bash` in `PATH`, process groups
433
+ (`detached: true`, `kill(-pid)`), `SIGTERM`/`SIGKILL`. Native Windows is not
434
+ supported and not claimed. The CI matrix (`.github/workflows/ci.yml`: ubuntu and macOS
435
+ × Node 18 and 22) is the target surface; a release claims only the runs it can cite,
436
+ and any platform outside the matrix is untested. Sandbox and approval controls of the
437
+ host stay in force; the runtime never bypasses them.