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.
- package/README.md +7 -5
- package/bin/pincer.js +42 -5
- package/package.json +2 -2
- package/template/.agents/skills/pincer-code/SKILL.md +51 -4
- package/template/.agents/skills/pincer-evaluate/SKILL.md +29 -2
- package/template/.agents/skills/pincer-narrow/SKILL.md +11 -2
- package/template/.agents/skills/pincer-plan/SKILL.md +9 -1
- package/template/.agents/skills/pincer-release/SKILL.md +14 -3
- package/template/.agents/skills/pincer-status/SKILL.md +17 -2
- package/template/.claude/commands/pincer-code.md +51 -4
- package/template/.claude/commands/pincer-evaluate.md +29 -2
- package/template/.claude/commands/pincer-narrow.md +11 -2
- package/template/.claude/commands/pincer-plan.md +9 -1
- package/template/.claude/commands/pincer-release.md +14 -3
- package/template/.claude/commands/pincer-status.md +17 -2
- package/template/.claude/hooks/hook-policy.cjs +17 -3
- package/template/.claude/references/ticket-template.md +4 -0
- package/template/.codex/README.md +3 -2
- package/template/.github/prompts/pincer-code.prompt.md +51 -4
- package/template/.github/prompts/pincer-evaluate.prompt.md +29 -2
- package/template/.github/prompts/pincer-narrow.prompt.md +11 -2
- package/template/.github/prompts/pincer-plan.prompt.md +9 -1
- package/template/.github/prompts/pincer-release.prompt.md +14 -3
- package/template/.github/prompts/pincer-status.prompt.md +17 -2
- package/template/AGENTS.md +6 -0
- package/template/docs/dry-run-checklist.md +46 -3
- package/template/docs/release-checklist.md +2 -1
- package/template/docs/runtime-contracts.md +437 -0
- package/template/scripts/pincer-evidence.cjs +5 -223
- package/template/scripts/pincer-runtime/evidence.cjs +391 -0
- package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
- package/template/scripts/pincer-runtime/identity.cjs +146 -0
- package/template/scripts/pincer-runtime/lifecycle.cjs +289 -0
- package/template/scripts/pincer-runtime/migrate.cjs +127 -0
- package/template/scripts/pincer-runtime/parse.cjs +297 -0
- package/template/scripts/pincer-runtime/readiness.cjs +89 -0
- package/template/scripts/pincer-runtime/runner.cjs +224 -0
- package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
- package/template/scripts/pincer-runtime/source.cjs +129 -0
- package/template/scripts/pincer-runtime/state.cjs +292 -0
- package/template/scripts/pincer-runtime/status.cjs +358 -0
- package/template/scripts/pincer-runtime.cjs +350 -0
- package/template/scripts/pincer-status.sh +11 -162
- package/template/scripts/pincer-ticket.sh +19 -139
- 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.
|