@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
@@ -21,12 +21,38 @@ export interface Finding {
21
21
  mutating?: boolean;
22
22
  }
23
23
 
24
+ // ── Committed envelope size caps (road-to-token-economy-dispatch Phase 6.1) ──
25
+ // The envelope is the ONLY return channel: a worker's full result lands on
26
+ // disk (runtime artifact dir, gitignored) and the envelope carries paths +
27
+ // verdict + a BOUNDED summary. Without caps, transcript-shaped output flows
28
+ // back into the orchestrator context and refunds the isolation win — the
29
+ // measured spawn floor is ~251k tokens; a capped envelope is ≤ ~3k. Caps are
30
+ // validator ERRORS, never silent truncation (never-silent discipline): an
31
+ // oversized envelope is rejected loudly and the author shortens it.
32
+ /** Max chars for the summary — bounded prose, not a transcript. */
33
+ export const MAX_SUMMARY_CHARS = 2000;
34
+ /** Max chars for one line-shaped field (handoff, a risk, a finding title). */
35
+ export const MAX_RESPONSE_LINE_CHARS = 240;
36
+ /** Max entries per array (findings, risks, artifact_paths) — mirrors the capsule. */
37
+ export const MAX_RESPONSE_ENTRIES = 40;
38
+ /** Max chars for one artifact path ref. Mirrors the capsule's ref cap. */
39
+ export const MAX_ARTIFACT_REF_CHARS = 200;
40
+ /** Committed max for the WHOLE serialized envelope (~3k tokens ceiling). */
41
+ export const MAX_ENVELOPE_CHARS = 12000;
42
+
24
43
  export interface SubagentResponse {
25
44
  summary: string;
26
45
  findings: Finding[];
27
46
  risks: string[];
28
47
  confidence: Confidence;
29
48
  handoff: string;
49
+ /**
50
+ * Where the worker's FULL results live on disk (runtime artifact dir,
51
+ * gitignored) — the orchestrator consumes from these paths on demand,
52
+ * never via wholesale transcript ingestion (Phase 6.1/6.2). Ref tokens
53
+ * only, capped like every other array.
54
+ */
55
+ artifact_paths?: string[];
30
56
  /**
31
57
  * Premises the worker acted on, `{statement, basis, epistemic_state}` —
32
58
  * the same shape and the same validator as the CHECKPOINT capsule
@@ -74,6 +100,55 @@ export function validateResponse(input: unknown): ValidationResult {
74
100
  }
75
101
  }
76
102
  }
103
+ // ── Committed size caps (Phase 6.1) — errors, never silent truncation ──
104
+ if (typeof r.summary === 'string' && r.summary.length > MAX_SUMMARY_CHARS) {
105
+ errors.push(`summary is ${r.summary.length} chars (max ${MAX_SUMMARY_CHARS}) — the envelope carries a bounded summary; the full result belongs on disk (artifact_paths)`);
106
+ }
107
+ if (typeof r.handoff === 'string' && r.handoff.length > MAX_RESPONSE_LINE_CHARS) {
108
+ errors.push(`handoff is ${r.handoff.length} chars (max ${MAX_RESPONSE_LINE_CHARS})`);
109
+ }
110
+ for (const [key, arr] of [
111
+ ['findings', r.findings],
112
+ ['risks', r.risks],
113
+ ['artifact_paths', r.artifact_paths],
114
+ ] as const) {
115
+ if (Array.isArray(arr) && arr.length > MAX_RESPONSE_ENTRIES) {
116
+ errors.push(`${key} carries ${arr.length} entries (max ${MAX_RESPONSE_ENTRIES}) — an envelope is a handoff, not a transcript`);
117
+ }
118
+ }
119
+ if (Array.isArray(r.risks)) {
120
+ for (const [i, risk] of (r.risks as unknown[]).entries()) {
121
+ if (typeof risk === 'string' && risk.length > MAX_RESPONSE_LINE_CHARS) {
122
+ errors.push(`risks[${i}] is ${risk.length} chars (max ${MAX_RESPONSE_LINE_CHARS})`);
123
+ }
124
+ }
125
+ }
126
+ if (Array.isArray(r.findings)) {
127
+ for (const [i, f] of (r.findings as Finding[]).entries()) {
128
+ if (f && typeof f.title === 'string' && f.title.length > MAX_RESPONSE_LINE_CHARS) {
129
+ errors.push(`finding[${i}] title is ${f.title.length} chars (max ${MAX_RESPONSE_LINE_CHARS})`);
130
+ }
131
+ }
132
+ }
133
+ if (r.artifact_paths !== undefined) {
134
+ if (!Array.isArray(r.artifact_paths)) {
135
+ errors.push('artifact_paths must be an array of path ref tokens');
136
+ } else {
137
+ for (const [i, p] of (r.artifact_paths as unknown[]).entries()) {
138
+ if (typeof p !== 'string' || p.includes('\n') || p.length > MAX_ARTIFACT_REF_CHARS) {
139
+ errors.push(`artifact_paths[${i}] must be a single-line path ref ≤ ${MAX_ARTIFACT_REF_CHARS} chars`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ try {
145
+ const serialized = JSON.stringify(input);
146
+ if (serialized.length > MAX_ENVELOPE_CHARS) {
147
+ errors.push(`envelope serializes to ${serialized.length} chars (max ${MAX_ENVELOPE_CHARS}) — move content to disk and reference it via artifact_paths`);
148
+ }
149
+ } catch {
150
+ errors.push('envelope is not JSON-serializable');
151
+ }
77
152
  return { valid: errors.length === 0, errors };
78
153
  }
79
154
 
@@ -45,6 +45,37 @@ import * as fs from 'node:fs';
45
45
  import * as path from 'node:path';
46
46
 
47
47
  import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
48
+ import { SESSION_ROLE_ENV } from '../_lib/session_role.js';
49
+ import { load_agent_settings } from '../_lib/agent_settings.js';
50
+
51
+ // Session-wide subagent model ceiling (token-economy-dispatch Phase 5.2):
52
+ // class-C setting, default absent, HUMAN-set only. When present, the CLI
53
+ // spawn below exports CLAUDE_CODE_SUBAGENT_MODEL so the spawned vendor
54
+ // session caps its own subagents' models. Memoised PER CWD, not per
55
+ // process (review finding 2026-08-10): a long-lived server process may
56
+ // serve several projects, and a single-slot memo would apply project A's
57
+ // spend cap to project B's spawns. A human edit still needs a process
58
+ // restart to be seen — acceptable for a per-install cap, stated here.
59
+ const _modelCeilingMemo = new Map<string, string | null>();
60
+ export function _subagentModelCeiling(cwd: string = process.cwd()): string | null {
61
+ const hit = _modelCeilingMemo.get(cwd);
62
+ if (hit !== undefined) return hit;
63
+ let ceiling: string | null;
64
+ try {
65
+ const settings = load_agent_settings({ cwd }) as Record<string, unknown>;
66
+ const sub = (settings['subagents'] ?? {}) as Record<string, unknown>;
67
+ const v = typeof sub['model_ceiling'] === 'string' ? sub['model_ceiling'].trim() : '';
68
+ ceiling = v.length > 0 ? v : null;
69
+ } catch {
70
+ ceiling = null; // unreadable settings → no ceiling (fail-open)
71
+ }
72
+ _modelCeilingMemo.set(cwd, ceiling);
73
+ return ceiling;
74
+ }
75
+ /** Test seam: reset the per-cwd ceiling memo. */
76
+ export function _resetModelCeilingMemo(): void {
77
+ _modelCeilingMemo.clear();
78
+ }
48
79
  import * as user_global_paths from '../_lib/user_global_paths.js';
49
80
  import { appendEvent } from './events_log.js';
50
81
 
@@ -1179,7 +1210,25 @@ export abstract class CliClient extends ExternalAIClient {
1179
1210
  // (loader preload, git *_COMMAND, NODE_OPTIONS, …) so an
1180
1211
  // attacker-influenced parent env cannot RCE via the spawned CLI
1181
1212
  // or a `git` it invokes internally.
1182
- env: hardenedSpawnEnv(),
1213
+ // Role marking (token-economy-dispatch Phase 2.2): a council
1214
+ // member CLI session is worker-class — it never talks to OUR
1215
+ // user, never dispatches sub-workers, never runs a council.
1216
+ // The spawned vendor CLI loads this repo's hook chains; the
1217
+ // marker lets the dispatcher run the thinner worker chain
1218
+ // (manifest `roles.worker.drop`; pre_tool_use guards are
1219
+ // structurally exempt). This is the ONE in-tree spawn point
1220
+ // that launches a separate CLI session today — Agent-tool
1221
+ // subagents share the host process env and cannot be marked
1222
+ // (probed live 2026-08-10; see _lib/session_role.ts).
1223
+ // The optional model ceiling (subagents.model_ceiling, class C)
1224
+ // rides the same spawn: a spend cap the human set per install.
1225
+ env: hardenedSpawnEnv({
1226
+ [SESSION_ROLE_ENV]: 'worker',
1227
+ ...((): Record<string, string> => {
1228
+ const ceiling = _subagentModelCeiling();
1229
+ return ceiling !== null ? { CLAUDE_CODE_SUBAGENT_MODEL: ceiling } : {};
1230
+ })(),
1231
+ }),
1183
1232
  };
1184
1233
  if (stdinPayload !== null) {
1185
1234
  spawnOpts.input = stdinPayload;
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Rung-0.5 ask transport — one prompt, one answer, no harness, no session
4
+ * (road-to-token-economy-dispatch Phase 4.1).
5
+ *
6
+ * The judgment ladder's gap this closes: rung 0 routes mechanical slices to
7
+ * deterministic scripts; rung 1 jumps straight to a full subagent spawn
8
+ * whose measured median cold start is ~251k tokens (dispatch_floor,
9
+ * 2026-08-10). A single bounded QUESTION — a classification, a small
10
+ * verification, a one-file semantic lookup — needs neither a session nor
11
+ * tools: one completion over the reconciled council transport chain
12
+ * (cli → api → ∅) answers it at roughly two orders of magnitude below the
13
+ * spawn floor.
14
+ *
15
+ * Hard caps, per the roadmap's anti-loop discipline (Risk 2 — "rung 0.5
16
+ * becomes a hidden agent loop"):
17
+ * - ONE completion. No retry beyond the transport chain's own cli→api
18
+ * fallback semantics inside the client.
19
+ * - NO tool use, NO session, NO follow-up turn.
20
+ * - Honest ∅: council disabled / no member / transport failure → null
21
+ * (CLI exit 3), never a fabricated answer and never an escalation this
22
+ * module performs itself. Escalating to a spawn is the CALLER's
23
+ * decision and is recorded as such (`escalated` on the caller's line).
24
+ *
25
+ * Telemetry: every completed ask appends ONE orchestration_record line
26
+ * (route_taken: "ask", spawn_count: 0, dispatch_tokens measured from the
27
+ * provider response) — the substitution/adoption metrics registered in
28
+ * `src/config/dispatch-economy-metrics.json` read these lines.
29
+ *
30
+ * Reuses, never re-implements: `council_cli.load_settings` +
31
+ * `build_members` (member construction, auth, transport resolution,
32
+ * cli-first billing classification — all unchanged), `CouncilResponse`
33
+ * accounting, `_lib/orchestration_record` validation.
34
+ *
35
+ * Usage:
36
+ * ./scripts-run src/scripts/ask_transport "is X true in file Y?" \
37
+ * [--system "<system prompt>"] [--member <name>] [--format text|json] \
38
+ * [--no-record] [--dir <audit-dir>]
39
+ *
40
+ * Exit codes: 0 answer produced · 2 usage error · 3 honest-∅ (no transport).
41
+ */
42
+ import * as crypto from 'node:crypto';
43
+ import * as fs from 'node:fs';
44
+ import * as path from 'node:path';
45
+ import { fileURLToPath, pathToFileURL } from 'node:url';
46
+
47
+ import type { ExternalAIClient } from './ai_council/clients.js';
48
+ import { buildOrchestrationLine } from './_lib/orchestration_record.js';
49
+ import { DEFAULT_DIR } from './orchestration_record.js';
50
+
51
+ export interface AskResult {
52
+ answer: string;
53
+ member: string;
54
+ model: string;
55
+ input_tokens: number;
56
+ output_tokens: number;
57
+ latency_ms: number;
58
+ }
59
+
60
+ export interface AskOptions {
61
+ /** Injected members (tests). Default: council_cli.load_settings + build_members. */
62
+ members?: ExternalAIClient[];
63
+ /** Pick a specific member by provider/name; default: first configured. */
64
+ member?: string;
65
+ systemPrompt?: string;
66
+ /** Telemetry append target; null disables recording (tests / --no-record). */
67
+ auditDir?: string | null;
68
+ /** Injected clock/id (tests). */
69
+ now?: () => Date;
70
+ id?: () => string;
71
+ }
72
+
73
+ const DEFAULT_SYSTEM =
74
+ 'You are a single-completion answer service. Answer the question directly, ' +
75
+ 'concisely, and honestly. If the question cannot be answered without tools, ' +
76
+ 'files, or more context, reply exactly: INSUFFICIENT_CONTEXT — one line on ' +
77
+ 'what is missing. Never invent facts.';
78
+
79
+ /** Lazily import the council CLI surface so tests with injected members never load it. */
80
+ async function defaultMembers(): Promise<ExternalAIClient[]> {
81
+ const mod = await import('./council_cli.js');
82
+ const settings = mod.load_settings();
83
+ return mod.build_members(settings);
84
+ }
85
+
86
+ function recordAskLine(auditDir: string, result: AskResult | null, member: string, ts: string, id: string): void {
87
+ const { line, errors } = buildOrchestrationLine({
88
+ spawn_count: 0,
89
+ token_delta: 0,
90
+ token_delta_provenance: 'estimated',
91
+ route_taken: 'ask',
92
+ verify_mode: 'none',
93
+ dispatch_outcome: result === null ? 'BLOCKED' : 'DONE',
94
+ dispatch_tokens: result === null ? null : result.input_tokens + result.output_tokens,
95
+ wall_clock_ms: result === null ? 0 : Math.max(0, Math.round(result.latency_ms)),
96
+ agent_combo: [member || 'none'],
97
+ origin: 'dispatch-economy-2026',
98
+ ts,
99
+ id,
100
+ });
101
+ if (errors.length || line === null) return; // telemetry must never fail the ask
102
+ try {
103
+ const file = path.join(auditDir, `${ts.slice(0, 7)}.jsonl`);
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
106
+ } catch {
107
+ /* fail-open — accounting never blocks the answer */
108
+ }
109
+ }
110
+
111
+ /**
112
+ * One bounded ask. Returns the answer or `null` (honest ∅ — council
113
+ * disabled, no member resolves, transport failure, or provider error).
114
+ */
115
+ export async function askOnce(prompt: string, opts: AskOptions = {}): Promise<AskResult | null> {
116
+ const trimmed = prompt.trim();
117
+ if (!trimmed) return null;
118
+
119
+ let members: ExternalAIClient[];
120
+ try {
121
+ members = opts.members ?? (await defaultMembers());
122
+ } catch {
123
+ return _finish(null, 'none', opts); // disabled / unreadable config → ∅
124
+ }
125
+ const memberName = (m: ExternalAIClient): string => {
126
+ const c = m as { name?: string; provider?: string };
127
+ return c.name || c.provider || 'unknown';
128
+ };
129
+ const pick =
130
+ opts.member !== undefined ? members.find((m) => memberName(m) === opts.member) : members[0];
131
+ if (pick === undefined) {
132
+ return _finish(null, opts.member ?? 'none', opts);
133
+ }
134
+ const provider = memberName(pick);
135
+
136
+ try {
137
+ const response = pick.ask(opts.systemPrompt ?? DEFAULT_SYSTEM, trimmed);
138
+ const r = response instanceof Promise ? await response : response;
139
+ if (r === null || r === undefined || (r.error !== null && r.error !== undefined && r.error !== '')) {
140
+ return _finish(null, provider, opts);
141
+ }
142
+ const text = (r.text ?? '').trim();
143
+ if (!text) return _finish(null, provider, opts);
144
+ return _finish(
145
+ {
146
+ answer: text,
147
+ member: provider,
148
+ model: r.model ?? 'unknown',
149
+ input_tokens: r.input_tokens ?? 0,
150
+ output_tokens: r.output_tokens ?? 0,
151
+ latency_ms: r.latency_ms ?? 0,
152
+ },
153
+ provider,
154
+ opts,
155
+ );
156
+ } catch {
157
+ return _finish(null, provider, opts); // ONE completion — no retry here
158
+ }
159
+ }
160
+
161
+ function _finish(result: AskResult | null, member: string, opts: AskOptions): AskResult | null {
162
+ const auditDir = opts.auditDir === undefined ? path.join(process.cwd(), DEFAULT_DIR) : opts.auditDir;
163
+ if (auditDir !== null) {
164
+ const ts = (opts.now ? opts.now() : new Date()).toISOString();
165
+ const id = opts.id ? opts.id() : crypto.randomUUID();
166
+ recordAskLine(auditDir, result, member, ts, id);
167
+ }
168
+ return result;
169
+ }
170
+
171
+ export async function main(argv: readonly string[]): Promise<number> {
172
+ let prompt = '';
173
+ let system: string | undefined;
174
+ let member: string | undefined;
175
+ let format: 'text' | 'json' = 'text';
176
+ let record = true;
177
+ let dir: string | undefined;
178
+ for (let i = 0; i < argv.length; i++) {
179
+ const a = argv[i] ?? '';
180
+ if (a === '--system') system = argv[++i];
181
+ else if (a === '--member') member = argv[++i];
182
+ else if (a === '--format') format = argv[++i] === 'json' ? 'json' : 'text';
183
+ else if (a === '--no-record') record = false;
184
+ else if (a === '--dir') dir = argv[++i];
185
+ else if (!a.startsWith('--')) prompt = prompt ? `${prompt} ${a}` : a;
186
+ }
187
+ if (!prompt.trim()) {
188
+ process.stderr.write('ask_transport: a question argument is required\n');
189
+ return 2;
190
+ }
191
+ const opts: AskOptions = {
192
+ ...(system !== undefined ? { systemPrompt: system } : {}),
193
+ ...(member !== undefined ? { member } : {}),
194
+ auditDir: record ? (dir ?? path.join(process.cwd(), DEFAULT_DIR)) : null,
195
+ };
196
+ const result = await askOnce(prompt, opts);
197
+ if (result === null) {
198
+ process.stderr.write('ask_transport: ∅ — no transport resolved or the single completion failed (no retry by design)\n');
199
+ return 3;
200
+ }
201
+ process.stdout.write(
202
+ format === 'json' ? JSON.stringify(result, null, 2) + '\n' : result.answer + '\n',
203
+ );
204
+ return 0;
205
+ }
206
+
207
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
208
+ function _isCliEntry(): boolean {
209
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) return false;
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()) {
220
+ main(process.argv.slice(2)).then((rc) => process.exit(rc));
221
+ }
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hook-injection byte bench + CI gate (road-to-token-economy-cache Phase 3.2)
4
+ * — the latency harness's twin for the INJECTION side.
5
+ *
6
+ * Runs every concern bound on the `claude` platform against the committed
7
+ * fixture envelope of each slot it is bound to (tests/fixtures/hooks/
8
+ * <event>.json), in-process via CONCERN_REGISTRY with the hook-stdin
9
+ * override, under AGENT_CONFIG_REPLAY=1 (state writes skipped). Measures the
10
+ * bytes of the stdout payload fields a concern injects — `reason` +
11
+ * `additional_context` + `context` — and gates them against
12
+ * `src/config/hook-token-budget.json`:
13
+ *
14
+ * - a concern whose emission exceeds its row (or the default cap) → RED
15
+ * - an EMITTING concern with no explicit row above the default is fine;
16
+ * the default cap IS its row — but a breach names the missing row so
17
+ * the fix is a registered decision, not a silent default bump
18
+ * - per-slot SUM over all bound concerns > the slot cap → RED
19
+ *
20
+ * HONEST SCOPE, stated up front: concerns are conditional-silence by design —
21
+ * a generic fixture triggers few of them, so most measure 0 bytes here. The
22
+ * gate therefore enforces the cap on whatever DOES fire under the committed
23
+ * fixtures (deterministic, CI-reproducible), while the census mode
24
+ * (`--record`) measures REAL sessions: it appends counts-only lines
25
+ * ({ts, slot, concern, bytes}) to agents/runtime/state/injection-census.jsonl
26
+ * — the repeat-injection-census blocker's instrument. Claiming the fixture
27
+ * numbers are the live distribution would be the coverage inflation this
28
+ * repo's gate discipline refuses; the two modes exist because neither
29
+ * substitutes for the other.
30
+ *
31
+ * Class A: in-process, no daemon, no network. Exit 0 green · 1 breach ·
32
+ * 2 unusable invocation. Dead-scope guarded: zero concerns run = RED.
33
+ *
34
+ * Usage:
35
+ * ./scripts-run src/scripts/bench_hook_injection [--format text|json]
36
+ * [--record] [--platform claude]
37
+ */
38
+ import * as fs from 'node:fs';
39
+ import * as path from 'node:path';
40
+ import { fileURLToPath, pathToFileURL } from 'node:url';
41
+
42
+ import { CONCERN_REGISTRY } from './hooks/concern_registry.js';
43
+ import { setHookStdinOverride, clearHookStdinOverride } from './hooks/hook_stdin.js';
44
+ import { _load_yaml, _resolve_concerns, EVENT_VOCABULARY, type JsonObject } from './hooks/dispatch_hook.js';
45
+
46
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
47
+ const _IN_BUNDLE = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
48
+ const REPO_ROOT = path.resolve(
49
+ path.dirname(fileURLToPath(import.meta.url)),
50
+ ...(_IN_BUNDLE ? ['..'] : ['..', '..']),
51
+ );
52
+ const MANIFEST = path.join(REPO_ROOT, 'src', 'scripts', 'hook_manifest.yaml');
53
+ const BUDGET = path.join(REPO_ROOT, 'src', 'config', 'hook-token-budget.json');
54
+ const FIXTURES = path.join(REPO_ROOT, 'tests', 'fixtures', 'hooks');
55
+ const CENSUS_FILE = path.join('agents', 'runtime', 'state', 'injection-census.jsonl');
56
+
57
+ export interface Measurement {
58
+ slot: string;
59
+ concern: string;
60
+ bytes: number;
61
+ cap: number;
62
+ breach: boolean;
63
+ }
64
+
65
+ interface Budget {
66
+ default_cap_bytes: number;
67
+ per_concern_caps_bytes: Record<string, number | string>;
68
+ per_slot_sum_caps_bytes: Record<string, number | string>;
69
+ }
70
+
71
+ function capFor(budget: Budget, concern: string): number {
72
+ const v = budget.per_concern_caps_bytes[concern];
73
+ return typeof v === 'number' ? v : budget.default_cap_bytes;
74
+ }
75
+
76
+ /** Run one registered concern in-process with the fixture envelope; return payload bytes. */
77
+ export function runConcernOnce(
78
+ script: string,
79
+ args: readonly string[],
80
+ envelope: JsonObject,
81
+ ): number {
82
+ const main = CONCERN_REGISTRY[script];
83
+ if (main === undefined) return -1; // not in registry — parity tests own that failure
84
+ let out = '';
85
+ const prevWrite = process.stdout.write;
86
+ const prevErr = process.stderr.write;
87
+ const prevArgv = process.argv;
88
+ const prevReplay = process.env['AGENT_CONFIG_REPLAY'];
89
+ setHookStdinOverride(JSON.stringify(envelope));
90
+ process.env['AGENT_CONFIG_REPLAY'] = '1';
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ process.stdout.write = ((chunk: any, enc?: any, cb?: any): boolean => {
93
+ out += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
94
+ const callback = typeof enc === 'function' ? enc : cb;
95
+ if (typeof callback === 'function') callback();
96
+ return true;
97
+ }) as typeof process.stdout.write;
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ process.stderr.write = ((chunk: any, enc?: any, cb?: any): boolean => {
100
+ const callback = typeof enc === 'function' ? enc : cb;
101
+ if (typeof callback === 'function') callback();
102
+ return true;
103
+ }) as typeof process.stderr.write;
104
+ try {
105
+ process.argv = [prevArgv[0] as string, script, ...args, '--platform', 'claude'];
106
+ main([...args, '--platform', 'claude']);
107
+ } catch {
108
+ /* a crashing concern injects nothing — latency/parity suites own crashes */
109
+ } finally {
110
+ process.stdout.write = prevWrite;
111
+ process.stderr.write = prevErr;
112
+ process.argv = prevArgv;
113
+ if (prevReplay === undefined) delete process.env['AGENT_CONFIG_REPLAY'];
114
+ else process.env['AGENT_CONFIG_REPLAY'] = prevReplay;
115
+ clearHookStdinOverride();
116
+ }
117
+ const text = out.trim();
118
+ if (!text) return 0;
119
+ try {
120
+ const parsed = JSON.parse(text) as Record<string, unknown>;
121
+ let bytes = 0;
122
+ for (const key of ['reason', 'additional_context', 'context']) {
123
+ const v = parsed[key];
124
+ if (typeof v === 'string') bytes += Buffer.byteLength(v, 'utf-8');
125
+ }
126
+ return bytes;
127
+ } catch {
128
+ return Buffer.byteLength(text, 'utf-8'); // non-JSON stdout counts whole
129
+ }
130
+ }
131
+
132
+ export function bench(opts: { platform?: string; budgetPath?: string } = {}): { measurements: Measurement[]; slotSums: Record<string, { bytes: number; cap: number; breach: boolean }>; errors: string[] } {
133
+ const platform = opts.platform ?? 'claude';
134
+ const manifest = _load_yaml(MANIFEST);
135
+ const budget = JSON.parse(fs.readFileSync(opts.budgetPath ?? BUDGET, 'utf8')) as Budget;
136
+ const measurements: Measurement[] = [];
137
+ const slotSums: Record<string, { bytes: number; cap: number; breach: boolean }> = {};
138
+ const errors: string[] = [];
139
+
140
+ for (const event of EVENT_VOCABULARY) {
141
+ const fixtureFile = path.join(FIXTURES, `${event}.json`);
142
+ if (!fs.existsSync(fixtureFile)) continue;
143
+ const envelope = JSON.parse(fs.readFileSync(fixtureFile, 'utf8')) as JsonObject;
144
+ const concerns = _resolve_concerns(manifest, platform, event);
145
+ let sum = 0;
146
+ for (const concern of concerns) {
147
+ const bytes = runConcernOnce(String(concern['script']), Array.isArray(concern['args']) ? concern['args'].map(String) : [], envelope);
148
+ if (bytes < 0) {
149
+ errors.push(`${event}/${String(concern['name'])}: not in CONCERN_REGISTRY`);
150
+ continue;
151
+ }
152
+ const cap = capFor(budget, String(concern['name']));
153
+ const breach = bytes > cap;
154
+ measurements.push({ slot: event, concern: String(concern['name']), bytes, cap, breach });
155
+ sum += bytes;
156
+ }
157
+ const slotCapRaw = budget.per_slot_sum_caps_bytes[event];
158
+ const slotCap = typeof slotCapRaw === 'number' ? slotCapRaw : Number.MAX_SAFE_INTEGER;
159
+ slotSums[event] = { bytes: sum, cap: slotCap, breach: sum > slotCap };
160
+ }
161
+ return { measurements, slotSums, errors };
162
+ }
163
+
164
+ export function main(argv: readonly string[]): number {
165
+ let format: 'text' | 'json' = 'text';
166
+ let record = false;
167
+ let platform = 'claude';
168
+ for (let i = 0; i < argv.length; i++) {
169
+ const a = argv[i];
170
+ if (a === '--format') format = argv[++i] === 'json' ? 'json' : 'text';
171
+ else if (a === '--record') record = true;
172
+ else if (a === '--platform') platform = argv[++i] ?? platform;
173
+ }
174
+ let result: ReturnType<typeof bench>;
175
+ try {
176
+ result = bench({ platform });
177
+ } catch (err) {
178
+ process.stderr.write(`bench_hook_injection: ${err instanceof Error ? err.message : String(err)}\n`);
179
+ return 2;
180
+ }
181
+ // Dead-scope guard: a bench that ran zero concerns proves nothing (a
182
+ // gate that scans nothing exits green is the recorded failure class).
183
+ if (result.measurements.length === 0) {
184
+ process.stderr.write('bench_hook_injection: DEAD SCOPE — zero concerns ran; fixtures or manifest unreadable\n');
185
+ return 1;
186
+ }
187
+ if (record) {
188
+ try {
189
+ const file = path.join(process.cwd(), CENSUS_FILE);
190
+ fs.mkdirSync(path.dirname(file), { recursive: true });
191
+ const ts = new Date().toISOString();
192
+ for (const m of result.measurements) {
193
+ if (m.bytes > 0) {
194
+ fs.appendFileSync(file, JSON.stringify({ ts, slot: m.slot, concern: m.concern, bytes: m.bytes }) + '\n');
195
+ }
196
+ }
197
+ } catch {
198
+ /* census is observability — never fails the bench */
199
+ }
200
+ }
201
+ const breaches = result.measurements.filter((m) => m.breach);
202
+ const slotBreaches = Object.entries(result.slotSums).filter(([, s]) => s.breach);
203
+ if (format === 'json') {
204
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
205
+ } else {
206
+ const emitting = result.measurements.filter((m) => m.bytes > 0);
207
+ process.stdout.write(`bench_hook_injection: ${result.measurements.length} concern-slot pairs · ${emitting.length} emitted under the committed fixtures\n`);
208
+ for (const m of emitting) {
209
+ process.stdout.write(` ${m.slot.padEnd(20)} ${m.concern.padEnd(24)} ${String(m.bytes).padStart(6)} B (cap ${m.cap})${m.breach ? ' ❌ BREACH' : ''}\n`);
210
+ }
211
+ for (const [slot, s] of Object.entries(result.slotSums)) {
212
+ if (s.bytes > 0) process.stdout.write(` slot-sum ${slot.padEnd(20)} ${String(s.bytes).padStart(6)} B (cap ${s.cap === Number.MAX_SAFE_INTEGER ? '—' : s.cap})${s.breach ? ' ❌ BREACH' : ''}\n`);
213
+ }
214
+ for (const e of result.errors) process.stdout.write(` warn: ${e}\n`);
215
+ }
216
+ if (breaches.length > 0 || slotBreaches.length > 0) {
217
+ process.stderr.write(`bench_hook_injection: ${breaches.length} concern breach(es), ${slotBreaches.length} slot-sum breach(es) — the budget row is the decision surface (src/config/hook-token-budget.json)\n`);
218
+ return 1;
219
+ }
220
+ return 0;
221
+ }
222
+
223
+ function _isCliEntry(): boolean {
224
+ if (_IN_BUNDLE) return false;
225
+ if (process.argv[1] === undefined) return false;
226
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
227
+ if (import.meta.url === argvUrl) return true;
228
+ try {
229
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+ if (_isCliEntry()) process.exit(main(process.argv.slice(2)));