@popovych.co/witness 0.19.4 → 0.21.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.
Files changed (53) hide show
  1. package/README.md +138 -22
  2. package/dist/arbiter.js +2 -2
  3. package/dist/calibrate.js +12 -6
  4. package/dist/cli.js +16 -4
  5. package/dist/config-file.js +195 -0
  6. package/dist/config.js +313 -240
  7. package/dist/context.js +108 -0
  8. package/dist/drift.js +1 -1
  9. package/dist/drive-report.js +1 -1
  10. package/dist/drive.js +26 -8
  11. package/dist/evidence.js +16 -5
  12. package/dist/gate.js +24 -30
  13. package/dist/gates/implement.js +3 -1
  14. package/dist/gates/ship.js +2 -3
  15. package/dist/harness.js +25 -16
  16. package/dist/install.js +37 -0
  17. package/dist/journal.js +20 -1
  18. package/dist/legacy-comments.js +30 -0
  19. package/dist/migration.js +177 -0
  20. package/dist/model.js +14 -24
  21. package/dist/pin.js +13 -0
  22. package/dist/reviewer.js +3 -1
  23. package/dist/runner.js +12 -26
  24. package/dist/settings.js +276 -0
  25. package/dist/ship.js +22 -4
  26. package/dist/slice.js +185 -0
  27. package/dist/steps.js +149 -0
  28. package/dist/verbs/calibrate.js +1 -1
  29. package/dist/verbs/check.js +50 -6
  30. package/dist/verbs/dashboard.js +2 -2
  31. package/dist/verbs/decide.js +12 -1
  32. package/dist/verbs/dispatch.js +1 -1
  33. package/dist/verbs/drive.js +12 -1
  34. package/dist/verbs/evidence.js +65 -7
  35. package/dist/verbs/init.js +37 -28
  36. package/dist/verbs/log.js +18 -2
  37. package/dist/verbs/migrate.js +201 -0
  38. package/dist/verbs/next.js +99 -17
  39. package/dist/verbs/start.js +22 -13
  40. package/dist/verbs/step-close.js +80 -0
  41. package/package.json +1 -1
  42. package/plugin/.claude-plugin/plugin.json +1 -1
  43. package/plugin/commands/witness.md +7 -5
  44. package/plugin/hooks/canon-guard.mjs +12 -1
  45. package/plugin/hooks/context-core.mjs +230 -0
  46. package/plugin/hooks/session-dashboard.sh +1 -1
  47. package/plugin/hooks/witness-pi.ts +74 -12
  48. package/plugin/skills/witness-brainstorm/SKILL.md +2 -1
  49. package/plugin/skills/witness-decompose/SKILL.md +3 -2
  50. package/plugin/skills/witness-design/SKILL.md +6 -3
  51. package/plugin/skills/witness-implement/SKILL.md +45 -15
  52. package/plugin/skills/witness-plan/SKILL.md +3 -2
  53. package/plugin/skills/witness-ship/SKILL.md +3 -2
package/README.md CHANGED
@@ -63,12 +63,30 @@ witness is two halves: **six skills**, distributed through the
63
63
  **engine + guard + dashboard**, which no skills installer can place — `witness init
64
64
  --agent <name>` writes those and commits them.
65
65
 
66
- **Claude Code** — one step, the native path:
66
+ **Claude Code** — the native path:
67
67
 
68
68
  ```bash
69
69
  /plugin marketplace add popovych-co/witness # skills, engine, guard and dashboard together
70
70
  ```
71
71
 
72
+ **Claude Code, on either route** (the marketplace plugin above, or `init --agent claude-code`),
73
+ also needs the `witness-context` plugin, installed once per machine — then start a new session:
74
+
75
+ ```bash
76
+ claude plugin marketplace add popovych-co/witness && claude plugin install witness-context@witness
77
+ ```
78
+
79
+ It puts the session's live context reading into every Bash command, which is what lets a
80
+ session hand over at `session.contextLimit` (see Run economics below). The reading comes from
81
+ a hooks module, and only a plugin can carry one. `init --agent claude-code` also declares the
82
+ plugin in `.claude/settings.json`, the way a project names a plugin it needs, but the
83
+ declaration alone does not load it in a headless session (`claude -p`, `witness drive`). A
84
+ session loads the plugin when it starts, so one already running when you install it does not:
85
+ start a new one. The install is a copy: after upgrading witness, `claude plugin update
86
+ witness-context@witness` refreshes it, for the sessions started afterwards. Without the plugin
87
+ witness still runs, on the step budget, and says so; `witness check`, run from a session, reports
88
+ whether that session sees the reading.
89
+
72
90
  **Pi** (and any other agent the installer supports):
73
91
 
74
92
  ```bash
@@ -144,20 +162,91 @@ either.
144
162
 
145
163
  ## Configuration keys
146
164
 
147
- | Key | Meaning |
148
- |---|---|
149
- | `harness: claude-code \| pi` | **the judge** — which harness runs this repo's gate reviewers, is measured by `calibrate`, and is probed by `check`. Judgment resolves `harness:` → `PI_CODING_AGENT` → `CLAUDECODE` → `claude-code`; session lines (`next`'s handoff, `dispatch`'s relay, `init --agent auto`) resolve detection first, because those name the CLI you are about to type at. Committed, so it binds every teammate's gates; `witness init --agent` writes it once and never re-points it. Undeclared repos are judged by whichever agent's session is open — `witness check` and `witness status` say so |
150
- | `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
151
- | `drive.sessionTimeoutMs` | milliseconds a `drive`-spawned session may run before SIGTERM (default 3600000) |
152
- | `drive.parallel` | sessions `witness drive` runs at once under autonomy, one per target (default 2) |
153
- | `drive.parallelSuites` | `true` lets test runs in two worktrees overlap (default false: they take turns, because suites that boot servers or browsers collide) |
154
- | `drive.caps.rounds` | gate rounds one target may run across budget resets (default 16) — the run parks a target that reaches it |
155
- | `drive.caps.upstream` | upstream resets one target may take (default 6) — the run parks a target that reaches it |
156
- | `drive.caps.spawns` | sessions one target may take in one drive run (default 24) — the run parks a target that reaches it |
157
- | `drive.caps.wallClockMs` | milliseconds of session time one target may take in one drive run (default 57600000 · 16 h) — the run parks a target that reaches it |
158
- | `autonomy: on \| off` | whether the CLI settles its own stops — through its recommendation or the arbiter it launches — and merges its own PRs once CI is green (default off). A committed repo fact: it counts only once HEAD says it, and the merge also needs the ship branch on origin to say it. `--manual` on a gate run keeps that run's stop for a human, and three stops always reach one: the look of a new screen, an unmet plan need and a trust grant |
159
- | `ship.merge: merge \| squash \| rebase` | how `witness ship` merges its own PR under `autonomy: on` (default merge) — only once the ship gate is approved and CI is green, and never when the approval lapsed or GitHub refuses |
160
- | `ship.ciSettleMs` | milliseconds a green check set must hold still before `witness ship` merges under autonomy (default 60000, at most 1800000) — gh's watch ends the moment nothing is pending, so a check that registers late would otherwise be missed. Green means at least one check passed and none failed, was cancelled or is pending |
165
+ `witness init` writes every setting into `witness.config.yaml`, each with a comment saying
166
+ what it does and what it accepts, and nothing is inherited: each gate states its own `model`
167
+ and `reviewers`, and the arbiter its own `arbiter.model`. A missing setting, an unknown key and
168
+ an invalid value all refuse, and the refusal says how to fix it. The table is generated from
169
+ the list the CLI reads (`src/settings.ts`).
170
+
171
+ <!-- settings:start -->
172
+ | Setting | What it does | Default |
173
+ |---|---|---|
174
+ | `harness` | the judge: the agent CLI that runs this repo's gate reviewers and arbiter · claude-code \| pi — accepts: claude-code \| pi — the judge: which agent CLI runs this repo's gate reviewers and arbiter | `claude-code` |
175
+ | `paths.specs` | folder for specs, repo-relative · git mv the docs when you change it — accepts: a repo-relative directory | `specs` |
176
+ | `paths.plans` | folder for plans, repo-relative · git mv the docs when you change it — accepts: a repo-relative directory | `plans` |
177
+ | `paths.designs` | folder for approved designs, repo-relative · git mv the docs when you change it — accepts: a repo-relative directory | `designs` |
178
+ | `docs.conventions` | code docs handed to code-reviewer at implement and ship · file list, [] = none — accepts: a list of repo-relative file paths ([] = none) | `[]` |
179
+ | `docs.design` | design docs handed to design-critic and design-reviewer · file list, [] = none — accepts: a list of repo-relative file paths ([] = none) | `[]` |
180
+ | `criteria.runner` | how a spec's criteria run · a command with {id}, or full-suite — accepts: a filtered template containing {id}, or full-suite | `'npm test -- -t "@spec:{id}"'` |
181
+ | `criteria.report` | the junit report witness reads · junit:<glob>, or none · full-suite needs one — accepts: none, or junit:<glob> (only junit is supported) | `none` |
182
+ | `criteria.exclude` | extra globs never scanned for criteria tags · [] = none — accepts: a list of globs ([] = none) | `[]` |
183
+ | `ship.test` | the suite command ship runs · full-suite criteria run it too — accepts: a shell command | `npm test` |
184
+ | `ship.lint` | the lint command ship runs — accepts: a shell command | `npm run lint` |
185
+ | `ship.branch` | the branch witness ships into — accepts: a branch name | `main` |
186
+ | `ship.merge` | how ship merges its own PR under autonomy · merge \| squash \| rebase — accepts: merge \| squash \| rebase | `merge` |
187
+ | `ship.ciSettleMs` | ms green CI must hold still before an autonomous merge · 0–1800000 — accepts: an integer from 0 to 1800000 — milliseconds a green check set must hold before witness merges | `60000` |
188
+ | `autonomy` | on = the CLI settles its own stops and merges green PRs · on \| off · counts once committed — accepts: on \| off | `off` |
189
+ | `arbiter.model` | model for the arbiter that settles stops and checks recaps under autonomy · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
190
+ | `session.contextLimit` | % of the context window at which a session hands over to a fresh one · 1–100 — accepts: an integer from 1 to 100 — % of the session's context window at which witness hands over to a fresh session | `20` |
191
+ | `implement.stepsPerDispatch` | steps an implement session takes when its context fill can't be read · ≥ 1 — accepts: an integer >= 1 — steps handed to each fresh implement agent | `3` |
192
+ | `gates.reviewerTimeoutMs` | ms one reviewer, or the arbiter, may run before it is stopped (10 min) · ≥ 1 — accepts: an integer >= 1 — milliseconds each reviewer invocation may take | `600000` |
193
+ | `gates.decompose.model` | model for this gate's reviewers · exact id, [provider/]id[:thinking], no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
194
+ | `gates.decompose.reviewers` | reviewers this gate runs · one list, or one list per class (feature, fix, chore) — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `[slicing-critic]` |
195
+ | `gates.plan.model` | model for this gate's reviewers · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
196
+ | `gates.plan.reviewers` | reviewers this gate runs · one list, or one list per class — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `[plan-critic]` |
197
+ | `gates.design.model` | model for this gate's reviewers · runs only on specs marked ui — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
198
+ | `gates.design.reviewers` | reviewers this gate runs · one list, or one list per class — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `[design-critic]` |
199
+ | `gates.implement.model` | model for this gate's reviewers AND for the agent that writes the code — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
200
+ | `gates.implement.reviewers` | reviewers this gate runs · one list, or one list per class — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `{feature: [code-reviewer, silent-failure-hunter, type-design, pr-test, design-reviewer], fix: [code-reviewer, silent-failure-hunter, design-reviewer], chore: [code-reviewer]}` |
201
+ | `gates.ship.model` | model for this gate's reviewers · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
202
+ | `gates.ship.reviewers` | reviewers this gate runs · one list, or one list per class — accepts: a reviewer list or a per-class map of lists in witness.config.yaml — e.g. reviewers: [code-reviewer] — a per-class map names feature, fix and chore | `[drift-reviewer, code-reviewer]` |
203
+ | `drive.parallel` | sessions drive runs at once, one per target · ≥ 1 — accepts: an integer >= 1 — sessions drive runs at once · one per target | `2` |
204
+ | `drive.parallelSuites` | let test runs in two worktrees overlap · true \| false — accepts: true \| false — whether test runs in two worktrees may overlap | `false` |
205
+ | `drive.sessionTimeoutMs` | ms one spawned session may run before it is stopped (1 h) · ≥ 1 — accepts: an integer >= 1 — milliseconds each spawned session may take before SIGTERM | `3600000` |
206
+ | `drive.caps.rounds` | gate rounds one target may run across budget resets, then it is parked · ≥ 1 — accepts: an integer >= 1 — gate rounds one target may run across budget resets before drive parks it | `16` |
207
+ | `drive.caps.upstream` | upstream resets one target may take, then it is parked · ≥ 1 — accepts: an integer >= 1 — upstream resets one target may take before drive parks it | `6` |
208
+ | `drive.caps.spawns` | sessions one target may take in one run, then it is parked · ≥ 1 — accepts: an integer >= 1 — sessions one target may take in one drive run | `24` |
209
+ | `drive.caps.wallClockMs` | ms of session time one target may take in one run (16 h), then it is parked · ≥ 1 — accepts: an integer >= 1 — milliseconds of session time one target may take in one drive run | `57600000` |
210
+ <!-- settings:end -->
211
+
212
+ **The judge (`harness`).** Which harness runs this repo's gate reviewers and its arbiter, is
213
+ measured by `calibrate`, and is probed by `check`. It is declared, never detected: `witness
214
+ init` writes the harness `--agent` names, else the one whose session runs `init`, else
215
+ `claude-code`, and says which. Session lines (`next`'s handoff, `dispatch`'s relay, `init
216
+ --agent auto`) resolve detection first, because those name the CLI you are about to type at.
217
+ Committed, so it binds every teammate's gates; `init --agent` never re-points it. An older file
218
+ without `harness:` is judged by whichever agent's session is open until `witness migrate` writes
219
+ it — `witness check` and `witness status` say so.
220
+
221
+ **The context limit (`session.contextLimit`).** The harness measures the fill; the CLI decides
222
+ where the journal carries the position — at a step close, after a revise, at a `witness ship`
223
+ exit and at `next` — and never mid-red. A session whose fill cannot be read says so (`context:
224
+ unmeasured (<reason>)`): an implement session then hands over by `implement.stepsPerDispatch`,
225
+ and nothing else hands over (DESIGN.md row 191).
226
+
227
+ **Autonomy (`autonomy`, `ship.merge`, `ship.ciSettleMs`).** Under `autonomy: on` the CLI settles
228
+ its own stops — through its recommendation or the arbiter it launches on `arbiter.model` — and
229
+ merges its own PRs once CI is green. A committed repo fact: it counts only once HEAD says it, and
230
+ the merge also needs the ship branch on origin to say it. `--manual` on a gate run keeps that
231
+ run's stop for a human, and three stops always reach one: the look of a new screen, an unmet plan
232
+ need and a trust grant. `witness ship` merges with `ship.merge` only once the ship gate is
233
+ approved and CI is green — never when the approval lapsed or GitHub refuses — and only after the
234
+ check set has held still for `ship.ciSettleMs`: gh's watch ends the moment nothing is pending, so
235
+ a check that registers late would otherwise be missed. Green means at least one check passed and
236
+ none failed, was cancelled or is pending.
237
+
238
+ **`drive`.** `drive.parallelSuites: false` makes test runs in two worktrees take turns, because
239
+ suites that boot servers or browsers collide. A target that reaches any `drive.caps.*` bound is
240
+ parked.
241
+
242
+ ### Upgrading
243
+
244
+ After each witness upgrade, run `witness migrate --dry-run` to see what the new version adds or
245
+ changes, then `witness migrate`: it brings `witness.config.yaml` to the current schema, keeps
246
+ every value you chose and every comment you wrote, names each change, and commits the file. A
247
+ value still at an old default moves to the new one, and the output says so. Until you migrate,
248
+ an older file keeps working: witness reads it as `migrate` would, changes nothing in it, and warns
249
+ (`witness check` shows `older-schema`). A file from a newer witness refuses: upgrade witness.
161
250
 
162
251
  There is no `provider:` key. `witness gate` spawns the DECLARED harness's headless
163
252
  mode for every reviewer (Decisions 88, 105): claude-code renders bare Anthropic ids, pi
@@ -198,7 +287,8 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
198
287
  | `next` | the one next action across every effort |
199
288
  | `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] [--ask "<text>"]` | runs every session itself · under autonomy it needs no terminal and reports when nothing else can move. Up to `drive.parallel` targets at once, one session per target; usage limits are waited out; a target past a cap is parked. `--detach` (autonomy only) runs it in the background into `.witness/drive.log`; `--ask` queues an ask first; `--manual` runs serially and asks you at every stop. One drive per repo. A session drive spawned is refused — agents are what drive spawns, never what spawns drive |
200
289
  | `ship <plan-id> [--manual]` | lanes → ship gate → PR → CI watch → under autonomy: merge on green · `--manual` keeps that run's stop and merge for a human |
201
- | `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
290
+ | `test-evidence <plan-id> --phase red\|green [--step <id>]` / `verify-red <plan-id> [--base <ref>] [--step <id>]` | journal red/green criteria evidence from a worktree; `--step` names the plan step the cycle closes — required before the plan's first implement gate (on a plan with a criteria step), left off for a fix round after it. A green that closes a step prints `context:`, `steps:` and `slice:` |
291
+ | `step-close <plan-id> --step <id>` | close a scaffolding step, which has no red→green to witness; prints `context:`, `steps:` and `slice:` as a closing green does, and closing it again changes nothing |
202
292
  | `adopt <path>` | absolve a finished hand-edit into the journal |
203
293
  | `abandon <plan-id \| effort-slug…>` | wind a plan/effort down; reverts only specs that effort itself wrote — several efforts go as one act, so a split or an amendment they share goes back together |
204
294
  | `dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n>` | journal a session slice's telemetry |
@@ -236,15 +326,41 @@ Releasing is `node scripts/release.mjs <major|minor|patch|x.y.z>` followed by tw
236
326
 
237
327
  ## Run economics (operator notes)
238
328
 
239
- Non-load-bearing knobs for the machine that hosts long implement runs — the design
240
- depends on none of them (DESIGN.md row 79):
329
+ A witness session ends at a configured share of its context window:
330
+ `session.contextLimit`, 20% by default (DESIGN.md row 191). The harness measures the
331
+ fill — the `witness-context` plugin on Claude Code and the witness extension on Pi put the
332
+ live reading into every shell command's environment — and witness counts no tokens of its
333
+ own. The CLI decides only where the journal already carries the position: at a step
334
+ close (`slice: end`), after a revise, at a `witness ship` exit and at `next`, by
335
+ printing a `relay:` line. A session never hands over mid-red. Once per session, at the
336
+ limit, one line follows a command's result, so a long step learns it is over:
337
+
338
+ ```
339
+ witness: context 21% of 1M has reached the 20% limit — finish what you are in (implement: the red→green), then hand over
340
+ ```
341
+
342
+ Where nothing supplies the reading, witness says so —
343
+ `context: unmeasured (no adapter) — step budget 3` — and an implement session hands over
344
+ after `implement.stepsPerDispatch` steps instead; nothing else hands over.
345
+
346
+ Operator notes, non-load-bearing — the design depends on none of them:
241
347
 
348
+ - **Mind the window.** The limit is a share, so one warm-up weighs differently by model:
349
+ 51k tokens is 5% of a 1M window and 25% of a 200k one. A session that opens over the
350
+ limit is warned (`warn: this session opens at …`) and works one step per slice; on a
351
+ small window, raise `session.contextLimit` or accept a warm-up per step. `witness drive`'s
352
+ defaults were set when a slice was three steps: `drive.sessionTimeoutMs` (1 h) and
353
+ `drive.caps.spawns` (24). A measured slice on a large window can run past an hour, and on a
354
+ 200k window every slice is one step, so a plan takes more sessions; raise either if drive
355
+ times out or caps a plan that is still moving.
242
356
  - **Keep the host awake.** A suspend longer than an hour mid-run expires the prompt
243
357
  cache; on wake the full agent context is re-written at cache-write price. On macOS:
244
358
  `witness drive` holds `caffeinate -ims` for its own lifetime (a closed lid on battery
245
359
  still sleeps); for a chat session, `caffeinate -dims`, or plug in and disable sleep.
246
360
  - **`CLAUDE_CODE_AUTO_COMPACT_WINDOW`** (set at CLI launch) lowers the harness's
247
- auto-compaction threshold as belt-and-suspenders under the dispatch budget. It is
248
- documented for main sessions only — whether subagents honor it is unverified.
249
- - The designed mechanisms are the dispatch budget (`implement.stepsPerDispatch`),
250
- the loop-width protocol, and `dispatch-report` telemetry — see DESIGN.md rows 79–81.
361
+ auto-compaction threshold, as belt-and-suspenders under the fill relay. Compaction
362
+ stays the harness's continuity mechanism, never witness's; the variable is documented
363
+ for main sessions only — whether subagents honor it is unverified.
364
+ - The designed mechanisms are the fill relay (`session.contextLimit`, with
365
+ `implement.stepsPerDispatch` for sessions it cannot measure), the loop-width protocol,
366
+ and `dispatch-report` telemetry — see DESIGN.md rows 79–81 and 191.
package/dist/arbiter.js CHANGED
@@ -57,14 +57,14 @@ export async function arbitrate(ctx, root, cfg, req) {
57
57
  if (!lensR.ok)
58
58
  return lensR;
59
59
  const lens = lensR.value;
60
- const hxR = resolveJudge(ctx.env, cfg.raw);
60
+ const hxR = resolveJudge(ctx.env, cfg.harness);
61
61
  if (!hxR.ok)
62
62
  return refuse(hxR.violations);
63
63
  const harness = hxR.value.harness;
64
64
  const localR = loadLocalConfig(root);
65
65
  if (!localR.ok)
66
66
  return localR;
67
- const modelR = resolveModel(cfg, loadMatrix(root, harness.name));
67
+ const modelR = resolveModel(cfg, loadMatrix(root, harness.name), 'arbiter');
68
68
  if (!modelR.ok)
69
69
  return refuse(modelR.violations);
70
70
  const chain = modelR.value.chain;
package/dist/calibrate.js CHANGED
@@ -9,6 +9,8 @@ import { loadConfig } from './config.js';
9
9
  import { changedFiles, diffBase, evidenceForDiff } from './evidence.js';
10
10
  import { readDoc, splitDoc, writeDoc } from './fm.js';
11
11
  import { git, stateCommit } from './gitio.js';
12
+ import { renderConfig } from './config-file.js';
13
+ import { defaultValues, withValues } from './settings.js';
12
14
  import { ok, refuse, renderRefusal, v } from './refusal.js';
13
15
  import { PROMPT_NAMES, invokeReviewer, parseVerdictText, resolvePrompt } from './reviewer.js';
14
16
  import { findById, loadCanon } from './scan.js';
@@ -387,7 +389,7 @@ function captureCtx(cwd, env) {
387
389
  const ctx = { cwd, env, isTTY: false, out: (l) => outs.push(l), err: (l) => errs.push(l), ask: async () => '' };
388
390
  return { ctx, out: () => outs.join('\n'), err: () => errs.join('\n') };
389
391
  }
390
- export function seedScratchRepo(prefix) {
392
+ export function seedScratchRepo(prefix, harness) {
391
393
  const root = mkdtempSync(join(tmpdir(), `cal-${prefix}-`));
392
394
  git(root, 'init', '-b', 'main');
393
395
  git(root, 'config', 'user.name', 'witness-calibration');
@@ -398,8 +400,12 @@ export function seedScratchRepo(prefix) {
398
400
  // `tests/` arg is resolved as a module path on Node 24, and a glob is taken literally
399
401
  // on Node 20 ("Could not find 'tests/**/*.test.mjs'": glob args landed in Node 21),
400
402
  // which silently scores every implement sample 0. Node's default discovery already
401
- // walks the repo for *.test.mjs and skips node_modules.
402
- 'schema: 1\ncriteria:\n runner: \'node --test --test-name-pattern "@spec:{id}"\'\n');
403
+ // walks the repo for *.test.mjs and skips node_modules. A full current file (row 193), so
404
+ // no calibration run prints the older-schema warning; every reviewer here is handed its
405
+ // model explicitly, so the prefilled pins change nothing.
406
+ renderConfig(withValues(defaultValues({ harness }), {
407
+ criteria: { runner: 'node --test --test-name-pattern "@spec:{id}"' },
408
+ })));
403
409
  writeFileSync(join(root, '.gitignore'), '.witness/lock\n.witness/allow.json\n.witness/calibration.local.yaml\n.witness/worktrees/\n');
404
410
  git(root, 'add', '-A');
405
411
  git(root, 'commit', '-m', 'seed');
@@ -431,7 +437,7 @@ function firstCriterionTag(meta) {
431
437
  return withTest ? String(withTest.test).replace(/^@spec:/, '') : '';
432
438
  }
433
439
  export async function runDecomposeSeed(ctx, harness, model, seed, extras) {
434
- const { root } = seedScratchRepo(`decompose-${seed.id}`);
440
+ const { root } = seedScratchRepo(`decompose-${seed.id}`, harness.name);
435
441
  const canonDir = join(seed.dir, 'canon');
436
442
  if (existsSync(canonDir)) {
437
443
  for (const f of readdirSync(canonDir))
@@ -520,7 +526,7 @@ export async function runDecomposeSeed(ctx, harness, model, seed, extras) {
520
526
  return ok({ ok: true, why: 'first-try valid' });
521
527
  }
522
528
  export async function runPlanSeed(ctx, harness, model, seed, extras) {
523
- const { root } = seedScratchRepo(`plan-${seed.id}`);
529
+ const { root } = seedScratchRepo(`plan-${seed.id}`, harness.name);
524
530
  const recapText = readFileSync(join(seed.dir, 'recap.json'), 'utf8');
525
531
  const recap = JSON.parse(recapText);
526
532
  writeFileSync(join(root, '.cal-recap.json'), recapText);
@@ -601,7 +607,7 @@ export async function defaultAgent(ctx, harness, worktree, prompt) {
601
607
  });
602
608
  }
603
609
  export async function runImplementSeed(ctx, harness, seed, agent) {
604
- const { root } = seedScratchRepo(`implement-${seed.id}`);
610
+ const { root } = seedScratchRepo(`implement-${seed.id}`, harness.name);
605
611
  cpSync(join(seed.dir, 'repo'), root, { recursive: true });
606
612
  git(root, 'add', '-A');
607
613
  git(root, 'commit', '-m', 'seed: repo');
package/dist/cli.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { configPath } from './config.js';
3
+ import { journalStamp } from './context.js';
3
4
  import { stateFloor } from './floor.js';
4
5
  import { primaryRoot } from './gitio.js';
6
+ import { withJournalCtx } from './journal.js';
5
7
  import { renderRefusal, v } from './refusal.js';
6
8
  import { NPX_LATEST, compareTriple, version } from './version.js';
7
9
  export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
8
10
  const VERBS = {
9
11
  recover: () => import('./verbs/recover.js'),
10
12
  init: () => import('./verbs/init.js'),
13
+ // Row 193 §4.6. Brings witness.config.yaml to this CLI's schema, naming every change.
14
+ migrate: () => import('./verbs/migrate.js'),
11
15
  recap: () => import('./verbs/recap.js'),
12
16
  // Row 176. The human's ask, queued as state so `next` routes it and no session can lose it.
13
17
  ask: () => import('./verbs/ask.js'),
@@ -24,6 +28,8 @@ const VERBS = {
24
28
  'test-evidence': () => import('./verbs/evidence.js'),
25
29
  'verify-red': () => import('./verbs/evidence.js').then((m) => ({ run: m.runVerifyRed })),
26
30
  'dispatch-report': () => import('./verbs/dispatch.js'),
31
+ // Row 191 §4.3. The close of a scaffolding step, the one plan step with no evidence to witness.
32
+ 'step-close': () => import('./verbs/step-close.js'),
27
33
  adopt: () => import('./verbs/adopt.js'),
28
34
  gate: () => import('./verbs/gate.js'),
29
35
  decide: () => import('./verbs/decide.js'),
@@ -66,6 +72,7 @@ const VERB_USAGE = {
66
72
  gate: 'witness gate <decompose|plan|implement|ship|design> <target> [--fresh] [--manual]',
67
73
  index: 'witness index',
68
74
  init: 'witness init [--agent claude-code|pi|auto]',
75
+ migrate: 'witness migrate [--dry-run]',
69
76
  log: 'witness log <stream> [--all] [--lineage]',
70
77
  next: 'witness next',
71
78
  read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
@@ -77,10 +84,11 @@ const VERB_USAGE = {
77
84
  ship: 'witness ship <plan-id> [--manual]',
78
85
  start: 'witness start <plan-id>',
79
86
  status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
87
+ 'step-close': 'witness step-close <plan-id> --step <id>',
80
88
  sync: 'witness sync',
81
89
  trust: 'witness trust <artifact-id> [--yes]',
82
- 'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
83
- 'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
90
+ 'test-evidence': 'witness test-evidence <plan-id> --phase red|green [--step <id>]',
91
+ 'verify-red': 'witness verify-red <plan-id> [--base <ref>] [--step <id>]',
84
92
  };
85
93
  // Row 116 moved the body to version.ts, which journal.ts can import without closing a
86
94
  // cycle. Re-exported here because eleven call sites already ask the CLI shell for it and
@@ -132,11 +140,15 @@ export async function main(ctx, argv) {
132
140
  }
133
141
  }
134
142
  }
143
+ // Row 191 §4.8. Every line this command journals carries the session it runs in, read once
144
+ // from this invocation's own env: an in-process `main` (drive's decisions, a test's CLI) is
145
+ // an invocation of its own, with its own env and so its own stamp.
146
+ const stamp = journalStamp(ctx.env);
135
147
  if (!verb || verb === 'help' || verb === '--help') {
136
148
  if (!verb) {
137
149
  const rootRes = primaryRoot(ctx.cwd);
138
150
  if (rootRes.ok && existsSync(configPath(rootRes.value))) {
139
- return (await import('./verbs/dashboard.js')).run(ctx, []);
151
+ return withJournalCtx(stamp, async () => (await import('./verbs/dashboard.js')).run(ctx, []));
140
152
  }
141
153
  }
142
154
  ctx.out(usage());
@@ -153,7 +165,7 @@ export async function main(ctx, argv) {
153
165
  return EXIT.REFUSED;
154
166
  }
155
167
  try {
156
- return await (await load()).run(ctx, rest);
168
+ return await withJournalCtx(stamp, async () => (await load()).run(ctx, rest));
157
169
  }
158
170
  catch (e) {
159
171
  // Row 113. Eight verbs parse argv with node's strict `parseArgs`, which THROWS on a
@@ -0,0 +1,195 @@
1
+ import { Lexer, isCollection, isMap, isScalar, parseDocument } from 'yaml';
2
+ import { LEGACY_COMMENTS } from './legacy-comments.js';
3
+ import { CHANGE_CLASSES, FIRST_LINE, GROUP_HEADINGS, SCHEMA_COMMENT, SETTINGS, getIn, inlineValue, } from './settings.js';
4
+ import { sectionsOf } from './migration.js';
5
+ const EMPTY = { top: [], above: new Map(), end: [] };
6
+ // Witness's comments start at this column, or two spaces past a longer line (§4.5's layout).
7
+ const COMMENT_COLUMN = 31;
8
+ const withComment = (content, comment) => `${content.length < COMMENT_COLUMN ? content.padEnd(COMMENT_COLUMN) : `${content} `}# ${comment}`;
9
+ const classComment = (cls) => `for a ${cls}`;
10
+ export { inlineValue } from './settings.js';
11
+ // Every map in block style, every list in flow style, sections in SECTION_ORDER (which the
12
+ // list's own order follows), witness's comment at the end of every setting line and never on a
13
+ // section line: a comment there made the canon guard's old reader fall back to the defaults (P3).
14
+ export function renderConfig(values, comments = EMPTY) {
15
+ const out = [FIRST_LINE, ...comments.top, withComment(`schema: ${String(values.schema)}`, SCHEMA_COMMENT)];
16
+ const printed = new Set();
17
+ const above = (path, depth) => {
18
+ printed.add(path);
19
+ for (const line of comments.above.get(path) ?? [])
20
+ out.push(`${' '.repeat(depth)}${line}`);
21
+ };
22
+ let group;
23
+ const opened = new Set();
24
+ for (const s of SETTINGS) {
25
+ const val = getIn(values, s.path);
26
+ if (val === undefined)
27
+ continue;
28
+ if (s.group !== group) {
29
+ group = s.group;
30
+ out.push('', GROUP_HEADINGS[group]);
31
+ }
32
+ const parts = s.path.split('.');
33
+ for (let i = 1; i < parts.length; i++) {
34
+ const section = parts.slice(0, i).join('.');
35
+ if (opened.has(section))
36
+ continue;
37
+ opened.add(section);
38
+ above(section, i - 1);
39
+ out.push(`${' '.repeat(i - 1)}${parts[i - 1]}:`);
40
+ }
41
+ const depth = parts.length - 1;
42
+ const indent = ' '.repeat(depth);
43
+ const key = parts[depth];
44
+ above(s.path, depth);
45
+ if (s.shape.kind === 'battery' && !Array.isArray(val)) {
46
+ out.push(`${indent}${key}:`);
47
+ for (const cls of CHANGE_CLASSES) {
48
+ above(`${s.path}.${cls}`, depth + 1);
49
+ out.push(withComment(`${indent} ${cls}: ${inlineValue(val[cls])}`, classComment(cls)));
50
+ }
51
+ }
52
+ else {
53
+ // A per-class comment whose map became a plain list sits above the list's line.
54
+ if (s.shape.kind === 'battery')
55
+ for (const cls of CHANGE_CLASSES)
56
+ above(`${s.path}.${cls}`, depth);
57
+ out.push(withComment(`${indent}${key}: ${inlineValue(val)}`, s.comment));
58
+ }
59
+ }
60
+ // Nothing a human wrote is dropped: a line filed under a path this file no longer prints
61
+ // goes to the end rather than nowhere.
62
+ for (const [path, lines] of comments.above)
63
+ if (!printed.has(path))
64
+ out.push(...lines);
65
+ out.push(...comments.end);
66
+ return `${out.join('\n')}\n`;
67
+ }
68
+ // ── Who wrote which comment ──────────────────────────────────────────────────────────────────
69
+ const CURRENT_COMMENTS = new Set([
70
+ ...SETTINGS.map((s) => s.comment),
71
+ ...CHANGE_CLASSES.map(classComment),
72
+ SCHEMA_COMMENT,
73
+ FIRST_LINE.slice(1).trim(),
74
+ ...Object.values(GROUP_HEADINGS).map((h) => h.slice(1).trim()),
75
+ ]);
76
+ // Where a human line can be printed: every setting and section, and a per-class reviewer line.
77
+ const PRINTABLE = new Set([
78
+ ...SETTINGS.map((s) => s.path),
79
+ ...sectionsOf(new Set(SETTINGS.map((s) => s.path))),
80
+ ...SETTINGS.filter((s) => s.shape.kind === 'battery').flatMap((s) => CHANGE_CLASSES.map((c) => `${s.path}.${c}`)),
81
+ ]);
82
+ // A line above a key this file no longer prints (`gates.model`, retired at schema 2) moves to
83
+ // the head of that key's section, which is where the key sat in the old layout's terms.
84
+ function printableFor(path) {
85
+ for (let p = path; p.includes('.');) {
86
+ if (PRINTABLE.has(p))
87
+ return p;
88
+ const section = p.slice(0, p.lastIndexOf('.'));
89
+ const first = SETTINGS.find((s) => s.path.startsWith(`${section}.`));
90
+ if (first)
91
+ return first.path;
92
+ p = section;
93
+ }
94
+ return PRINTABLE.has(path) ? path : undefined;
95
+ }
96
+ // The lexer's own control tokens are not source text; every other token is, in order.
97
+ const CONTROL = /^[\x02\x18\x1f]$/;
98
+ export function readComments(text) {
99
+ const src = text.replace(/^/, '').replace(/\r\n?/g, '\n');
100
+ const lineStarts = [0];
101
+ for (let i = 0; i < src.length; i++)
102
+ if (src[i] === '\n')
103
+ lineStarts.push(i + 1);
104
+ const lineOf = (offset) => {
105
+ let lo = 0;
106
+ let hi = lineStarts.length - 1;
107
+ while (lo < hi) {
108
+ const mid = (lo + hi + 1) >> 1;
109
+ if (lineStarts[mid] <= offset)
110
+ lo = mid;
111
+ else
112
+ hi = mid - 1;
113
+ }
114
+ return lo;
115
+ };
116
+ // Every key the file holds, block or flow: where it starts, its column, and the span of its
117
+ // value when that is a map or a list (a block map's span runs on over the comments that end
118
+ // it). Lists are values, not keys.
119
+ const keys = [];
120
+ const walk = (node, prefix) => {
121
+ if (!isMap(node))
122
+ return;
123
+ for (const pair of node.items) {
124
+ if (!isScalar(pair.key) || !pair.key.range)
125
+ continue;
126
+ const path = prefix ? `${prefix}.${String(pair.key.value)}` : String(pair.key.value);
127
+ const at = pair.key.range[0];
128
+ // Only a map or a list can hold a comment line: a scalar's span also runs over a deeper
129
+ // comment on the line after it, which sits between two keys, not inside the value.
130
+ const range = isCollection(pair.value) ? pair.value.range : undefined;
131
+ keys.push({ path, offset: at, line: lineOf(at), column: at - lineStarts[lineOf(at)], value: range ? [range[0], range[2]] : undefined });
132
+ walk(pair.value, path);
133
+ }
134
+ };
135
+ walk(parseDocument(src).contents, '');
136
+ keys.sort((a, b) => a.offset - b.offset);
137
+ const human = { top: [], above: new Map(), end: [] };
138
+ let legacy = 0;
139
+ let moved = 0;
140
+ const file = (path, line) => human.above.set(path, [...(human.above.get(path) ?? []), line]);
141
+ let offset = 0;
142
+ for (const token of new Lexer().lex(src)) {
143
+ if (CONTROL.test(token))
144
+ continue;
145
+ const at = offset;
146
+ offset += token.length;
147
+ if (!token.startsWith('#'))
148
+ continue;
149
+ const line = lineOf(at);
150
+ const comment = token.trimEnd();
151
+ const body = comment.slice(1).trim();
152
+ const ownLine = src.slice(lineStarts[line], at).trim() === '';
153
+ if (LEGACY_COMMENTS.has(body) || CURRENT_COMMENTS.has(body)) {
154
+ // A line of its own vanishes; one on a setting's line, or the first line, is replaced.
155
+ if (ownLine && line !== 0 && LEGACY_COMMENTS.has(body))
156
+ legacy++;
157
+ continue;
158
+ }
159
+ let key;
160
+ if (ownLine) {
161
+ // A line of its own sits above the next key — unless it sits inside an earlier key's value
162
+ // (between a list's items, at the indented end of a map) and that next key is outside it:
163
+ // then it belongs to that value's key, wherever the rewrite moves the next one (final
164
+ // review I2). Indentation decides the map end: a block map's span runs on to the next key.
165
+ const column = at - lineStarts[line];
166
+ const next = keys.find((k) => k.offset > at);
167
+ const holder = keys.filter((k) => k.value && k.value[0] <= at && at < k.value[1] && k.column < column).at(-1);
168
+ key = holder && !(next && next.path.startsWith(`${holder.path}.`)) ? holder.path : next?.path;
169
+ if (key === undefined) {
170
+ human.end.push(comment);
171
+ continue;
172
+ }
173
+ if (key === next?.path && key === keys[0]?.path) {
174
+ human.top.push(comment);
175
+ continue;
176
+ }
177
+ if (key !== next?.path)
178
+ moved++;
179
+ }
180
+ else {
181
+ // At the end of a line: the key that line opens, else the one whose value it sits inside.
182
+ key = keys.find((k) => k.line === line)?.path ?? keys.filter((k) => k.offset < at).at(-1)?.path;
183
+ moved++;
184
+ }
185
+ const target = key === undefined || key === 'schema' ? undefined : printableFor(key);
186
+ if (target === undefined)
187
+ human.top.push(comment);
188
+ else {
189
+ if (ownLine && target !== key)
190
+ moved++;
191
+ file(target, comment);
192
+ }
193
+ }
194
+ return { human, legacy, moved };
195
+ }