@popovych.co/witness 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,6 +117,21 @@ witness commits the payload. It refuses the whole run if a payload path carries
117
117
  uncommitted change (`payload-dirty`) or if the CLI you are running is older than the
118
118
  payload already installed (`cli-behind-payload`).
119
119
 
120
+ From **0.10.0** onward `init` installs into **every home of the repository** — the primary
121
+ root and each live `.witness/worktrees/<plan-id>` — because a worktree is a branch
122
+ checkout, so its payload is a different file on a different timeline. A worktree cut
123
+ before an upgrade used to keep the old pin forever, and the old pin is what selects the
124
+ CLI that runs there. Every home is preflighted before any is written, so a dirty payload
125
+ in one refuses the whole run rather than leaving the set half-upgraded, and `witness check`
126
+ now reports `payload-stale` per home and names the stale one.
127
+
128
+ The same release makes the bound structural: every journal entry records the CLI that
129
+ wrote it, and a CLI older than anything the repository's state has seen refuses every verb
130
+ with `cli-behind-state` rather than answering from rules the repository has moved past.
131
+ `witness floor --show` reports the bound. If you are deliberately rolling back — a bad
132
+ release, say — lower it first with `witness floor --set <version> --note <why>`; the
133
+ decision is journaled, and `floor` is the one verb that still runs under a bound it fails.
134
+
120
135
  ### Support tiers
121
136
 
122
137
  | Tier | Agents | What they get |
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync } from 'node:fs';
3
2
  import { configPath } from './config.js';
3
+ import { stateFloor } from './floor.js';
4
4
  import { primaryRoot } from './gitio.js';
5
5
  import { renderRefusal, v } from './refusal.js';
6
+ import { NPX_LATEST, compareTriple, version } from './version.js';
6
7
  export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
7
8
  const VERBS = {
8
9
  recover: () => import('./verbs/recover.js'),
@@ -33,6 +34,9 @@ const VERBS = {
33
34
  rename: () => import('./verbs/rename.js'),
34
35
  sync: () => import('./verbs/sync.js'),
35
36
  calibrate: () => import('./verbs/calibrate.js'),
37
+ // Row 116's valve. Exempt from the floor gate in `main`, because a repository that
38
+ // refuses every verb must still be able to run the one that unrefuses it.
39
+ floor: () => import('./verbs/floor.js'),
36
40
  };
37
41
  // answered centrally so no verb can crash on --help (several parse argv
38
42
  // strictly); each line mirrors the verb's own usage refusal where one exists
@@ -43,6 +47,7 @@ const VERB_USAGE = {
43
47
  check: 'witness check',
44
48
  clean: 'witness clean',
45
49
  decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
50
+ floor: 'witness floor --show | --set <triple> --note <why>',
46
51
  design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
47
52
  diff: 'witness diff <spec-id>',
48
53
  'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
@@ -62,10 +67,10 @@ const VERB_USAGE = {
62
67
  'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
63
68
  'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
64
69
  };
65
- export function version() {
66
- const pkg = JSON.parse(readFileSync(join(new URL('.', import.meta.url).pathname, '..', 'package.json'), 'utf8'));
67
- return pkg.version;
68
- }
70
+ // Row 116 moved the body to version.ts, which journal.ts can import without closing a
71
+ // cycle. Re-exported here because eleven call sites already ask the CLI shell for it and
72
+ // the question they are asking has not changed.
73
+ export { version };
69
74
  export function usage() {
70
75
  return [
71
76
  'witness — spec-led pipeline: specs are state, plans are motion',
@@ -80,6 +85,38 @@ export async function main(ctx, argv) {
80
85
  ctx.out(version());
81
86
  return EXIT.OK;
82
87
  }
88
+ // Row 116. Above the verb table on purpose — including above the bare-`witness`
89
+ // dashboard route, which reads the same state every verb does. The invariant is "one
90
+ // repository, one witness version", and a per-verb opt-in is a list some future verb
91
+ // forgets to join.
92
+ //
93
+ // This is the guard row 102's `cli-behind-payload` structurally could not be. That one
94
+ // fires inside the home it protects, so a home frozen at an old pin runs an old CLI that
95
+ // never learned to check — which is how two sessions a lifecycle apart each computed a
96
+ // different next stage, each redirected to the other's home, and each honestly ended its
97
+ // turn. The state is the one thing every home shares, so the state is where the bound
98
+ // belongs.
99
+ //
100
+ // Forward-only, and the text must never imply otherwise: a CLI published before this row
101
+ // cannot run this code. Homes already frozen are found by `check` and repaired by `init`.
102
+ //
103
+ // Two exemptions, both load-bearing. `floor` is how a bad publish is recovered from — a
104
+ // repository that refuses every verb must still be able to run the one that unrefuses it.
105
+ // An explicit help request is information, and refusing to print a usage line teaches
106
+ // the human nothing they can act on.
107
+ const helpRequested = verb === 'help' || verb === '--help'
108
+ || rest.includes('--help') || rest.includes('-h');
109
+ if (!helpRequested && verb !== 'floor') {
110
+ const gateRoot = primaryRoot(ctx.cwd);
111
+ if (gateRoot.ok) {
112
+ const floor = stateFloor(gateRoot.value);
113
+ if (floor !== undefined && (compareTriple(version(), floor.pin) ?? 0) < 0) {
114
+ renderRefusal([v('witness', 'cli-behind-state', `this CLI is ${version()}, this repository's state was written by ${floor.pin}`, `a CLI at or ahead of ${floor.pin} — run ${NPX_LATEST} init --agent <name>; if you are `
115
+ + `deliberately rolling back, lower the bound first with witness floor --set ${version()} --note <why>`)]).forEach((l) => ctx.err(l));
116
+ return EXIT.REFUSED;
117
+ }
118
+ }
119
+ }
83
120
  if (!verb || verb === 'help' || verb === '--help') {
84
121
  if (!verb) {
85
122
  const rootRes = primaryRoot(ctx.cwd);
package/dist/floor.js ADDED
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { journalRel } from './journal.js';
4
+ import { compareTriple } from './version.js';
5
+ // Row 116. The state's own answer to "what is the oldest CLI allowed to touch this
6
+ // repository", derived from the `w` stamps entryLine writes and stored nowhere. Derived
7
+ // because a stored floor is a second source of truth that drifts from the entries it
8
+ // summarises — the shape rows 93, 95 and 96 all name — and because a derived one cannot
9
+ // be lowered by editing a file, only by a journaled decision (verbs/floor.ts).
10
+ //
11
+ // MAXIMUM, not most-recent: a downgraded CLI writing a lower stamp after a higher one is
12
+ // exactly the event this guards against, and a floor that fell to the last writer would
13
+ // ratify the regression it exists to refuse.
14
+ //
15
+ // `undefined` means the state carries no readable stamp at all — every repository written
16
+ // before this row shipped. It is silence, not zero: a floor of 0.0.0 would be a claim the
17
+ // state never made, and it would refuse nothing anyway.
18
+ export function stateFloor(root) {
19
+ const dir = join(root, '.witness', 'journal');
20
+ if (!existsSync(dir))
21
+ return undefined;
22
+ // An explicit decision outranks the derived maximum — including downward, which is the
23
+ // only reason the verb exists. Latest wins, so a second rollback supersedes the first:
24
+ // the last decision is the state, the same doctrine D94 applies to gate decisions.
25
+ const pinned = entries(root, FLOOR_STREAM)
26
+ .filter((e) => e.t === 'policy-pin' && e.key === FLOOR_KEY && typeof e.pin === 'string')
27
+ .at(-1);
28
+ if (pinned !== undefined)
29
+ return { pin: pinned.pin, stream: 'a human decision' };
30
+ let best;
31
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl')).sort()) {
32
+ const stream = file.slice(0, -'.jsonl'.length);
33
+ for (const e of entries(root, stream)) {
34
+ if (typeof e.w !== 'string')
35
+ continue;
36
+ const pin = e.w;
37
+ // `compareTriple(pin, pin)` is the parse check: undefined means CANNOT COMPARE, the
38
+ // same contract install.ts's two guards read it under. A stamp we cannot read must
39
+ // never raise a bound — that would refuse a legal CLI over a typo.
40
+ if (compareTriple(pin, pin) === undefined)
41
+ continue;
42
+ if (best === undefined || (compareTriple(pin, best.pin) ?? 0) > 0)
43
+ best = { pin, stream };
44
+ }
45
+ }
46
+ return best;
47
+ }
48
+ // The journal stream a floor decision lives in. Its first entry is a policy-pin, never a
49
+ // recap, so `effortStreams` does not mistake it for an effort.
50
+ export const FLOOR_STREAM = 'floor';
51
+ export const FLOOR_KEY = 'floor';
52
+ // Deliberately NOT journal.ts's readStream, whose bare JSON.parse throws. This runs on
53
+ // EVERY verb now, so one unreadable line would brick the whole CLI — including `floor`
54
+ // itself, the one verb that could unstick it. readStream's strict parse stays right for
55
+ // callers that need the entry; a bound derived from stamps needs only the lines it can
56
+ // actually read, and skipping the rest is the honest reading of a damaged file.
57
+ function entries(root, stream) {
58
+ const path = join(root, journalRel(stream));
59
+ if (!existsSync(path))
60
+ return [];
61
+ const out = [];
62
+ for (const raw of readFileSync(path, 'utf8').split('\n')) {
63
+ if (raw === '')
64
+ continue;
65
+ try {
66
+ out.push(JSON.parse(raw));
67
+ }
68
+ catch {
69
+ // a line we cannot parse carries no decision and no stamp we can trust — the same
70
+ // answer as a line that never had one
71
+ }
72
+ }
73
+ return out;
74
+ }
package/dist/install.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { version } from './cli.js';
@@ -114,6 +114,45 @@ export function installPayload(root, harness) {
114
114
  }
115
115
  return ok(out);
116
116
  }
117
+ // Row 117. "Upgrade the repository" has always meant "upgrade one checkout of it":
118
+ // installPayload had exactly one call site and it targeted the primary root, so nothing
119
+ // ever refreshed a live worktree. A worktree is a branch checkout, so its payload is a
120
+ // different file on a different timeline — the same property row 87 relies on to get the
121
+ // payload to the agent at all, read the other way round.
122
+ //
123
+ // Root first, then worktrees in sorted order: the root is the home the human is standing
124
+ // in, and if a later home fails they need the report to read top-down.
125
+ export function payloadHomes(root) {
126
+ const dir = join(root, '.witness', 'worktrees');
127
+ if (!existsSync(dir))
128
+ return [root];
129
+ return [root, ...readdirSync(dir, { withFileTypes: true })
130
+ .filter((e) => e.isDirectory())
131
+ .map((e) => join(dir, e.name))
132
+ .sort()];
133
+ }
134
+ // Preflight EVERY home before writing ANY. install.ts's standing doctrine is that a
135
+ // refusal leaves nothing half-installed; across homes that matters more, not less — a
136
+ // half-upgraded set of homes IS the skew row 116 refuses and this row prevents.
137
+ export function installAllHomes(root, harness) {
138
+ const homes = payloadHomes(root);
139
+ const failures = homes.flatMap((home) => {
140
+ const pre = preflightPayload(home, harness);
141
+ // Name the home in the field, or a `payload-dirty` row over `.claude/commands/witness.md`
142
+ // is ambiguous across four checkouts of the same repository.
143
+ return pre.ok ? [] : pre.violations.map((x) => ({ ...x, field: home === root ? x.field : `${home}: ${x.field}` }));
144
+ });
145
+ if (failures.length > 0)
146
+ return refuse(failures);
147
+ const out = [];
148
+ for (const home of homes) {
149
+ const synced = installPayload(home, harness);
150
+ if (!synced.ok)
151
+ return refuse(synced.violations);
152
+ out.push({ home, synced: synced.value });
153
+ }
154
+ return ok(out);
155
+ }
117
156
  function hasCommand(entries, command) {
118
157
  return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
119
158
  }
package/dist/journal.js CHANGED
@@ -1,11 +1,18 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { version } from './version.js';
3
4
  const dir = (root) => join(root, '.witness', 'journal');
4
5
  export const journalRel = (id) => `.witness/journal/${id}.jsonl`;
5
6
  export function streamExists(root, id) {
6
7
  return existsSync(join(root, journalRel(id)));
7
8
  }
8
- export const entryLine = (entry) => JSON.stringify({ v: 1, ...entry });
9
+ // Row 116. `w` is the CLI that wrote this line. Stamped HERE because entryLine is the
10
+ // single funnel every state write passes through, so one edit covers fourteen entry types
11
+ // and every verb without a widened signature to disagree over. It is what lets the state
12
+ // name its own floor (floor.ts) instead of a stored number that drifts from the entries it
13
+ // summarises — and it is why a repo can refuse a CLI older than its own history without
14
+ // consulting any payload file, which is the guard row 102 structurally could not be.
15
+ export const entryLine = (entry) => JSON.stringify({ v: 1, w: version(), ...entry });
9
16
  export function appendEntry(root, id, entry) {
10
17
  mkdirSync(dir(root), { recursive: true });
11
18
  const line = entryLine(entry);
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { adoptedCommits } from '../adopt.js';
4
4
  import { EXIT, version } from '../cli.js';
5
5
  import { DEFAULT_HARNESS, HARNESSES, judgeLine, loadHarness, resolveJudge, resolveSkills, skillPins } from '../harness.js';
6
- import { packageRoot } from '../install.js';
6
+ import { packageRoot, payloadHomes } from '../install.js';
7
7
  import { latestPublished } from '../registry.js';
8
8
  import { NPX_LATEST, compareTriple } from '../version.js';
9
9
  import { probe } from '../probe.js';
@@ -254,14 +254,24 @@ export async function run(ctx, argv = []) {
254
254
  // whole suite stays green, because vitest reads from the repo root. installPayload
255
255
  // is where that condition refuses (`source-missing`); a diagnostic verb must not
256
256
  // crash on it, and must not report the repo as stale for it either.
257
- const stale = installed.filter((p) => {
257
+ //
258
+ // Row 118: per HOME, not per repository. A worktree is a branch checkout, so its
259
+ // payload is a different file that can be a different age — and that is exactly the
260
+ // state row 116's floor cannot see, because the floor binds only CLIs at or after
261
+ // the version that introduced it while the frozen homes were cut before it. The
262
+ // root keeps its bare relative path; a worktree is named, or four checkouts of one
263
+ // repository all report `.claude/commands/witness.md` and the human cannot tell
264
+ // which of them is the rotten one.
265
+ const stale = payloadHomes(root).flatMap((home) => installed
266
+ .filter((p) => {
258
267
  const src = join(packageRoot(), p.from);
259
- if (!existsSync(src))
268
+ if (!existsSync(src) || !existsSync(join(home, p.to)))
260
269
  return false;
261
- return readFileSync(join(root, p.to), 'utf8') !== readFileSync(src, 'utf8');
262
- });
270
+ return readFileSync(join(home, p.to), 'utf8') !== readFileSync(src, 'utf8');
271
+ })
272
+ .map((p) => (home === root ? p.to : `${home}: ${p.to}`)));
263
273
  if (stale.length > 0) {
264
- findings.push(f('warn', 'harness', `${name}: payload`, 'payload-stale', `${stale.map((p) => p.to).join(' · ')} differ from what ${version()} ships — run ${NPX_LATEST} init --agent ${name}`));
274
+ findings.push(f('warn', 'harness', `${name}: payload`, 'payload-stale', `${stale.join(' · ')} differ from what ${version()} ships — run ${NPX_LATEST} init --agent ${name}`));
265
275
  }
266
276
  }
267
277
  const skills = resolveSkills(ctx.env, root, harness);
@@ -0,0 +1,49 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { EXIT } from '../cli.js';
3
+ import { FLOOR_KEY, FLOOR_STREAM, stateFloor } from '../floor.js';
4
+ import { primaryRoot } from '../gitio.js';
5
+ import { appendEntry } from '../journal.js';
6
+ import { renderRefusal, v } from '../refusal.js';
7
+ import { kv } from '../toon.js';
8
+ import { compareTriple, version } from '../version.js';
9
+ // Row 116's safety valve. The floor is derived from what the state has seen, which is
10
+ // correct until a published version turns out to be broken: then every repository that ran
11
+ // it once carries a bound no fixed CLI can satisfy, and the tool has locked its users out
12
+ // over its own defect. Lowering it is a human act with a reason, so it is journaled as a
13
+ // policy-pin — the entry type row 83 added for exactly this shape — and never a flag on
14
+ // another verb or a file someone can edit without leaving a trace.
15
+ export async function run(ctx, argv) {
16
+ const { values } = parseArgs({
17
+ args: argv,
18
+ options: { show: { type: 'boolean' }, set: { type: 'string' }, note: { type: 'string' } },
19
+ });
20
+ const rootRes = primaryRoot(ctx.cwd);
21
+ if (!rootRes.ok) {
22
+ renderRefusal(rootRes.violations).forEach((l) => ctx.err(l));
23
+ return EXIT.REFUSED;
24
+ }
25
+ const root = rootRes.value;
26
+ if (values.set !== undefined) {
27
+ // Both faults in one refusal, the way row 111 collects an approve's: a human who fixes
28
+ // one and learns about the other on the re-run has spent a turn witness could have saved.
29
+ const violations = [];
30
+ if (compareTriple(values.set, values.set) === undefined) {
31
+ violations.push(v('--set', 'bad-pin', values.set, 'a numeric triple such as 0.9.0 — prerelease ordering is out of scope (row 102)'));
32
+ }
33
+ if (values.note === undefined || values.note.trim() === '') {
34
+ violations.push(v('--note', 'note-required', 'absent', 'a reason — lowering the bound is a decision the journal has to be able to explain later'));
35
+ }
36
+ if (violations.length > 0) {
37
+ renderRefusal(violations).forEach((l) => ctx.err(l));
38
+ return EXIT.REFUSED;
39
+ }
40
+ appendEntry(root, FLOOR_STREAM, { t: 'policy-pin', key: FLOOR_KEY, pin: values.set, note: values.note });
41
+ ctx.out(kv('floor', `${values.set} — set by hand: ${values.note}`));
42
+ return EXIT.OK;
43
+ }
44
+ const floor = stateFloor(root);
45
+ ctx.out(floor === undefined
46
+ ? kv('floor', 'none — this state predates the writer stamp, so it claims no bound')
47
+ : kv('floor', `${floor.pin} · from ${floor.stream} · this CLI is ${version()}`));
48
+ return EXIT.OK;
49
+ }
@@ -6,7 +6,7 @@ import { configPath } from '../config.js';
6
6
  import { writeDoc } from '../fm.js';
7
7
  import { commitWithTrailer, primaryRoot, tryGit } from '../gitio.js';
8
8
  import { loadHarness, resolveDriver } from '../harness.js';
9
- import { installPayload, mergeSettings, preflightPayload, readIfExists, recordHarness, writeSettings, } from '../install.js';
9
+ import { installAllHomes, mergeSettings, preflightPayload, readIfExists, recordHarness, writeSettings, } from '../install.js';
10
10
  import { probe } from '../probe.js';
11
11
  import { acquireLock } from '../lock.js';
12
12
  import { renderRefusal, v } from '../refusal.js';
@@ -119,13 +119,30 @@ export async function run(ctx, argv = []) {
119
119
  }
120
120
  let synced;
121
121
  if (harness !== undefined) {
122
- const payload = installPayload(root, harness);
122
+ const payload = installAllHomes(root, harness);
123
123
  if (!payload.ok) {
124
124
  renderRefusal(payload.violations).forEach(ctx.err);
125
125
  return EXIT.REFUSED;
126
126
  }
127
- synced = payload.value;
127
+ synced = payload.value.find((p) => p.home === root)?.synced ?? { written: [], overwritten: [] };
128
128
  files.push(...synced.written, ...synced.overwritten);
129
+ // Row 117. Each worktree is a separate checkout on a separate branch, so its payload
130
+ // can only be committed there — the root's commit below cannot reach it.
131
+ // commitWithTrailer, not a hand-rolled `commit --only`: `--only` resolves its
132
+ // pathspec against the index, so a freshly written payload the worktree has never
133
+ // tracked is "did not match any file(s) known to git" until the add it already does.
134
+ // It also carries the state-commit trailer and treats a no-op as silent success.
135
+ for (const { home, synced: s } of payload.value.filter((p) => p.home !== root)) {
136
+ const touched = [...s.written, ...s.overwritten];
137
+ if (touched.length === 0)
138
+ continue;
139
+ const committed = commitWithTrailer(home, touched, `init(${harness.name}): agent payloads`);
140
+ if (!committed.ok) {
141
+ renderRefusal(committed.violations).forEach(ctx.err);
142
+ return EXIT.REFUSED;
143
+ }
144
+ ctx.out(kv('payload-synced', `${home} — ${touched.join(' · ')}`));
145
+ }
129
146
  if (harness.settings !== undefined) {
130
147
  const merged = mergeSettings(readIfExists(root, harness.settings));
131
148
  if (merged.changed) {
@@ -5,7 +5,7 @@ import { EXIT } from '../cli.js';
5
5
  import { loadConfig } from '../config.js';
6
6
  import { ID_RE } from '../dsl.js';
7
7
  import { primaryRoot, stateCommit } from '../gitio.js';
8
- import { appendEntry, effortAbandoned, journalRel, readStream, streamExists } from '../journal.js';
8
+ import { appendEntry, effortAbandoned, entryLine, journalRel, readStream, streamExists } from '../journal.js';
9
9
  import { acquireLock } from '../lock.js';
10
10
  import { renderRefusal, v } from '../refusal.js';
11
11
  import { kv } from '../toon.js';
@@ -120,7 +120,9 @@ export async function run(ctx, argv) {
120
120
  t: 'recap', effort: recap.effort, class: recap.class,
121
121
  goals: recap.goals, non_goals: recap.non_goals, constraints: recap.constraints, slices: recap.slices,
122
122
  };
123
- const line = JSON.stringify({ v: 1, ...entry });
123
+ // entryLine, never a second inline JSON.stringify — completeTxn re-appends when this
124
+ // marker line differs from what appendEntry wrote, so the two must be one derivation.
125
+ const line = entryLine(entry);
124
126
  const stream = journalRel(recap.effort);
125
127
  const res = withTxn(root, { op: `recap(${recap.effort})`, files: [stream], journal: { stream: recap.effort, line } }, () => {
126
128
  appendEntry(root, recap.effort, entry);
@@ -193,7 +193,12 @@ export async function run(ctx, argv) {
193
193
  ...(existing === undefined ? { created: true } : {}),
194
194
  ...(supersedes !== undefined ? { supersedes } : {}),
195
195
  };
196
- const line = JSON.stringify({ v: 1, ...entry });
196
+ // entryLine, never a second inline JSON.stringify: completeTxn decides whether to
197
+ // re-append by comparing this marker line against the last journal line, so any field
198
+ // entryLine adds and this does not makes the two differ and the recovery duplicate the
199
+ // entry. Row 116's `w` stamp is what caught it; the shape is the one rows 93, 95 and
200
+ // 96 all name, and every other txn writer already calls entryLine.
201
+ const line = entryLine(entry);
197
202
  const supersededDesignRel = reslice ? designRel(cfg.value.paths, reslice.entry.artifact) : undefined;
198
203
  const files = [rel, stream,
199
204
  ...(reslice ? [reslice.doc.rel, journalRel(reslice.entry.artifact)] : []),
@@ -238,7 +243,7 @@ function journalRefusal(ctx, root, effort, artifact, violations) {
238
243
  t: 'write-refused', effort, artifact,
239
244
  rules: violations.map(({ field, rule }) => ({ field, rule })),
240
245
  };
241
- const line = JSON.stringify({ v: 1, ...entry });
246
+ const line = entryLine(entry);
242
247
  const stream = journalRel(effort);
243
248
  const res = withTxn(root, { op: `write-refused(${artifact})`, files: [stream], journal: { stream: effort, line } }, () => {
244
249
  appendEntry(root, effort, entry);
package/dist/version.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  // The one home for "which version of witness is which". install.ts parsed the pin to
2
4
  // decide whether a payload was only a pin apart (row 102 killed that rule) and check.ts
3
5
  // re-declared the same regex inline — one question answered in two places, which is the
@@ -36,3 +38,13 @@ export function compareTriple(a, b) {
36
38
  // through a frozen /witness is `npx …@0.5.1 init`, which restamps 0.5.1 onto 0.5.1 — a
37
39
  // no-op that reads as compliance. Remedies name the version.
38
40
  export const NPX_LATEST = 'npx -y @popovych.co/witness@latest';
41
+ // Row 116. Moved here from cli.ts. `journal.ts` stamps every entry with it and cannot
42
+ // import the CLI shell without closing a cycle (cli → verbs/* → journal). This module
43
+ // already claimed to be the one home for "which version of witness is which" while the
44
+ // reader itself lived in the shell — the same split rows 93, 95 and 96 keep naming. The
45
+ // single readFileSync is the exception to this file's no-I/O rule and the only one it
46
+ // may ever have.
47
+ export function version() {
48
+ const pkg = JSON.parse(readFileSync(join(new URL('.', import.meta.url).pathname, '..', 'package.json'), 'utf8'));
49
+ return pkg.version;
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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.9.0",
4
+ "version": "0.10.0",
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.9.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
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.9.0} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0} 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.9.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
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.9.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
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.9.0}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
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.9.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
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.9.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
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.9.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
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.)