pincer-workflow 1.0.1 → 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 CHANGED
@@ -16,7 +16,7 @@ Install it in your project:
16
16
  npx pincer-workflow@latest init --platform all
17
17
  ```
18
18
 
19
- The same package is also attached to the [GitHub release](https://github.com/orchestratedbyalex/pincer-workflow/releases/tag/v1.0.1).
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
21
  Node.js 22+, git and bash are required. PINCER uses your coding tool's own
22
22
  authentication; it does not require a provider API key.
@@ -140,23 +140,41 @@ each installed platform will run.
140
140
  ## Update
141
141
 
142
142
  ```bash
143
- npx pincer-workflow@latest update # edited files get a .new sidecar
144
- npx pincer-workflow@latest doctor # checks the install and agreement
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
145
146
  ```
146
147
 
147
148
  With an agreement in place, `update` also refreshes the library and regenerates the
148
149
  project copies you did not edit; an edited copy stays and gets the new version beside
149
150
  it as `<file>.new`.
150
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
+
151
165
  ## Trying it locally before release
152
166
 
153
167
  ```bash
154
- npm pack # in this repository: pincer-workflow-1.0.1.tgz
155
- cp pincer-workflow-1.0.1.tgz ../some-project/ && cd ../some-project
156
- npx ./pincer-workflow-1.0.1.tgz init --platform all # the ./ matters: npx treats a bare path as a command
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
157
171
  scripts/pincer-status.sh # prints "no agreement yet" and "Next: /pincer-blueprint"
158
172
  ```
159
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
+
160
178
  ## Status of the older channels
161
179
 
162
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 doctor check the health of an install
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
- if (platforms.includes('claude')) console.log(' Claude Code start `claude` in this repo and run /pincer-blueprint');
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
- async function cmdUpdate() {
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
- console.log(`\nUpdating PINCER ${manifest.version} -> ${VERSION} for: ${manifest.platforms.join(', ')}\n`);
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, manifest.platforms, baseline, manifest.generated));
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,6 +1,6 @@
1
1
  {
2
2
  "name": "pincer-workflow",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
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"
@@ -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
- for (const token of lexShell(source)) {
68
- if (token.value !== undefined) words.push(token.value);
69
- else if (['|', ';', '&&', '||', '(', ')'].includes(token.op)) finish(token.op);
70
- else operators.push(token.op);
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 through to
272
- // ticketShellMutation so its target is tested like any other path. Without this,
273
- // adding a verb to the list below silently opened a new carrier for it.
274
- if (commands[0].operators.some(op => op === '>' || op === '>>')) return false;
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, or a file-writing executable, with
373
- // an engine path among the arguments (the script a shell or node runs does not count).
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 (command.operators.some(op => op === '>' || op === '>>')) return true;
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 (command.operators.some(op => op === '>' || op === '>>')) return true;
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;
@@ -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`: the report, or
105
- `--json` for data. `--stage <name>` prints `on` and exits 0, or the reason and exits 3;
106
- a name that is not a stage exits 2. An invalid `harness.json` still renders the report
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
@@ -243,7 +242,7 @@ subagent env vars, Copilot org policy and Codex project trust override any file.
243
242
 
244
243
  ## Platform notes
245
244
 
246
- - 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.
247
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.
248
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.
249
248
  - Everywhere: Node 22 or newer, git and bash. `gh` only for the forge-facing steps the skills name. The engine runs offline.
@@ -8,6 +8,7 @@ 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;
@@ -54,11 +55,12 @@ function specEntries(root, specsDir) {
54
55
  }
55
56
  function ticketEntry(root, file) {
56
57
  const text = readText(root, file);
57
- 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: [] };
58
59
  if (text === null) { entry.problem = 'cannot read the file'; return entry; }
59
60
  const v = parse.validateTicket(file, text);
60
61
  const fields = v.fields;
61
62
  entry.status = fields.status || null;
63
+ entry.spec = fields.spec || null;
62
64
  entry.started = fields.started || null;
63
65
  entry.verified = fields.verified || null;
64
66
  entry.lastCheck = fields.last_check || null;
@@ -80,10 +82,44 @@ function manifests(root, evidenceDir) {
80
82
  .filter((file) => fs.existsSync(path.join(root, file)));
81
83
  }
82
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
+
83
119
  // The whole picture, as data. `harness` is null without a valid agreement;
84
120
  // `harnessProblem` says why when the file exists but does not validate.
85
121
  function gather(root) {
86
- 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: '' };
87
123
  const loaded = harnessModule.load(root);
88
124
  let homes = harnessModule.homes(null);
89
125
  if (loaded.ok) {
@@ -99,6 +135,7 @@ function gather(root) {
99
135
  report.specs = specEntries(root, homes.specs);
100
136
  report.tickets = parse.ticketFiles(root, homes.tickets).map((file) => ticketEntry(root, file));
101
137
  report.evidence = manifests(root, homes.evidence);
138
+ if (wantsEvidence(report)) report.unevaluated = unevaluated(root, report);
102
139
  for (const t of report.tickets) {
103
140
  if (t.problem) { report.warnings.push(`${t.file}: ${t.problem}`); continue; }
104
141
  if (t.status === 'done' && !t.ready) report.warnings.push(`${t.id} done: ${t.readiness}`);
@@ -108,7 +145,11 @@ function gather(root) {
108
145
  return report;
109
146
  }
110
147
 
111
- // The one thing to do next, first rule that matches.
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.
112
153
  function nextCommand(report) {
113
154
  if (!report.harness) return '/pincer-blueprint';
114
155
  const tickets = report.tickets;
@@ -126,7 +167,8 @@ function nextCommand(report) {
126
167
  const stale = tickets.find((t) => t.status === 'done' && !t.ready);
127
168
  if (stale) return `${SCRIPT} verify ${stale.id}`;
128
169
  if (draft) return `/pincer-plan ${draft.file} (awaiting approval)`;
129
- if (report.stages.evaluate === 'on' && report.harness.stages.evaluate.evidence && report.evidence.length === 0) return '/pincer-evaluate';
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';
130
172
  const unaccepted = tickets.find((t) => t.status === 'done' && !t.accepted);
131
173
  if (unaccepted) {
132
174
  // Acceptance is the floor even with release off: then the next step is the accept call itself.