@popovych.co/witness 0.5.0 → 0.5.1

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.
@@ -11,6 +11,7 @@ import { effortOf, worktreeTreeSha } from '../reviewed.js';
11
11
  import { worktreePath } from '../worktree.js';
12
12
  import { codePromptBody } from './implement.js';
13
13
  import { registerGate } from '../gate.js';
14
+ import { gateSettled } from '../verbs/next.js';
14
15
  import { lastGateRun } from '../rounds.js';
15
16
  import { serializeDoc } from '../fm.js';
16
17
  async function commandLane(wt, root, ctx, name, cmd) {
@@ -46,16 +47,19 @@ registerGate({
46
47
  const checks = [];
47
48
  const entries = readStream(root, planId);
48
49
  const lastImplement = lastGateRun(entries, 'implement');
49
- const implementSettled = (() => {
50
- if (!lastImplement)
51
- return false;
52
- if (lastImplement.outcome === 'passed')
53
- return true;
54
- const after = entries.slice(entries.lastIndexOf(lastImplement) + 1);
55
- return after.some((e) => e.t === 'human-decision' &&
56
- e.gate === 'implement' &&
57
- e.decision === 'approve');
58
- })();
50
+ // One settle predicate, not two. This block used to inline its own copy of
51
+ // gateSettled, which meant `ship` and `next` could answer "is implement settled?"
52
+ // differently for the same journal — the split-brain that reads as a deadlock when
53
+ // two sessions compare notes.
54
+ //
55
+ // The missing sha argument is DELIBERATE and load-bearing, not an oversight. D75's
56
+ // `worktreeTreeSha` covers the whole worktree, so ship's own `pr #N` stamp — pulled
57
+ // into the worktree by the watch-phase rebase — moves the tree and would lapse the
58
+ // very gate ship is checking: approve → pr → lapse → gate, the livelock D75/D77
59
+ // record. `next` re-arms on tree movement because authoring is what it routes to;
60
+ // ship asks only whether implement was ever settled. Passing a sha here re-opens
61
+ // that livelock — do not "fix" this by adding one.
62
+ const implementSettled = gateSettled(entries, 'implement');
59
63
  checks.push({ name: 'implement-gate', ok: implementSettled,
60
64
  detail: lastImplement
61
65
  ? `last implement round ${lastImplement.round}: ${lastImplement.outcome}`
package/dist/gitio.js CHANGED
@@ -63,13 +63,35 @@ export function dirtyStatePaths(root) {
63
63
  .filter((p) => !LOCAL_STATE_FILES.has(p));
64
64
  }
65
65
  export const TRAILER = 'Witness-State: 1';
66
+ // Row 87's rule: nothing to COMMIT is the same legal silent success as nothing to write.
67
+ // It lives here, at the one choke point every state commit passes through, because git
68
+ // rejects BOTH empty cases fatally and a caller cannot tell them apart in advance:
69
+ // • no paths at all → `commit --only ... --` dies "No paths with --include/--only"
70
+ // (a drift sweep with no live specs journals nothing and stamps nothing);
71
+ // • paths with no diff → `commit --only` exits non-zero (a restamp that restores a
72
+ // file to exactly its HEAD content, or a pathspec the repo gitignores).
73
+ // Either way there is no state to record, so HEAD is already the answer. Implementing
74
+ // this at init.ts's call site alone left every other caller throwing out of gitio and
75
+ // surfacing through bin.ts as `unexpected-error` — "report a bug" on a healthy repo.
76
+ function headSha(root) {
77
+ const head = tryGit(root, 'rev-parse', 'HEAD');
78
+ return head.ok ? head.out : ''; // unborn HEAD: no commit exists to name
79
+ }
66
80
  export function commitWithTrailer(root, files, subject) {
81
+ if (files.length === 0)
82
+ return ok({ sha: headSha(root) });
67
83
  // tryGit, not git: a path already fully processed by a prior `git mv` (both sides
68
84
  // of a rename staged atomically) is absent from working tree AND index, so `add`
69
85
  // reports it unmatched — even though the rename itself is already staged. `commit
70
86
  // --only` resolves its pathspecs against the index-vs-HEAD diff, where the rename
71
87
  // is visible, so it still succeeds and is the real validation here.
72
88
  tryGit(root, 'add', '--', ...files);
89
+ // Ask git what actually changed rather than assuming every file we touched is dirty.
90
+ // Runs AFTER the add, so a freshly staged untracked path reads as `A ` and commits;
91
+ // a staged rename reads as `R ` on whichever side the pathspec names.
92
+ if (tryGit(root, 'status', '--porcelain', '--', ...files).out === '') {
93
+ return ok({ sha: headSha(root) });
94
+ }
73
95
  // --no-verify: state commits are machine-authored and scope-restricted to non-source
74
96
  // paths (isStatePath), so a host lint/test hook has nothing to validate here — while
75
97
  // its stash/restore step can destroy the human's unrelated dirty work, and a
@@ -14,7 +14,7 @@ import { handoffLine, relayLine, resolveHarness } from '../harness.js';
14
14
  import { worktreeFlow, worktreePath } from '../worktree.js';
15
15
  import { lazyStamp } from '../stamp.js';
16
16
  import { ok, refuse, renderRefusal, v } from '../refusal.js';
17
- import { kv } from '../toon.js';
17
+ import { kv, rows } from '../toon.js';
18
18
  import { boundReached, lastGateRun, openReopen, pendingDecision } from '../rounds.js';
19
19
  // A gate is settled only if the verdict that settled it still describes the CURRENT
20
20
  // content. `reviewed_sha` is the tree we actually judged; when the caller can compute
@@ -60,6 +60,21 @@ function authoringOwed(entries, gate, currentSha) {
60
60
  e.gate === gate &&
61
61
  ['revise', 'revise-upstream'].includes(e.decision));
62
62
  }
63
+ // D75 re-arms a gate whose reviewed sha has moved, and `worktreeTreeSha` covers the
64
+ // WHOLE worktree — so any state commit reaching it lapses the gate. That is correct, but
65
+ // the row it produces is a bare `gate implement`, indistinguishable from a CLI stuck on
66
+ // a stale answer. A human who just watched that gate pass reads it as the latter, and
67
+ // with a second session in the worktree answering `ship` the pair looks like a deadlock
68
+ // with no error anywhere. The lapse is a fact the CLI already knows; say it.
69
+ function lapseNote(entries, gate, currentSha) {
70
+ const last = lastGateRun(entries, gate);
71
+ if (!last || currentSha === undefined || last.reviewed_sha === currentSha)
72
+ return undefined;
73
+ // sha-free: asks "was it ever settled", which is the only thing that can lapse
74
+ if (!gateSettled(entries, gate))
75
+ return undefined;
76
+ return `${gate} approval lapsed — judged @${last.reviewed_sha.slice(0, 7)}, worktree now @${currentSha.slice(0, 7)} — re-gate to judge the current tree`;
77
+ }
63
78
  // The action for ONE flow. A flow is a plan with status `in-progress`: it begins at
64
79
  // `witness start` and ends at the merge stamp. Anything else returns undefined and is
65
80
  // not a flow. Exported because `--flow` and the dashboard must answer with the SAME
@@ -87,8 +102,13 @@ export function flowAction(root, cfg, plan) {
87
102
  const satisfied = files.length > 0 && baseR.ok && evidenceForDiff(wt, root, plan, baseR.value).satisfied;
88
103
  if (!satisfied)
89
104
  return { line: `witness test-evidence ${id} --phase red|green`, stage: 'implement', target: id, ...inWorktree };
90
- if (!gateSettled(entries, 'implement', worktreeTreeSha(wt)))
91
- return { line: `witness gate implement ${id}`, target: id, ...inWorktree };
105
+ const treeSha = worktreeTreeSha(wt);
106
+ if (!gateSettled(entries, 'implement', treeSha)) {
107
+ return {
108
+ line: `witness gate implement ${id}`, target: id, ...inWorktree,
109
+ ...noteOf(lapseNote(entries, 'implement', treeSha)),
110
+ };
111
+ }
92
112
  return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
93
113
  }
94
114
  // How far along a flow is — the drain order when several are actionable. Most-advanced
@@ -198,8 +218,13 @@ export function computeNext(root, ctx, canon, cfg) {
198
218
  .sort((a, b) => flowRank(b) - flowRank(a) || String(a.target).localeCompare(String(b.target)));
199
219
  if (flows.length > 0) {
200
220
  const waiting = pendingDecisionsAll(root, efforts, specs, plans);
221
+ // noteOf, not an overwrite: a lapse note explains WHY this row is the gate rather
222
+ // than the ship it was a moment ago, and must not be evicted by the waiting list.
201
223
  return waiting.length > 0
202
- ? { ...flows[0], note: `${waiting.length} waiting: ${waiting.map((w) => `${w.gate} ${w.target}`).join(' · ')}` }
224
+ ? {
225
+ ...flows[0],
226
+ ...noteOf(flows[0].note, `${waiting.length} waiting: ${waiting.map((w) => `${w.gate} ${w.target}`).join(' · ')}`),
227
+ }
203
228
  : flows[0];
204
229
  }
205
230
  // TIER 2 — human-owed decisions and bound endgames. Unchanged from today's rungs; they
@@ -398,6 +423,14 @@ export async function run(ctx, argv) {
398
423
  const lazy = lazyStamp(root, ctx, canon);
399
424
  if (lazy.stamped.length > 0)
400
425
  canon = loadCanon(root);
426
+ // `dashboard` renders these; `next` computed them and dropped them. A merge stamp that
427
+ // cannot proceed (commit refused, PR closed unmerged, lock held) leaves the flow parked
428
+ // at ship forever, and `next` — the one verb the driving loop calls every turn — said
429
+ // nothing at all. Printed BEFORE the routing block so it cannot split the contiguous
430
+ // next:/stage:/target:/note:/home:/run:/relay: unit the stage skills read verbatim.
431
+ if (lazy.stale.length > 0) {
432
+ rows('stale', ['plan', 'why'], lazy.stale).forEach(ctx.out);
433
+ }
401
434
  const { values } = parseArgs({ args: argv, options: { flow: { type: 'string' } }, allowPositionals: true });
402
435
  // Precedence: an explicit --flow is a CLAIM about a flow and refuses when false.
403
436
  // A worktree cwd is AMBIENT context — it may scope a read-only question, never select
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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.5.0",
4
+ "version": "0.5.1",
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.5.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
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.5.0} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1} 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.5.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -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.5.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
14
14
  ```
15
15
 
16
16
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -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.5.0}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
20
20
  ```
21
21
 
22
22
  - **Never edit `specs/**`, `plans/**`, or `designs/**`** — the CLI is the sole writer of state. Author HTML in `$(mktemp -d)` and hand it to `witness design`. (The canon guard blocks direct edits; the trailer audit catches end-runs.)
@@ -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.5.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -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.5.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -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.5.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)