pincer-workflow 1.0.0 → 1.0.2
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 +36 -14
- package/bin/pincer.js +48 -13
- package/package.json +2 -2
- package/template/.agents/skills/pincer-blueprint/SKILL.md +1 -1
- package/template/.agents/skills/pincer-status/SKILL.md +1 -1
- package/template/.claude/hooks/hook-policy.cjs +73 -14
- package/template/.claude/pincer/library/stages/release/SKILL.md +23 -20
- package/template/docs/engine.md +26 -14
- package/template/docs/release-checklist.md +18 -13
- package/template/scripts/pincer/generate.cjs +3 -1
- package/template/scripts/pincer/status.cjs +62 -8
- package/template/scripts/pincer/ticket.cjs +2 -1
package/README.md
CHANGED
|
@@ -9,14 +9,15 @@ Copilot from one kit, and it has no dependencies.
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
PINCER 1.0 is
|
|
13
|
-
|
|
14
|
-
into your project and install that exact release:
|
|
12
|
+
PINCER 1.0 is live on [npm](https://www.npmjs.com/package/pincer-workflow).
|
|
13
|
+
Install it in your project:
|
|
15
14
|
|
|
16
15
|
```bash
|
|
17
|
-
npx
|
|
16
|
+
npx pincer-workflow@latest init --platform all
|
|
18
17
|
```
|
|
19
18
|
|
|
19
|
+
The same package is also attached to the [GitHub release](https://github.com/orchestratedbyalex/pincer-workflow/releases/tag/v1.0.2).
|
|
20
|
+
|
|
20
21
|
Node.js 22+, git and bash are required. PINCER uses your coding tool's own
|
|
21
22
|
authentication; it does not require a provider API key.
|
|
22
23
|
|
|
@@ -27,8 +28,9 @@ Then start your agent in the repository and run the blueprint:
|
|
|
27
28
|
$pincer-blueprint # Codex CLI
|
|
28
29
|
```
|
|
29
30
|
|
|
30
|
-
Until the blueprint has run there is no agreement, and every other stage refuses
|
|
31
|
-
|
|
31
|
+
Until the blueprint has run there is no agreement, and every other stage refuses.
|
|
32
|
+
Codex-only installs point to `$pincer-blueprint`; Claude Code, Copilot and mixed
|
|
33
|
+
installs use `/pincer-blueprint`. A draft spec stays at the plan approval step. `scripts/pincer-status.sh` works in any
|
|
32
34
|
shell from the first minute and always ends with the next command.
|
|
33
35
|
|
|
34
36
|
## The journey
|
|
@@ -53,7 +55,7 @@ The stages the agreement keeps, and what the interview can bend:
|
|
|
53
55
|
| narrow | `/pincer-narrow` | spec to tickets with runnable checks | tickets as local files or as forge issues; one ticket per change |
|
|
54
56
|
| code | `/pincer-code` | build tickets in order, one commit each, verify, done | branch per ticket or commit to main; PR or no PR; implementer agent or the main session |
|
|
55
57
|
| evaluate | `/pincer-evaluate` | review the diff with the roster, run the checks, save evidence | review in the PR instead of a local report; evidence on or off; the roster |
|
|
56
|
-
| release | `/pincer-release` | read-only audit, then acceptance recorded | audit a merged PR and a deploy instead of a clean tree; who accepts |
|
|
58
|
+
| release | `/pincer-release` | read-only audit, then acceptance recorded and committed | audit a merged PR and a deploy instead of a clean tree; who accepts |
|
|
57
59
|
| status | `/pincer-status` | where the work stands, the next command | nothing; always on |
|
|
58
60
|
|
|
59
61
|
Each stage is a short skill with the same six sections (Reads, Steps, Checks, Writes,
|
|
@@ -68,7 +70,9 @@ yours to edit; the Codex skills and Copilot prompts are generated from them.
|
|
|
68
70
|
check, or with an unticked criterion.
|
|
69
71
|
- `scripts/pincer-ticket.sh accept T-NN --by "<name>" --words "<their words>"` records
|
|
70
72
|
the acceptance of a done ticket: a person named in the agreement, quoted. The agent
|
|
71
|
-
runs it only after the person said so.
|
|
73
|
+
runs it only after the person said so. Release commits those three acceptance
|
|
74
|
+
fields separately, checks the final tree is clean, and keeps the evaluated
|
|
75
|
+
candidate distinct from the acceptance commit.
|
|
72
76
|
- The eight lifecycle fields of a ticket are written only by that script. On Claude
|
|
73
77
|
Code a hook enforces it; on Codex and Copilot it is a standing rule, and status flags
|
|
74
78
|
a missing, failed or stale receipt and every done ticket without acceptance.
|
|
@@ -98,7 +102,7 @@ codes, the gate, the manifest, generation, recovery.
|
|
|
98
102
|
| `.claude/agents/` | the review agents the roster names, with the model of their tier |
|
|
99
103
|
| `.codex/agents/` | the same agents as Codex agent files, generated when Codex is installed |
|
|
100
104
|
| `.claude/pincer/library/models.json` | the tier table: the model each platform runs a tier on |
|
|
101
|
-
| `docs/engine.md`, `docs/release-checklist.md` | the engine contract and the
|
|
105
|
+
| `docs/engine.md`, `docs/release-checklist.md` | the engine contract and the release audit |
|
|
102
106
|
| `specs/`, `tickets/`, `evidence/` | the default homes, all overridable in `harness.json` |
|
|
103
107
|
|
|
104
108
|
Secrets never enter the kit: `init` adds `.env`, `.env.*` and `!.env.example` to
|
|
@@ -136,23 +140,41 @@ each installed platform will run.
|
|
|
136
140
|
## Update
|
|
137
141
|
|
|
138
142
|
```bash
|
|
139
|
-
npx
|
|
140
|
-
npx
|
|
143
|
+
npx pincer-workflow@latest update # edited files get a .new sidecar
|
|
144
|
+
npx pincer-workflow@latest update --platform claude # the same, and add a platform to the install
|
|
145
|
+
npx pincer-workflow@latest doctor # checks the install and agreement
|
|
141
146
|
```
|
|
142
147
|
|
|
143
148
|
With an agreement in place, `update` also refreshes the library and regenerates the
|
|
144
149
|
project copies you did not edit; an edited copy stays and gets the new version beside
|
|
145
150
|
it as `<file>.new`.
|
|
146
151
|
|
|
152
|
+
`update --platform <claude|codex|copilot|all>` adds a platform to an existing install,
|
|
153
|
+
for example Claude Code to a project that started on Codex. The platforms already
|
|
154
|
+
installed stay, the agreement and the tickets are shared, a file the project already
|
|
155
|
+
had (its own `CLAUDE.md`) is kept with the kit's version beside it as `.new`, and the
|
|
156
|
+
stage copies are generated for the new platform. `init` refuses a second install and
|
|
157
|
+
points here.
|
|
158
|
+
|
|
159
|
+
`doctor` compares every installed file with the package that runs it, not only the
|
|
160
|
+
version number. A file you changed is listed as locally edited. A file that is still
|
|
161
|
+
what was installed but differs from this package came from another build with the
|
|
162
|
+
same version number (a pre-release, a local `npm pack`); `doctor` fails and names
|
|
163
|
+
it, and `update` brings the install to this build.
|
|
164
|
+
|
|
147
165
|
## Trying it locally before release
|
|
148
166
|
|
|
149
167
|
```bash
|
|
150
|
-
npm pack # in this repository: pincer-workflow-1.0.
|
|
151
|
-
cp pincer-workflow-1.0.
|
|
152
|
-
npx ./pincer-workflow-1.0.
|
|
168
|
+
npm pack # in this repository: pincer-workflow-1.0.2.tgz
|
|
169
|
+
cp pincer-workflow-1.0.2.tgz ../some-project/ && cd ../some-project
|
|
170
|
+
npx ./pincer-workflow-1.0.2.tgz init --platform all # the ./ matters: npx treats a bare path as a command
|
|
153
171
|
scripts/pincer-status.sh # prints "no agreement yet" and "Next: /pincer-blueprint"
|
|
154
172
|
```
|
|
155
173
|
|
|
174
|
+
Use a project outside this repository. Claude Code loads every `CLAUDE.md` above the
|
|
175
|
+
working directory, so a trial project nested in this checkout also gets the kit's
|
|
176
|
+
developer instructions and does not behave like a real install.
|
|
177
|
+
|
|
156
178
|
## Status of the older channels
|
|
157
179
|
|
|
158
180
|
- The Claude Code plugin channel is paused; the kit installs with `npx` on every platform.
|
package/bin/pincer.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// npx pincer-workflow init install the kit into the current repo (asks which platforms)
|
|
6
6
|
// npx pincer-workflow update refresh an install, preserving local edits; regenerate the project copies
|
|
7
|
-
// npx pincer-workflow
|
|
7
|
+
// npx pincer-workflow update --platform <name> the same, and add a platform to the install
|
|
8
|
+
// npx pincer-workflow doctor check the health of an install, file by file against this package
|
|
8
9
|
//
|
|
9
10
|
// No dependencies by design. The kit is markdown plus a small Node core; this tool
|
|
10
11
|
// copies it carefully and never overwrites a file you edited: you get a *.new file
|
|
@@ -229,11 +230,15 @@ function regenerate(dir) {
|
|
|
229
230
|
console.log(` generated: ${result.written.length} written, ${result.skipped.length} unchanged, ${result.conflicted.length} kept, ${result.removed.length} removed`);
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
// How each platform starts a stage; `stage` is blueprint on a new install and status on one that grew.
|
|
234
|
+
function platformSteps(platforms, stage) {
|
|
235
|
+
if (platforms.includes('claude')) console.log(` Claude Code start \`claude\` in this repo and run /pincer-${stage}`);
|
|
236
|
+
if (platforms.includes('codex')) console.log(` Codex CLI start \`codex\` in this repo and type $pincer-${stage} (skills load from .agents/skills/; posture notes in .codex/README.md)`);
|
|
237
|
+
if (platforms.includes('copilot')) console.log(` Copilot enable "chat.promptFiles": true in VS Code settings, then /pincer-${stage} in chat`);
|
|
238
|
+
}
|
|
232
239
|
function nextSteps(platforms) {
|
|
233
240
|
console.log('\nNext: run the blueprint. It looks at the repository, interviews you, and writes the agreement (harness.json and the top of AGENTS.md).');
|
|
234
|
-
|
|
235
|
-
if (platforms.includes('codex')) console.log(' Codex CLI start `codex` in this repo and type $pincer-blueprint (skills load from .agents/skills/; posture notes in .codex/README.md)');
|
|
236
|
-
if (platforms.includes('copilot')) console.log(' Copilot enable "chat.promptFiles": true in VS Code settings, then /pincer-blueprint in chat');
|
|
241
|
+
platformSteps(platforms, 'blueprint');
|
|
237
242
|
console.log(' Any session scripts/pincer-status.sh shows where the work stands and the next command');
|
|
238
243
|
}
|
|
239
244
|
|
|
@@ -259,24 +264,33 @@ function parsePlatformFlag(args) {
|
|
|
259
264
|
|
|
260
265
|
async function cmdInit(args) {
|
|
261
266
|
const dir = process.cwd();
|
|
262
|
-
if (readManifest(dir)) fail(`this repo already has PINCER installed (${MANIFEST} exists) — use \`pincer update
|
|
267
|
+
if (readManifest(dir)) fail(`this repo already has PINCER installed (${MANIFEST} exists) — use \`pincer update\`, or \`pincer update --platform <name>\` to add a platform.`);
|
|
263
268
|
const platforms = parsePlatformFlag(args) || (process.stdin.isTTY ? await askPlatforms() : [...PLATFORMS]);
|
|
264
269
|
console.log(`\nInstalling PINCER v${VERSION} for: ${platforms.join(', ')}\n`);
|
|
265
270
|
report(install(dir, platforms, null, {}));
|
|
266
271
|
nextSteps(platforms);
|
|
267
272
|
}
|
|
268
273
|
|
|
269
|
-
|
|
274
|
+
// `--platform` adds to the install: the platforms already there stay, the named ones
|
|
275
|
+
// that are new get their files the way init would write them, and an existing file
|
|
276
|
+
// of theirs (a CLAUDE.md the project already had) is kept with a .new beside it.
|
|
277
|
+
async function cmdUpdate(args = []) {
|
|
270
278
|
const dir = process.cwd();
|
|
271
279
|
const manifest = readManifest(dir);
|
|
272
280
|
if (!manifest) fail(`no ${MANIFEST} here — run \`pincer init\` first.`);
|
|
273
|
-
|
|
281
|
+
const added = (parsePlatformFlag(args) || []).filter((p) => !manifest.platforms.includes(p));
|
|
282
|
+
const platforms = [...manifest.platforms, ...added];
|
|
283
|
+
console.log(`\nUpdating PINCER ${manifest.version} -> ${VERSION} for: ${platforms.join(', ')}${added.length ? ` (adding: ${added.join(', ')})` : ''}\n`);
|
|
274
284
|
// A manifest without a schema could have adopted a local edit as its baseline;
|
|
275
285
|
// there is no safe way to tell those from upstream files after the fact.
|
|
276
286
|
const baseline = TRUSTED_SCHEMAS.includes(manifest.schema) ? manifest.files : null;
|
|
277
287
|
if (!baseline) console.log(' Legacy baselines are untrusted; differing files will be preserved for review.');
|
|
278
|
-
report(install(dir,
|
|
288
|
+
report(install(dir, platforms, baseline, manifest.generated));
|
|
279
289
|
regenerate(dir);
|
|
290
|
+
if (added.length) {
|
|
291
|
+
console.log(`\nAdded ${added.join(', ')}. The agreement and the tickets are shared; start there with the status stage:`);
|
|
292
|
+
platformSteps(added, 'status');
|
|
293
|
+
}
|
|
280
294
|
}
|
|
281
295
|
|
|
282
296
|
function cmdDoctor() {
|
|
@@ -302,6 +316,30 @@ function cmdDoctor() {
|
|
|
302
316
|
const stale = fs.existsSync(TEMPLATE) && manifest.version !== VERSION;
|
|
303
317
|
check(!stale, `install is current (v${manifest.version})`, 'run: pincer update');
|
|
304
318
|
|
|
319
|
+
// The version label is not proof: a pre-release or a local build can carry the same
|
|
320
|
+
// number. Every installed file is compared with this package's copy. One that differs
|
|
321
|
+
// and was changed since it was installed is a local edit; one that still is what was
|
|
322
|
+
// installed came from another build. With an older label the line above says it all.
|
|
323
|
+
const shipped = fs.existsSync(TEMPLATE) ? new Set(filesFor(manifest.platforms)) : new Set();
|
|
324
|
+
const edited = [];
|
|
325
|
+
const foreign = [];
|
|
326
|
+
for (const [rel, installed] of Object.entries(manifest.files)) {
|
|
327
|
+
const p = path.join(dir, rel);
|
|
328
|
+
if (!fs.existsSync(p)) continue;
|
|
329
|
+
const current = sha(fs.readFileSync(p));
|
|
330
|
+
if (shipped.has(rel) && current === sha(fs.readFileSync(path.join(TEMPLATE, rel)))) continue;
|
|
331
|
+
if (current !== installed) edited.push(rel);
|
|
332
|
+
else foreign.push(rel);
|
|
333
|
+
}
|
|
334
|
+
const absent = [...shipped].filter((rel) => !(rel in manifest.files));
|
|
335
|
+
if (fs.existsSync(TEMPLATE) && !stale) {
|
|
336
|
+
const differences = [
|
|
337
|
+
foreign.length ? `from another build labelled v${manifest.version}: ${foreign.join(', ')}` : '',
|
|
338
|
+
absent.length ? `not installed: ${absent.join(', ')}` : '',
|
|
339
|
+
].filter(Boolean).join('; ');
|
|
340
|
+
check(!differences, `installed files match this package (v${VERSION})`, `${differences} — run: pincer update`);
|
|
341
|
+
}
|
|
342
|
+
|
|
305
343
|
// The agreement, checked with the engine this install carries.
|
|
306
344
|
if (!fs.existsSync(path.join(dir, 'harness.json'))) {
|
|
307
345
|
console.log(' note no harness.json yet — run /pincer-blueprint');
|
|
@@ -318,9 +356,6 @@ function cmdDoctor() {
|
|
|
318
356
|
|
|
319
357
|
const obsolete = OBSOLETE.filter((rel) => fs.existsSync(path.join(dir, rel)) && !(rel in manifest.files) && !(rel in generated));
|
|
320
358
|
if (obsolete.length) console.log(` note obsolete kit file(s) from an earlier version, safe to delete: ${obsolete.join(', ')}`);
|
|
321
|
-
const edited = Object.entries(manifest.files)
|
|
322
|
-
.filter(([rel, h]) => fs.existsSync(path.join(dir, rel)) && sha(fs.readFileSync(path.join(dir, rel))) !== h)
|
|
323
|
-
.map(([rel]) => rel);
|
|
324
359
|
if (edited.length) console.log(` note locally edited (kept as-is on update): ${edited.join(', ')}`);
|
|
325
360
|
|
|
326
361
|
const leftovers = [...new Set([...walk(TEMPLATE), ...Object.keys(manifest.files), ...Object.keys(generated)])].flatMap((rel) => {
|
|
@@ -339,7 +374,7 @@ function usage() {
|
|
|
339
374
|
|
|
340
375
|
Usage:
|
|
341
376
|
npx pincer-workflow init [--platform claude|codex|copilot|all]
|
|
342
|
-
npx pincer-workflow update
|
|
377
|
+
npx pincer-workflow update [--platform claude|codex|copilot|all] (--platform adds a platform)
|
|
343
378
|
npx pincer-workflow doctor
|
|
344
379
|
|
|
345
380
|
After init, run the blueprint (/pincer-blueprint or $pincer-blueprint): it writes the
|
|
@@ -351,7 +386,7 @@ const invoked = process.argv[1] && (() => { try { return fs.realpathSync(process
|
|
|
351
386
|
if (invoked) {
|
|
352
387
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
353
388
|
if (cmd === 'init') await cmdInit(rest);
|
|
354
|
-
else if (cmd === 'update') await cmdUpdate();
|
|
389
|
+
else if (cmd === 'update') await cmdUpdate(rest);
|
|
355
390
|
else if (cmd === 'doctor') cmdDoctor();
|
|
356
391
|
else { usage(); process.exit(cmd ? 1 : 0); }
|
|
357
392
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pincer-workflow",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "PINCER
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "PINCER — a harness builder for AI coding agents. It looks at your project, interviews you, writes the project agreement, and runs the pipeline that agreement describes. Claude Code, Codex CLI and GitHub Copilot.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pincer": "bin/pincer.js"
|
|
7
7
|
},
|
|
@@ -28,7 +28,7 @@ says; add no tool, step or role they did not choose.
|
|
|
28
28
|
`.claude/skills/pincer-blueprint/formats/harness.md` (every field of `harness.json`)
|
|
29
29
|
and `.claude/skills/pincer-blueprint/formats/roster-rules.md` (how the roster follows the answers).
|
|
30
30
|
- The prompts under `.claude/skills/pincer-blueprint/prompts/`, one per question, named in the steps.
|
|
31
|
-
-
|
|
31
|
+
- the text that follows the `$pincer-blueprint` mention, if any (when omitted, use the skill's documented default): notes the person gave up front. Treat them as pre-filled answers to confirm, not as answers.
|
|
32
32
|
|
|
33
33
|
## Steps
|
|
34
34
|
1. [session] Run `scripts/pincer-status.sh --stage blueprint` (always `on`; the plain report also says whether `harness.json` exists), then `node scripts/pincer-blueprint.cjs recon --json --network` and read the whole result: `kind`, `reasons`, `facts`, `prefill`, `unavailable`.
|
|
@@ -18,7 +18,7 @@ Run it first in any new session.
|
|
|
18
18
|
- The ticket file of any `in_progress` ticket, and `git status`, to offer a resumption.
|
|
19
19
|
|
|
20
20
|
## Steps
|
|
21
|
-
1. [session] Run `scripts/pincer-status.sh` with
|
|
21
|
+
1. [session] Run `scripts/pincer-status.sh` with the text that follows the `$pincer-status` mention, if any (when omitted, use the skill's documented default) when given (for example `--json`). This stage is always on, so `--stage status` only prints `on`; the plain report is what you need. Read every line, the warnings included.
|
|
22
22
|
2. [session] Report in three lines: where the work is (project, stages, tickets done and open), what is blocked (warnings, invalid tickets, missing acceptances, a stage that is off and that the next step needs), and the `Next:` line quoted as the script printed it.
|
|
23
23
|
3. [session] If a ticket is `in_progress`: read its file, run `git status`, say what is uncommitted, and offer to resume with the command the report names (usually `scripts/pincer-ticket.sh verify T-NN`).
|
|
24
24
|
4. [session] If a warning names a ticket file (a missing, failed or stale receipt, lifecycle fields edited by hand): say what fixes it (run `verify` again; let the person repair the field) and never restore, reset or rewrite a ticket file to clear it.
|
|
@@ -44,6 +44,10 @@ function lexShell(source) {
|
|
|
44
44
|
continue;
|
|
45
45
|
}
|
|
46
46
|
if ('|;&><()'.includes(ch)) {
|
|
47
|
+
// A number glued to a redirect (2>, 1>>, 0<) is its file descriptor, not an argument.
|
|
48
|
+
// The number after `>&` is that redirect's target, even with another redirect glued on.
|
|
49
|
+
const dupTarget = tokens.length > 1 && tokens[tokens.length - 1].op === '&' && tokens[tokens.length - 2].op === '>';
|
|
50
|
+
if ((ch === '>' || ch === '<') && /^[0-9]+$/.test(word) && !dupTarget) word = '';
|
|
47
51
|
push();
|
|
48
52
|
const pair = source.slice(i, i + 2);
|
|
49
53
|
if (['||', '&&', '>>', '<<'].includes(pair)) { tokens.push({ op: pair }); i++; }
|
|
@@ -56,18 +60,42 @@ function lexShell(source) {
|
|
|
56
60
|
return tokens;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
// Where an output redirect can never write a file: the sinks every shell has.
|
|
64
|
+
const SINKS = ['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty'];
|
|
65
|
+
|
|
66
|
+
// Each command's words, its operators and the targets of its output redirects.
|
|
67
|
+
// `redirects` holds one entry per `>`, `>>`, `>|`, `&>` or `>&file`: the target
|
|
68
|
+
// word, or null when none follows. Duplicating or closing a file descriptor
|
|
69
|
+
// (`2>&1`, `>&2`, `2>&-`) and writing to a sink open no file, so they are left
|
|
70
|
+
// out of `redirects` and their target is not an argument of the command either.
|
|
59
71
|
function shellCommands(source) {
|
|
72
|
+
const tokens = lexShell(source);
|
|
60
73
|
const commands = [];
|
|
61
74
|
let words = [];
|
|
62
75
|
let operators = [];
|
|
76
|
+
let redirects = [];
|
|
63
77
|
const finish = (separator = '') => {
|
|
64
|
-
if (words.length || operators.length) commands.push({ words, operators, separator });
|
|
65
|
-
words = []; operators = [];
|
|
78
|
+
if (words.length || operators.length) commands.push({ words, operators, redirects, separator });
|
|
79
|
+
words = []; operators = []; redirects = [];
|
|
66
80
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
const word = (index) => (tokens[index] && tokens[index].value !== undefined ? tokens[index].value : null);
|
|
82
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
83
|
+
const token = tokens[i];
|
|
84
|
+
if (token.value !== undefined) { words.push(token.value); continue; }
|
|
85
|
+
if (token.op !== '>' && token.op !== '>>') {
|
|
86
|
+
if (['|', ';', '&&', '||', '(', ')'].includes(token.op)) finish(token.op);
|
|
87
|
+
else operators.push(token.op);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
operators.push(token.op);
|
|
91
|
+
let at = i + 1;
|
|
92
|
+
const amp = token.op === '>' && tokens[at] && tokens[at].op === '&';
|
|
93
|
+
const clobber = token.op === '>' && !amp && tokens[at] && tokens[at].op === '|';
|
|
94
|
+
if (amp || clobber) at++;
|
|
95
|
+
const target = word(at);
|
|
96
|
+
if (target !== null && ((amp && /^([0-9]+-?|-)$/.test(target)) || SINKS.includes(target))) { i = at; continue; }
|
|
97
|
+
redirects.push(target);
|
|
98
|
+
if (clobber) i++; // `>|` is one operator, not a pipe: its target stays with this command
|
|
71
99
|
}
|
|
72
100
|
finish();
|
|
73
101
|
return commands;
|
|
@@ -177,6 +205,8 @@ function ticketPath(value) {
|
|
|
177
205
|
const ENGINE_FILES = ['scripts/pincer-ticket.sh', 'scripts/pincer-status.sh', 'scripts/pincer-blueprint.cjs', 'scripts/pincer-evidence.cjs', 'scripts/sync-prompts.sh', '.claude/settings.json'];
|
|
178
206
|
const ENGINE_DIRS = ['scripts/pincer', '.claude/hooks'];
|
|
179
207
|
const ENGINE_WORD = /(^|[\\/\s'"`=(])(\.\/)?(scripts[\\/]pincer([\\/]|$)|scripts[\\/](pincer-ticket\.sh|pincer-status\.sh|pincer-blueprint\.cjs|pincer-evidence\.cjs|sync-prompts\.sh)|\.claude[\\/]hooks([\\/]|$)|\.claude[\\/]settings\.json)/;
|
|
208
|
+
// A redirect target that names a ticket, however the path is spelled.
|
|
209
|
+
const TICKET_TARGET = /(^|[\\/])tickets[\\/]+T-[0-9]+/i;
|
|
180
210
|
const ENGINE_MESSAGE = 'the engine (scripts/pincer-*.sh, scripts/pincer-*.cjs, scripts/pincer/) and the hooks (.claude/hooks/, .claude/settings.json) are installed by pincer-workflow and never edited by an agent; refresh them with `npx pincer-workflow update`.';
|
|
181
211
|
|
|
182
212
|
function projectRoot() {
|
|
@@ -262,16 +292,39 @@ function guardEdits(tool, toolInput) {
|
|
|
262
292
|
if (!sameState(before ?? '', after)) block('ticket lifecycle fields may only be changed by pincer-ticket.sh.');
|
|
263
293
|
}
|
|
264
294
|
|
|
295
|
+
// What an output redirect can reach: 'protected' (a ticket, the engine or the
|
|
296
|
+
// hooks), 'other' (a literal path that is none of those) or 'unknown'. A target
|
|
297
|
+
// is unknown when it cannot be resolved here: none was written, the shell
|
|
298
|
+
// computes it ($VAR, a substitution, a glob, ~), or the same command line
|
|
299
|
+
// changes directory or links a file first (`moved`), so a relative or aliased
|
|
300
|
+
// path may land anywhere. Unknown targets are judged as every redirect used to be.
|
|
301
|
+
// A relative target is resolved from the project root and from where the shell
|
|
302
|
+
// stands (the payload's cwd, which an earlier `cd` may have moved).
|
|
303
|
+
function redirectReach(target, moved) {
|
|
304
|
+
if (target === null) return 'unknown';
|
|
305
|
+
if (ENGINE_WORD.test(target) || TICKET_TARGET.test(target)) return 'protected';
|
|
306
|
+
if (moved || /[$`*?[\]{}~!]/.test(target)) return 'unknown';
|
|
307
|
+
const bases = [projectRoot(), process.cwd(), typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : projectRoot()];
|
|
308
|
+
const spellings = [...new Set(bases.map(base => path.resolve(base, target)))];
|
|
309
|
+
for (const file of [...spellings]) {
|
|
310
|
+
try { spellings.push(fs.realpathSync(file)); } catch { /* not there yet: nothing to follow */ }
|
|
311
|
+
}
|
|
312
|
+
if (spellings.some(file => enginePath(file) || ENGINE_WORD.test(file) || TICKET_TARGET.test(file))) return 'protected';
|
|
313
|
+
// A bare T-NN name is a ticket wherever the shell happens to stand.
|
|
314
|
+
if (!path.isAbsolute(target) && /^T-[0-9]+/i.test(path.basename(target))) return 'unknown';
|
|
315
|
+
return 'other';
|
|
316
|
+
}
|
|
317
|
+
|
|
265
318
|
// The one shell writer of lifecycle fields: `pincer-ticket.sh <start|verify|done> T-NN`
|
|
266
319
|
// (three words) or `pincer-ticket.sh accept T-NN ...`, directly or through
|
|
267
320
|
// `node scripts/pincer/cli.cjs ticket ...`, alone on its line.
|
|
268
321
|
function isExactPincerCall(source) {
|
|
269
322
|
const commands = shellCommands(source).filter(command => command.words.length);
|
|
270
323
|
if (commands.length !== 1 || commands[0].separator) return false;
|
|
271
|
-
// An output redirection is a write, whatever runs in front of it: fall
|
|
272
|
-
// ticketShellMutation so its target is tested like any other path. Without
|
|
273
|
-
// adding a verb to the list below silently opened a new carrier for it.
|
|
274
|
-
if (commands[0].
|
|
324
|
+
// An output redirection to a file is a write, whatever runs in front of it: fall
|
|
325
|
+
// through to ticketShellMutation so its target is tested like any other path. Without
|
|
326
|
+
// this, adding a verb to the list below silently opened a new carrier for it.
|
|
327
|
+
if (commands[0].redirects.length) return false;
|
|
275
328
|
const { executable, args, words: raw } = commandParts(commands[0]);
|
|
276
329
|
let words = [raw[raw.length - args.length - 1], ...args];
|
|
277
330
|
if (['bash', 'sh', 'node'].includes(executable)) words = words.slice(1);
|
|
@@ -353,8 +406,13 @@ function ticketShellMutation(source, depth = 0) {
|
|
|
353
406
|
if (depth > 2) return false;
|
|
354
407
|
if (isExactPincerCall(source)) return false;
|
|
355
408
|
const commands = shellCommands(source);
|
|
409
|
+
const moved = commands.some(command => ['cd', 'pushd', 'popd', 'ln'].includes(commandParts(command).executable));
|
|
356
410
|
for (const command of commands) {
|
|
357
411
|
const { executable, args, words } = commandParts(command);
|
|
412
|
+
// A redirect onto a ticket, the engine or the hooks is a write whatever the command is.
|
|
413
|
+
const reach = command.redirects.map(target => redirectReach(target, moved));
|
|
414
|
+
if (reach.includes('protected')) return true;
|
|
415
|
+
const blindWrite = reach.includes('unknown');
|
|
358
416
|
if (['sh', 'bash', 'zsh'].includes(executable)) {
|
|
359
417
|
const c = shellCommandFlag(args);
|
|
360
418
|
if (c !== -1 && typeof args[c + 1] === 'string' && ticketShellMutation(args[c + 1], depth + 1)) return true;
|
|
@@ -369,17 +427,18 @@ function ticketShellMutation(source, depth = 0) {
|
|
|
369
427
|
}
|
|
370
428
|
// A lookalike of the writer anywhere but the project's own path is refused outright.
|
|
371
429
|
if (words.some(lookalikeWriter)) return true;
|
|
372
|
-
// Writes to the engine or the hooks: a redirect
|
|
373
|
-
// an engine path among the arguments (the script a shell
|
|
430
|
+
// Writes to the engine or the hooks: a redirect whose target cannot be resolved, or a
|
|
431
|
+
// file-writing executable, with an engine path among the arguments (the script a shell
|
|
432
|
+
// or node runs does not count).
|
|
374
433
|
const scriptArgs = ['node', 'bash', 'sh', 'zsh'].includes(executable) ? 1 : 0;
|
|
375
434
|
if (args.slice(scriptArgs).some(arg => ENGINE_WORD.test(arg))) {
|
|
376
|
-
if (
|
|
435
|
+
if (blindWrite) return true;
|
|
377
436
|
if (['rm', 'mv', 'cp', 'install', 'truncate', 'tee', 'ed', 'ex', 'python', 'python3', 'ruby'].includes(executable)) return true;
|
|
378
437
|
if (['sed', 'perl'].includes(executable) && args.some(arg => /^-[^-]*i/.test(arg) || arg === '--in-place' || arg.startsWith('--in-place='))) return true;
|
|
379
438
|
}
|
|
380
439
|
const hasTicket = words.some(ticketPath) || /(^|[\s'"`])tickets[\\/]T-[0-9]+[^\s'"`]*/.test(source);
|
|
381
440
|
if (!hasTicket) continue;
|
|
382
|
-
if (
|
|
441
|
+
if (blindWrite) return true;
|
|
383
442
|
if (['rm', 'mv', 'cp', 'install', 'truncate', 'touch', 'tee', 'ed', 'ex'].includes(executable)) return true;
|
|
384
443
|
if (['python', 'python3', 'node', 'ruby'].includes(executable)) return true;
|
|
385
444
|
if (['sed', 'perl'].includes(executable) && args.some(arg => /^-[^-]*i/.test(arg) || arg === '--in-place' || arg.startsWith('--in-place='))) return true;
|
|
@@ -1,42 +1,45 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pincer-release
|
|
3
|
-
description: "
|
|
3
|
+
description: "Audit the evaluated candidate, record the person's acceptance, and commit only that acceptance before the final clean-tree check"
|
|
4
4
|
argument-hint: "[none]"
|
|
5
5
|
---
|
|
6
6
|
# Release
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
verdict is FAIL and names the change.
|
|
8
|
+
Runs a read-only audit first, then records and commits the person's acceptance.
|
|
9
|
+
The audit fixes nothing: a failed implementation or evidence check returns to its
|
|
10
|
+
stage through a new ticket. Acceptance is a separate, narrowly scoped write after
|
|
11
|
+
the person says yes. Keep the evaluated candidate, the audited checkout and the
|
|
12
|
+
acceptance commit distinct; an acceptance commit is not a new tested implementation.
|
|
14
13
|
|
|
15
14
|
## Reads
|
|
16
15
|
- `docs/release-checklist.md`: the items, grouped Tickets, Evidence, Review, Acceptance.
|
|
17
|
-
- `harness.json`: `stages.release.audit` (`tree` or `pr`), `systems.deploy`, `repo.checks`, `repo.pr`, `homes.evidence`, `people` (who may accept).
|
|
16
|
+
- `harness.json`: `stages.release.audit` (`tree` or `pr`), `systems.deploy`, `repo.checks`, `repo.pr`, `repo.branches`, `homes.evidence`, `people` (who may accept).
|
|
18
17
|
- `scripts/pincer-status.sh`: every ticket's state, receipts, acceptances and warnings.
|
|
19
18
|
- The evidence manifest under the evidence home when `stages.evaluate.evidence` is on; the review report.
|
|
20
19
|
- With `pr`: `gh pr view <number> --json state,mergedAt,statusCheckRollup` and `gh pr checks <number>`.
|
|
21
20
|
- Each ticket's `## Done when` and `## How the person checks`.
|
|
22
21
|
|
|
23
22
|
## Steps
|
|
24
|
-
1. [session] Run `scripts/pincer-status.sh --stage release`; on a non-zero exit print its message and stop.
|
|
25
|
-
2. [session] Run
|
|
26
|
-
3. [session]
|
|
27
|
-
4. [session] Evidence and review:
|
|
28
|
-
5. [session]
|
|
29
|
-
6. [session]
|
|
30
|
-
7. [session]
|
|
23
|
+
1. [session] Run `scripts/pincer-status.sh --stage release`; on a non-zero exit print its message and stop. Read `harness.json` and `docs/release-checklist.md`. Record the full `audit_head` from `git rev-parse HEAD`. With evidence on, record the evaluated `candidate` from its manifest; without evidence, the candidate is `audit_head`. Compare the candidate to `audit_head`: only the retained evaluation artifacts and previously recorded acceptance fields may differ. Any implementation, check, spec or agreement change needs a new evaluation; never relabel an old manifest as the current candidate.
|
|
24
|
+
2. [session] Run `scripts/pincer-status.sh`. Every ticket of the change must be `done` with `verified` on its line. Before acceptance, a `T-NN done without acceptance` warning is expected for an unaccepted ticket and does not block the technical audit; every other warning fails it. Record those tickets as pending acceptance, never as accepted.
|
|
25
|
+
3. [session] Read-only audit. `git status --short` must be empty before and after it, including the index. With `tree`, run every command in `repo.checks` (or `npm test` when empty) directly and quote the output. With `pr`, the implementation pull request must be merged with green checks and the deploy named in `systems.deploy` must have been observed. Do not run `verify`, `done`, `accept` or any other file write during this phase. A check that changes the tree fails the audit; do not clear the change to make it pass.
|
|
26
|
+
4. [session] Evidence and review: with evidence on, run `node scripts/pincer-evidence.cjs validate <manifest> --candidate <candidate>`; it must print `ok`, and every listed artifact must be tracked. The review report must exist and every finding have a disposition; every implementation fix after evaluate needs its own `T-NN:` commit and a new evaluation. Confirm `HEAD` is still `audit_head`.
|
|
27
|
+
5. [session] Present the checklist with evidence per item. Technical items are pass or fail; acceptance items remain pending until the person responds. A technical failure stops here and names the stage to fix it. Otherwise show the result, each ticket's `## Done when` lines, and how to check it, then ask whether the person accepts. This is not a final PASS yet.
|
|
28
|
+
6. [session] Only after the person says so, run `scripts/pincer-ticket.sh accept T-NN --by "<name>" --words "<their words>"` for each ticket they accept. The name must be an acceptor in `people`; quote their actual words, never a paraphrase or a synthetic approval. Do not rewrite an already accepted ticket unless the person explicitly replaces that acceptance. A declined or unanswered ticket stays unaccepted and the final verdict remains pending or failed.
|
|
29
|
+
7. [session] Commit the acceptance separately. Inspect `git diff --name-only`, `git diff --unified=0 -- <accepted ticket paths>`, and the index. The entire diff from `audit_head` may change only `accepted`, `accepted_by`, and `accepted_words` in those tickets' frontmatter; no ticket body, verification receipt, other field, source file or evidence artifact may change. Unexpected changes stop release; do not hide, restore or include them. Stage only the explicit ticket paths, inspect `git diff --cached`, then commit as `accept: <candidate short sha>`. Record the full `acceptance_commit`. If no new acceptance was written, make no empty commit and retain the existing acceptance commit identity. Follow the agreement's branch and PR policy; pushing or merging needs the user's existing authorization or a new explicit instruction. When that policy requires an acceptance PR, keep release pending until it is merged and its required checks pass.
|
|
30
|
+
8. [session] Final check. `git status --short` must be empty. Inspect the committed diff from `audit_head` through the acceptance commit again: only the three acceptance fields in the accepted tickets may differ. Run status: every ticket of the change must be done, verified and accepted by a named person, with no warnings. When evidence is on, revalidate the unchanged evidence against the original `candidate`, not the acceptance commit. A changed artifact, unexpected commit or implementation change fails this check and requires a new evaluation.
|
|
31
|
+
9. [session] Verdict: `PASS — candidate <candidate>, acceptance commit <acceptance_commit>: workflow artifacts complete and accepted` only when all applicable items pass, including any required acceptance PR. Otherwise report `PENDING — candidate <candidate>: <remaining acceptance or publication step>` or `FAIL — candidate <candidate>: <failed items>; fix via <command>`. Name every failed item first. A workflow bug names the project copy under `.claude/skills/` whose steps should change. Finish with `scripts/pincer-status.sh`.
|
|
31
32
|
|
|
32
33
|
## Checks
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
34
|
+
- The technical audit changed no files; the acceptance phase changed only the three allowed frontmatter fields and committed them separately.
|
|
35
|
+
- `git status --short` is empty at the end; no implementation change was hidden inside acceptance.
|
|
36
|
+
- Every acceptance names a person from `people` with `accepts: true` and quotes their words.
|
|
37
|
+
- Evidence still validates against its original candidate; the verdict separately names the acceptance commit and any pending remote step.
|
|
36
38
|
|
|
37
39
|
## Writes
|
|
38
|
-
- The acceptance fields
|
|
39
|
-
-
|
|
40
|
+
- The three acceptance fields, only through `scripts/pincer-ticket.sh accept`, only after the person said so.
|
|
41
|
+
- One acceptance-only commit when new acceptance was recorded; any required branch or PR follows the agreement and the user's authorization.
|
|
42
|
+
- No implementation, verification receipt, agreement or evidence edits; no implicit push, merge or publication.
|
|
40
43
|
|
|
41
44
|
## Without subagents
|
|
42
45
|
No agents are involved; the audit is the session's own reading of status, git and the evidence.
|
package/template/docs/engine.md
CHANGED
|
@@ -101,10 +101,9 @@ agreement, stage off or invalid `harness.json`, 130 interrupted.
|
|
|
101
101
|
- `done T-NN`: `in_progress` to `done` when the receipt matches the current block, no newer check failed, and every criterion is ticked; stamps `finished`.
|
|
102
102
|
- `accept T-NN --by "<name>" --words "<their words>"`: on a done ticket writes the three acceptance fields together; refuses a name that is not an acceptor and empty words.
|
|
103
103
|
|
|
104
|
-
`scripts/pincer-status.sh` runs `node scripts/pincer/cli.cjs status`:
|
|
105
|
-
`--json` for data. `--stage <name>` prints `on` and exits 0, or the reason and exits 3;
|
|
106
|
-
|
|
107
|
-
and then exits 3.
|
|
104
|
+
`scripts/pincer-status.sh` runs `node scripts/pincer/cli.cjs status`:
|
|
105
|
+
- The report, or `--json` for data. `--stage <name>` prints `on` and exits 0, or the reason and exits 3; a name that is not a stage exits 2. An invalid `harness.json` still renders the report and then exits 3.
|
|
106
|
+
- `Next:` is the first rule that matches, judged on the change in hand and not on the project's history: verify a ticket in progress, code an open one, wait for a draft spec's approval, narrow an approved spec that no ticket names in its `spec:` field, then evaluate, then release. With `stages.evaluate.evidence` on, evaluate stays next until one manifest covers every done ticket not yet accepted: the ticket file at the manifest's `candidate` commit is done with the receipt it carries now. `--json` lists the others under `unevaluated`; outside a git repository any manifest counts.
|
|
108
107
|
|
|
109
108
|
`scripts/pincer-evidence.cjs` runs `node scripts/pincer/cli.cjs evidence`:
|
|
110
109
|
`validate <manifest> [--candidate <sha>]` prints `ok <candidate>` or one
|
|
@@ -129,15 +128,28 @@ on every platform.
|
|
|
129
128
|
|
|
130
129
|
## Acceptance
|
|
131
130
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
ticket
|
|
138
|
-
|
|
139
|
-
Release
|
|
140
|
-
`
|
|
131
|
+
A ticket is `done` only on a receipt and accepted only when a named person said so.
|
|
132
|
+
`accept` writes `accepted`, `accepted_by` and `accepted_words` together; the validator
|
|
133
|
+
refuses a partial trio. The name must be in `people` with `accepts: true`; quote the
|
|
134
|
+
person's words verbatim. Acceptance does not block the next ticket, but status warns
|
|
135
|
+
about each done ticket without it and points to release (or `accept` if release is off).
|
|
136
|
+
Release passes only when every done ticket is accepted. Run `accept` only after the person says so; it never commits or publishes.
|
|
137
|
+
|
|
138
|
+
Release first audits read-only, allowing only `done without acceptance` warnings; all other warnings fail.
|
|
139
|
+
Record the manifest's evaluated `candidate` and current `audit_head` separately; only retained evidence
|
|
140
|
+
and existing acceptance fields may differ, never implementation, checks, spec or agreement.
|
|
141
|
+
Without evidence, `audit_head` is the candidate. The technical audit starts and ends with a clean tree.
|
|
142
|
+
|
|
143
|
+
After the person accepts, inspect working-tree, staged and committed diffs against `audit_head`:
|
|
144
|
+
only `accepted`, `accepted_by` and `accepted_words` in those tickets' frontmatter may change.
|
|
145
|
+
Commit separately as `accept: <candidate short sha>`, recording its full `acceptance_commit`;
|
|
146
|
+
skip an empty commit when nothing was newly accepted. Require a clean tree, status without warnings
|
|
147
|
+
and unchanged evidence validated against the original candidate. The verdict names both identities;
|
|
148
|
+
acceptance is not a newly tested candidate. Unexpected changes stop release for re-evaluation.
|
|
149
|
+
|
|
150
|
+
Branch and PR rules still apply: a required acceptance PR must be merged with passing checks before PASS.
|
|
151
|
+
Never push, merge or publish without the user's authorization. Unanswered acceptance or an incomplete
|
|
152
|
+
required remote step is pending, not passed.
|
|
141
153
|
|
|
142
154
|
## Evidence manifest schema 1
|
|
143
155
|
|
|
@@ -230,7 +242,7 @@ subagent env vars, Copilot org policy and Codex project trust override any file.
|
|
|
230
242
|
|
|
231
243
|
## Platform notes
|
|
232
244
|
|
|
233
|
-
- Claude Code: `.claude/settings.json` wires two PreToolUse hooks. `block-dangerous.sh` refuses destructive commands. `ticket-guard.sh` refuses edits to the eight lifecycle fields and any shell command that writes, resets or restores a ticket file, allowing only exact calls of the project's own `scripts/pincer-ticket.sh start|verify|done|accept` and the `cli.cjs ticket` form at the same path; a lookalike script elsewhere is refused. It also refuses edits and shell writes to the engine (`scripts/pincer-*`, `scripts/pincer/`) and to the hooks (`.claude/hooks/`, `.claude/settings.json`), which `npx pincer-workflow update` refreshes. The guard matches `T-NN-*.md` under any directory named `tickets`; a `homes.tickets` that does not end in `tickets` loses hook coverage, and the rule in `AGENTS.md` carries the weight alone. The hook does not read `harness.json`. It is a guardrail against documented mistake forms, not a security boundary: the sandbox and the approval controls remain that boundary.
|
|
245
|
+
- Claude Code: `.claude/settings.json` wires two PreToolUse hooks. `block-dangerous.sh` refuses destructive commands. `ticket-guard.sh` refuses edits to the eight lifecycle fields and any shell command that writes, resets or restores a ticket file, allowing only exact calls of the project's own `scripts/pincer-ticket.sh start|verify|done|accept` and the `cli.cjs ticket` form at the same path; a lookalike script elsewhere is refused. It also refuses edits and shell writes to the engine (`scripts/pincer-*`, `scripts/pincer/`) and to the hooks (`.claude/hooks/`, `.claude/settings.json`), which `npx pincer-workflow update` refreshes. A redirect counts as a write by its target: onto a ticket, the engine or the hooks it is refused whatever the command; duplicating a file descriptor (`2>&1`, `>&2`), discarding output (`2>/dev/null`) and saving a read to another literal path are reads and pass; a target the shell computes (a variable, a glob, a path after `cd`) is refused when the command names a protected file. The guard matches `T-NN-*.md` under any directory named `tickets`; a `homes.tickets` that does not end in `tickets` loses hook coverage, and the rule in `AGENTS.md` carries the weight alone. The hook does not read `harness.json`. It is a guardrail against documented mistake forms, not a security boundary: the sandbox and the approval controls remain that boundary.
|
|
234
246
|
- Codex: reads `AGENTS.md` natively; the stages are skills under `.agents/skills/`, invoked as `$pincer-<stage>`; the roster agents are `.codex/agents/<name>.toml`, generated from the library, and Codex loads `.codex/` only in a project marked trusted. No hook. Posture in `~/.codex/config.toml`: `approval_policy = "on-request"` and `sandbox_mode = "workspace-write"`; never run with approvals disabled. Status warns about missing, failed or stale receipts and about hand-edited fields.
|
|
235
247
|
- Copilot: `.github/copilot-instructions.md` points at `AGENTS.md`; the stages are prompt files under `.github/prompts/`, invoked as `/pincer-<stage>` with prompt files enabled; the roster agents are the files under `.claude/agents/`, which VS Code reads in the Claude format. No hook; the same rule.
|
|
236
248
|
- Everywhere: Node 22 or newer, git and bash. `gh` only for the forge-facing steps the skills name. The engine runs offline.
|
|
@@ -1,32 +1,37 @@
|
|
|
1
1
|
# Release checklist
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
`/pincer-release` first audits without writing files, then records the person's
|
|
4
|
+
acceptance and commits only those acceptance fields. Technical checks must pass
|
|
5
|
+
before asking for acceptance. Mark acceptance items pending until the person has
|
|
6
|
+
responded; pending acceptance alone is not a technical audit failure. Final PASS
|
|
7
|
+
requires every applicable item below. Give one sentence of evidence per item.
|
|
6
8
|
|
|
7
9
|
## Tickets
|
|
8
10
|
|
|
9
|
-
- [ ]
|
|
11
|
+
- [ ] status prints the agreement; before acceptance, its only permitted warnings are `T-NN done without acceptance`; after acceptance there are no warnings
|
|
10
12
|
- [ ] every ticket of the change is `done` with `verified` on its status line
|
|
11
13
|
- [ ] no ticket line reads `invalid`
|
|
12
14
|
- [ ] `git log --oneline` shows one `T-NN: <title>` commit per ticket, in dependency order
|
|
13
15
|
|
|
14
16
|
## Evidence
|
|
15
17
|
|
|
16
|
-
- [ ] `
|
|
17
|
-
- [ ]
|
|
18
|
-
- [ ] with
|
|
19
|
-
- [ ] with `
|
|
18
|
+
- [ ] record the full evaluated `candidate` and pre-acceptance `audit_head`; their diff contains only retained evaluation artifacts and previously recorded acceptance fields, with no implementation, check, spec or agreement changes
|
|
19
|
+
- [ ] `git status --short` is empty before and after the read-only audit; no check wrote files or moved `HEAD`
|
|
20
|
+
- [ ] with tree audit, every command in `repo.checks` passes when run directly, output quoted
|
|
21
|
+
- [ ] with evidence on, `node scripts/pincer-evidence.cjs validate <manifest> --candidate <candidate>` prints `ok <candidate>` and every listed artifact is tracked; repeat after acceptance against the same original candidate
|
|
22
|
+
- [ ] with PR audit, the implementation PR is merged with green checks and the named deploy was observed; any acceptance PR required by the agreement is also merged with its required checks passing before final PASS
|
|
20
23
|
|
|
21
24
|
## Review
|
|
22
25
|
|
|
23
|
-
- [ ] the review report exists
|
|
24
|
-
- [ ] every fix after evaluate went through its own ticket and `T-NN:` commit,
|
|
25
|
-
- [ ] no secret environment file is tracked: `git ls-files | grep -i '^\.env'` shows only `.env.example
|
|
26
|
+
- [ ] the review report exists and every finding has a file, a line and a disposition
|
|
27
|
+
- [ ] every implementation fix after evaluate went through its own ticket and `T-NN:` commit, followed by a new evaluation
|
|
28
|
+
- [ ] no secret environment file is tracked: `git ls-files | grep -i '^\.env'` shows only `.env.example`, or no entries when no environment example is needed
|
|
26
29
|
|
|
27
30
|
## Acceptance
|
|
28
31
|
|
|
29
32
|
- [ ] the person saw the result and every ticket's `Done when` lines before being asked
|
|
30
33
|
- [ ] every done ticket shows `accepted by <name>` in status, the name from `people` with `accepts: true`
|
|
31
|
-
- [ ] `accepted_words` holds the person's own words, not a paraphrase
|
|
32
|
-
- [ ]
|
|
34
|
+
- [ ] `accepted_words` holds the person's own words, not a paraphrase or synthetic approval
|
|
35
|
+
- [ ] only `accept` wrote acceptance; the complete staged and committed diff from `audit_head` changes only `accepted`, `accepted_by`, and `accepted_words` in the intended tickets' frontmatter, never their body, receipt or any other file
|
|
36
|
+
- [ ] new acceptance is retained in a separate `accept: <candidate short sha>` commit; the verdict identifies both the evaluated candidate and acceptance commit, and `git status --short` is empty
|
|
37
|
+
- [ ] no push, merge or publication occurred without the user's authorization; any required remote acceptance step is complete or explicitly pending rather than passed
|
|
@@ -158,7 +158,9 @@ function renderCodex(stage, fields, body, codex) {
|
|
|
158
158
|
const sentence = optional
|
|
159
159
|
? `the text that follows the \`$pincer-${stage}\` mention, if any (when omitted, use the skill's documented default)`
|
|
160
160
|
: `the text that follows the \`$pincer-${stage}\` mention in the user's message (ask only if the required input is missing)`;
|
|
161
|
-
|
|
161
|
+
// Replace the whole inline-code placeholder before bare occurrences so the
|
|
162
|
+
// sentence's own skill code span is not nested inside another pair of ticks.
|
|
163
|
+
const text = codexMentions(body.replace(/`\$ARGUMENTS`|\$ARGUMENTS/g, () => sentence));
|
|
162
164
|
return `---\nname: pincer-${stage}\ndescription: ${quoted(describe(stage, fields))}\n---\n${generatedComment(skillFile(stage))}\n\n${codexHint(codex)}${bodyText(text)}`;
|
|
163
165
|
}
|
|
164
166
|
function renderCopilot(stage, fields, body, copilot) {
|
|
@@ -8,10 +8,18 @@ const parse = require('./parse.cjs');
|
|
|
8
8
|
const harnessModule = require('./harness.cjs');
|
|
9
9
|
const models = require('./models.cjs');
|
|
10
10
|
const generate = require('./generate.cjs');
|
|
11
|
+
const util = require('./util.cjs');
|
|
11
12
|
|
|
12
13
|
const SCRIPT = 'scripts/pincer-ticket.sh';
|
|
13
14
|
const { STAGES, ALWAYS_ON, HARNESS_FILE } = harnessModule;
|
|
14
15
|
|
|
16
|
+
// Only a Codex-only install unambiguously chooses dollar-style skill mentions.
|
|
17
|
+
// Mixed installs keep the shared slash spelling; shell paths are never rewritten.
|
|
18
|
+
function commandMentions(root, text) {
|
|
19
|
+
const platforms = generate.manifestPlatforms(root);
|
|
20
|
+
return platforms.length === 1 && platforms[0] === 'codex' ? generate.codexMentions(text) : text;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
// Is a ticket's receipt current? Lifted from 0.7: the wording is what a done
|
|
16
24
|
// ticket that needs attention has always shown.
|
|
17
25
|
function readiness(text, fields) {
|
|
@@ -47,11 +55,12 @@ function specEntries(root, specsDir) {
|
|
|
47
55
|
}
|
|
48
56
|
function ticketEntry(root, file) {
|
|
49
57
|
const text = readText(root, file);
|
|
50
|
-
const entry = { id: idOf(file), file, status: null, started: null, verified: null, lastCheck: null, accepted: null, acceptedBy: null, ready: false, problem: null, depends: [] };
|
|
58
|
+
const entry = { id: idOf(file), file, status: null, spec: null, started: null, verified: null, lastCheck: null, accepted: null, acceptedBy: null, ready: false, problem: null, depends: [] };
|
|
51
59
|
if (text === null) { entry.problem = 'cannot read the file'; return entry; }
|
|
52
60
|
const v = parse.validateTicket(file, text);
|
|
53
61
|
const fields = v.fields;
|
|
54
62
|
entry.status = fields.status || null;
|
|
63
|
+
entry.spec = fields.spec || null;
|
|
55
64
|
entry.started = fields.started || null;
|
|
56
65
|
entry.verified = fields.verified || null;
|
|
57
66
|
entry.lastCheck = fields.last_check || null;
|
|
@@ -73,10 +82,44 @@ function manifests(root, evidenceDir) {
|
|
|
73
82
|
.filter((file) => fs.existsSync(path.join(root, file)));
|
|
74
83
|
}
|
|
75
84
|
|
|
85
|
+
// The done tickets still awaiting acceptance that no saved evidence covers. A
|
|
86
|
+
// manifest covers a ticket when the ticket file at the manifest's candidate
|
|
87
|
+
// commit is done with the receipt the ticket carries now: the candidate holds
|
|
88
|
+
// that finished work, and nothing was verified again after it. The acceptance
|
|
89
|
+
// fields may differ. One manifest has to cover all of them. Outside a git
|
|
90
|
+
// repository this cannot be told, and any manifest counts.
|
|
91
|
+
function unevaluated(root, report) {
|
|
92
|
+
const pending = report.tickets.filter((t) => !t.problem && t.status === 'done' && !t.accepted);
|
|
93
|
+
const ids = pending.map((t) => t.id);
|
|
94
|
+
if (!pending.length || !report.evidence.length) return ids;
|
|
95
|
+
if (util.tryGit(root, ['rev-parse', '--is-inside-work-tree']) !== 'true') return [];
|
|
96
|
+
const candidates = report.evidence
|
|
97
|
+
.map((file) => util.readJson(path.join(root, file)))
|
|
98
|
+
.filter((doc) => doc && typeof doc === 'object' && typeof doc.candidate === 'string' && /^[0-9a-f]{40}$/.test(doc.candidate))
|
|
99
|
+
.sort((a, b) => String(b.created || '').localeCompare(String(a.created || '')))
|
|
100
|
+
.map((doc) => doc.candidate);
|
|
101
|
+
const covers = (candidate, t) => {
|
|
102
|
+
const then = util.tryGit(root, ['show', `${candidate}:./${t.file}`]);
|
|
103
|
+
return then !== null && parse.frontmatterField(then, 'status') === 'done' && parse.frontmatterField(then, 'verified') === (t.verified || '');
|
|
104
|
+
};
|
|
105
|
+
return candidates.some((candidate) => pending.every((t) => covers(candidate, t))) ? [] : ids;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// An approved spec that still needs tickets: no ticket names it in its `spec:`
|
|
109
|
+
// field. A ticket without that field may belong to any spec, so while one is
|
|
110
|
+
// still on its way to acceptance nothing is called unnarrowed.
|
|
111
|
+
const specRef = (ref) => path.posix.normalize(String(ref).replace(/\\/g, '/'));
|
|
112
|
+
function unnarrowed(report) {
|
|
113
|
+
if (report.stages.plan !== 'on') return null;
|
|
114
|
+
if (report.tickets.some((t) => !t.spec && !(t.status === 'done' && t.accepted))) return null;
|
|
115
|
+
const named = new Set(report.tickets.filter((t) => t.spec).map((t) => specRef(t.spec)));
|
|
116
|
+
return report.specs.find((s) => s.status === 'approved' && !named.has(specRef(s.file))) || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
76
119
|
// The whole picture, as data. `harness` is null without a valid agreement;
|
|
77
120
|
// `harnessProblem` says why when the file exists but does not validate.
|
|
78
121
|
function gather(root) {
|
|
79
|
-
const report = { name: path.basename(root), harness: null, harnessProblem: null, stages: {}, models: null, specs: [], tickets: [], evidence: [], warnings: [], next: '' };
|
|
122
|
+
const report = { name: path.basename(root), harness: null, harnessProblem: null, stages: {}, models: null, specs: [], tickets: [], evidence: [], unevaluated: [], warnings: [], next: '' };
|
|
80
123
|
const loaded = harnessModule.load(root);
|
|
81
124
|
let homes = harnessModule.homes(null);
|
|
82
125
|
if (loaded.ok) {
|
|
@@ -92,29 +135,40 @@ function gather(root) {
|
|
|
92
135
|
report.specs = specEntries(root, homes.specs);
|
|
93
136
|
report.tickets = parse.ticketFiles(root, homes.tickets).map((file) => ticketEntry(root, file));
|
|
94
137
|
report.evidence = manifests(root, homes.evidence);
|
|
138
|
+
if (wantsEvidence(report)) report.unevaluated = unevaluated(root, report);
|
|
95
139
|
for (const t of report.tickets) {
|
|
96
140
|
if (t.problem) { report.warnings.push(`${t.file}: ${t.problem}`); continue; }
|
|
97
141
|
if (t.status === 'done' && !t.ready) report.warnings.push(`${t.id} done: ${t.readiness}`);
|
|
98
142
|
if (t.status === 'done' && !t.accepted) report.warnings.push(`${t.id} done without acceptance`);
|
|
99
143
|
}
|
|
100
|
-
report.next = nextCommand(report);
|
|
144
|
+
report.next = commandMentions(root, nextCommand(report));
|
|
101
145
|
return report;
|
|
102
146
|
}
|
|
103
147
|
|
|
104
|
-
|
|
148
|
+
const wantsEvidence = (report) => Boolean(report.harness) && report.stages.evaluate === 'on' && Boolean(report.harness.stages.evaluate.evidence);
|
|
149
|
+
|
|
150
|
+
// The one thing to do next, first rule that matches. Every rule looks at the
|
|
151
|
+
// change in hand, never at the project's history: an approved spec no ticket
|
|
152
|
+
// names is narrowed, and finished tickets are evaluated on their own candidate.
|
|
105
153
|
function nextCommand(report) {
|
|
106
154
|
if (!report.harness) return '/pincer-blueprint';
|
|
107
155
|
const tickets = report.tickets;
|
|
108
156
|
const broken = tickets.find((t) => t.problem);
|
|
109
157
|
if (broken) return `fix ${broken.file}`;
|
|
158
|
+
const draft = report.stages.plan === 'on' && report.specs.find((s) => !['approved', 'built'].includes(s.status));
|
|
110
159
|
if (report.stages.plan === 'on' && report.specs.length === 0 && tickets.length === 0) return '/pincer-plan <brief>';
|
|
111
|
-
if (tickets.length === 0)
|
|
160
|
+
if (tickets.length === 0) {
|
|
161
|
+
if (draft) return `/pincer-plan ${draft.file} (awaiting approval)`;
|
|
162
|
+
return report.stages.narrow === 'on' ? '/pincer-narrow' : '/pincer-code';
|
|
163
|
+
}
|
|
112
164
|
const inProgress = tickets.find((t) => t.status === 'in_progress');
|
|
113
165
|
if (inProgress) return `${SCRIPT} verify ${inProgress.id}`;
|
|
114
166
|
if (tickets.some((t) => t.status === 'open')) return '/pincer-code';
|
|
115
167
|
const stale = tickets.find((t) => t.status === 'done' && !t.ready);
|
|
116
168
|
if (stale) return `${SCRIPT} verify ${stale.id}`;
|
|
117
|
-
if (
|
|
169
|
+
if (draft) return `/pincer-plan ${draft.file} (awaiting approval)`;
|
|
170
|
+
if (unnarrowed(report)) return report.stages.narrow === 'on' ? '/pincer-narrow' : '/pincer-code';
|
|
171
|
+
if (wantsEvidence(report) && (report.evidence.length === 0 || (report.unevaluated || []).length)) return '/pincer-evaluate';
|
|
118
172
|
const unaccepted = tickets.find((t) => t.status === 'done' && !t.accepted);
|
|
119
173
|
if (unaccepted) {
|
|
120
174
|
// Acceptance is the floor even with release off: then the next step is the accept call itself.
|
|
@@ -153,9 +207,9 @@ function stageGate(root, name) {
|
|
|
153
207
|
if (!STAGES.includes(name)) return { exit: 2, message: `${name} is not a stage (${STAGES.join(', ')})` };
|
|
154
208
|
if (ALWAYS_ON.includes(name)) return { exit: 0, message: 'on' };
|
|
155
209
|
const loaded = harnessModule.load(root);
|
|
156
|
-
if (!loaded.ok) return { exit: 3, message: loaded.missing ? loaded.message : `${HARNESS_FILE}: ${loaded.message}` };
|
|
210
|
+
if (!loaded.ok) return { exit: 3, message: loaded.missing ? commandMentions(root, loaded.message) : `${HARNESS_FILE}: ${loaded.message}` };
|
|
157
211
|
if (!harnessModule.stageOn(loaded.harness, name)) return { exit: 3, message: `stage ${name} is off in ${HARNESS_FILE}` };
|
|
158
212
|
return { exit: 0, message: 'on' };
|
|
159
213
|
}
|
|
160
214
|
|
|
161
|
-
module.exports = { readiness, gather, nextCommand, render, stageGate };
|
|
215
|
+
module.exports = { readiness, gather, nextCommand, render, stageGate, commandMentions };
|
|
@@ -9,6 +9,7 @@ const path = require('node:path');
|
|
|
9
9
|
const { spawn } = require('node:child_process');
|
|
10
10
|
const parse = require('./parse.cjs');
|
|
11
11
|
const harnessModule = require('./harness.cjs');
|
|
12
|
+
const { commandMentions } = require('./status.cjs');
|
|
12
13
|
const { nowIso, atomicWrite } = require('./util.cjs');
|
|
13
14
|
|
|
14
15
|
const EXIT = { OK: 0, REFUSED: 1, USAGE: 2, HARNESS: 3, INTERRUPTED: 130 };
|
|
@@ -24,7 +25,7 @@ const refuse = (message, options) => { throw new Refusal(message, options); };
|
|
|
24
25
|
// Every verb needs an agreement with the code stage on; tickets live in its home.
|
|
25
26
|
function agreement(root) {
|
|
26
27
|
const loaded = harnessModule.load(root);
|
|
27
|
-
if (!loaded.ok) refuse(`${harnessModule.HARNESS_FILE}: ${loaded.message}`, { exit: EXIT.HARNESS, prefix: '' });
|
|
28
|
+
if (!loaded.ok) refuse(`${harnessModule.HARNESS_FILE}: ${loaded.missing ? commandMentions(root, loaded.message) : loaded.message}`, { exit: EXIT.HARNESS, prefix: '' });
|
|
28
29
|
if (!harnessModule.stageOn(loaded.harness, 'code')) refuse(`stage code is off in ${harnessModule.HARNESS_FILE}`, { exit: EXIT.HARNESS, prefix: '' });
|
|
29
30
|
return { harness: loaded.harness, tickets: harnessModule.homes(loaded.harness).tickets };
|
|
30
31
|
}
|