@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.
- package/README.md +138 -22
- package/dist/arbiter.js +2 -2
- package/dist/calibrate.js +12 -6
- package/dist/cli.js +16 -4
- package/dist/config-file.js +195 -0
- package/dist/config.js +313 -240
- package/dist/context.js +108 -0
- package/dist/drift.js +1 -1
- package/dist/drive-report.js +1 -1
- package/dist/drive.js +26 -8
- package/dist/evidence.js +16 -5
- package/dist/gate.js +24 -30
- package/dist/gates/implement.js +3 -1
- package/dist/gates/ship.js +2 -3
- package/dist/harness.js +25 -16
- package/dist/install.js +37 -0
- package/dist/journal.js +20 -1
- package/dist/legacy-comments.js +30 -0
- package/dist/migration.js +177 -0
- package/dist/model.js +14 -24
- package/dist/pin.js +13 -0
- package/dist/reviewer.js +3 -1
- package/dist/runner.js +12 -26
- package/dist/settings.js +276 -0
- package/dist/ship.js +22 -4
- package/dist/slice.js +185 -0
- package/dist/steps.js +149 -0
- package/dist/verbs/calibrate.js +1 -1
- package/dist/verbs/check.js +50 -6
- package/dist/verbs/dashboard.js +2 -2
- package/dist/verbs/decide.js +12 -1
- package/dist/verbs/dispatch.js +1 -1
- package/dist/verbs/drive.js +12 -1
- package/dist/verbs/evidence.js +65 -7
- package/dist/verbs/init.js +37 -28
- package/dist/verbs/log.js +18 -2
- package/dist/verbs/migrate.js +201 -0
- package/dist/verbs/next.js +99 -17
- package/dist/verbs/start.js +22 -13
- package/dist/verbs/step-close.js +80 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +7 -5
- package/plugin/hooks/canon-guard.mjs +12 -1
- package/plugin/hooks/context-core.mjs +230 -0
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/hooks/witness-pi.ts +74 -12
- package/plugin/skills/witness-brainstorm/SKILL.md +2 -1
- package/plugin/skills/witness-decompose/SKILL.md +3 -2
- package/plugin/skills/witness-design/SKILL.md +6 -3
- package/plugin/skills/witness-implement/SKILL.md +45 -15
- package/plugin/skills/witness-plan/SKILL.md +3 -2
- 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** —
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
|
155
|
-
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|