@event4u/agent-config 9.29.0 → 9.31.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +32 -214
  3. package/README.md +1 -0
  4. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  5. package/dist/agent-src/commands/explain-run.md +31 -1
  6. package/dist/agent-src/commands/team/delegate.md +1 -0
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  9. package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
  10. package/dist/agent-src/rules/preservation-guard.md +9 -0
  11. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  12. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  13. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +2 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-2WCLVDWC.js} +45 -2
  18. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  19. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  20. package/dist/cli-delegate/chunk-EJITJBJX.js +172 -0
  21. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-ITIQSGOQ.js} +3 -3
  22. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  23. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  24. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  25. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-QEEB4LHO.js} +22 -35
  26. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  27. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-UVKNIJH2.js} +14 -0
  28. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  29. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  30. package/dist/cli-delegate/cmd_conformance.js +2861 -96
  31. package/dist/cli-delegate/cmd_doctor.js +3 -2
  32. package/dist/cli-delegate/cmd_explain.js +2 -2
  33. package/dist/cli-delegate/cmd_export.js +4 -4
  34. package/dist/cli-delegate/cmd_handoff.js +87 -7
  35. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  36. package/dist/cli-delegate/cmd_migrate.js +2 -2
  37. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  38. package/dist/cli-delegate/cmd_prune.js +4 -4
  39. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  40. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  41. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  42. package/dist/cli-delegate/cmd_settings_get.js +3 -56
  43. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  44. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  45. package/dist/cli-delegate/cmd_sync.js +4 -4
  46. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  47. package/dist/cli-delegate/cmd_update.js +1 -1
  48. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  49. package/dist/cli-delegate/cmd_validate.js +4 -4
  50. package/dist/cli-delegate/cmd_versions.js +1 -1
  51. package/dist/discovery/deprecation-report.md +1 -1
  52. package/dist/discovery/discovery-manifest.json +7 -7
  53. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  54. package/dist/discovery/discovery-manifest.summary.md +1 -1
  55. package/dist/discovery/orphan-report.md +1 -1
  56. package/dist/discovery/packs.json +2 -2
  57. package/dist/discovery/trust-report.md +1 -1
  58. package/dist/discovery/workspaces.json +2 -2
  59. package/dist/hooks/dispatch.js +2143 -641
  60. package/dist/install/install.mjs +133 -14
  61. package/dist/mcp/registry-manifest.json +2 -2
  62. package/dist/mcp/server.json +2 -2
  63. package/dist/mcp/server.mjs +3967 -761
  64. package/dist/scripts/_lib/agent_settings.js +111 -2
  65. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  66. package/dist/server/schemas/settings.js +6 -0
  67. package/dist/server/schemas/settings.js.map +1 -1
  68. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  69. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  70. package/dist/ui/index.html +1 -1
  71. package/docs/contracts/settings-classes.md +21 -5
  72. package/package.json +1 -1
  73. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  75. package/src/config/agent-settings.template.yml +36 -0
  76. package/src/config/dispatch-economy-metrics.json +61 -0
  77. package/src/config/evaluator-budgets.json +4 -4
  78. package/src/config/hook-token-budget.json +91 -0
  79. package/src/config/pack-size-budget.json +3 -2
  80. package/src/config/recycle-threshold-budget.json +12 -0
  81. package/src/config/rule-enforcement-baseline.json +86 -0
  82. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  83. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  84. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  85. package/src/scripts/_dispatch.bash +23 -0
  86. package/src/scripts/_lib/agent_settings.ts +119 -2
  87. package/src/scripts/_lib/carrier_divergence.ts +47 -0
  88. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  89. package/src/scripts/_lib/orchestration_record.ts +41 -10
  90. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  91. package/src/scripts/_lib/self_repair.ts +194 -0
  92. package/src/scripts/_lib/session_eol.ts +230 -0
  93. package/src/scripts/_lib/session_role.ts +42 -0
  94. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  95. package/src/scripts/_lib/subagent_response.ts +75 -0
  96. package/src/scripts/ai_council/clients.ts +50 -1
  97. package/src/scripts/ask_transport.ts +221 -0
  98. package/src/scripts/bench_hook_injection.ts +234 -0
  99. package/src/scripts/check_adr_frontmatter.ts +84 -40
  100. package/src/scripts/check_branch_freshness.ts +1 -0
  101. package/src/scripts/check_corpus_staleness.ts +18 -4
  102. package/src/scripts/check_detector_corpus.ts +17 -0
  103. package/src/scripts/check_gitignore_freshness.ts +42 -8
  104. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  105. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  106. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  107. package/src/scripts/check_static_layer_stability.ts +154 -0
  108. package/src/scripts/check_suppression_hygiene.ts +48 -0
  109. package/src/scripts/dispatch_economy_report.ts +448 -0
  110. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  111. package/src/scripts/explain_run.ts +206 -4
  112. package/src/scripts/generate_settings_reference.ts +1 -0
  113. package/src/scripts/handoff_context_hook.ts +112 -4
  114. package/src/scripts/hook_manifest.yaml +117 -10
  115. package/src/scripts/hooks/concern_registry.ts +8 -0
  116. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  117. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  118. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  119. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  120. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  121. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  122. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  123. package/src/scripts/hooks/turn_end_gate_hook.ts +715 -0
  124. package/src/scripts/install-hooks.sh +1 -1
  125. package/src/scripts/language_mirror_hook.ts +143 -1
  126. package/src/scripts/lint_decision_impact.ts +14 -1
  127. package/src/scripts/lint_hook_manifest.ts +82 -0
  128. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  129. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  130. package/src/scripts/measure_turn_end_gate.ts +272 -0
  131. package/src/scripts/orchestration_record.ts +4 -1
  132. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  133. package/src/scripts/report_carrier_divergence.ts +72 -24
  134. package/src/scripts/report_conformance_funnel.ts +291 -0
  135. package/src/scripts/report_imperative_density.ts +1 -0
  136. package/src/scripts/routing_doctor.ts +97 -0
  137. package/src/scripts/self_repair_cli.ts +51 -3
  138. package/src/scripts/session_eol_report.ts +261 -0
  139. package/src/server/schemas/settings.ts +14 -0
  140. package/src/templates/compact-instructions.md +40 -0
  141. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  142. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Static-layer stability lint (road-to-token-economy-cache Phase 2.2 — the
4
+ * hygiene half the Phase-1 null left standing; spike note:
5
+ * agents/settings/contexts/cache-injection-anatomy.md).
6
+ *
7
+ * The always-loaded layer is re-sent on every session and every spawn; a
8
+ * MACHINE-VOLATILE marker inside it (an absolute local path naming a real
9
+ * user, a session/run-id shape) is both a cache-stability hazard and a
10
+ * portability leak. Scope is deliberately NARROW and honest:
11
+ *
12
+ * - scanned: dist/agent-src/rules/*.md EXCLUDING the kernel set (the
13
+ * kernel prefix has its own byte-drift guard,
14
+ * check_kernel_prefix_stability — no double coverage, roadmap Risk 8)
15
+ * - flagged: absolute home paths (/Users/<name>/, /home/<name>/,
16
+ * C:\Users\<name>\) and UUID-shaped run/session ids
17
+ * - NOT flagged: dates in prose (stable content, not build-volatile) —
18
+ * build-time volatility is already impossible for .md by the ADR-201
19
+ * byte-exact projection (dist == rewrite(src)); this lint catches what
20
+ * an AUTHOR pastes, which the projection faithfully preserves.
21
+ *
22
+ * Exit 0 green · 1 findings · 2 unusable. Dead-scope guarded via
23
+ * assertScanned. Self-test: tests/scripts/check_static_layer_stability.test.ts
24
+ * proves red on a fixture carrying a home path and a UUID.
25
+ */
26
+ import * as fs from 'node:fs';
27
+ import * as path from 'node:path';
28
+ import { fileURLToPath, pathToFileURL } from 'node:url';
29
+
30
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
31
+
32
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
33
+ const _IN_BUNDLE = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
34
+ const REPO_ROOT = path.resolve(
35
+ path.dirname(fileURLToPath(import.meta.url)),
36
+ ...(_IN_BUNDLE ? ['..'] : ['..', '..']),
37
+ );
38
+ const RULES_DIR = path.join(REPO_ROOT, 'dist', 'agent-src', 'rules');
39
+ const ROUTER = path.join(REPO_ROOT, 'dist', 'router.json');
40
+
41
+ /** Machine-volatile shapes. Home paths anchor on the OS home prefix; the
42
+ * UUID shape requires all five groups so ordinary hex constants stay quiet. */
43
+ const HOME_PATH_RE = /(?:\/(?:Users|home)\/[A-Za-z][\w.-]*\/|[A-Za-z]:\\Users\\[A-Za-z][\w.-]*\\)/;
44
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i;
45
+ /** Per-line opt-out for a deliberate, justified exception. */
46
+ const IGNORE_MARKER = 'static-layer-allow';
47
+
48
+ export interface Finding {
49
+ file: string;
50
+ line: number;
51
+ kind: 'home-path' | 'uuid';
52
+ excerpt: string;
53
+ }
54
+
55
+ function kernelBasenames(routerPath: string): Set<string> {
56
+ try {
57
+ const router = JSON.parse(fs.readFileSync(routerPath, 'utf8')) as { kernel?: unknown };
58
+ if (Array.isArray(router.kernel)) {
59
+ return new Set(
60
+ router.kernel
61
+ .filter((k): k is string => typeof k === 'string')
62
+ .map((k) => (k.endsWith('.md') ? k : `${k}.md`)),
63
+ );
64
+ }
65
+ } catch {
66
+ /* fall through — an unreadable router means we scan everything (fail toward coverage) */
67
+ }
68
+ return new Set();
69
+ }
70
+
71
+ export function scan(opts: { rulesDir?: string; routerPath?: string } = {}): { findings: Finding[]; scanned: number } {
72
+ const rulesDir = opts.rulesDir ?? RULES_DIR;
73
+ const kernel = kernelBasenames(opts.routerPath ?? ROUTER);
74
+ const findings: Finding[] = [];
75
+ let scanned = 0;
76
+ const files = fs.existsSync(rulesDir) ? fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md')) : [];
77
+ for (const f of files) {
78
+ if (kernel.has(f)) continue; // kernel prefix has its own drift guard
79
+ scanned += 1;
80
+ const text = fs.readFileSync(path.join(rulesDir, f), 'utf8');
81
+ const lines = text.split('\n');
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i] ?? '';
84
+ if (line.includes(IGNORE_MARKER)) continue;
85
+ if (HOME_PATH_RE.test(line)) {
86
+ findings.push({ file: f, line: i + 1, kind: 'home-path', excerpt: line.trim().slice(0, 120) });
87
+ } else if (UUID_RE.test(line)) {
88
+ findings.push({ file: f, line: i + 1, kind: 'uuid', excerpt: line.trim().slice(0, 120) });
89
+ }
90
+ }
91
+ }
92
+ return { findings, scanned };
93
+ }
94
+
95
+ export function main(argv: readonly string[]): number {
96
+ let rulesDir: string | undefined;
97
+ let routerPath: string | undefined;
98
+ for (let i = 0; i < argv.length; i++) {
99
+ if (argv[i] === '--rules-dir') rulesDir = argv[++i];
100
+ else if (argv[i] === '--router') routerPath = argv[++i];
101
+ }
102
+ const opts: Parameters<typeof scan>[0] = {
103
+ ...(rulesDir !== undefined ? { rulesDir } : {}),
104
+ ...(routerPath !== undefined ? { routerPath } : {}),
105
+ };
106
+ let result: ReturnType<typeof scan>;
107
+ try {
108
+ result = scan(opts);
109
+ } catch (err) {
110
+ process.stderr.write(`check_static_layer_stability: ${err instanceof Error ? err.message : String(err)}\n`);
111
+ return 2;
112
+ }
113
+ try {
114
+ assertScanned({
115
+ gate: 'check_static_layer_stability',
116
+ scanned: result.scanned,
117
+ units: 'non-kernel always-loaded rule file(s)',
118
+ roots: [rulesDir ?? RULES_DIR],
119
+ });
120
+ } catch (exc) {
121
+ if (exc instanceof DeadScopeError) {
122
+ process.stderr.write(`error: ${exc.message}\n`);
123
+ return 1;
124
+ }
125
+ throw exc;
126
+ }
127
+ for (const f of result.findings) {
128
+ process.stderr.write(
129
+ `❌ ${f.file}:${f.line} — ${f.kind} in the always-loaded layer (machine-volatile marker; re-sent on every spawn): ${f.excerpt}\n`,
130
+ );
131
+ }
132
+ if (result.findings.length > 0) {
133
+ process.stderr.write(
134
+ `check_static_layer_stability: ${result.findings.length} finding(s) across ${result.scanned} scanned file(s). ` +
135
+ `A deliberate exception carries '${IGNORE_MARKER}' on the line with its reason.\n`,
136
+ );
137
+ return 1;
138
+ }
139
+ process.stdout.write(`✅ check_static_layer_stability: ${result.scanned} non-kernel always-loaded file(s) clean\n`);
140
+ return 0;
141
+ }
142
+
143
+ function _isCliEntry(): boolean {
144
+ if (_IN_BUNDLE) return false;
145
+ if (process.argv[1] === undefined) return false;
146
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
147
+ if (import.meta.url === argvUrl) return true;
148
+ try {
149
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ if (_isCliEntry()) process.exit(main(process.argv.slice(2)));
@@ -117,6 +117,24 @@ export interface SuppressionSpec {
117
117
  * migration to content anchors visible instead of aspirational.
118
118
  */
119
119
  driftKey?: { positionField: string; anchorField: string };
120
+ /**
121
+ * Accept this baseline as absent at the base ref — for the ONE change that
122
+ * introduces it, then removed.
123
+ *
124
+ * Without it a brand-new baseline is a permanent red: the ratchet compares
125
+ * against `origin/main`, where the file does not exist yet, and
126
+ * `ratchet_base_ref` is deliberately fail-closed there because
127
+ * absent-at-base and mistyped-path look identical from inside. Setting it
128
+ * makes the verdict `new_baseline` rather than growth, so the bootstrap
129
+ * entries are not each reported as a new hole.
130
+ *
131
+ * Remove the flag once the introducing commit is an ancestor of the base
132
+ * ref — leaving it set would silently accept a later mistyped path. That
133
+ * removal is **enforced, not remembered**: the scan below fails when the
134
+ * flag is set and the baseline already resolves at the base ref, so the
135
+ * flag closes itself one merge after it was needed.
136
+ */
137
+ newInThisChange?: boolean;
120
138
  /** Why this list exists at all — printed with the count, so absence is visible. */
121
139
  what: string;
122
140
  }
@@ -199,6 +217,19 @@ export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
199
217
  growth: 'requires_falsifier',
200
218
  what: 'per-gate known-debt counts',
201
219
  },
220
+ {
221
+ // The root IS the list — a bare array of rule filenames, so entries are
222
+ // content-keyed by construction and cannot drift on line numbers.
223
+ // `forbidden` growth is the whole point: this baseline exists so a NEW
224
+ // rule cannot join the silent set, and `lint_rule_enforcement_declaration`
225
+ // additionally fails when an entry stops being undeclared, so the list is
226
+ // shrink-only from both directions.
227
+ file: 'src/config/rule-enforcement-baseline.json',
228
+ listKey: null,
229
+ tier: 'string_list',
230
+ newInThisChange: true,
231
+ what: 'rules predating the enforced_by-declaration ratchet',
232
+ },
202
233
  ];
203
234
 
204
235
  export interface SuppressionFinding {
@@ -367,7 +398,24 @@ export function main(): number {
367
398
  baseRef,
368
399
  repoRoot: REPO_ROOT,
369
400
  entriesOf: (parsed) => entriesOfSpec(spec, parsed),
401
+ ...(spec.newInThisChange === true ? { allowNewBaseline: true } : {}),
370
402
  });
403
+ // Self-closing bootstrap flag. `newInThisChange` exists for the ONE
404
+ // change that introduces a baseline, and the comment on the field says
405
+ // to remove it afterwards — which is exactly the kind of instruction
406
+ // that gets forgotten, because nothing goes red when it is ignored and
407
+ // a stale flag silently accepts a later mistyped path. So the check
408
+ // closes itself: once the baseline resolves at the base ref, the
409
+ // verdict is no longer `new_baseline`, and keeping the flag is a
410
+ // finding rather than a note in someone's follow-up list.
411
+ if (spec.newInThisChange === true && comparison.verdict !== 'new_baseline') {
412
+ const detail =
413
+ `\`newInThisChange\` is still set, but the baseline now resolves at ${baseRef} — ` +
414
+ 'the flag has served its purpose and must be removed from SUPPRESSION_INVENTORY. ' +
415
+ 'Leaving it set makes a future mistyped path pass as a new baseline forever.';
416
+ ledger.fail(spec.file, detail);
417
+ findings.push({ file: spec.file, entry: '(whole file)', kind: 'uninventoried', detail });
418
+ }
371
419
  } catch (exc) {
372
420
  if (exc instanceof BaseRefUnavailableError) {
373
421
  ledger.fail(spec.file, exc.message);
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dispatch-economy report — computes the two registered metrics from
4
+ * `src/config/dispatch-economy-metrics.json` (road-to-token-economy-dispatch
5
+ * Phase 1):
6
+ *
7
+ * dispatch_floor median init_tokens per role + init/work ratio per leg
8
+ * rules_efficiency median rules_used / rules_carried per worker envelope
9
+ *
10
+ * Metric semantics (restated from the registration file, load-bearing):
11
+ *
12
+ * init_tokens = billable_input of the leg's FIRST assistant record
13
+ * work_tokens = leg total billable_input − init_tokens
14
+ * billable_input = input_tokens + cache_read_input_tokens
15
+ * + cache_creation_input_tokens
16
+ *
17
+ * Sources, in the registration file's priority order:
18
+ * 1. Transcript ledger via `_lib/cc_transcript.ts` (per-agentId legs,
19
+ * message.id+requestId dedupe) — provenance `measured`.
20
+ * 2. Audit-log orchestration lines carrying `init_tokens`/`work_tokens`
21
+ * with `floor_provenance: "measured"`, and the `rules_carried`/
22
+ * `rules_used` pairs for rules_efficiency.
23
+ *
24
+ * ROLE ATTRIBUTION — honest limitation until the Phase 2 role axis ships:
25
+ * transcripts carry no session role, so every transcript leg reports as
26
+ * role `worker`; reviewer legs are attributed ONLY from audit-line
27
+ * `agent_combo` entries matching /review|judge|validator/i. The output
28
+ * names the attribution source per number.
29
+ *
30
+ * Class A (ADR-124 no-runtime-boundary): in-process, per-invocation, no
31
+ * socket, no daemon, no network, no writes — stdout report only. This is a
32
+ * REPORT, not a gate: exit code is 0 on every computed path (2 only on
33
+ * unusable invocation, e.g. unreadable registration file).
34
+ *
35
+ * Usage:
36
+ * ./scripts-run src/scripts/dispatch_economy_report \
37
+ * [--format text|json] [--root <transcripts-root>] [--audit-dir <dir>]
38
+ * [--max-age-days 14]
39
+ */
40
+ import * as fs from 'node:fs';
41
+ import * as path from 'node:path';
42
+ import { fileURLToPath, pathToFileURL } from 'node:url';
43
+
44
+ import {
45
+ billableInputTokens,
46
+ DEFAULT_PROJECTS_ROOT,
47
+ scanTranscripts,
48
+ weightedInputUnits,
49
+ type TranscriptRecord,
50
+ } from './_lib/cc_transcript.js';
51
+
52
+ const _HERE = fileURLToPath(import.meta.url);
53
+ // src/scripts/dispatch_economy_report.ts → parents[1] is the repo root (same
54
+ // 2-up resolution cache_realization_report.ts uses at the same depth).
55
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
56
+ const REGISTRATION_FILE = path.join(REPO_ROOT, 'src', 'config', 'dispatch-economy-metrics.json');
57
+ const DEFAULT_AUDIT_DIR = path.join('agents', 'runtime', 'state', 'audit');
58
+
59
+ /** Reviewer attribution off audit-line agent_combo — id-shaped types only. */
60
+ const REVIEWER_TYPE_RE = /review|judge|validator/i;
61
+
62
+ export interface LegStat {
63
+ agentId: string;
64
+ calls: number;
65
+ /** Raw billable_input of the first record — comparable to the cache-economy 235.5k baseline. */
66
+ init_tokens: number;
67
+ /** Raw billable_input of the rest of the leg. */
68
+ work_tokens: number;
69
+ /** COST-shaped ratio: weightedInputUnits(init) / weightedInputUnits(rest)
70
+ * (read 0.1× · 5m write 1.25× · 1h write 2×). Raw billable_input would
71
+ * count every turn's cache-READ re-transmission at full weight and make
72
+ * the "more spent loading context than working" threshold unmeetable by
73
+ * construction — measured live 2026-08-10: raw ratio 0.02 on legs whose
74
+ * weighted ratio is the number the threshold means. null when the leg
75
+ * has a single call (floor-only, no work to ratio against). */
76
+ ratio: number | null;
77
+ }
78
+
79
+ export interface RoleFloor {
80
+ role: 'worker' | 'reviewer';
81
+ attribution: 'transcript-leg (role axis not shipped — all legs report as worker)' | 'audit-line agent_combo';
82
+ legs: number;
83
+ single_call_legs: number;
84
+ median_init_tokens: number | null;
85
+ median_ratio_finite: number | null;
86
+ }
87
+
88
+ export interface RulesEfficiency {
89
+ envelopes_with_pair: number;
90
+ median_quota: number | null;
91
+ }
92
+
93
+ export interface Report {
94
+ schema: 'dispatch-economy-report/v1';
95
+ registration: { registered_at: string; review_by: string };
96
+ window: { max_age_days: number; transcript_records: number; legs: number };
97
+ dispatch_floor: {
98
+ roles: RoleFloor[];
99
+ thresholds: { projection_mandatory_ratio: number; success_ratio_after_projection: number };
100
+ /** Single-run reading — the registered verdict is a sustained-window call at review date. */
101
+ projection_mandatory_signal: boolean | null;
102
+ };
103
+ rules_efficiency: RulesEfficiency & { threshold_low_quota: number; low_quota_signal: boolean | null };
104
+ ask_economy: {
105
+ ask_lines: number;
106
+ adopted: number;
107
+ escalated: number;
108
+ threshold_escalation_kill: number;
109
+ /** null until asks exist; the registered verdict is a review-window call. */
110
+ escalation_kill_signal: boolean | null;
111
+ };
112
+ return_channel: {
113
+ lines_with_field: number;
114
+ median_chars: number | null;
115
+ envelope_cap_chars: number;
116
+ /** median above the committed envelope cap = transcript-shaped returns flowing back. */
117
+ cap_breach_signal: boolean | null;
118
+ };
119
+ notes: string[];
120
+ }
121
+
122
+ export function median(values: readonly number[]): number | null {
123
+ if (values.length === 0) return null;
124
+ const s = [...values].sort((a, b) => a - b);
125
+ const mid = Math.floor(s.length / 2);
126
+ const lo = s[mid - 1];
127
+ const hi = s[mid];
128
+ if (s.length % 2 === 1) return hi ?? null;
129
+ return lo !== undefined && hi !== undefined ? (lo + hi) / 2 : null;
130
+ }
131
+
132
+ /** Group subagent records into per-agentId legs and compute init/work per leg. */
133
+ export function computeLegs(records: readonly TranscriptRecord[]): LegStat[] {
134
+ const byAgent = new Map<string, TranscriptRecord[]>();
135
+ for (const r of records) {
136
+ if (r.bucket !== 'subagent' || r.agentId === null) continue;
137
+ const list = byAgent.get(r.agentId) ?? [];
138
+ list.push(r);
139
+ byAgent.set(r.agentId, list);
140
+ }
141
+ const legs: LegStat[] = [];
142
+ for (const [agentId, recs] of byAgent) {
143
+ // Timestamp sort; records without timestamps keep file order (stable sort).
144
+ const sorted = [...recs].sort((a, b) => {
145
+ if (a.timestamp === null || b.timestamp === null) return 0;
146
+ return a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0;
147
+ });
148
+ const first = sorted[0];
149
+ if (first === undefined) continue;
150
+ const init = billableInputTokens(first.usage);
151
+ const total = sorted.reduce((acc, r) => acc + billableInputTokens(r.usage), 0);
152
+ const work = total - init;
153
+ const initUnits = weightedInputUnits(first.usage);
154
+ const workUnits = sorted.slice(1).reduce((acc, r) => acc + weightedInputUnits(r.usage), 0);
155
+ legs.push({
156
+ agentId,
157
+ calls: sorted.length,
158
+ init_tokens: init,
159
+ work_tokens: work,
160
+ ratio: workUnits > 0 ? initUnits / workUnits : null,
161
+ });
162
+ }
163
+ return legs.sort((a, b) => (a.agentId < b.agentId ? -1 : 1));
164
+ }
165
+
166
+ interface AuditOrchestration {
167
+ rules_carried?: unknown;
168
+ rules_used?: unknown;
169
+ init_tokens?: unknown;
170
+ work_tokens?: unknown;
171
+ floor_provenance?: unknown;
172
+ agent_combo?: unknown;
173
+ route_taken?: unknown;
174
+ outcome?: unknown;
175
+ escalated?: unknown;
176
+ return_channel_chars?: unknown;
177
+ origin?: unknown;
178
+ }
179
+
180
+ function isFiniteNonNegInt(v: unknown): v is number {
181
+ return typeof v === 'number' && Number.isInteger(v) && v >= 0;
182
+ }
183
+
184
+ /**
185
+ * Read every orchestration object out of the audit dir's monthly JSONL files,
186
+ * honouring the same age window the transcript scan uses. Files are monthly,
187
+ * so the mtime filter (mtime updates on append) is the available granularity —
188
+ * a file whose LAST append is inside the window is read whole; per-line `ts`
189
+ * filtering then trims to the window exactly.
190
+ */
191
+ export function readAuditOrchestrations(
192
+ auditDir: string,
193
+ opts: { maxAgeDays?: number; now?: Date } = {},
194
+ ): AuditOrchestration[] {
195
+ if (!fs.existsSync(auditDir)) return [];
196
+ const out: AuditOrchestration[] = [];
197
+ const cutoffMs =
198
+ opts.maxAgeDays !== undefined
199
+ ? (opts.now ?? new Date()).getTime() - opts.maxAgeDays * 24 * 60 * 60 * 1000
200
+ : null;
201
+ let entries: string[];
202
+ try {
203
+ entries = fs.readdirSync(auditDir).filter((f) => f.endsWith('.jsonl')).sort();
204
+ } catch {
205
+ return [];
206
+ }
207
+ if (cutoffMs !== null) {
208
+ entries = entries.filter((f) => {
209
+ try {
210
+ return fs.statSync(path.join(auditDir, f)).mtimeMs >= cutoffMs;
211
+ } catch {
212
+ return false;
213
+ }
214
+ });
215
+ }
216
+ for (const f of entries) {
217
+ let text: string;
218
+ try {
219
+ text = fs.readFileSync(path.join(auditDir, f), 'utf8');
220
+ } catch {
221
+ continue;
222
+ }
223
+ for (const line of text.split('\n')) {
224
+ if (line.trim().length === 0) continue;
225
+ try {
226
+ const parsed = JSON.parse(line) as { orchestration?: AuditOrchestration; ts?: unknown };
227
+ if (!parsed.orchestration || typeof parsed.orchestration !== 'object') continue;
228
+ if (cutoffMs !== null && typeof parsed.ts === 'string') {
229
+ const t = Date.parse(parsed.ts);
230
+ if (Number.isFinite(t) && t < cutoffMs) continue; // outside the window
231
+ }
232
+ out.push(parsed.orchestration);
233
+ } catch {
234
+ continue;
235
+ }
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+
241
+ export function buildReport(opts: {
242
+ root: string;
243
+ auditDir: string;
244
+ maxAgeDays: number;
245
+ now?: Date;
246
+ }): Report {
247
+ const registrationRaw = fs.readFileSync(REGISTRATION_FILE, 'utf8');
248
+ const registration = JSON.parse(registrationRaw) as {
249
+ registered_at: string;
250
+ review_by: string;
251
+ metrics: {
252
+ dispatch_floor: { thresholds: { projection_mandatory_ratio: number; success_ratio_after_projection: number } };
253
+ rules_efficiency: { thresholds: { low_quota_signal: number } };
254
+ ask_economy: { thresholds: { escalation_kill: number } };
255
+ return_channel: { thresholds: { envelope_cap_chars: number } };
256
+ };
257
+ };
258
+
259
+ const scanOpts: Parameters<typeof scanTranscripts>[0] = {
260
+ root: opts.root,
261
+ maxAgeDays: opts.maxAgeDays,
262
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
263
+ };
264
+ const scan = scanTranscripts(scanOpts);
265
+ const legs = computeLegs(scan.records);
266
+ const finiteRatios = legs.map((l) => l.ratio).filter((r): r is number => r !== null);
267
+ const singleCall = legs.filter((l) => l.ratio === null).length;
268
+
269
+ const orchestrations = readAuditOrchestrations(opts.auditDir, {
270
+ maxAgeDays: opts.maxAgeDays,
271
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
272
+ });
273
+ const reviewerMeasured: { init: number; work: number }[] = [];
274
+ const quotas: number[] = [];
275
+ let askLines = 0;
276
+ let askAdopted = 0;
277
+ let askEscalated = 0;
278
+ const returnChars: number[] = [];
279
+ for (const o of orchestrations) {
280
+ if (o.route_taken === 'ask') {
281
+ askLines += 1;
282
+ if (o.outcome === 'DONE') askAdopted += 1;
283
+ }
284
+ // Ask->spawn escalation is recorded on the CALLER's follow-up SPAWN
285
+ // line (route_taken=subagent, escalated=true, origin-tagged) — the
286
+ // ask line itself cannot know it was insufficient. Counting
287
+ // escalated on ask lines would make the registered kill criterion
288
+ // unmeasurable by construction (review finding, 2026-08-10).
289
+ if (o.route_taken === 'subagent' && o.escalated === true && o.origin === 'dispatch-economy-2026') {
290
+ askEscalated += 1;
291
+ }
292
+ if (isFiniteNonNegInt(o.return_channel_chars)) {
293
+ returnChars.push(o.return_channel_chars);
294
+ }
295
+ if (isFiniteNonNegInt(o.rules_carried) && isFiniteNonNegInt(o.rules_used) && o.rules_carried > 0) {
296
+ quotas.push(o.rules_used / o.rules_carried);
297
+ }
298
+ const isReviewer = Array.isArray(o.agent_combo) && o.agent_combo.some((t) => typeof t === 'string' && REVIEWER_TYPE_RE.test(t));
299
+ if (isReviewer && o.floor_provenance === 'measured' && isFiniteNonNegInt(o.init_tokens) && isFiniteNonNegInt(o.work_tokens)) {
300
+ reviewerMeasured.push({ init: o.init_tokens, work: o.work_tokens });
301
+ }
302
+ }
303
+
304
+ const workerFloor: RoleFloor = {
305
+ role: 'worker',
306
+ attribution: 'transcript-leg (role axis not shipped — all legs report as worker)',
307
+ legs: legs.length,
308
+ single_call_legs: singleCall,
309
+ median_init_tokens: median(legs.map((l) => l.init_tokens)),
310
+ median_ratio_finite: median(finiteRatios),
311
+ };
312
+ // Audit lines carry raw token counts only (no cache split), so the
313
+ // reviewer ratio is raw init/work — flagged via the attribution string.
314
+ const reviewerRatios = reviewerMeasured.filter((p) => p.work > 0).map((p) => p.init / p.work);
315
+ const reviewerFloor: RoleFloor = {
316
+ role: 'reviewer',
317
+ attribution: 'audit-line agent_combo',
318
+ legs: reviewerMeasured.length,
319
+ single_call_legs: reviewerMeasured.filter((p) => p.work === 0).length,
320
+ median_init_tokens: median(reviewerMeasured.map((p) => p.init)),
321
+ median_ratio_finite: median(reviewerRatios),
322
+ };
323
+
324
+ const t = registration.metrics.dispatch_floor.thresholds;
325
+ const lowQuota = registration.metrics.rules_efficiency.thresholds.low_quota_signal;
326
+ const medianQuota = median(quotas);
327
+ const medianWorkerRatio = workerFloor.median_ratio_finite;
328
+
329
+ const notes: string[] = [
330
+ `dedup: ${scan.totalSeen} raw assistant records → ${scan.dedupedCount} (replay inflation removed)`,
331
+ 'single_call_legs are floor-only legs (all init, zero work) — they are excluded from the finite-ratio median and support, never dilute, the projection-mandatory reading',
332
+ 'projection_mandatory_signal is a single-run reading; the registered verdict is a sustained-window call at review date (dispatch-economy-metrics.json)',
333
+ ];
334
+
335
+ return {
336
+ schema: 'dispatch-economy-report/v1',
337
+ registration: { registered_at: registration.registered_at, review_by: registration.review_by },
338
+ window: { max_age_days: opts.maxAgeDays, transcript_records: scan.dedupedCount, legs: legs.length },
339
+ dispatch_floor: {
340
+ roles: [workerFloor, reviewerFloor],
341
+ thresholds: t,
342
+ projection_mandatory_signal: medianWorkerRatio === null ? null : medianWorkerRatio > t.projection_mandatory_ratio,
343
+ },
344
+ rules_efficiency: {
345
+ envelopes_with_pair: quotas.length,
346
+ median_quota: medianQuota,
347
+ threshold_low_quota: lowQuota,
348
+ low_quota_signal: medianQuota === null ? null : medianQuota < lowQuota,
349
+ },
350
+ ask_economy: {
351
+ ask_lines: askLines,
352
+ adopted: askAdopted,
353
+ escalated: askEscalated,
354
+ threshold_escalation_kill: registration.metrics.ask_economy.thresholds.escalation_kill,
355
+ escalation_kill_signal:
356
+ askLines === 0
357
+ ? null
358
+ : askEscalated / askLines > registration.metrics.ask_economy.thresholds.escalation_kill,
359
+ },
360
+ return_channel: {
361
+ lines_with_field: returnChars.length,
362
+ median_chars: median(returnChars),
363
+ envelope_cap_chars: registration.metrics.return_channel.thresholds.envelope_cap_chars,
364
+ cap_breach_signal:
365
+ returnChars.length === 0
366
+ ? null
367
+ : (median(returnChars) ?? 0) > registration.metrics.return_channel.thresholds.envelope_cap_chars,
368
+ },
369
+ notes,
370
+ };
371
+ }
372
+
373
+ function fmtTokens(n: number | null): string {
374
+ if (n === null) return '—';
375
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n));
376
+ }
377
+
378
+ function renderText(r: Report): string {
379
+ const lines: string[] = [];
380
+ lines.push('dispatch-economy report (registered: ' + r.registration.registered_at + ' · review by: ' + r.registration.review_by + ')');
381
+ lines.push(`window: ${r.window.max_age_days}d · ${r.window.transcript_records} deduped records · ${r.window.legs} legs`);
382
+ lines.push('');
383
+ lines.push('dispatch_floor:');
384
+ for (const role of r.dispatch_floor.roles) {
385
+ lines.push(
386
+ ` ${role.role.padEnd(8)} legs=${role.legs} (single-call: ${role.single_call_legs}) · median init=${fmtTokens(role.median_init_tokens)} · median init/work=${role.median_ratio_finite === null ? '—' : role.median_ratio_finite.toFixed(2)}`,
387
+ );
388
+ lines.push(` attribution: ${role.attribution}`);
389
+ }
390
+ lines.push(
391
+ ` projection-mandatory signal (ratio > ${r.dispatch_floor.thresholds.projection_mandatory_ratio}): ${r.dispatch_floor.projection_mandatory_signal === null ? 'no data' : String(r.dispatch_floor.projection_mandatory_signal)}`,
392
+ );
393
+ lines.push('');
394
+ lines.push('rules_efficiency:');
395
+ lines.push(
396
+ ` envelopes with pair=${r.rules_efficiency.envelopes_with_pair} · median quota=${r.rules_efficiency.median_quota === null ? '—' : r.rules_efficiency.median_quota.toFixed(2)} · low-quota signal (< ${r.rules_efficiency.threshold_low_quota}): ${r.rules_efficiency.low_quota_signal === null ? 'no data' : String(r.rules_efficiency.low_quota_signal)}`,
397
+ );
398
+ lines.push('');
399
+ lines.push('return_channel:');
400
+ lines.push(
401
+ ` lines=${r.return_channel.lines_with_field} · median chars=${r.return_channel.median_chars === null ? '—' : Math.round(r.return_channel.median_chars)} · cap-breach signal (> ${r.return_channel.envelope_cap_chars}): ${r.return_channel.cap_breach_signal === null ? 'no data' : String(r.return_channel.cap_breach_signal)}`,
402
+ );
403
+ lines.push('');
404
+ lines.push('ask_economy:');
405
+ lines.push(
406
+ ` ask lines=${r.ask_economy.ask_lines} · adopted=${r.ask_economy.adopted} · escalated=${r.ask_economy.escalated} · escalation-kill signal (> ${r.ask_economy.threshold_escalation_kill}): ${r.ask_economy.escalation_kill_signal === null ? 'no data' : String(r.ask_economy.escalation_kill_signal)}`,
407
+ );
408
+ lines.push('');
409
+ for (const n of r.notes) lines.push(`note: ${n}`);
410
+ return lines.join('\n') + '\n';
411
+ }
412
+
413
+ export function main(argv: readonly string[]): number {
414
+ let format: 'text' | 'json' = 'text';
415
+ let root = DEFAULT_PROJECTS_ROOT;
416
+ let auditDir = path.join(process.cwd(), DEFAULT_AUDIT_DIR);
417
+ let maxAgeDays = 14;
418
+ for (let i = 0; i < argv.length; i++) {
419
+ const a = argv[i];
420
+ if (a === '--format') format = argv[++i] === 'json' ? 'json' : 'text';
421
+ else if (a === '--root') root = argv[++i] ?? root;
422
+ else if (a === '--audit-dir') auditDir = argv[++i] ?? auditDir;
423
+ else if (a === '--max-age-days') maxAgeDays = Number(argv[++i] ?? maxAgeDays) || maxAgeDays;
424
+ }
425
+ let report: Report;
426
+ try {
427
+ report = buildReport({ root, auditDir, maxAgeDays });
428
+ } catch (err) {
429
+ process.stderr.write(`dispatch_economy_report: ${err instanceof Error ? err.message : String(err)}\n`);
430
+ return 2;
431
+ }
432
+ process.stdout.write(format === 'json' ? JSON.stringify(report, null, 2) + '\n' : renderText(report));
433
+ return 0;
434
+ }
435
+
436
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
437
+ function _isCliEntry(): boolean {
438
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) return false;
439
+ if (process.argv[1] === undefined) return false;
440
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
441
+ if (import.meta.url === argvUrl) return true;
442
+ try {
443
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
448
+ if (_isCliEntry()) process.exit(main(process.argv.slice(2)));