@event4u/agent-config 12.0.0 → 12.1.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 (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +122 -0
  3. package/agents/templates/.ai-council.yml.example +46 -3
  4. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  5. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  6. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  7. package/dist/agent-src/scripts/update_roadmap_progress.ts +32 -1
  8. package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -7
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/agent-src/templates/roadmaps.md +41 -3
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli/registry.js.map +1 -1
  13. package/dist/cli-delegate/chunk-K34QTSD5.js +3895 -0
  14. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-LKZ5DOYW.js} +557 -297
  15. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-O52B4F5S.js} +123 -2338
  16. package/dist/cli-delegate/cmd_conformance.js +4 -4
  17. package/dist/cli-delegate/cmd_doctor.js +2 -2
  18. package/dist/cli-delegate/cmd_export.js +1 -1
  19. package/dist/cli-delegate/cmd_handoff.js +1 -1
  20. package/dist/cli-delegate/cmd_prune.js +1 -1
  21. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  22. package/dist/cli-delegate/cmd_sync.js +1 -1
  23. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  24. package/dist/cli-delegate/cmd_validate.js +1 -1
  25. package/dist/discovery/deprecation-report.md +1 -1
  26. package/dist/discovery/discovery-manifest.json +4 -4
  27. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  28. package/dist/discovery/discovery-manifest.summary.md +1 -1
  29. package/dist/discovery/dormancy-report.md +1 -1
  30. package/dist/discovery/orphan-report.md +1 -1
  31. package/dist/discovery/packs.json +2 -2
  32. package/dist/discovery/trust-report.md +1 -1
  33. package/dist/discovery/workspaces.json +2 -2
  34. package/dist/hooks/dispatch.js +2574 -1080
  35. package/dist/install/install.mjs +744 -482
  36. package/dist/mcp/registry-manifest.json +2 -2
  37. package/dist/mcp/server.json +2 -2
  38. package/dist/mcp/server.mjs +113 -10
  39. package/dist/router.json +1 -1
  40. package/dist/scripts/_lib/model_tier.js +9 -0
  41. package/dist/scripts/_lib/model_tier.js.map +1 -1
  42. package/dist/scripts/condense.js +50 -0
  43. package/dist/scripts/condense.js.map +1 -1
  44. package/docs/MIGRATION.md +20 -1
  45. package/package.json +1 -1
  46. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  47. package/src/agent-src/scripts/update_roadmap_progress.ts +32 -1
  48. package/src/config/evaluator-budgets.json +4 -4
  49. package/src/config/gate-coverage.yml +39 -9
  50. package/src/config/gate-violation-baselines.json +6 -0
  51. package/src/config/hook-token-budget.json +7 -1
  52. package/src/config/preamble-payload-budget.json +28 -0
  53. package/src/scripts/_dispatch.bash +10 -0
  54. package/src/scripts/_lib/git_common_dir.ts +173 -0
  55. package/src/scripts/_lib/model_tier.ts +9 -0
  56. package/src/scripts/_lib/release_highlights.ts +108 -7
  57. package/src/scripts/_lib/skill_catalogue.ts +862 -0
  58. package/src/scripts/ai_council/clients.ts +182 -3
  59. package/src/scripts/ai_council/config.ts +16 -1
  60. package/src/scripts/ai_council/low_impact.ts +14 -1
  61. package/src/scripts/ai_council/orchestrator.ts +14 -1
  62. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  63. package/src/scripts/capture_skill_catalogue.ts +185 -328
  64. package/src/scripts/check_branch_freshness.ts +7 -1
  65. package/src/scripts/check_release_published.ts +6 -2
  66. package/src/scripts/check_release_trunk_sync.ts +18 -2
  67. package/src/scripts/condense.ts +50 -1
  68. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  69. package/src/scripts/evidence_report.ts +15 -4
  70. package/src/scripts/hook_manifest.yaml +47 -13
  71. package/src/scripts/hooks/concern_registry.ts +2 -0
  72. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  73. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  74. package/src/scripts/hot_context_hook.ts +13 -1
  75. package/src/scripts/install.ts +129 -0
  76. package/src/scripts/janitor.ts +77 -2
  77. package/src/scripts/lint_plan_risk_register.ts +22 -9
  78. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  79. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  80. package/src/scripts/migration_status.ts +17 -1
  81. package/src/scripts/probe_review_binding_drift.ts +731 -0
  82. package/src/scripts/release.ts +95 -1
  83. package/src/scripts/schemas/command.schema.json +2 -2
  84. package/src/scripts/schemas/skill.schema.json +2 -1
  85. package/src/scripts/schemas/subagent.schema.json +2 -2
  86. package/src/scripts/session_eol_report.ts +81 -0
  87. package/src/scripts/workspace_doctor.ts +464 -0
  88. package/src/scripts/worktree_cleanup_check.ts +2 -1
  89. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
@@ -1450,7 +1450,31 @@ export interface ClaudePathsPlan {
1450
1450
  /** Patterns to emit under `paths:`, in declaration order. */
1451
1451
  globs: string[];
1452
1452
  /** Patterns deliberately not emitted, with the reason a reader can act on. */
1453
- dropped: { pattern: string; reason: 'unresolved-placeholder' | 'over-budget' }[];
1453
+ dropped: { pattern: string; reason: 'unresolved-placeholder' | 'over-budget' | 'mixed-triggers' }[];
1454
+ }
1455
+
1456
+ /**
1457
+ * Does this rule declare any trigger that is NOT path-shaped?
1458
+ *
1459
+ * Load-bearing for `_claude_paths_plan` below, because one `triggers:` list
1460
+ * means two different things on two hosts. Cursor and Windsurf treat globs as
1461
+ * ADDITIVE — a rule with empty globs still reaches the agent through its
1462
+ * description (see `_emit_cursor_mdc`'s own comment). Claude Code treats
1463
+ * `paths:` as EXCLUSIVE: with it the rule loads on a path match; without it the
1464
+ * rule loads unconditionally. The emitter fed both semantics from one list, so
1465
+ * a rule that declared keywords *and* one path glob silently became
1466
+ * path-gated-only on Claude.
1467
+ */
1468
+ export function _has_non_path_trigger(meta: Record<string, unknown>): boolean {
1469
+ const triggers = meta['triggers'];
1470
+ if (!Array.isArray(triggers)) return false;
1471
+ for (const t of triggers) {
1472
+ if (t === null || typeof t !== 'object' || Array.isArray(t)) continue;
1473
+ const obj = t as Record<string, unknown>;
1474
+ if (typeof obj['keyword'] === 'string' && obj['keyword']) return true;
1475
+ if (typeof obj['phrase'] === 'string' && obj['phrase']) return true;
1476
+ }
1477
+ return false;
1454
1478
  }
1455
1479
 
1456
1480
  /**
@@ -1470,6 +1494,31 @@ export function _claude_paths_plan(meta: Record<string, unknown>): ClaudePathsPl
1470
1494
  }
1471
1495
  const globs: string[] = [];
1472
1496
  const dropped: ClaudePathsPlan['dropped'] = [];
1497
+
1498
+ // A rule that ALSO declares keyword / phrase triggers gets no `paths:` at
1499
+ // all, and therefore keeps loading unconditionally on Claude Code.
1500
+ //
1501
+ // Emitting `paths:` here would narrow the rule to a path match and discard
1502
+ // every keyword the author wrote, because this host reads the list as the
1503
+ // whole gate rather than as one way in. Measured before this guard: 25 of
1504
+ // 110 projected rules carried `paths:` and 19 of them also carried keyword
1505
+ // triggers — `design-fidelity` lost 21 of them, in exactly the pasted-
1506
+ // screenshot and capability-URL handovers its own routing section names as
1507
+ // the classes it exists to catch. Nothing in the tree recorded a decision
1508
+ // to make that trade, which is why it is treated as a defect.
1509
+ //
1510
+ // The drop is REPORTED rather than silent, so a rule whose obligation
1511
+ // really is path-bound is visible as a candidate for removing its keyword
1512
+ // triggers — the narrowing then becomes an authoring decision instead of an
1513
+ // emitter side effect. Cursor and Windsurf are untouched: globs are
1514
+ // additive there, so the same rule keeps both routes.
1515
+ if (_has_non_path_trigger(meta)) {
1516
+ for (const raw of derive_trigger_globs(meta)) {
1517
+ dropped.push({ pattern: raw, reason: 'mixed-triggers' });
1518
+ }
1519
+ return { globs, dropped };
1520
+ }
1521
+
1473
1522
  let spent = 0;
1474
1523
  for (const raw of derive_trigger_globs(meta)) {
1475
1524
  if (_is_unresolved_placeholder(raw)) {
@@ -222,13 +222,23 @@ export const REVIEW_SCOPE_NAME_ONLY_FLAGS: readonly string[] = [
222
222
  '-O/dev/null',
223
223
  ];
224
224
 
225
- /** `git diff` argv for the review scope (patch body), config-pinned. */
226
- export function reviewScopeDiffArgs(base: string): string[] {
225
+ /**
226
+ * `git diff` argv for the review scope (patch body), config-pinned.
227
+ *
228
+ * `head` defaults to `HEAD` — the only value the dispatcher and the validator
229
+ * ever pass, since both bind the scope being dispatched *now*. It is a
230
+ * parameter so that an out-of-band measurement can re-derive the scope at a
231
+ * HISTORICAL revision through this same definition instead of assembling a
232
+ * second `git diff` argv beside it: a private copy would drift from the
233
+ * normative flag list above the first time a flag changes, and then report a
234
+ * hash no gate would ever produce.
235
+ */
236
+ export function reviewScopeDiffArgs(base: string, head = 'HEAD'): string[] {
227
237
  return [
228
238
  ...REVIEW_SCOPE_GIT_CONFIG,
229
239
  'diff',
230
240
  ...REVIEW_SCOPE_DIFF_FLAGS,
231
- `${base}...HEAD`,
241
+ `${base}...${head}`,
232
242
  '--',
233
243
  ':/',
234
244
  ...REVIEW_SCOPE_EXCLUDES,
@@ -236,12 +246,12 @@ export function reviewScopeDiffArgs(base: string): string[] {
236
246
  }
237
247
 
238
248
  /** `git diff --name-only` argv for the review scope (changed-file list), config-pinned. */
239
- export function reviewScopeNameOnlyArgs(base: string): string[] {
249
+ export function reviewScopeNameOnlyArgs(base: string, head = 'HEAD'): string[] {
240
250
  return [
241
251
  ...REVIEW_SCOPE_GIT_CONFIG,
242
252
  'diff',
243
253
  ...REVIEW_SCOPE_NAME_ONLY_FLAGS,
244
- `${base}...HEAD`,
254
+ `${base}...${head}`,
245
255
  '--',
246
256
  ':/',
247
257
  ...REVIEW_SCOPE_EXCLUDES,
@@ -29,6 +29,8 @@ import * as path from 'node:path';
29
29
  import process from 'node:process';
30
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
31
 
32
+ import { workspaceIdentity } from './_lib/git_common_dir.js';
33
+
32
34
  // argparse `prog` is hardcoded to "evidence_report.py" in the retired Python implementation,
33
35
  // so usage strings stay byte-identical regardless of the `.py`/`.ts` filename.
34
36
  const PROG = 'evidence_report.py';
@@ -39,15 +41,24 @@ const DESCRIPTION = 'Evidence Report template automation.';
39
41
  // Repo-root resolution
40
42
  // ---------------------------------------------------------------------------
41
43
 
42
- /** Return repo root via git, falling back to relative-path heuristic. */
44
+ /**
45
+ * Return repo root, falling back to the relative-path heuristic.
46
+ *
47
+ * Census row R4. The previous implementation spawned
48
+ * `git rev-parse --show-toplevel` with **no `cwd` and no env scrub**, so an
49
+ * inherited `GIT_DIR` (every git hook exports one) silently answered about the
50
+ * hook's repository instead of this one. `workspaceIdentity()` reads files, so
51
+ * there is no child process to redirect. The fallback is unchanged, and in a
52
+ * plain invocation both forms return the same path.
53
+ */
43
54
  function _repo_root(): string {
44
- const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' });
45
- if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
55
+ const root = workspaceIdentity().repoRoot;
56
+ if (!root.resolved) {
46
57
  // Script lives at src/scripts/; repo root is two levels up.
47
58
  const here = fileURLToPath(import.meta.url);
48
59
  return path.resolve(path.dirname(here), '..', '..');
49
60
  }
50
- return result.stdout.trim();
61
+ return root.value;
51
62
  }
52
63
 
53
64
  function _git_head_short(): string {
@@ -556,6 +556,30 @@ concerns:
556
556
  args: []
557
557
  fail_closed: false
558
558
  severity: advisory
559
+ # road-to-inbox-harvest-2026-08-d-context-ledger Phase 1 Step 1.1 — the
560
+ # tool-result byte counter. `hook-token-budget.json` registers the gap it
561
+ # closes in its own words: bytes-into-context from tool RESULTS are
562
+ # instrumented nowhere, so every context-cost claim in this tree is made
563
+ # against a denominator missing its dominant term.
564
+ #
565
+ # CAPTURE ONLY. It appends `{ts, tool, bytes, measurable}` to a gitignored
566
+ # census file and emits NOTHING to the model — zero injection bytes, which
567
+ # is why it carries a metric registration rather than a per-concern byte
568
+ # cap. The threshold question (when is a result too large) is deliberately
569
+ # unanswered: the distribution has never been observed, and the house rule
570
+ # is baseline before mechanism.
571
+ #
572
+ # The record type has four fields and none can hold free text — a tool
573
+ # NAME, a byte COUNT, a timestamp and a measurable flag. Tool results
574
+ # routinely carry file contents and command output; none of it can reach
575
+ # this output because the output has no field able to carry it.
576
+ #
577
+ # Never blocks, never warns: exit 0 on every path. fail_closed: false.
578
+ tool-result-bytes:
579
+ script: src/scripts/hooks/tool_result_bytes_hook.ts
580
+ args: []
581
+ fail_closed: false
582
+ severity: advisory
559
583
  # road-to-subagent-lifecycle-integrity Phase 3 Step 1 — spawn guard, SHADOW
560
584
  # ONLY, per docs/contracts/concern-activation-policy.md. On every Agent/Task
561
585
  # call it appends one `spawn_guard_shadow` line recording what a guard WOULD
@@ -623,7 +647,7 @@ platforms:
623
647
  session_end: [chat-history, memory-learn, session-register]
624
648
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
625
649
  pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
626
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
650
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
627
651
 
628
652
  claude:
629
653
  session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
@@ -631,14 +655,24 @@ platforms:
631
655
  stop: [chat-history, hot-context, verify-before-complete, team-review-gate, end-review-nudge, turn-end-gate, self-repair, session-register, session-eol]
632
656
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, delegation-nudge, git-authorization, session-canary, self-repair, session-register]
633
657
  pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard, spawn-guard-shadow]
634
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape, language-mirror]
635
- # round-5 § 6.1 — the ONLY binding on this slot in the tree. `pre_compact`
636
- # sets a pin-lost marker; the `post_tool_use` entry above re-emits the pin
637
- # exactly once while that marker is set, then clears it. One extra
638
- # injection per compaction EVENT — never one per tool call, which is the
639
- # shape § 6.2 refuses. claude only: it is the sole verified platform, and
640
- # the measured compaction defect is a Claude Code transcript fact.
641
- pre_compact: [language-mirror]
658
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape, language-mirror]
659
+ # `language-mirror` — round-5 § 6.1. `pre_compact` sets a pin-lost marker;
660
+ # the `post_tool_use` entry above re-emits the pin exactly once while that
661
+ # marker is set, then clears it. One extra injection per compaction EVENT —
662
+ # never one per tool call, which is the shape § 6.2 refuses. claude only: it
663
+ # is the sole verified platform, and the measured compaction defect is a
664
+ # Claude Code transcript fact. (It was the ONLY binding on this slot until
665
+ # the entry below; that sentence is retired rather than left to rot.)
666
+ #
667
+ # `hot-context` — road-to-inbox-harvest-2026-08-d-context-ledger Step 2.1.
668
+ # This is the one slot that fires WHILE state is being destroyed, and it
669
+ # carried no WRITER at all: the cache a post-compaction `session_start`
670
+ # restores was written at the last `stop`, so everything the session did
671
+ # between that stop and the compaction was lost with it. The concern is
672
+ # already bound on `stop` and `session_start`; this adds the write at the
673
+ # moment the loss actually happens. Deterministic rebuild, emits nothing
674
+ # here — the 4,096-byte row it carries is for its session_start restore.
675
+ pre_compact: [language-mirror, hot-context]
642
676
  # road-to-subagent-lifecycle-integrity Phase 1 Step 1. `claude` is the only
643
677
  # platform where both native events were measured present on the installed
644
678
  # binary (2.1.229 — see the evidence page cited on the alias rows below).
@@ -675,7 +709,7 @@ platforms:
675
709
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
676
710
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
677
711
  pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard, spawn-guard-shadow]
678
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
712
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
679
713
  # subagent-ledger IS bound here, unlike the two nudges above, and the
680
714
  # difference is the delivery channel rather than the platform: the nudges
681
715
  # depend on stdout reaching the model, which this trampoline discards by
@@ -698,7 +732,7 @@ platforms:
698
732
  session_end: [chat-history, memory-learn, session-register]
699
733
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
700
734
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
701
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
735
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
702
736
 
703
737
  # Phase 7.6 — Cline. Hooks live under `.clinerules/hooks/<HookName>`
704
738
  # (project, no file extension, must be executable per Cline docs) or
@@ -713,7 +747,7 @@ platforms:
713
747
  session_end: [chat-history, memory-learn, session-register]
714
748
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
715
749
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
716
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
750
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
717
751
 
718
752
  # Phase 7.7 — Windsurf (Cascade). Hooks live at `.windsurf/hooks.json`
719
753
  # (project) or `~/.codeium/windsurf/hooks.json` (user). Cascade has
@@ -749,7 +783,7 @@ platforms:
749
783
  session_end: [chat-history, memory-learn, session-register]
750
784
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
751
785
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
752
- post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
786
+ post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
753
787
 
754
788
  # Phase 7.9 — Copilot has no hook surface. Concerns route through
755
789
  # rule-only fallback; the dispatcher silently no-ops on --platform copilot.
@@ -63,6 +63,7 @@ import { main as editShapeMain } from './edit_shape_hook.js';
63
63
  import { main as rereadGuardMain } from './reread_guard_hook.js';
64
64
  import { main as sessionEolMain } from './session_eol_hook.js';
65
65
  import { main as subagentLedgerMain } from './subagent_ledger_hook.js';
66
+ import { main as toolResultBytesMain } from './tool_result_bytes_hook.js';
66
67
  import { main as spawnGuardShadowMain } from './spawn_guard_shadow_hook.js';
67
68
 
68
69
  /** A concern `main` — argv-taking or not; both shapes exist. */
@@ -109,5 +110,6 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
109
110
  'src/scripts/hooks/reread_guard_hook.ts': rereadGuardMain as ConcernMain,
110
111
  'src/scripts/hooks/session_eol_hook.ts': sessionEolMain as ConcernMain,
111
112
  'src/scripts/hooks/subagent_ledger_hook.ts': subagentLedgerMain as ConcernMain,
113
+ 'src/scripts/hooks/tool_result_bytes_hook.ts': toolResultBytesMain as ConcernMain,
112
114
  'src/scripts/hooks/spawn_guard_shadow_hook.ts': spawnGuardShadowMain as ConcernMain,
113
115
  };
@@ -243,6 +243,27 @@ export function buildRecordInput(facts: DispatchFacts, ts: string, id: string):
243
243
 
244
244
  if (!facts.isAsync) {
245
245
  if (facts.resolvedModel) {
246
+ // road-to-inbox-harvest-2026-08-d-top-band-model-economy Step 1.1.
247
+ //
248
+ // The raw id goes into the field the schema already reserves for it
249
+ // ("the model id the provider reported SERVING"). Without it, a
250
+ // dispatch that ran on a band the three-tier vocabulary cannot
251
+ // express is byte-identical in the record to one where the host
252
+ // reported no model at all: `extractModelFamily` returns null for
253
+ // both, so `tiers` is omitted for both.
254
+ //
255
+ // That collapse is precisely what blocks ADR-035's reopen
256
+ // condition. "Did a vendor ship a band the three tiers cannot
257
+ // express" is answerable only if an unmapped id is distinguishable
258
+ // from an absent one, and recording the served id separates them:
259
+ // unmapped ⇒ `model_served` set with `tiers` absent; unreported ⇒
260
+ // both absent.
261
+ //
262
+ // Vendor-neutral by construction: the id is stored verbatim as an
263
+ // opaque host string. No band above `high` is named and no mapping
264
+ // is invented — naming a fourth band is the decision this
265
+ // measurement exists to inform, never one it may pre-empt.
266
+ input.model_served = facts.resolvedModel;
246
267
  const family = extractModelFamily(facts.resolvedModel);
247
268
  if (family) input.tiers = [family];
248
269
  }
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Tool-result byte counter — capture only, no behaviour change
4
+ * (road-to-inbox-harvest-2026-08-d-context-ledger Phase 1, Step 1.1).
5
+ *
6
+ * Why this exists. `hook-token-budget.json` registers the gap in its own
7
+ * words: *"bytes-into-context from tool RESULTS is not instrumented
8
+ * anywhere (the census covers hook payloads only)"*. The injection census
9
+ * measures what HOOKS put into context — a few kilobytes per turn under a
10
+ * per-slot cap. It does not measure what TOOLS put into context, which is
11
+ * the larger number by any plausible margin and the one nobody can cite.
12
+ * Every claim about context cost in this tree is therefore made against a
13
+ * denominator that excludes its own dominant term.
14
+ *
15
+ * This concern is the instrument and ONLY the instrument. It appends; it
16
+ * never decides, never advises, and emits nothing to the model. The
17
+ * threshold question — when is a tool result too large — is deliberately
18
+ * NOT answered here: the distribution has never been observed, and a
19
+ * threshold set before the distribution is a number invented rather than
20
+ * measured. That ordering is the house rule this repository has paid to
21
+ * learn more than once.
22
+ *
23
+ * ── What is recorded, and what is refused ──────────────────────────────
24
+ *
25
+ * PRIVACY BY CONSTRUCTION — never widen this file to record content.
26
+ *
27
+ * The record type has exactly three fields and NONE of them can hold free
28
+ * text: a timestamp, a tool NAME (an id-shaped enum — `Read`, `Bash`,
29
+ * `Grep` — the same single host string `orchestration_record_hook` already
30
+ * records), and a BYTE COUNT. A tool result routinely carries file
31
+ * contents, command output, secrets in an env dump, and customer data from
32
+ * an API response. None of it reaches this file's output, because the
33
+ * output has no field able to carry it. A record shape that cannot hold a
34
+ * secret has no scrubber that can fail — the same construction
35
+ * `domain-safety-pii` § Surface 2 prescribes for logs and the reason the
36
+ * sibling `subagent-ledger` refuses `last_assistant_message` in any form.
37
+ *
38
+ * The result is measured, never read: `_resultBytes` computes a length and
39
+ * discards the value in the same expression.
40
+ *
41
+ * ── Always exit 0 ─────────────────────────────────────────────────────
42
+ *
43
+ * A `warn` (exit 2) is read as a hard BLOCK on this host, and this concern
44
+ * has nothing to say to the model. Every path returns EXIT_ALLOW —
45
+ * malformed envelope, unparseable payload, unwritable disk. An instrument
46
+ * that can fail a turn is a mechanism, and this is not one yet.
47
+ *
48
+ * ── Absence is recorded, not inferred ─────────────────────────────────
49
+ *
50
+ * A payload carrying no readable result writes a line with `bytes: null`
51
+ * and `measurable: false` rather than writing nothing. The two cases are
52
+ * otherwise byte-identical in the output — "this host does not carry tool
53
+ * results in its PostToolUse payload" and "no tools ran" would produce the
54
+ * same empty file, and the first is a finding about the instrument's own
55
+ * coverage that the baseline has to be able to see. This is the
56
+ * instrument-goes-quiet failure the sibling ledger's round-1 review fixed
57
+ * one layer down, applied here before it can be shipped.
58
+ */
59
+ import * as fs from 'node:fs';
60
+ import * as path from 'node:path';
61
+ import { fileURLToPath, pathToFileURL } from 'node:url';
62
+
63
+ import { is_replay_mode } from './state_io.js';
64
+ import { readHookStdin } from './hook_stdin.js';
65
+
66
+ const EXIT_ALLOW = 0;
67
+
68
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
69
+ type JsonObject = { [k: string]: JsonValue };
70
+
71
+ function isObject(v: unknown): v is JsonObject {
72
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
73
+ }
74
+
75
+ /**
76
+ * Census file, relative to the consumer repo root. Sibling of
77
+ * `injection-census.jsonl` in the same directory and the same
78
+ * `{ts, …, bytes}` line shape — one family, two units: that file counts
79
+ * bytes this suite injects, this one counts bytes the host's tools do.
80
+ * Gitignored via `/agents/runtime/`.
81
+ */
82
+ export const CENSUS_FILE = path.join('agents', 'runtime', 'state', 'tool-result-census.jsonl');
83
+
84
+ /** The payload keys a host may use for a tool's result, widest first. */
85
+ const RESULT_KEYS = ['tool_response', 'toolResponse', 'tool_result', 'toolUseResult'] as const;
86
+
87
+ /**
88
+ * UTF-8 byte length of a tool result, or `null` when the payload carries
89
+ * none under any known key.
90
+ *
91
+ * A result is an object on most tools and a bare string on some (`Bash`
92
+ * returns its stdout directly — see the sibling hook's own test corpus), so
93
+ * both shapes are measured. The sibling's `extractToolResult` returns
94
+ * `JsonObject | null` and would report a bare-string result as absent,
95
+ * which is correct for its purpose (it reads named usage fields) and wrong
96
+ * for this one: a 400 KB command output is exactly the case this counter
97
+ * exists to see, and it is precisely the shape that extractor drops.
98
+ *
99
+ * Bytes, not characters: a multibyte result costs what it costs on the
100
+ * wire, and `String.length` would under-report it.
101
+ */
102
+ export function _resultBytes(payload: JsonObject): number | null {
103
+ for (const key of RESULT_KEYS) {
104
+ const v = payload[key];
105
+ if (v === undefined || v === null) continue;
106
+ if (typeof v === 'string') return Buffer.byteLength(v, 'utf8');
107
+ try {
108
+ return Buffer.byteLength(JSON.stringify(v), 'utf8');
109
+ } catch {
110
+ // Circular or otherwise unserialisable — unmeasurable, not zero.
111
+ return null;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** The tool's name, read from the same positions the sibling hook reads. */
118
+ export function _toolName(payload: JsonObject, envelope: JsonObject): string | null {
119
+ const candidates = [
120
+ payload['tool_name'],
121
+ payload['toolName'],
122
+ payload['tool'],
123
+ envelope['tool_name'],
124
+ envelope['tool'],
125
+ ];
126
+ for (const v of candidates) {
127
+ if (typeof v === 'string' && v) return v;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function unwrapPayload(envelope: JsonObject): JsonObject {
133
+ const inner = envelope['payload'];
134
+ return isObject(inner) ? inner : envelope;
135
+ }
136
+
137
+ /** `${ts.slice(0,7)}` is NOT used here — the census is one flat file, as its sibling is. */
138
+ function censusFile(consumerRoot: string): string {
139
+ return path.join(consumerRoot, CENSUS_FILE);
140
+ }
141
+
142
+ export interface CensusLine {
143
+ ts: string;
144
+ tool: string | null;
145
+ bytes: number | null;
146
+ /** `false` ⇒ the payload carried no readable result. See the header. */
147
+ measurable: boolean;
148
+ }
149
+
150
+ export function _appendCensusLine(consumerRoot: string, line: CensusLine): void {
151
+ if (is_replay_mode()) return;
152
+ const file = censusFile(consumerRoot);
153
+ fs.mkdirSync(path.dirname(file), { recursive: true });
154
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
155
+ }
156
+
157
+ /** Process an ALREADY-PARSED dispatcher envelope. Always returns EXIT_ALLOW. */
158
+ export function processEnvelope(envelope: JsonValue, consumerRoot: string): number {
159
+ try {
160
+ if (!isObject(envelope)) return EXIT_ALLOW;
161
+ if (envelope['event'] !== 'post_tool_use') return EXIT_ALLOW;
162
+
163
+ const payload = unwrapPayload(envelope);
164
+ const bytes = _resultBytes(payload);
165
+
166
+ _appendCensusLine(consumerRoot, {
167
+ ts: new Date().toISOString(),
168
+ tool: _toolName(payload, envelope),
169
+ bytes,
170
+ measurable: bytes !== null,
171
+ });
172
+ } catch {
173
+ // Malformed payload, unwritable disk, anything — never disturb the run.
174
+ return EXIT_ALLOW;
175
+ }
176
+ return EXIT_ALLOW;
177
+ }
178
+
179
+ /** Resolve the consumer repo root, same precedence as the sibling ledger. */
180
+ export function resolveConsumerRoot(envelope: JsonValue): string {
181
+ if (isObject(envelope)) {
182
+ for (const key of ['workspace_root', 'project_root']) {
183
+ const v = envelope[key];
184
+ if (typeof v === 'string' && v) return v;
185
+ }
186
+ const cwd = unwrapPayload(envelope)['cwd'];
187
+ if (typeof cwd === 'string' && cwd) return cwd;
188
+ }
189
+ return process.cwd();
190
+ }
191
+
192
+ export function main(): number {
193
+ const raw = readHookStdin();
194
+ let envelope: JsonValue = {};
195
+ try {
196
+ envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
197
+ } catch {
198
+ return EXIT_ALLOW;
199
+ }
200
+ return processEnvelope(envelope, resolveConsumerRoot(envelope));
201
+ }
202
+
203
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
204
+ // every module shares the bundle's `import.meta.url`.
205
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
206
+ function _isCliEntry(): boolean {
207
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
208
+ return false;
209
+ }
210
+ if (process.argv[1] === undefined) return false;
211
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
212
+ if (import.meta.url === argvUrl) return true;
213
+ try {
214
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+ if (_isCliEntry()) process.exit(main());
@@ -379,7 +379,19 @@ export function main(): number {
379
379
  if (_is_replay_mode()) {
380
380
  return 0; // replay fixtures: read-only, no state mutation
381
381
  }
382
- if (event === 'stop' || event === 'session_end') {
382
+ // `pre_compact` joins the write set (road-to-inbox-harvest-2026-08-d-
383
+ // context-ledger Step 2.1). It is the only slot that fires WHILE state
384
+ // is being destroyed, and until now it carried no writer at all: the
385
+ // cache a post-compaction `session_start` restores was written at the
386
+ // last `stop`, so everything the session did since then was lost with
387
+ // the compaction. Writing here makes the restore reflect the moment
388
+ // before the loss instead of the moment before the last turn.
389
+ //
390
+ // Cheap and idempotent by construction: `_write_hot_context` is a
391
+ // deterministic rebuild from the history file, so an extra fire costs
392
+ // one rebuild and can never produce a worse cache than the older one it
393
+ // replaces. It emits nothing — the restore side is unchanged.
394
+ if (event === 'stop' || event === 'session_end' || event === 'pre_compact') {
383
395
  _write_hot_context(root);
384
396
  } else if (event === 'session_start') {
385
397
  const source = String(payload.source ?? '');