@event4u/agent-config 15.0.0 → 16.0.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 (78) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +142 -348
  5. package/dist/agent-src/commands/roadmap/create.md +3 -2
  6. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +50 -0
  7. package/dist/agent-src/rules/user-interaction.md +31 -30
  8. package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-H32DGUJH.js} +14 -89
  11. package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
  12. package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
  13. package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
  14. package/dist/cli-delegate/cmd_conformance.js +831 -272
  15. package/dist/cli-delegate/cmd_explain.js +4 -2
  16. package/dist/cli-delegate/cmd_handoff.js +2 -2
  17. package/dist/cli-delegate/cmd_packs_active.js +2 -1
  18. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  19. package/dist/cli-delegate/cmd_settings_set.js +13 -0
  20. package/dist/discovery/deprecation-report.md +1 -1
  21. package/dist/discovery/discovery-manifest.json +5 -5
  22. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  23. package/dist/discovery/discovery-manifest.summary.md +1 -1
  24. package/dist/discovery/dormancy-report.md +1 -1
  25. package/dist/discovery/orphan-report.md +1 -1
  26. package/dist/discovery/packs.json +2 -2
  27. package/dist/discovery/trust-report.md +1 -1
  28. package/dist/discovery/workspaces.json +2 -2
  29. package/dist/hooks/dispatch.js +1274 -571
  30. package/dist/install/install.mjs +1 -0
  31. package/dist/mcp/registry-manifest.json +2 -2
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/mcp/server.mjs +1218 -632
  34. package/dist/scripts/_lib/installed_lock.js +1 -1
  35. package/dist/server/io/yamlIO.js +50 -0
  36. package/package.json +1 -1
  37. package/src/config/continuity-surface.json +7 -0
  38. package/src/config/gate-violation-baselines.json +3 -3
  39. package/src/config/host-injection-effect.json +1 -1
  40. package/src/config/preamble-payload-budget.json +12 -7
  41. package/src/config/preamble-payload-exceptions.json +28 -0
  42. package/src/scripts/_dispatch.bash +3 -1
  43. package/src/scripts/_lib/base_ref_payload.ts +315 -0
  44. package/src/scripts/_lib/dropped_decision.ts +142 -0
  45. package/src/scripts/_lib/installed_lock.ts +1 -1
  46. package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
  47. package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
  48. package/src/scripts/_lib/release_findings_ingest.ts +324 -0
  49. package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
  50. package/src/scripts/_lib/review_baseline.ts +254 -0
  51. package/src/scripts/_lib/review_skipped_record.ts +61 -1
  52. package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
  53. package/src/scripts/_lib/turn_end_refusals.ts +51 -4
  54. package/src/scripts/check_beta_review_markers.ts +35 -4
  55. package/src/scripts/check_finding_dispositions.ts +167 -16
  56. package/src/scripts/check_held_object_arrivals.ts +381 -0
  57. package/src/scripts/check_preamble_payload_budget.ts +399 -122
  58. package/src/scripts/check_release_published.ts +1 -1
  59. package/src/scripts/check_reply_consistency.ts +86 -17
  60. package/src/scripts/hook_manifest.json +1 -1
  61. package/src/scripts/hook_manifest.yaml +39 -3
  62. package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
  63. package/src/scripts/hooks/concern_registry.ts +2 -0
  64. package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
  65. package/src/scripts/hooks/review_baseline_hook.ts +130 -0
  66. package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
  67. package/src/scripts/hooks_doctor.ts +4 -1
  68. package/src/scripts/install.ts +1 -1
  69. package/src/scripts/interruption_report.ts +8 -2
  70. package/src/scripts/janitor.ts +8 -1
  71. package/src/scripts/measure_turn_end_gate.ts +302 -5
  72. package/src/scripts/release.ts +52 -64
  73. package/src/scripts/release_drill.ts +193 -14
  74. package/src/scripts/release_env.ts +1 -1
  75. package/src/scripts/release_publication.ts +1 -1
  76. package/src/scripts/report_held_object_arrivals.ts +456 -0
  77. package/src/scripts/sync_agent_settings.ts +140 -18
  78. package/src/server/io/yamlIO.ts +48 -0
@@ -166,7 +166,7 @@ function splitlines(text) {
166
166
  }
167
167
  return parts;
168
168
  }
169
- function _render(version, tools, installed_at, fingerprint) {
169
+ export function _render(version, tools, installed_at, fingerprint) {
170
170
  const lines = [
171
171
  `schema_version: ${SCHEMA_VERSION}`,
172
172
  `install_layout_version: ${INSTALL_LAYOUT_VERSION}`,
@@ -365,6 +365,51 @@ function replaceFlatDottedKey(template, dottedPath, value) {
365
365
  lines.splice(hits[i], 1);
366
366
  return { found: true, body: lines.join('\n') };
367
367
  }
368
+ /**
369
+ * Drop every top-level `a.b.c:` line for a path whose NESTED form the caller
370
+ * has just resolved.
371
+ *
372
+ * Which reader this preserves, because the two forms can disagree and only one
373
+ * of them is ever consulted: by YAML semantics `a.b.c` is reached by walking
374
+ * the `a` mapping, so a top-level key literally named "a.b.c" is a DIFFERENT
375
+ * path that no lookup of the dotted one visits. Removing it therefore changes
376
+ * no reader's answer — strict and lenient alike already resolve through the
377
+ * nesting — while leaving it keeps the one line a human scanning the file
378
+ * reads as the value, saying something the program never agreed with. It
379
+ * cannot self-heal either: the duplicate-key pass sees a single occurrence,
380
+ * and `mergeIntoTemplate` writes the nested side, so the line is permanent.
381
+ *
382
+ * The nested twin IS the safety argument, not a convenience. A flat `a.b:`
383
+ * standing ALONE is a key somebody may have meant — `mergeIntoTemplate`'s own
384
+ * fallback branch writes exactly that shape for a path the template lacks — and
385
+ * this function is never reached for one: the caller takes the
386
+ * `replaceFlatDottedKey` branch instead, which updates that line in place.
387
+ *
388
+ * Two bounds, each of which turns the sweep into a data-loss bug if dropped:
389
+ *
390
+ * - Two segments minimum. A one-segment path joins to a name with no dot, so
391
+ * the match below would hit the very line `replaceScalar` just wrote and
392
+ * delete the key the caller asked to set — a rewrite silently becoming a
393
+ * removal. Same guard, same reason, as `replaceFlatDottedKey`.
394
+ * - Top level only, the bound `replaceFlatDottedKey` also draws: an INDENTED
395
+ * `a.b:` is somebody else's child key (`parent:` / ` a.b:` means
396
+ * `parent['a.b']`), an unrelated path this sweep has no claim on.
397
+ */
398
+ function dropFlatDottedTwins(template, dottedPath) {
399
+ if (dottedPath.length < 2)
400
+ return template;
401
+ const flat = dottedPath.join('.');
402
+ const lines = template.split('\n');
403
+ const kept = lines.filter((line) => {
404
+ if (line.length !== line.trimStart().length)
405
+ return true;
406
+ const m = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*:/.exec(line);
407
+ return m === null || m[1] !== flat;
408
+ });
409
+ if (kept.length === lines.length)
410
+ return template;
411
+ return kept.join('\n');
412
+ }
368
413
  /**
369
414
  * Apply every leaf change from `newValues` to `templateBody`. Paths that
370
415
  * are not present in the template are appended at the end. Comments are
@@ -383,6 +428,11 @@ export function mergeIntoTemplate(templateBody, newValues) {
383
428
  // see `findScalarLine`. Writing the value it already holds is a hit.
384
429
  if (findScalarLine(body.split('\n'), sections, leaf) !== -1) {
385
430
  body = replaceScalar(body, entry.path, entry.value);
431
+ // The nested form won, so any flat twin of this same path is dead
432
+ // and stays dead. The probe above is what licenses the removal:
433
+ // without a nested key to resolve through, a flat `a.b:` is the
434
+ // real entry and the branch below owns it.
435
+ body = dropFlatDottedTwins(body, entry.path);
386
436
  continue;
387
437
  }
388
438
  // Not in the template's nested form. It may still be here as a flat
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "15.0.0",
4
+ "version": "16.0.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including the counts in these badges.",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -274,6 +274,13 @@
274
274
  "locus": "src/scripts/review_axis_report.ts:209",
275
275
  "reason": "Review-axis report state. Analysis output."
276
276
  },
277
+ {
278
+ "id": "review-baseline",
279
+ "axis": "persistent_continuity_artefacts",
280
+ "disposition": "excluded",
281
+ "locus": "src/scripts/_lib/review_baseline.ts:68",
282
+ "reason": "Start-of-session mutation baseline the end-review nudge subtracts. Two numbers and a timestamp, written once at session_start and read at stop; it carries no continuity across sessions and by construction cannot hold a path, a diff or a file body."
283
+ },
277
284
  {
278
285
  "id": "routing-telemetry.jsonl",
279
286
  "axis": "persistent_continuity_artefacts",
@@ -34,9 +34,9 @@
34
34
  "note": "The nine active non-draft roadmaps that predate the `relates:` requirement (road-to-agentic-engineering-assurance, road-to-component-library-lifecycle, road-to-council-evidence-integrity, road-to-frontend-fidelity-calibration, road-to-monorepo-scope-and-detection, road-to-observability-plate, road-to-role-scoped-spawn-profiles, road-to-skill-delivery-over-mcp, road-to-standing-payload-diet). This is a NEW obligation rather than a repaired scan root, and the ratchet is the point: `depends:` shipped in the template with nothing requiring it and measured 0 adoption, so requiring `relates:` retroactively across the estate would be an owner-reserved write with no analysis behind the edges it would have to invent (held as step 4.3, deferred). Clearing an entry here is one roadmap at a time: run `agent-config roadmap:context` on it, write the rows the probe surfaces, and lower the count."
35
35
  },
36
36
  "check_source_size_budget": {
37
- "count": 17959,
38
- "landed": "2026-09-09",
39
- "note": "Lowered 17,963 -> 17,959 on 2026-09-09 by feat/cdd-fidelity-council, on the same shape as the entry below and with the same discriminator. The branch's src-link rewrite (a blind review found 253 of 268 links under dist/agent-src/guidelines/ dead, because condense.ts folded only the docs/ prefixes and never (../)+src/rules|skills) came in at +15 excess: condense.ts sits ~1,208 lines PAST the 1500 cap, so every line there is one unit. Paid down rather than baselined, in the way this entry's doctrine prescribes. `_rewrite_body_links` and its two regex constants moved out of condense.ts into _lib/guidelines_lane.ts, 151 lines and therefore UNDER the cap, so the function and its prose now cost nothing. It moved on merit rather than only on arithmetic: every rule the rewriter applies is a statement about which source tree projects where, which is that module's own subject, and condense.ts held it only because that is where the projector lives. condense.ts 2,712 -> 2,708 net, after the one import line and the one call-site rename -- a branch that ADDED a rewrite rule leaves the file four lines shorter than it found it. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the branch's new src/ files tracked and nothing untracked, so a clean CI checkout of this commit measures the same 17,959. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 17,973 -> 17,963 on 2026-09-08 by drain/graph-shipped-close. The branch registers five code-graph MCP tools (road-to-a-graph-that-is-shipped 4.1). Their five records and schemas are ~250 lines, and putting them in mcp_server/tools.ts -- which sits ~500 lines PAST the 1500 cap, so every line there is one unit -- would have cost ~250 excess. They live in mcp_server/graph_tools.ts instead (~280 lines, UNDER the cap, so they and their prose cost nothing), and tools.ts spreads them into ALLOWLIST in two lines: one import and one `...GRAPH_TOOLS,`. Those two were paid for rather than baselined, in the way this entry's doctrine prescribes: `_strip` and `_resolvePath` -- a whitespace trim and a tolerant realpath, generic by nature -- moved out of tools.ts into mcp_server/path_util.ts, also under the cap. They are imported under their original underscore names, so all 28 call sites are unchanged and a two-function move does not read as a sweep. tools.ts 2,025 -> 2,015: -12 from the extraction, +2 from the registration, net -10 for a branch that ADDED a feature. CORRECTED 2026-09-08 after an independent review checked the figure against the tree -- this sentence read '2,025 -> 2,013, net -12, against the +2 the registration needed', which states an intermediate file length as the final one and leaves the arithmetic (-12) and the conclusion (-10) describing different trees. `git show origin/main:...tools.ts | wc -l` is 2025 and the working tree is 2015. The TOTAL, 17,963, was always the gate's own output and is unchanged. Every other file this branch touches or creates -- code_graph/verbs.ts, code_graph/cli.ts, code_graph/build.ts, code_graph/detect.ts, code_graph/query.ts, code_graph/sqlite_store.ts, code_graph/types.ts, _lib/regression_neighbourhood.ts -- is under the 1500 ceiling before and after, so it contributes zero excess whatever it grew by. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the two new src/ files staged and nothing untracked, so a clean CI checkout of this commit measures the same 17,963. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 17,974 -> 17,973 on 2026-09-08 by drain/topology-c1. The branch adds a live-path emit to council_cli.ts, which sits 2,454 lines PAST the 1500 cap so every line there is one unit, and paid its own way down rather than raising the baseline, in the way this entry's doctrine prescribes. The twelve-line `all_responses` assembly (a spread, three conditionals and a push) moved out of council_cli.ts into ai_council/deliberation_metrics.ts::runCallsFrom, 353 lines and therefore UNDER the cap, so the six stage labels and their prose cost nothing. It moved on merit rather than only on arithmetic: 'which calls did this run make, and at which stage' is that module's own subject, and the CLI held the list only as a flat array with the stage boundaries implicit in the order -- both readings now come out of one walk, so the billing total and the attribution can never be computed over different sets. council_cli.ts 3,955 -> 3,954 net, after the two call lines, the import and the one payload key the feature needed. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the two new src/ files staged and nothing untracked, so a clean CI checkout of this commit measures the same 17,973. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 18,016 -> 17,974 on 2026-09-07 by fix/single-delivery-unconditional. SETTLED FIGURE, and it supersedes an intra-session 17,962 this same branch committed earlier — read the direction against MAIN, which is what the ratchet protects: main is 18,016, the branch leaves 17,974, net -42. The 17,962 was this branch's own low before a neutral review returned twelve findings; fixing them added a real mechanism to mcp_server and condense.ts (the flat-wrapper carriage gate the review showed was missing, plus its four-skip predicate), and condense.ts sits ~1,215 lines PAST the cap so each of those lines is one unit. Paid down first, in the way this entry's doctrine prescribes: _suggestSkillForTaskHandler and its 18-line docstring moved out of mcp_server/tools.ts (2,079 -> 2,025) into mcp_server/suggest_skill.ts, 72 lines and UNDER the cap, so its prose costs nothing. The residual +12 against the intra-session low is the gate's own output on the settled tree and is recorded rather than engineered away, because the alternative was extracting _nested_command_subpath and its two callers out of condense.ts - which needs MODULE_STATE, _relativeToPosix and _domains_command_logical passed in, a wider diff than the finding warrants and one no review asked for. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the three new src/ files staged and nothing untracked, so a clean CI checkout of this commit measures the same 17,974. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: MERGE RECONCILIATION 2026-09-07, 18,061/18,017 -> 18,016. drain/admissible-council-seats and fix/release-obligation-answerable each lowered from the SAME 18,062 base in parallel — main to 18,061 (net -1: curlJsonPost, the seat-status helpers and the per-seat config parsing moved out of the three over-ceiling council files) and the branch to 18,017 (net -45: the /dev/tty prompt helpers moved out of release.ts into _lib/tty_prompt.ts, 76 lines and UNDER the cap, taking release.ts 1871 -> 1826). Taking either side alone is wrong in a different direction, which is the reconciliation doctrine this entry already carried from 2026-09-03: main's value silently discards the branch's earned -45, and the branch's value RAISES the baseline against main and weakens the gate. The committed number is neither — it is the gate's own output on the MERGED tree, where both reductions apply, and it equals the arithmetic (18,062 - 45 - 1 = 18,016) rather than being reconciled by it. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports ZERO untracked files, so a clean CI checkout of this commit measures the same 18,016. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in the merge commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 18,062 -> 18,061 on 2026-09-07 by drain/admissible-council-seats. The branch's own additions to the three over-ceiling files (ai_council/config.ts, council_cli.ts, ai_council/clients.ts) came in at +96 excess and were paid down to -1 rather than baselined, in the four ways this entry's doctrine prescribes: `curlJsonPost` moved out of clients.ts into ai_council/api_transport.ts (UNDER the cap, so the prose and the three new provider shims cost nothing); the seat-status helpers moved out of council_cli.ts into ai_council/status_surface.ts, taking an orphaned doc comment with them; the three new per-seat config fields' parsing moved out of config.ts into ai_council/seat_policy.ts::parseSeatFields; and three prose blocks that the branch's own transport repair made FACTUALLY FALSE (the `openai` / `google-genai` Python-SDK claims, and a `_curlJsonPost` pointer to a function that had moved) were corrected and shortened in the same change. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports zero untracked files with the five new src/ files staged, so a clean CI checkout of this commit measures the same 18,061. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried here because tests/scripts/check_source_size_budget.test.ts:166 asserts baseline == live and therefore blocks where the gate only advises -- the lowering commit must carry its own baseline update. Lowered 18,068 -> 18,062 on 2026-09-06 by drain/host-enforcement-truth. Collapsing the five per-host binding constants in install.ts into src/scripts/hooks/host_lowering.yaml removed 29 lines from a file ~3,900 past the 1500 cap, where every line costs one excess; the journal's surface column and its extracted migration helper gave 23 back. Lowered 18,245 -> 18,230 on 2026-09-02 by drain/publication-integrity-option-a. Phase 2 Option A moved the authoring instruction out of the generator: release.ts, which sits ~530 lines PAST the 1500 cap so every line there costs one excess, loses the comment emission, a 16-line HEAD_DEFAULT doc block and its alias const, and gains a shorter writer docstring. The doc block moved to _lib/release_material.ts, which is 289 lines and therefore UNDER the cap, so the same prose now costs nothing. Net -15. The branch first came in at +8 and paid its own way down rather than raising the baseline.",
37
+ "count": 17947,
38
+ "landed": "2026-09-11",
39
+ "note": "Lowered 17,959 -> 17,947 on 2026-09-11 by the findings-ledger branch, on this entry's own doctrine. The branch adds step 7 to release.ts, which sits ~326 lines PAST the 1500 cap so every line there is one unit, and it came in at +145. Paid down rather than baselined, in two moves. `settle_findings_ledger` and `_blocking_without_disposition` moved out of release.ts into _lib/release_findings_ingest.ts -- 95 lines, and that module is under the cap, so the step and its rationale now cost nothing. It moved on merit rather than only on arithmetic: the module already owns every message the step prints and every argv it builds, and release.ts held only the sequencing. Then the `--ci` fallback dispatch of the three tag-triggered workflows -- 24 lines of loop and warning with no reader elsewhere in step 10 -- moved into _lib/release_tag_dispatch.ts, also under the cap. release.ts 1,826 -> 1,814: a branch that ADDED a pipeline step leaves the file twelve lines shorter than it found it. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the branch's new src/ file staged and nothing untracked, so a clean CI checkout of this commit measures the same 17,947. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 17,963 -> 17,959 on 2026-09-09 by feat/cdd-fidelity-council, on the same shape as the entry below and with the same discriminator. The branch's src-link rewrite (a blind review found 253 of 268 links under dist/agent-src/guidelines/ dead, because condense.ts folded only the docs/ prefixes and never (../)+src/rules|skills) came in at +15 excess: condense.ts sits ~1,208 lines PAST the 1500 cap, so every line there is one unit. Paid down rather than baselined, in the way this entry's doctrine prescribes. `_rewrite_body_links` and its two regex constants moved out of condense.ts into _lib/guidelines_lane.ts, 151 lines and therefore UNDER the cap, so the function and its prose now cost nothing. It moved on merit rather than only on arithmetic: every rule the rewriter applies is a statement about which source tree projects where, which is that module's own subject, and condense.ts held it only because that is where the projector lives. condense.ts 2,712 -> 2,708 net, after the one import line and the one call-site rename -- a branch that ADDED a rewrite rule leaves the file four lines shorter than it found it. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the branch's new src/ files tracked and nothing untracked, so a clean CI checkout of this commit measures the same 17,959. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 17,973 -> 17,963 on 2026-09-08 by drain/graph-shipped-close. The branch registers five code-graph MCP tools (road-to-a-graph-that-is-shipped 4.1). Their five records and schemas are ~250 lines, and putting them in mcp_server/tools.ts -- which sits ~500 lines PAST the 1500 cap, so every line there is one unit -- would have cost ~250 excess. They live in mcp_server/graph_tools.ts instead (~280 lines, UNDER the cap, so they and their prose cost nothing), and tools.ts spreads them into ALLOWLIST in two lines: one import and one `...GRAPH_TOOLS,`. Those two were paid for rather than baselined, in the way this entry's doctrine prescribes: `_strip` and `_resolvePath` -- a whitespace trim and a tolerant realpath, generic by nature -- moved out of tools.ts into mcp_server/path_util.ts, also under the cap. They are imported under their original underscore names, so all 28 call sites are unchanged and a two-function move does not read as a sweep. tools.ts 2,025 -> 2,015: -12 from the extraction, +2 from the registration, net -10 for a branch that ADDED a feature. CORRECTED 2026-09-08 after an independent review checked the figure against the tree -- this sentence read '2,025 -> 2,013, net -12, against the +2 the registration needed', which states an intermediate file length as the final one and leaves the arithmetic (-12) and the conclusion (-10) describing different trees. `git show origin/main:...tools.ts | wc -l` is 2025 and the working tree is 2015. The TOTAL, 17,963, was always the gate's own output and is unchanged. Every other file this branch touches or creates -- code_graph/verbs.ts, code_graph/cli.ts, code_graph/build.ts, code_graph/detect.ts, code_graph/query.ts, code_graph/sqlite_store.ts, code_graph/types.ts, _lib/regression_neighbourhood.ts -- is under the 1500 ceiling before and after, so it contributes zero excess whatever it grew by. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the two new src/ files staged and nothing untracked, so a clean CI checkout of this commit measures the same 17,963. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 17,974 -> 17,973 on 2026-09-08 by drain/topology-c1. The branch adds a live-path emit to council_cli.ts, which sits 2,454 lines PAST the 1500 cap so every line there is one unit, and paid its own way down rather than raising the baseline, in the way this entry's doctrine prescribes. The twelve-line `all_responses` assembly (a spread, three conditionals and a push) moved out of council_cli.ts into ai_council/deliberation_metrics.ts::runCallsFrom, 353 lines and therefore UNDER the cap, so the six stage labels and their prose cost nothing. It moved on merit rather than only on arithmetic: 'which calls did this run make, and at which stage' is that module's own subject, and the CLI held the list only as a flat array with the stage boundaries implicit in the order -- both readings now come out of one walk, so the billing total and the attribution can never be computed over different sets. council_cli.ts 3,955 -> 3,954 net, after the two call lines, the import and the one payload key the feature needed. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the two new src/ files staged and nothing untracked, so a clean CI checkout of this commit measures the same 17,973. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 18,016 -> 17,974 on 2026-09-07 by fix/single-delivery-unconditional. SETTLED FIGURE, and it supersedes an intra-session 17,962 this same branch committed earlier — read the direction against MAIN, which is what the ratchet protects: main is 18,016, the branch leaves 17,974, net -42. The 17,962 was this branch's own low before a neutral review returned twelve findings; fixing them added a real mechanism to mcp_server and condense.ts (the flat-wrapper carriage gate the review showed was missing, plus its four-skip predicate), and condense.ts sits ~1,215 lines PAST the cap so each of those lines is one unit. Paid down first, in the way this entry's doctrine prescribes: _suggestSkillForTaskHandler and its 18-line docstring moved out of mcp_server/tools.ts (2,079 -> 2,025) into mcp_server/suggest_skill.ts, 72 lines and UNDER the cap, so its prose costs nothing. The residual +12 against the intra-session low is the gate's own output on the settled tree and is recorded rather than engineered away, because the alternative was extracting _nested_command_subpath and its two callers out of condense.ts - which needs MODULE_STATE, _relativeToPosix and _domains_command_logical passed in, a wider diff than the finding warrants and one no review asked for. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports the three new src/ files staged and nothing untracked, so a clean CI checkout of this commit measures the same 17,974. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in this commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: MERGE RECONCILIATION 2026-09-07, 18,061/18,017 -> 18,016. drain/admissible-council-seats and fix/release-obligation-answerable each lowered from the SAME 18,062 base in parallel — main to 18,061 (net -1: curlJsonPost, the seat-status helpers and the per-seat config parsing moved out of the three over-ceiling council files) and the branch to 18,017 (net -45: the /dev/tty prompt helpers moved out of release.ts into _lib/tty_prompt.ts, 76 lines and UNDER the cap, taking release.ts 1871 -> 1826). Taking either side alone is wrong in a different direction, which is the reconciliation doctrine this entry already carried from 2026-09-03: main's value silently discards the branch's earned -45, and the branch's value RAISES the baseline against main and weakens the gate. The committed number is neither — it is the gate's own output on the MERGED tree, where both reductions apply, and it equals the arithmetic (18,062 - 45 - 1 = 18,016) rather than being reconciled by it. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports ZERO untracked files, so a clean CI checkout of this commit measures the same 18,016. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried in the merge commit because tests/scripts/check_source_size_budget.test.ts asserts baseline == live and therefore BLOCKS where the gate only advises. PRIOR ENTRY: Lowered 18,062 -> 18,061 on 2026-09-07 by drain/admissible-council-seats. The branch's own additions to the three over-ceiling files (ai_council/config.ts, council_cli.ts, ai_council/clients.ts) came in at +96 excess and were paid down to -1 rather than baselined, in the four ways this entry's doctrine prescribes: `curlJsonPost` moved out of clients.ts into ai_council/api_transport.ts (UNDER the cap, so the prose and the three new provider shims cost nothing); the seat-status helpers moved out of council_cli.ts into ai_council/status_surface.ts, taking an orphaned doc comment with them; the three new per-seat config fields' parsing moved out of config.ts into ai_council/seat_policy.ts::parseSeatFields; and three prose blocks that the branch's own transport repair made FACTUALLY FALSE (the `openai` / `google-genai` Python-SDK claims, and a `_curlJsonPost` pointer to a function that had moved) were corrected and shortened in the same change. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the 1500-line ceiling, and `git status --porcelain -uall -- src/` reports zero untracked files with the five new src/ files staged, so a clean CI checkout of this commit measures the same 18,061. If CI reads anything else, CI's number is the correct one and this note is the record of why. Carried here because tests/scripts/check_source_size_budget.test.ts:166 asserts baseline == live and therefore blocks where the gate only advises -- the lowering commit must carry its own baseline update. Lowered 18,068 -> 18,062 on 2026-09-06 by drain/host-enforcement-truth. Collapsing the five per-host binding constants in install.ts into src/scripts/hooks/host_lowering.yaml removed 29 lines from a file ~3,900 past the 1500 cap, where every line costs one excess; the journal's surface column and its extracted migration helper gave 23 back. Lowered 18,245 -> 18,230 on 2026-09-02 by drain/publication-integrity-option-a. Phase 2 Option A moved the authoring instruction out of the generator: release.ts, which sits ~530 lines PAST the 1500 cap so every line there costs one excess, loses the comment emission, a 16-line HEAD_DEFAULT doc block and its alias const, and gains a shorter writer docstring. The doc block moved to _lib/release_material.ts, which is 289 lines and therefore UNDER the cap, so the same prose now costs nothing. Net -15. The branch first came in at +8 and paid its own way down rather than raising the baseline.",
40
40
  "reason": "Lowered 18,230 -> 18,209 on 2026-09-03 by drain/artifact-location. `doctor --strict` needed 16 lines of integration wiring in cmd_doctor.ts, which sits ~2,100 lines PAST the 1500 cap so every line there costs one excess. The branch first came in at +137 and paid its own way down rather than raising the baseline: the strict semantics, threshold, kill switch, payload helper and argv fragments moved to _cli/_doctor_strict.ts (~160 lines, under the cap, costs nothing), taking it to +15; then the 37-line drift renderer moved to _cli/_doctor_emit.ts. Net -21. The renderer was chosen deliberately: pure rendering, two dependencies, no argparse involvement and therefore none of the byte-parity risk the parser surface carries against the retired Python implementation; nothing outside cmd_doctor.ts imported it. An AI council on the residual split (a: raise by 15 / d: descope the feature); neither was taken, because a parallel branch demonstrated the same day that paying down is achievable in this codebase, which is the evidence the split lacked. This gate's own test comment prescribes it: a commit that splits a god-file lowers the excess and must carry the lowered baseline. MERGE RECONCILIATION 2026-09-03, 18,209 -> 18,205: drain/wired-instruments and drain/artifact-location each lowered from the SAME 18,230 base in parallel -- the branch to 18,226 (net -4, run_continuation_hook.ts's termination ladder moved to _lib/continuation_ladder.ts and runtime_journal.ts's prose to _lib/outcome_vocabularies.ts, both under the cap) and main to 18,209 (net -21, the doctor --strict split above). Taking either side alone is wrong in a different direction: main's value silently discards the branch's earned -4, and the branch's value RAISES the baseline against main and weakens the gate. The committed number is neither -- it is the gate's own output on the merged tree, where both reductions apply. THIS LOCAL READING IS ADMISSIBLE HERE, and the discriminator matters because this file's ci-parity:local-only entry records a 165 -> 164 lowering that was reverted for exactly the opposite case: that count depends on how the environment resolves the projection surface, whereas this gate walks only SOURCE_ROOTS = ['src'] (check_source_size_budget.ts:88) counting excess lines over the 1500 ceiling, and `git status --porcelain -uall -- src/` reported ZERO untracked files, so a clean CI checkout of this commit measures the same 18,205. If CI reads anything else, CI's number is the correct one and this note is the record of why. LOWERED 18,205 -> 18,193 on 2026-09-03 by fix/release-curation-ask. The change adds a fifth publication guard, and its own additions landed at +10 excess: release.ts 2,008 -> 2,018, every line of which is past the 1500 cap. Paid down rather than baselined, in the two ways this gate's doctrine prescribes. The guard's rationale (~13 lines of docstring, including why prMerged is a parameter rather than a call-site if) lives in release_publication.ts, 915 lines and therefore UNDER the cap, so the prose costs nothing; and the two pure text helpers _splitlines and _rstrip moved out of release.ts into release_env.ts (333 lines, also under the cap), taking release.ts to 1,996 -- 12 BELOW where it started. The branch came in at +10 and leaves at -12. Recorded while moving them: _splitlines is duplicated a third and fourth time at adoption_report.ts:106 and readme_linter.ts:418; collapsing all three onto one definition touches three modules and was left out of this change rather than done silently. LOWERED 18,193 -> 18,180 on 2026-09-03 by fix/release-branch-reuse. The step-1 branch decision -- twenty lines of if/else in release.ts -- became one call to checkout_release_branch in release_publication.ts, which is 1,019 lines and therefore UNDER the 1500 cap, so the twenty lines plus a ~35-line rationale now cost nothing. release.ts 1,996 -> 1,983, every one of those lines being past the cap. The drill grew 765 -> 918 for two new scenarios and the snapshot/restore that keeps them from writing the real CHANGELOG, and it is under the cap too. A change that adds a feature and lowers the excess is the shape this gate's doctrine asks for; the alternative was a +13 raise for logic that had no business in the god-file. LOWERED 18,180 -> 18,068 on 2026-09-05 by fix/release-gate-producer-parity. The change wires the governance-mix obligation into the writer and the two local guards and adds a release-gate locality registry; its own additions to release.ts landed at +19 excess, every one of those lines past the 1500 cap. Paid down rather than baselined, in the way this entry's doctrine prescribes: the 132-line test-count-trend unit (_TEST_COUNT_LINE_RE, _TEST_LIST_MAX_BUFFER, _count_from_list_result, _count_tests_current, _previous_test_count_from_changelog, _render_test_trend_line) moved from release.ts into release_publication.ts, which is under the cap and where it now belongs on merit rather than only on arithmetic -- the same diff makes the missing `Tests: N` footer a section-level publication blocker, so the writer that emits the footer and the guard that refuses its absence sit in one module. release.ts 1,983 -> 1,871. All four names are re-exported from release.ts, so tests/scripts/release.test.ts is unchanged. LOCAL READING, ADMISSIBLE ON THIS ENTRY'S OWN DISCRIMINATOR: the gate walks only SOURCE_ROOTS=['src'] counting excess over the ceiling, and `git status --porcelain -uall -- src/` reports ZERO untracked files with the two new src/ files staged, so a clean CI checkout of this commit measures the same 18,068. If CI reads anything else, CI's number is the correct one and this note is the record of why."
41
41
  },
42
42
  "ci-parity:local-only": {
@@ -5,7 +5,7 @@
5
5
  "host_version": null,
6
6
  "transcript": null,
7
7
  "date": "2026-09-10",
8
- "reason": "UPDATED 2026-09-10. The premise of the earlier reason is now FALSE and the state is unchanged, which is the finding. That reason said the concern had emitted zero bytes in every session on record because `lean_projection.mode` resolved to `eager-all` throughout. It has now delivered: with `mode: delivery` set locally, a live Claude Code session (2.1.268) received 2 rule bodies / 7,685 B on `user_prompt_submit`, and later in the same session 4 bodies / 16,314 B. So delivery into a live model context is no longer the open question. AN INDEPENDENT READER — a fresh context that did not produce the turn — was given the transcript and a neutral three-way question, and returned ACTED, with a qualification it raised itself and which is why this row is NOT `observed-true`: \"the ACTED verdict rests on 'referring to its substance', not on 'doing something it asks for'\". The model quoted the injection's own byte count (a string the reader confirmed appears nowhere else in the transcript, so the reference is demonstrable) and treated the payload as an OBJECT OF STUDY. On the two delivered rules' actual obligations it returned NONE: no point in the turn surfaces a source discrepancy (`cross-source-consistency`) or classifies the prompt continuation-vs-interrupt (`user-interrupt-priority`). E3 asks for a model that VISIBLY ACTS ON a delivered body; citing that a body arrived is not that, and it is especially not that when the observed turn was one that had been WAITING for the delivery — which makes the reference cheap and is the K1 shape one layer further in. A STRUCTURAL LIMIT THE READER ALSO FOUND, and it bounds what any future attempt can prove: thinking blocks are recorded with length 0 in the transcript, so an internal application of a delivered rule is invisible by construction. Only externally visible behaviour can ever satisfy E3 here. The language pin delivered in the same payload is separately COINCIDENT rather than acted on — the prompt was German anyway, and the pin's own text concedes its trigger is not observable from the transcript."
8
+ "reason": "UPDATED 2026-09-10. The premise of the earlier reason is now FALSE and the state is unchanged, which is the finding. That reason said the concern had emitted zero bytes in every session on record because `lean_projection.mode` resolved to `eager-all` throughout. It has now delivered: with `mode: delivery` set locally, a live Claude Code session (2.1.268) received 2 rule bodies / 7,685 B on `user_prompt_submit`, and later in the same session 4 bodies / 16,314 B. So delivery into a live model context is no longer the open question. AN INDEPENDENT READER — a fresh context that did not produce the turn — was given the transcript and a neutral three-way question, and returned ACTED, with a qualification it raised itself and which is why this row is NOT `observed-true`: \"the ACTED verdict rests on 'referring to its substance', not on 'doing something it asks for'\". The model quoted the injection's own byte count (a string the reader confirmed appears nowhere else in the transcript, so the reference is demonstrable) and treated the payload as an OBJECT OF STUDY. On the two delivered rules' actual obligations it returned NONE: no point in the turn surfaces a source discrepancy (`cross-source-consistency`) or classifies the prompt continuation-vs-interrupt (`user-interrupt-priority`). E3 asks for a model that VISIBLY ACTS ON a delivered body; citing that a body arrived is not that, and it is especially not that when the observed turn was one that had been WAITING for the delivery — which makes the reference cheap and is the K1 shape one layer further in. A STRUCTURAL LIMIT THE READER ALSO FOUND, and it bounds what any future attempt can prove: thinking blocks are recorded with length 0 in the transcript, so an internal application of a delivered rule is invisible by construction. Only externally visible behaviour can ever satisfy E3 here. The language pin delivered in the same payload is separately COINCIDENT rather than acted on — the prompt was German anyway, and the pin's own text concedes its trigger is not observable from the transcript. UPDATED 2026-09-11. The state is UNCHANGED and the update is the round that tried hardest to change it. Four ordinary-work turns carrying live deliveries were mechanically selected from the whole transcript store and each judged by a fresh reader on a neutral four-question prompt: one clean NONE, two UNCLEAR, and one NONE on a packet whose rule body was truncated (recorded as defective rather than repaired quietly). Condition 1 was satisfied TWICE, so the bar is not unreachable; condition 2 failed 4/4, and in one candidate the delivered clause that would have CHANGED the outcome went unfollowed while the clause the agent had independent reason to follow was honoured. Full round, method and limits: agents/evidence/analysis/e3-candidate-round-2026-09-11.md. An AI council (2/2 convergent, 2026-09-11) amended E3 prospectively rather than lowering it: a predeclared externally observable obligation class is the admissible witness set, the four conditions stand unchanged, and an eventual observed-true is CLASS-SCOPED — it may not admit this host wholesale for reasoning-only rules. unobserved stays a RESULT here, not a gap. SUPERSEDED THE SAME DAY, AND THE SUPERSESSION IS THE POINT. The enumeration that amendment called for found a confound that makes the four verdicts non-identifying: 106 of 106 `type: auto` rules already carry a byte-equivalent obligation body in the same session unconditionally — 89 in the user-global rule layer, 3 differing by a single blank line, and the 14 maintainer-scoped ones inline in the system prompt. All ten rules judged in the four candidates are body-identical across both channels. So the injected copy adds no text, removing the injection does not remove the body, and the intervention varies one copy versus two rather than absent versus present. The corpus explanation and duplicate exposure fit the four results equally and nothing in the round separates them. Council round 3 (2/2 present, same day) split on the label — anthropic not-measurable-as-specified, openai continue-only-in-a-clean-environment — and converged on the terms: suspend condition-2 judgement wherever a second copy of the obligation exists, record an exposure audit over every auto-loaded channel before admitting a candidate, and require injection to be the SOLE source of the tested body. The residual measurable set in this repository is empty."
9
9
  },
10
10
  "_cursor_md_tree_loaded": {
11
11
  "state": "unobserved",
@@ -76,16 +76,20 @@
76
76
  "ci_delivery": {
77
77
  "workflow": ".github/workflows/standing-payload-delta.yml",
78
78
  "trigger": "pull_request",
79
- "posture": "blocking_on_ceiling_breach_with_grace_ceiling",
79
+ "posture": "blocking_on_measured_ceiling",
80
80
  "design_ceiling": 107646,
81
- "grace_ceiling": 138490,
82
- "grace_measured_at": "2026-09-08",
81
+ "required_status_check": "Standing payload delta + budget gate -- added to required_status_checks on ruleset 'main protection' (id 17749383) on 2026-09-11, with OrganizationAdmin as a bypass actor (bypass_mode: always). AI council option (b), 2/2: plain 'required' against an empty bypass list made any INFRASTRUCTURE red an unbypassable merge block for the only person who could fix it, which is the owner constraint read backwards. Verify with `gh api repos/:owner/:repo/rulesets`, never by reading this file. The bypass contract: permitted only for an infrastructure failure or a demonstrated gate defect, never for disagreement with the payload policy or for ordinary exceptional growth (that is what preamble-payload-exceptions.json is for); every use records cause and remediation; the gate is repaired or reverted before the next ordinary merge.",
82
+ "the_ceiling_is_measured": "READ THIS BEFORE THE FIELDS ABOVE. THERE IS NO STORED CEILING IN THIS FILE. The bound is COMPUTED per run: ceiling = max(design_ceiling, payload measured at the base ref + active grants). Nothing here can be edited to widen it, which is the whole point -- a stored number stayed where a human last put it, so payload a merge REMOVED could be added straight back into the space it freed and nothing objected. A measured ceiling captures every merged reduction automatically, and on a tree that is over design it means ZERO NET GROWTH per pull request. The last stored value was 138,490, measured 2026-09-08, deleted 2026-09-11 in stage 2 of the migration; the two RAISES it took while its own text said it may never move up are kept under superseded_stored_ceiling as the evidence behind ADR-264, and neither is reachable now because there is no number left to raise. What the shrink-only ratchet bounds instead is design_ceiling plus every existing exception grant and watermark, and it refuses a DELETED grant -- the one failure a shrink-only comparison misses by construction. Mechanism and the six ledger contract rules: src/scripts/_lib/measured_payload_ceiling.ts. Decisions: ADR-275 (stage 1, the formula and its prerequisites) and ADR-276 (stage 2, this deletion plus the required check). Break-glass: src/config/preamble-payload-exceptions.json.",
83
83
  "decision": "OPTION (b) of b-preamble-ci-placement: fold into the existing standing-payload-delta.yml rather than add a per-PR job or a scheduled main job. That workflow already computes the neighbouring number and already DECLINED to fail on the premise that this gate 'already fails on growth' -- a premise that was false, because nothing in .github/ ran this gate at all. Making it fail is the smallest change that removes the contradiction instead of adding a third surface. AI council 2026-08-24, 2/2 convergent, on a maintainer delegation.",
84
84
  "why_a_single_ci_run_is_enough": "The 'per-spawn' in this metric's name describes WHERE the cost is paid, not random sampling. The measurement is a deterministic census of revision-controlled inputs -- rules, the skill catalogue and CLAUDE.md, all read from the checkout -- so one CI run represents the checked-out revision exactly. Variation between two runs on the same commit would be a CHECKER DEFECT, never budget noise, and is asserted against by the repeatability test in tests/scripts/check_preamble_payload_budget.test.ts. Both council seats rejected the single-sample objection on this ground.",
85
- "why_a_grace_ceiling": "HEAD measures 138,212 against a design ceiling of 107,646 -- 28.4 % over. Making the gate blocking at the design ceiling would fail EVERY pull request from the moment it lands, which is a self-inflicted CI stop rather than a gate: nobody can reduce 30,566 tokens inside the PR that turns the gate on. The grace ceiling is set AT the measurement so growth beyond today reds immediately while today's tree passes, and it ratchets DOWN toward the design ceiling as reductions land. It may never move UP. **IT NEVER EXPIRED, and the sentence that used to stand here said it did.** This field read \"It expires at the milestone-1 date, at which point the design ceiling applies\" from the day the concession landed, and nothing in the tree ever implemented that: `grace_end_date` was read in exactly two places, an `echo` in the workflow and a test-helper return type, and `check_preamble_payload_budget.ts` carried no date logic at all. So the design ceiling did not become operative on 2026-11-10; it becomes operative when a human edits this file. The date was deleted rather than moved, because moving it would have re-published the same fiction with a later number -- an AI council under a written owner delegation converged on that (2/2, 2026-09-10) and forbade both the extension and building the missing expiry, the latter because arming a repository-wide stop on a date where `status_2026_08_24.committed_reduction_mechanism` is still the string \"NONE\" creates a cliff nobody can clear. What survives is the half that was always real and is unchanged here: the ceiling is 138,490, it is compared against the measured total on every pull request, and it may only walk down. Evidence: agents/evidence/analysis/grace-ceiling-expiry-is-unenforced-2026-09-10.md; decision: ADR-274.",
86
- "decision_record": "docs/decisions/ADR-264-standing-payload-grace-ceiling-may-not-rise.md -- resolves the contradiction a reader hits in why_a_grace_ceiling above, which says the ceiling is 'set AT the measurement' AND that 'It may never move UP', while grace_ceiling_history records it moving up twice. ADR-264 decides AGAINST the measurement framing and FOR the cap: the ceiling may not rise, standing-rule growth needs a compensating reduction elsewhere, and the two recorded raises stand as violations caused by the contradictory wording rather than as precedent. It also designs, and deliberately does NOT ship, a 128-token aggregate Iron Law reserve -- because the deciding seat's own condition was that wording without enforcement and protected approval is an exploitable trust gap, and this citation is therefore the whole of this file's edit. No bound here is changed by it. ADR-274 is the second record on this block and changes no bound either: it deletes the unenforced `grace_end_date` and leaves ADR-264's cap standing untouched. It also records that ADR-264's third `review_trigger` clause -- \"the milestone-1 date arrives and the design ceiling of 107,646 applies\" -- describes an event no code produces and therefore cannot fire; that ADR is not edited here, because its `reopen_policy` is `owner` and nothing in this change needs its decision reopened.",
85
+ "why_a_stored_ceiling_existed_until_2026_09_11": "HISTORY, kept rather than deleted. The stored ceiling this paragraph describes is GONE as of 2026-09-11 (ADR-276); the bound is measured at the base ref now. The field survives because the correction it carries -- that the concession's advertised expiry was never enforced by anything -- is the record of a fiction this tree published for weeks, and deleting the ceiling is not a reason to delete the finding. Read it in the past tense. HEAD measures 138,212 against a design ceiling of 107,646 -- 28.4 % over. Making the gate blocking at the design ceiling would fail EVERY pull request from the moment it lands, which is a self-inflicted CI stop rather than a gate: nobody can reduce 30,566 tokens inside the PR that turns the gate on. The grace ceiling is set AT the measurement so growth beyond today reds immediately while today's tree passes, and it ratchets DOWN toward the design ceiling as reductions land. It may never move UP. **IT NEVER EXPIRED, and the sentence that used to stand here said it did.** This field read \"It expires at the milestone-1 date, at which point the design ceiling applies\" from the day the concession landed, and nothing in the tree ever implemented that: `grace_end_date` was read in exactly two places, an `echo` in the workflow and a test-helper return type, and `check_preamble_payload_budget.ts` carried no date logic at all. So the design ceiling did not become operative on 2026-11-10; it becomes operative when a human edits this file. The date was deleted rather than moved, because moving it would have re-published the same fiction with a later number -- an AI council under a written owner delegation converged on that (2/2, 2026-09-10) and forbade both the extension and building the missing expiry, the latter because arming a repository-wide stop on a date where `status_2026_08_24.committed_reduction_mechanism` is still the string \"NONE\" creates a cliff nobody can clear. What survived that correction was the half that was always real: a bound compared against the measured total on every pull request, which may only walk down. That half is now the MEASURED ceiling, and it walks down by itself rather than waiting for a human. Evidence: agents/evidence/analysis/grace-ceiling-expiry-is-unenforced-2026-09-10.md; decisions: ADR-274 (the expiry), ADR-275 (the measured formula), ADR-276 (this deletion).",
86
+ "decision_record": "docs/decisions/ADR-264-standing-payload-grace-ceiling-may-not-rise.md -- resolves the contradiction a reader hits in why_a_stored_ceiling_existed_until_2026_09_11 above, which says the stored ceiling was 'set AT the measurement' AND that 'It may never move UP', while its own raise history under superseded_stored_ceiling records it moving up twice. ADR-264 decides AGAINST the measurement framing and FOR the cap: the ceiling may not rise, standing-rule growth needs a compensating reduction elsewhere, and the two recorded raises stand as violations caused by the contradictory wording rather than as precedent. It also designs, and deliberately does NOT ship, a 128-token aggregate Iron Law reserve -- because the deciding seat's own condition was that wording without enforcement and protected approval is an exploitable trust gap, and this citation is therefore the whole of this file's edit. No bound here is changed by it. ADR-274 is the second record on this block and changes no bound either: it deletes the unenforced `grace_end_date` and leaves ADR-264's cap standing untouched. It also records that ADR-264's third `review_trigger` clause -- \"the milestone-1 date arrives and the design ceiling of 107,646 applies\" -- describes an event no code produces and therefore cannot fire; that ADR is not edited here, because its `reopen_policy` is `owner` and nothing in this change needs its decision reopened.",
87
87
  "honest_limit": "Editing this workflow makes it FAIL; it does not make it REQUIRED. Whether a failing check blocks a merge is branch-protection configuration, which is a repo-admin action outside this change and outside an autonomous run. So the claim this record supports is 'the gate can now fail a PR', never 'the gate now blocks a merge'. Verify the latter with `gh api repos/:owner/:repo/rulesets` rather than by reading this file.",
88
- "grace_ceiling_history": [
88
+ "superseded_stored_ceiling": {
89
+ "_note": "The stored ceiling this file carried from 2026-08-24 until 2026-09-11, kept as the record of what the measured formula replaced. Last value 138,490, measured 2026-09-08. Retained rather than deleted because its two RAISES are the evidence behind ADR-264 -- a ceiling whose own text said it may never move up, moving up twice under careful prose each time. Both predate src/scripts/_lib/standing_bound_ratchet.ts; neither is reachable now, because there is no stored number left to raise. CORRECTION carried with the entries: both `why` texts below call the concession a dated window expiring 2026-11-10. It was never dated in any executable sense -- nothing read the expiry key except an echo -- and the key was deleted 2026-09-10 (ADR-274). The texts are kept verbatim as the record of what was believed at the time; this note is the correction.",
90
+ "last_stored_value": 138490,
91
+ "last_measured_at": "2026-09-08",
92
+ "raises": [
89
93
  {
90
94
  "from": 138273,
91
95
  "to": 138490,
@@ -98,7 +102,8 @@
98
102
  "at": "2026-09-02",
99
103
  "why": "code-comment-discipline gained three Iron Law lines: a comment naming where the code came from is evidence in the wrong place, provenance is not a WHY, and a roadmap cannot license a comment the rule forbids. +61 tok after two condensations — the section was first written at +546, moved to its pointer target (code-clarity.md) at +172, and folded into the existing Iron Law block rather than opening a second one at +61. What remains is obligation surface that preservation-guard forbids condensing further, and it cannot move to the guideline because a rule whose Iron Law is elsewhere is a rule nobody reads. The defect it closes was measured, not argued: 509 German comment lines and 21 report- or provenance-shaped comments across 41 of 45 changed source files in a consumer repository, with both governing rules holding an enforcement hole — one naming a .md-only validator for a source-file clause, the other naming no enforcer at all. This raises the GRACE ceiling, which is a dated window (expires 2026-11-10), not the design ceiling of 107646, which this tree passed long before this change."
100
104
  }
101
- ]
105
+ ]
106
+ }
102
107
  },
103
108
  "status_2026_08_24": {
104
109
  "state": "at_risk",
@@ -0,0 +1,28 @@
1
+ {
2
+ "_comment": "The break-glass ledger for the standing-payload ceiling. EMPTY, and expected to stay empty: 96.8 % of the last 250 merged pull requests moved this payload by zero or less, so the ordinary path is zero net growth and fully autonomous. This file is for the tail of the tail -- a change whose offsetting reduction is genuinely unsafe. Reading order: src/scripts/_lib/measured_payload_ceiling.ts carries the mechanism and the six contract rules with the reasoning behind each; ADR-275 carries the decision; src/config/preamble-payload-budget.json carries the ceiling this widens.",
3
+ "schema_version": 1,
4
+ "registered_at": "2026-09-11",
5
+ "owner": "maintainer",
6
+ "exceptions": [],
7
+ "contract": {
8
+ "ceiling_effect": "ceiling = max(design_ceiling, min(payload_at_base_ref, watermark) + granted_tokens, stored_ceiling?). The base is PINNED to the watermark while a grant is live, which is what stops the grant becoming permanent: without the pin, the grant raises the measurement, the next pull request measures the raised base, and the exceptional growth is the new ordinary one merge later.",
9
+ "at_most_one_active": "At most ONE grant may be active at a time. Overlapping grants have no defined composition -- a later watermark can already contain an earlier grant's growth, so summing counts it twice. Repay or expire one before recording the next. (AI council 2026-09-11, blocking finding 1.)",
10
+ "approval_must_be_authenticated": "approval_event names a platform event OUTSIDE the diff that requests the grant, and the gate honours the grant only when a caller with platform access has verified it (--approved <id>). An approval recorded solely by the change requesting it is self-asserted data: the same diff can claim the grant, the approver and the date. An unverified grant contributes ZERO tokens and refuses the run. (Blocking finding 2, and the council's hardest pushback in both seats.)",
11
+ "deletion_cannot_discharge_debt": "A record may not be deleted. Removing an entry that exists at the base ref is refused by the shrink-only ratchet: a deleted record is not a repaid one, and erasing it erases the evidence along with the debt. (Blocking finding 3.)",
12
+ "repayment_is_measured": "repaid_at RECORDS a measurement, it does not assert one. Repayment is established by the payload returning to the watermark; a record marked repaid while the measurement says otherwise is refused. (Blocking finding 4.)",
13
+ "consumption_is_derived": "There is deliberately no consumed_tokens field. Consumption is measured - watermark, computed by the gate. A stored copy would be an editable assertion about a measurement. It matters because any future numeric cap must come from actual CONSUMED tokens rather than granted ones -- both seats refused to derive a cap from granted amounts. (Blocking finding 5 / Q3.)",
14
+ "expiry_is_evaluated_at_gate_time": "Expiry is read when the gate runs, which on a required check is merge time. A grant that expires while its pull request sits in review reds that pull request. The fix is a fresh approved grant, never an edit to the old one's date -- which the shrink-only ratchet refuses anyway. (Blocking finding 6.)",
15
+ "no_numeric_cap": "There is no aggregate token cap, and its absence is a finding rather than an omission. The 250-PR delta distribution (p50 0, p90 0, p95 0/+167, max +288) is CENSORED by the very ratchet a cap would relax, so every percentile describes what got through rather than what was attempted. A p95 cap would license the top 5 % of ordinary work to use the break-glass routinely, which makes it not exceptional. Until rejection instrumentation and derived consumption uncensor the distribution, the approver is the cap.",
16
+ "honest_limit": "This file is editable by the pull request it governs, exactly like the gate code. It is an auditable control, not a preventive one -- the council's own words for the sibling mechanism: 'the mechanism does not prevent the exploit; it makes the exploit auditable'. The two rules that keep it from being weaker than that are approval verification (an unverified grant buys nothing) and the deletion check (a removed grant is still owed)."
17
+ },
18
+ "entry_shape": {
19
+ "id": "stable identifier; it is the record's identity and may not be reused",
20
+ "granted_tokens": "positive integer; may never be enlarged after the fact -- record a new grant instead",
21
+ "watermark": "the payload measured immediately BEFORE the grant; immutable, including across a rebase",
22
+ "reason": "why an offsetting reduction was unsafe for this change specifically",
23
+ "approval_event": "reference to the platform event that authorised it, outside this diff",
24
+ "approved_at": "YYYY-MM-DD",
25
+ "expires_at": "YYYY-MM-DD; after it, an unrepaid grant is a hard refusal",
26
+ "repaid_at": "YYYY-MM-DD once the payload has measurably returned to the watermark, else null"
27
+ }
28
+ }
@@ -269,7 +269,9 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
269
269
  settings:sync Additively merge new template keys into an existing
270
270
  .agent-settings.yml (user lines preserved verbatim).
271
271
  Run automatically by `upgrade`. Flags: --path <file>
272
- | --dry-run | --check (exit 2 on drift) | --profile.
272
+ | --dry-run | --check (exit 2 when a write is
273
+ required — template drift OR duplicate-key repair;
274
+ the stderr message names which) | --profile.
273
275
  settings:migrate Lift project-local .agent-settings.yml / .agent-user.yml into
274
276
  ~/.event4u/agent-config/ (the global-only consumer surface,
275
277
  ADR-020). Idempotent; --force overwrites a non-empty global
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Read a prefix-stable surface set out of a git ref, into a scratch tree.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * `road-to-delivery-for-every-host` 4.4 replaces a STORED standing-payload
7
+ * ceiling with a MEASURED one: `max(design_ceiling, payload_at_base_ref)`. An
8
+ * AI council (2/2, 2026-09-10) converged on that formula and on the reason — a
9
+ * stored ceiling stays where it was put until a human lowers it, so payload a
10
+ * merge removed can be added straight back into the space it freed. A measured
11
+ * ceiling captures every merged reduction automatically.
12
+ *
13
+ * To measure the base ref, the base tree has to exist somewhere. This module is
14
+ * that step and nothing else: it materialises the declared prefix-stable roots
15
+ * at a ref and hands back a directory. What to measure, and what to do with the
16
+ * number, belong to the caller.
17
+ *
18
+ * WHY NOT `git archive`, WHICH IS THE OBVIOUS TOOL
19
+ * ------------------------------------------------
20
+ * Because it silently drops declared surfaces. `git archive` honours
21
+ * `export-ignore`, and `.gitattributes` in this repository marks `/CLAUDE.md`
22
+ * export-ignore so release tarballs stay clean. `CLAUDE.md` is ALSO a declared
23
+ * prefix-stable surface worth 746 tok. Measured while building this module:
24
+ * `git archive HEAD -- CLAUDE.md | tar -t` emits **zero entries and exits 0**,
25
+ * so an archive-based base reading came back at 137,667 against a direct
26
+ * reading of 138,413 — 746 tok light, on a tree nobody had changed. A light
27
+ * base reading is a TIGHT ceiling, so every pull request would have reddened by
28
+ * 746 tokens it did not add.
29
+ *
30
+ * That is a packaging concern leaking into a measurement, and the two must not
31
+ * share a switch. `git ls-tree` + `git cat-file` read the object graph directly
32
+ * and have no notion of `export-ignore`, so the surface set measured here is
33
+ * the surface set the registry declares — which is the only definition this
34
+ * gate may use.
35
+ *
36
+ * SYMLINKED SURFACES ARE CLOSED OVER
37
+ * -----------------------------------
38
+ * `CLAUDE.md` is a git symlink (mode 120000) to `AGENTS.md`, which is not
39
+ * itself a declared surface. Materialising only the declared roots would leave
40
+ * a dangling link, which the census reads as an absent file — the same silent
41
+ * 746-token shortfall by a second route. Link targets are resolved at the git
42
+ * layer (a 120000 blob's content IS its target) and materialised alongside, to
43
+ * a fixpoint.
44
+ *
45
+ * FAILURE IS A VALUE, NEVER AN EXCEPTION AND NEVER A ZERO
46
+ * -------------------------------------------------------
47
+ * Every failure path returns `tree: null` with a stated reason. A census over a
48
+ * tree that could not be built returns a small number rather than an error, and
49
+ * a small base reading is a tight ceiling — the direction that reds honest
50
+ * work. The caller decides whether an unreadable base skips or refuses; this
51
+ * module refuses to guess on its behalf.
52
+ */
53
+ import { spawnSync } from 'node:child_process';
54
+ import * as fs from 'node:fs';
55
+ import * as os from 'node:os';
56
+ import * as path from 'node:path';
57
+
58
+ import { prefixStableRoots } from './prefix_stable_surfaces.js';
59
+
60
+ /** Injection seam: run git, capture stdout as a buffer plus the exit status. */
61
+ export type GitBufferRunner = (
62
+ args: readonly string[],
63
+ cwd: string,
64
+ stdin?: string,
65
+ ) => { ok: boolean; stdout: Buffer };
66
+
67
+ export const realGitBuffer: GitBufferRunner = (args, cwd, stdin) => {
68
+ const r = spawnSync('git', [...args], {
69
+ cwd,
70
+ maxBuffer: 512 * 1024 * 1024,
71
+ ...(stdin === undefined ? {} : { input: stdin }),
72
+ });
73
+ return { ok: r.status === 0, stdout: r.stdout ?? Buffer.alloc(0) };
74
+ };
75
+
76
+ interface TreeEntry {
77
+ mode: string;
78
+ sha: string;
79
+ /** Repo-relative path. */
80
+ rel: string;
81
+ }
82
+
83
+ export interface TreeAtRef {
84
+ /** Absolute path of the scratch tree, or `null` when it could not be built. */
85
+ tree: string | null;
86
+ /** Why it could not be built. `null` on success. */
87
+ note: string | null;
88
+ /** Repo-relative paths materialised. Empty on failure. */
89
+ files: string[];
90
+ /** Call when done. Safe to call on a failed reading. */
91
+ dispose: () => void;
92
+ }
93
+
94
+ /** How deep a chain of symlinked surfaces is followed before it is left alone. */
95
+ const MAX_LINK_DEPTH = 4;
96
+
97
+ function normalise(p: string): string {
98
+ return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
99
+ }
100
+
101
+ /**
102
+ * Every blob under the given paths at `ref`.
103
+ *
104
+ * `-z` because a path may contain a character `git` would otherwise quote, and
105
+ * a quoted path silently names a file that does not exist.
106
+ */
107
+ function listTree(git: GitBufferRunner, repoRoot: string, ref: string, paths: readonly string[]): TreeEntry[] | null {
108
+ const r = git(['ls-tree', '-r', '-z', '--full-tree', ref, '--', ...paths], repoRoot);
109
+ if (!r.ok) return null;
110
+ const out: TreeEntry[] = [];
111
+ for (const record of r.stdout.toString('utf-8').split('\0')) {
112
+ if (record === '') continue;
113
+ // `<mode> <type> <sha>\t<path>`
114
+ const tab = record.indexOf('\t');
115
+ if (tab === -1) continue;
116
+ const meta = record.slice(0, tab).split(' ');
117
+ const mode = meta[0];
118
+ const sha = meta[2];
119
+ if (mode === undefined || sha === undefined) continue;
120
+ out.push({ mode, sha, rel: normalise(record.slice(tab + 1)) });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Blob contents for a list of entries, in one `git cat-file --batch` call.
127
+ *
128
+ * One request line per ENTRY rather than per distinct sha: two files with
129
+ * identical content share a sha, and de-duplicating would misalign responses
130
+ * against entries for no measurable gain at this size (728 blobs).
131
+ */
132
+ function readBlobs(git: GitBufferRunner, repoRoot: string, entries: readonly TreeEntry[]): Buffer[] | null {
133
+ if (entries.length === 0) return [];
134
+ const r = git(['cat-file', '--batch'], repoRoot, entries.map((e) => e.sha).join('\n') + '\n');
135
+ if (!r.ok) return null;
136
+ const buf = r.stdout;
137
+ const out: Buffer[] = [];
138
+ let at = 0;
139
+ for (let i = 0; i < entries.length; i += 1) {
140
+ const nl = buf.indexOf(0x0a, at);
141
+ if (nl === -1) return null;
142
+ // `<sha> <type> <size>` — a missing object answers `<name> missing`.
143
+ const header = buf.subarray(at, nl).toString('utf-8').split(' ');
144
+ const size = Number(header[2]);
145
+ if (!Number.isFinite(size)) return null;
146
+ const start = nl + 1;
147
+ out.push(buf.subarray(start, start + size));
148
+ // Each payload is followed by a single trailing newline.
149
+ at = start + size + 1;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Materialise the declared prefix-stable roots at `ref` into a scratch tree.
156
+ *
157
+ * `roots` defaults to the canonical registry rather than a literal list here:
158
+ * `prefix_stable_surfaces.ts` is the one place that boundary is declared, and a
159
+ * second copy is the drift shape this repository has already paid for.
160
+ */
161
+ export function extractSurfacesAtRef(opts: {
162
+ repoRoot: string;
163
+ ref: string;
164
+ roots?: readonly string[];
165
+ git?: GitBufferRunner;
166
+ }): TreeAtRef {
167
+ const git = opts.git ?? realGitBuffer;
168
+ const declared = (opts.roots ?? prefixStableRoots()).map(normalise).filter((r) => r !== '');
169
+ const noop = (): void => undefined;
170
+ if (declared.length === 0) {
171
+ return {
172
+ tree: null,
173
+ files: [],
174
+ note: 'no prefix-stable roots are declared, so there is nothing to read at the base ref',
175
+ dispose: noop,
176
+ };
177
+ }
178
+
179
+ const entries = listTree(git, opts.repoRoot, ref(opts.ref), declared);
180
+ if (entries === null) {
181
+ return {
182
+ tree: null,
183
+ files: [],
184
+ note: `'${opts.ref}' could not be read as a tree here — the ref is unreachable in this checkout`,
185
+ dispose: noop,
186
+ };
187
+ }
188
+ if (entries.length === 0) {
189
+ return {
190
+ tree: null,
191
+ files: [],
192
+ note:
193
+ `no declared prefix-stable root exists at ${opts.ref} — ` +
194
+ 'the surfaces moved, or the ref predates them',
195
+ dispose: noop,
196
+ };
197
+ }
198
+
199
+ // Close over symlinked surfaces: a dangling link measures as an absent file.
200
+ const seen = new Set(entries.map((e) => e.rel));
201
+ let frontier = entries.filter((e) => e.mode === '120000');
202
+ for (let depth = 0; depth < MAX_LINK_DEPTH && frontier.length > 0; depth += 1) {
203
+ const blobs = readBlobs(git, opts.repoRoot, frontier);
204
+ if (blobs === null) break;
205
+ const wanted: string[] = [];
206
+ frontier.forEach((e, i) => {
207
+ const target = (blobs[i] ?? Buffer.alloc(0)).toString('utf-8').trim();
208
+ if (target === '' || path.isAbsolute(target)) return;
209
+ const resolved = normalise(path.posix.join(path.posix.dirname(e.rel), target));
210
+ if (resolved.startsWith('..') || seen.has(resolved)) return;
211
+ wanted.push(resolved);
212
+ });
213
+ if (wanted.length === 0) break;
214
+ const extra = listTree(git, opts.repoRoot, ref(opts.ref), wanted);
215
+ if (extra === null || extra.length === 0) break;
216
+ const fresh = extra.filter((e) => !seen.has(e.rel));
217
+ for (const e of fresh) {
218
+ seen.add(e.rel);
219
+ entries.push(e);
220
+ }
221
+ frontier = fresh.filter((e) => e.mode === '120000');
222
+ }
223
+
224
+ const blobs = readBlobs(git, opts.repoRoot, entries);
225
+ if (blobs === null) {
226
+ return {
227
+ tree: null,
228
+ files: [],
229
+ note: `the blobs at ${opts.ref} could not be read back — git cat-file returned an unusable stream`,
230
+ dispose: noop,
231
+ };
232
+ }
233
+
234
+ let tmp: string;
235
+ try {
236
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'payload-at-ref-'));
237
+ } catch (err) {
238
+ return {
239
+ tree: null,
240
+ files: [],
241
+ note: `a scratch directory could not be created: ${(err as Error).message}`,
242
+ dispose: noop,
243
+ };
244
+ }
245
+ const dispose = (): void => {
246
+ fs.rmSync(tmp, { recursive: true, force: true });
247
+ };
248
+
249
+ try {
250
+ entries.forEach((e, i) => {
251
+ const dest = path.join(tmp, e.rel);
252
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
253
+ const body = blobs[i] ?? Buffer.alloc(0);
254
+ if (e.mode === '120000') {
255
+ // Written as a real symlink so the census resolves it exactly as
256
+ // it does in the working tree — the target is materialised too.
257
+ fs.symlinkSync(body.toString('utf-8').trim(), dest);
258
+ } else {
259
+ fs.writeFileSync(dest, body);
260
+ }
261
+ });
262
+ } catch (err) {
263
+ dispose();
264
+ return {
265
+ tree: null,
266
+ files: [],
267
+ note: `writing the scratch tree for ${opts.ref} failed: ${(err as Error).message}`,
268
+ dispose: noop,
269
+ };
270
+ }
271
+
272
+ return { tree: tmp, files: entries.map((e) => e.rel), note: null, dispose };
273
+ }
274
+
275
+ /** `git ls-tree` needs a tree-ish; `<ref>` alone is one, but guard the empty case. */
276
+ function ref(r: string): string {
277
+ return r.trim();
278
+ }
279
+
280
+ export interface PayloadAtRef {
281
+ ref: string;
282
+ /** Total tokens at the ref, or `null` when the tree could not be read. */
283
+ tokens: number | null;
284
+ note: string | null;
285
+ }
286
+
287
+ /**
288
+ * Measure the payload at `ref` with the caller's own census.
289
+ *
290
+ * The census is injected rather than imported so this module carries none of
291
+ * the gate's measurement code, and so a test can prove the materialisation
292
+ * independently of what is counted. The CALLER passing its own census is also
293
+ * what makes base and head the same measurement — the property that keeps a
294
+ * measured ceiling from drifting against the number it bounds.
295
+ */
296
+ export function measurePayloadAtRef(opts: {
297
+ repoRoot: string;
298
+ ref: string;
299
+ measure: (treeRoot: string) => number;
300
+ git?: GitBufferRunner;
301
+ }): PayloadAtRef {
302
+ if (opts.ref.trim() === '') {
303
+ return { ref: opts.ref, tokens: null, note: 'no base ref was resolved, so there is no tree to measure' };
304
+ }
305
+ const gitOpt = opts.git === undefined ? {} : { git: opts.git };
306
+ const t = extractSurfacesAtRef({ repoRoot: opts.repoRoot, ref: opts.ref, ...gitOpt });
307
+ if (t.tree === null) return { ref: opts.ref, tokens: null, note: t.note };
308
+ try {
309
+ return { ref: opts.ref, tokens: opts.measure(t.tree), note: null };
310
+ } catch (err) {
311
+ return { ref: opts.ref, tokens: null, note: `the census failed on the base tree: ${(err as Error).message}` };
312
+ } finally {
313
+ t.dispose();
314
+ }
315
+ }