@popovych.co/witness 0.14.0 → 0.14.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/dist/drive.js CHANGED
@@ -47,11 +47,12 @@ export function classifyAction(action, root) {
47
47
  if (line.includes('merge PR') || (action.note?.includes('merge PR') ?? false)) {
48
48
  return { kind: 'merge', line: action.note?.includes('merge PR') ? action.note : line };
49
49
  }
50
- // `witness check` is computeNext's terminal answer. It carries two states that the
51
- // action alone cannot tell apart — nothing to route, and canon errors that stopped
52
- // routing early — so idle PRINTS the line rather than swallowing it: a human who sees
53
- // `drive: idle — witness check` runs the verb that distinguishes them. Spawning a
54
- // session to guess would burn a context on a diagnosis one command answers.
50
+ // `witness check` is computeNext's terminal answer in two states: canon errors that
51
+ // stopped routing early (bare line), and an exhausted ladder (line + a "nothing is
52
+ // owed" note, D157). Idle PRINTS the line rather than swallowing it: a human who sees
53
+ // `drive: idle — witness check` runs the verb, and the note has already told the
54
+ // routed session which state it is in. Spawning a session to guess would burn a
55
+ // context on a diagnosis one command answers.
55
56
  if (line === 'witness check' && action.stage === undefined)
56
57
  return { kind: 'idle' };
57
58
  return { kind: 'spawn', home: action.home ?? root, stage: action.stage, target: action.target, model: action.model };
@@ -3,7 +3,7 @@ import { serializeDoc } from '../fm.js';
3
3
  import { canonicalSha, short } from '../sha.js';
4
4
  import { latestRecap } from '../journal.js';
5
5
  import { baseForSpec } from '../history.js';
6
- import { findById } from '../scan.js';
6
+ import { findById, plannableParent } from '../scan.js';
7
7
  import { effortOf, planPairSha } from '../reviewed.js';
8
8
  import { validateDoc } from '../schemas.js';
9
9
  import { registerGate } from '../gate.js';
@@ -29,9 +29,7 @@ registerGate({
29
29
  const schemaProblems = validateDoc(plan.meta, plan.body).map((x) => `${x.field}:${x.rule}`);
30
30
  checks.push({ name: 'schema', ok: schemaProblems.length === 0, detail: schemaProblems.slice(0, 5).join(' · ') });
31
31
  const parentStatus = String(parent.meta.status);
32
- const parentOk = parent.meta.type === 'principles'
33
- ? parentStatus === 'approved'
34
- : parentStatus === 'approved' || parentStatus === 'live';
32
+ const parentOk = plannableParent(parent);
35
33
  checks.push({ name: 'parent-approved', ok: parentOk,
36
34
  detail: `${String(parent.meta.id)} is ${parentStatus}` });
37
35
  const parentSha = canonicalSha(parent.meta, parent.body);
package/dist/gitio.js CHANGED
@@ -2,11 +2,17 @@ import { execFileSync } from 'node:child_process';
2
2
  import { dirname } from 'node:path';
3
3
  import { canonPaths } from './config.js';
4
4
  import { ok, refuse, v } from './refusal.js';
5
+ // D158 (#20). A reviewer-bound diff is legitimately multi-megabyte and Node's spawnSync
6
+ // default is 1 MiB, which crashed ship/gate as `spawnSync git ENOBUFS` — and, worse,
7
+ // silently truncated every tryGit consumer into a wrong answer. Sized like reviewer.ts's
8
+ // 64 MiB and ship.ts's 16 MiB; V8's string ceiling bounds any sync read regardless.
9
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
5
10
  export function git(root, ...args) {
6
11
  return execFileSync('git', args, {
7
12
  cwd: root,
8
13
  encoding: 'utf8',
9
14
  stdio: ['ignore', 'pipe', 'pipe'],
15
+ maxBuffer: GIT_MAX_BUFFER,
10
16
  }).replace(/\n$/, '');
11
17
  }
12
18
  export function tryGit(root, ...args) {
package/dist/matcher.js CHANGED
@@ -35,7 +35,15 @@ export function sourceTags(root, excludes) {
35
35
  if (skip.some((re) => re.test(rel)))
36
36
  continue;
37
37
  const abs = join(root, rel);
38
- if (!existsSync(abs) || statSync(abs).size > MAX_SCAN_BYTES)
38
+ if (!existsSync(abs))
39
+ continue;
40
+ // D159 (#23). The listing is paths, not files: `--cached` includes a gitlink
41
+ // (submodule, mode 160000) and `--others` a symlink to a directory (skills-add
42
+ // wiring) — both stat as directories whose size clears the cap, and readFileSync
43
+ // threw EISDIR. Same isFile() guard codePromptBody applies to untracked paths;
44
+ // first shipped as a hand patch in a consumer's npx cache, upstreamed here.
45
+ const st = statSync(abs);
46
+ if (!st.isFile() || st.size > MAX_SCAN_BYTES)
39
47
  continue;
40
48
  const content = readFileSync(abs, 'utf8');
41
49
  if (content.includes('\0'))
package/dist/runner.js CHANGED
@@ -43,7 +43,10 @@ export function criteriaExcludes(cfg) {
43
43
  }
44
44
  export function execCommand(runRoot, ctx, cmd) {
45
45
  try {
46
- const out = execSync(cmd, { cwd: runRoot, env: ctx.env, stdio: 'pipe' });
46
+ // D158. Suite stdout is unbounded and only the last 4000 chars are kept — but at
47
+ // Node's 1 MiB default a chatty GREEN suite threw ENOBUFS into the catch and read
48
+ // as a failed lane. 64 MiB: the same bound the reviewer spawn uses.
49
+ const out = execSync(cmd, { cwd: runRoot, env: ctx.env, stdio: 'pipe', maxBuffer: 64 * 1024 * 1024 });
47
50
  return { exitZero: true, output: String(out).slice(-4000) };
48
51
  }
49
52
  catch (e) {
package/dist/scan.js CHANGED
@@ -45,6 +45,13 @@ export function criteriaOwner(canon, id) {
45
45
  export function findById(canon, id) {
46
46
  return canon.docs.find((d) => d.meta.id === id);
47
47
  }
48
+ // D157. The one derivation of "this doc can parent a plan today". `verbs/next.ts`'s
49
+ // queue and `gates/plan.ts`'s parent check both consume it; a second derivation is the
50
+ // drift that left `next` refusing to emit the exact command the gate accepts (issue #21).
51
+ export function plannableParent(doc) {
52
+ const s = String(doc.meta.status);
53
+ return doc.meta.type === 'principles' ? s === 'approved' : s === 'approved' || s === 'live';
54
+ }
48
55
  export function findCycle(canon, extra) {
49
56
  const deps = new Map();
50
57
  for (const d of canon.docs) {
@@ -4,7 +4,7 @@ import { EXIT } from '../cli.js';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { pendingTxn } from '../txn.js';
6
6
  import { primaryRoot } from '../gitio.js';
7
- import { findById, loadCanon } from '../scan.js';
7
+ import { findById, loadCanon, plannableParent } from '../scan.js';
8
8
  import { designArtifactCurrent, designPending, designUnseen } from '../design.js';
9
9
  import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
10
10
  import { diffReviewedSha, effortOf, effortReviewedSha, effortSpecs, effortWrites, implementReviewedSha, planPairSha } from '../reviewed.js';
@@ -425,6 +425,22 @@ function liveOwner(root, efforts, ...ids) {
425
425
  .map((id) => efforts.find((e) => effortWrites(root, e.slug).has(id))?.slug)
426
426
  .find((slug) => slug !== undefined);
427
427
  }
428
+ // D157. The queue offered `<spec>-plan-1` unconditionally, which amends a DONE plan the
429
+ // moment a spec earns a second slice. An abandoned id is re-authorable — `write` returns
430
+ // it to draft — so the lowest abandoned suffix is offered back; otherwise the first
431
+ // unused one. When this runs, the spec's plans are all done or abandoned by filter B.
432
+ function nextPlanId(plans, spec) {
433
+ const suffixed = plans
434
+ .map((p) => ({ id: String(p.meta.id), status: String(p.meta.status) }))
435
+ .filter((p) => p.id.startsWith(`${spec}-plan-`))
436
+ .map((p) => ({ n: Number(p.id.slice(`${spec}-plan-`.length)), status: p.status }))
437
+ .filter((p) => Number.isInteger(p.n) && p.n > 0);
438
+ const abandoned = suffixed.filter((p) => p.status === 'abandoned').map((p) => p.n);
439
+ if (abandoned.length > 0)
440
+ return `${spec}-plan-${Math.min(...abandoned)}`;
441
+ const taken = suffixed.map((p) => p.n);
442
+ return `${spec}-plan-${taken.length > 0 ? Math.max(...taken) + 1 : 1}`;
443
+ }
428
444
  // The stage rides with the line: routing to a recap is BRAINSTORM work, and a caller that
429
445
  // kept its own `stage: 'plan'` would hand the plan skill a recap command to run.
430
446
  function planWriteAction(root, efforts, planId, parentId, target) {
@@ -607,8 +623,20 @@ export function computeNext(root, ctx, canon, cfg) {
607
623
  const s = String(doc.meta.status);
608
624
  return doc.meta.type === 'plan' ? s === 'done' : s === 'live' || doc.meta.type === 'principles';
609
625
  };
626
+ // D157. A spec is queued when the plan gate would accept it as a parent AND someone owes
627
+ // it a plan. `approved` is owed by definition (decompose just passed). `live` is owed
628
+ // only while a live effort that wrote it owns zero non-abandoned plans — efforts never
629
+ // auto-terminate, so without that clause every finished spec would queue forever.
630
+ // Reaching this rung proves decompose is settled: rung 6 returns first otherwise.
631
+ const owedByEffort = (id) => efforts.some((e) => {
632
+ const w = effortWrites(root, e.slug);
633
+ if (!w.has(id))
634
+ return false;
635
+ return !plans.some((p) => w.has(String(p.meta.id)) && String(p.meta.status) !== 'abandoned');
636
+ });
610
637
  const planless = canon.docs
611
- .filter((d) => d.meta.type === 'spec' && String(d.meta.status) === 'approved')
638
+ .filter((d) => d.meta.type === 'spec' && plannableParent(d))
639
+ .filter((d) => String(d.meta.status) === 'approved' || owedByEffort(String(d.meta.id)))
612
640
  .filter((d) => !plans.some((p) => String(p.meta.parent) === String(d.meta.id) &&
613
641
  !['done', 'abandoned'].includes(String(p.meta.status))))
614
642
  .filter((d) => (d.meta.depends ?? []).every(ready))
@@ -617,9 +645,9 @@ export function computeNext(root, ctx, canon, cfg) {
617
645
  // A spec whose plan write can actually be booked outranks one that needs a new effort
618
646
  // opened first — stalling the whole pipeline on a recap while runnable work sits behind
619
647
  // it is a worse answer, and alphabetical order alone does not know the difference.
620
- const spec = planless.find((s) => liveOwner(root, efforts, `${s}-plan-1`, s) !== undefined)
648
+ const spec = planless.find((s) => liveOwner(root, efforts, nextPlanId(plans, s), s) !== undefined)
621
649
  ?? planless[0];
622
- const act = planWriteAction(root, efforts, `${spec}-plan-1`, spec, spec);
650
+ const act = planWriteAction(root, efforts, nextPlanId(plans, spec), spec, spec);
623
651
  if (planless.length <= 1)
624
652
  return act;
625
653
  // The note said `multiple ready — choose: a b c`, which is a list, not a ranking: it
@@ -653,7 +681,11 @@ export function computeNext(root, ctx, canon, cfg) {
653
681
  return { line: `witness start ${String(plan.meta.id)}`, target: String(plan.meta.id) };
654
682
  }
655
683
  }
656
- return { line: 'witness check' };
684
+ // Rule B — the ladder is exhausted. Same line as rule A (canon errors, the early
685
+ // return above) but a different state; the note is what tells them apart on screen
686
+ // (D157 — the missing note is what turned issue #21 from a one-command diagnosis
687
+ // into a filed bug).
688
+ return { line: 'witness check', ...noteOf('nothing is owed — no effort, spec or plan has a next step; a clean check confirms it') };
657
689
  }
658
690
  // Exported for `drive`, which claims a flow with the same `--flow` semantics `next` has:
659
691
  // a claim that refuses when false. A second copy of these three refusals would be a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "witness",
3
3
  "description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
4
- "version": "0.14.0",
4
+ "version": "0.14.2",
5
5
  "author": {
6
6
  "name": "Kostiantyn Popovych"
7
7
  },
@@ -10,7 +10,7 @@ The CLI decides, you act. If `$ARGUMENTS` contains `--manual`, append `--manual`
10
10
  Resolve the CLI once:
11
11
 
12
12
  ```bash
13
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.2}"
14
14
  ```
15
15
 
16
16
  If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
@@ -3,4 +3,4 @@
3
3
  # context). Silent everywhere that isn't a witness repo; failures swallowed:
4
4
  # session start must never hang or die on this.
5
5
  [ -f witness.config.yaml ] || exit 0
6
- ${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.0} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.2} 2>/dev/null || true
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.2}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
10
10
  Resolve the CLI once per session:
11
11
 
12
12
  ```bash
13
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.2}"
14
14
  ```
15
15
 
16
16
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
16
16
  Resolve the CLI once per session:
17
17
 
18
18
  ```bash
19
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.0}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.2}"
20
20
  ```
21
21
 
22
22
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.2}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.2}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.2}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.