@popovych.co/witness 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +85 -16
  2. package/dist/arbiter.js +2 -2
  3. package/dist/calibrate.js +12 -6
  4. package/dist/cli.js +3 -0
  5. package/dist/config-file.js +195 -0
  6. package/dist/config.js +312 -262
  7. package/dist/context.js +8 -5
  8. package/dist/drift.js +1 -1
  9. package/dist/drive-report.js +5 -10
  10. package/dist/drive.js +1 -1
  11. package/dist/evidence.js +1 -2
  12. package/dist/gate.js +7 -20
  13. package/dist/gates/ship.js +2 -3
  14. package/dist/harness.js +6 -7
  15. package/dist/journal.js +5 -2
  16. package/dist/legacy-comments.js +30 -0
  17. package/dist/migration.js +177 -0
  18. package/dist/model.js +14 -24
  19. package/dist/pin.js +13 -0
  20. package/dist/runner.js +12 -26
  21. package/dist/settings.js +276 -0
  22. package/dist/ship.js +4 -8
  23. package/dist/slice.js +32 -19
  24. package/dist/steps.js +56 -33
  25. package/dist/verbs/calibrate.js +1 -1
  26. package/dist/verbs/check.js +8 -7
  27. package/dist/verbs/dashboard.js +2 -2
  28. package/dist/verbs/decide.js +2 -5
  29. package/dist/verbs/dispatch.js +1 -1
  30. package/dist/verbs/evidence.js +16 -12
  31. package/dist/verbs/init.js +28 -27
  32. package/dist/verbs/log.js +1 -12
  33. package/dist/verbs/migrate.js +201 -0
  34. package/dist/verbs/next.js +60 -36
  35. package/dist/verbs/start.js +1 -2
  36. package/dist/verbs/step-close.js +4 -5
  37. package/package.json +1 -1
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/commands/witness.md +1 -1
  40. package/plugin/hooks/canon-guard.mjs +12 -1
  41. package/plugin/hooks/context-core.mjs +20 -10
  42. package/plugin/hooks/session-dashboard.sh +1 -1
  43. package/plugin/skills/witness-brainstorm/SKILL.md +1 -1
  44. package/plugin/skills/witness-decompose/SKILL.md +1 -1
  45. package/plugin/skills/witness-design/SKILL.md +4 -2
  46. package/plugin/skills/witness-implement/SKILL.md +3 -3
  47. package/plugin/skills/witness-plan/SKILL.md +1 -1
  48. package/plugin/skills/witness-ship/SKILL.md +1 -1
package/README.md CHANGED
@@ -162,22 +162,91 @@ either.
162
162
 
163
163
  ## Configuration keys
164
164
 
165
- | Key | Meaning |
166
- |---|---|
167
- | `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 |
168
- | `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
169
- | `session.contextLimit` | the share of a session's context window, in percent, at which witness hands the work to a fresh session (integer 1–100, default 20). The harness measures the fill; the CLI decides where the journal carries the position — at a step close, after a revise, at a `witness ship` exit and at `next` — and never mid-red. A session whose fill cannot be read says so (`context: unmeasured (<reason>)`): an implement session then hands over by `implement.stepsPerDispatch`, and nothing else hands over (DESIGN.md row 191) |
170
- | `implement.stepsPerDispatch` | the step budget: steps an implement session closes before it hands over when its fill cannot be read (default 3) — the fallback for `session.contextLimit` |
171
- | `drive.sessionTimeoutMs` | milliseconds a `drive`-spawned session may run before SIGTERM (default 3600000) |
172
- | `drive.parallel` | sessions `witness drive` runs at once under autonomy, one per target (default 2) |
173
- | `drive.parallelSuites` | `true` lets test runs in two worktrees overlap (default false: they take turns, because suites that boot servers or browsers collide) |
174
- | `drive.caps.rounds` | gate rounds one target may run across budget resets (default 16) — the run parks a target that reaches it |
175
- | `drive.caps.upstream` | upstream resets one target may take (default 6) — the run parks a target that reaches it |
176
- | `drive.caps.spawns` | sessions one target may take in one drive run (default 24) — the run parks a target that reaches it |
177
- | `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 |
178
- | `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 |
179
- | `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 |
180
- | `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.
181
250
 
182
251
  There is no `provider:` key. `witness gate` spawns the DECLARED harness's headless
183
252
  mode for every reviewer (Decisions 88, 105): claude-code renders bare Anthropic ids, pi
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
@@ -10,6 +10,8 @@ export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
10
10
  const VERBS = {
11
11
  recover: () => import('./verbs/recover.js'),
12
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'),
13
15
  recap: () => import('./verbs/recap.js'),
14
16
  // Row 176. The human's ask, queued as state so `next` routes it and no session can lose it.
15
17
  ask: () => import('./verbs/ask.js'),
@@ -70,6 +72,7 @@ const VERB_USAGE = {
70
72
  gate: 'witness gate <decompose|plan|implement|ship|design> <target> [--fresh] [--manual]',
71
73
  index: 'witness index',
72
74
  init: 'witness init [--agent claude-code|pi|auto]',
75
+ migrate: 'witness migrate [--dry-run]',
73
76
  log: 'witness log <stream> [--all] [--lineage]',
74
77
  next: 'witness next',
75
78
  read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
@@ -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
+ }