@event4u/agent-config 9.28.0 → 9.30.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 (209) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +6 -6
  3. package/CHANGELOG.md +172 -5
  4. package/README.md +2 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  7. package/dist/agent-src/commands/cost/report.md +2 -2
  8. package/dist/agent-src/commands/explain-run.md +31 -1
  9. package/dist/agent-src/commands/team/adversarial.md +8 -7
  10. package/dist/agent-src/commands/team/delegate.md +14 -12
  11. package/dist/agent-src/commands/team/review.md +9 -8
  12. package/dist/agent-src/commands/team/status.md +9 -8
  13. package/dist/agent-src/commands/team.md +29 -19
  14. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  15. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  16. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  17. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +22 -1
  18. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  19. package/dist/agent-src/contexts/execution/subagent-response-contract.md +65 -1
  20. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  21. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  22. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  23. package/dist/agent-src/rules/delegation-policy.md +11 -7
  24. package/dist/agent-src/rules/preservation-guard.md +9 -0
  25. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  26. package/dist/agent-src/rules/token-budget-discipline.md +1 -1
  27. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  28. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  29. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  30. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  31. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  32. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  33. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +17 -5
  34. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  35. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  36. package/dist/agent-src/templates/AGENTS.md +4 -4
  37. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  38. package/dist/agent-src/templates/roadmaps.md +23 -0
  39. package/dist/cli/registry.js +2 -0
  40. package/dist/cli/registry.js.map +1 -1
  41. package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
  42. package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-BTJNRUX3.js} +22 -35
  43. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-DDUNNFYX.js} +24 -17
  44. package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-EQY4FLJB.js} +3 -3
  45. package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-IYTGIG2Y.js} +155 -91
  46. package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
  47. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  48. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  49. package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
  50. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  51. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  52. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  53. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  54. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  55. package/dist/cli-delegate/cmd_conformance.js +2803 -137
  56. package/dist/cli-delegate/cmd_doctor.js +10 -5
  57. package/dist/cli-delegate/cmd_explain.js +2 -2
  58. package/dist/cli-delegate/cmd_export.js +4 -4
  59. package/dist/cli-delegate/cmd_handoff.js +88 -8
  60. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  61. package/dist/cli-delegate/cmd_migrate.js +2 -2
  62. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  63. package/dist/cli-delegate/cmd_prune.js +4 -4
  64. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  65. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  66. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  67. package/dist/cli-delegate/cmd_settings_get.js +2 -8
  68. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  69. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  70. package/dist/cli-delegate/cmd_sync.js +4 -4
  71. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  72. package/dist/cli-delegate/cmd_update.js +1 -1
  73. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  74. package/dist/cli-delegate/cmd_validate.js +4 -4
  75. package/dist/cli-delegate/cmd_versions.js +1 -1
  76. package/dist/discovery/deprecation-report.md +1 -1
  77. package/dist/discovery/discovery-manifest.json +72 -23
  78. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  79. package/dist/discovery/discovery-manifest.summary.md +3 -2
  80. package/dist/discovery/orphan-report.md +1 -1
  81. package/dist/discovery/packs.json +39 -2
  82. package/dist/discovery/trust-report.md +2 -2
  83. package/dist/discovery/workspaces.json +12 -4
  84. package/dist/hooks/dispatch.js +2278 -611
  85. package/dist/install/install.mjs +56 -17
  86. package/dist/mcp/registry-manifest.json +3 -3
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +4122 -699
  89. package/dist/router.json +1 -1
  90. package/dist/scripts/_lib/agent_settings.js +54 -0
  91. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  92. package/dist/scripts/condense.js +147 -2
  93. package/dist/scripts/condense.js.map +1 -1
  94. package/dist/server/schemas/settings.js +12 -7
  95. package/dist/server/schemas/settings.js.map +1 -1
  96. package/dist/shared/settingsCarveOut.js +0 -9
  97. package/dist/shared/settingsCarveOut.js.map +1 -1
  98. package/dist/shared/settingsClasses.js +11 -9
  99. package/dist/shared/settingsClasses.js.map +1 -1
  100. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  101. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  102. package/dist/ui/index.html +1 -1
  103. package/docs/contracts/settings-classes.md +58 -12
  104. package/package.json +1 -1
  105. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  106. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  107. package/src/agent-src/templates/AGENTS.md +4 -4
  108. package/src/config/agent-settings.template.yml +88 -66
  109. package/src/config/discovery/packs.yml +11 -0
  110. package/src/config/discovery/workspaces.yml +1 -1
  111. package/src/config/dispatch-economy-metrics.json +61 -0
  112. package/src/config/evaluator-budgets.json +4 -4
  113. package/src/config/gate-violation-baselines.json +3 -3
  114. package/src/config/hook-token-budget.json +91 -0
  115. package/src/config/recycle-threshold-budget.json +12 -0
  116. package/src/config/rule-enforcement-baseline.json +86 -0
  117. package/src/scripts/_cli/cmd_conformance.ts +31 -44
  118. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  119. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  120. package/src/scripts/_dispatch.bash +23 -0
  121. package/src/scripts/_lib/agent_settings.ts +58 -0
  122. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  123. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  124. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  125. package/src/scripts/_lib/host_capability.ts +63 -6
  126. package/src/scripts/_lib/judgment_ladder.ts +634 -0
  127. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  128. package/src/scripts/_lib/orchestration_record.ts +78 -10
  129. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  130. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  131. package/src/scripts/_lib/self_repair.ts +354 -13
  132. package/src/scripts/_lib/self_repair_store.ts +28 -0
  133. package/src/scripts/_lib/session_eol.ts +230 -0
  134. package/src/scripts/_lib/session_role.ts +42 -0
  135. package/src/scripts/_lib/subagent_capsule.ts +341 -0
  136. package/src/scripts/_lib/subagent_response.ts +94 -0
  137. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  138. package/src/scripts/_lib/subagent_steering.ts +9 -4
  139. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  140. package/src/scripts/_lib/worker_budget.ts +37 -2
  141. package/src/scripts/ai_council/clients.ts +126 -55
  142. package/src/scripts/ai_council/config.ts +94 -6
  143. package/src/scripts/ai_council/handoff.ts +113 -0
  144. package/src/scripts/ai_council/modes.ts +19 -4
  145. package/src/scripts/ai_council/orchestrator.ts +105 -0
  146. package/src/scripts/ai_council/quorum.ts +84 -0
  147. package/src/scripts/ai_council/session.ts +44 -0
  148. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  149. package/src/scripts/ai_team/availability.ts +110 -0
  150. package/src/scripts/ai_team/config.ts +34 -14
  151. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  152. package/src/scripts/ask_transport.ts +221 -0
  153. package/src/scripts/bench_critic_protocol.ts +396 -0
  154. package/src/scripts/bench_hook_injection.ts +234 -0
  155. package/src/scripts/check_adr_frontmatter.ts +84 -40
  156. package/src/scripts/check_branch_freshness.ts +1 -0
  157. package/src/scripts/check_corpus_staleness.ts +18 -4
  158. package/src/scripts/check_detector_corpus.ts +219 -0
  159. package/src/scripts/check_gitignore_freshness.ts +42 -8
  160. package/src/scripts/check_hook_bundle_freshness.ts +24 -1
  161. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  162. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  163. package/src/scripts/check_static_layer_stability.ts +154 -0
  164. package/src/scripts/check_suppression_hygiene.ts +48 -0
  165. package/src/scripts/condense.ts +162 -2
  166. package/src/scripts/council_cli.ts +401 -25
  167. package/src/scripts/dispatch_economy_report.ts +448 -0
  168. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  169. package/src/scripts/explain_run.ts +206 -4
  170. package/src/scripts/forensics_report.ts +333 -0
  171. package/src/scripts/generate_settings_reference.ts +1 -0
  172. package/src/scripts/handoff_context_hook.ts +112 -4
  173. package/src/scripts/hook_manifest.yaml +117 -10
  174. package/src/scripts/hooks/concern_registry.ts +8 -0
  175. package/src/scripts/hooks/delegation_nudge_hook.ts +106 -35
  176. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  177. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  178. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  179. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  180. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  181. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  182. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  183. package/src/scripts/install-hooks.sh +1 -1
  184. package/src/scripts/language_mirror_hook.ts +89 -0
  185. package/src/scripts/lint_decision_impact.ts +14 -1
  186. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  187. package/src/scripts/lint_hook_manifest.ts +82 -0
  188. package/src/scripts/lint_never_silent.ts +276 -0
  189. package/src/scripts/lint_no_activation_gates.ts +271 -0
  190. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  191. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  192. package/src/scripts/measure_turn_end_gate.ts +194 -0
  193. package/src/scripts/orchestration_record.ts +12 -1
  194. package/src/scripts/rebuild_hook_bundle.ts +178 -0
  195. package/src/scripts/release.ts +74 -2
  196. package/src/scripts/report_conformance_funnel.ts +284 -0
  197. package/src/scripts/report_imperative_density.ts +1 -0
  198. package/src/scripts/routing_doctor.ts +118 -36
  199. package/src/scripts/score_skill_selection.ts +19 -5
  200. package/src/scripts/self_repair_cli.ts +337 -59
  201. package/src/scripts/session_eol_report.ts +261 -0
  202. package/src/scripts/team_events_spike.ts +271 -0
  203. package/src/scripts/team_review_gate_hook.ts +31 -9
  204. package/src/server/schemas/settings.ts +24 -17
  205. package/src/shared/settingsCarveOut.ts +0 -10
  206. package/src/shared/settingsClasses.ts +11 -9
  207. package/src/templates/compact-instructions.md +40 -0
  208. package/dist/ui/assets/index-DmR0mbk4.js +0 -26
  209. package/dist/ui/assets/index-DmR0mbk4.js.map +0 -1
@@ -26,6 +26,11 @@
26
26
  * - already `rtk …` wrapped, or piped / compound (`|`, `&&`, `||`, `;`)
27
27
  * - any program not in the verbose-CLI allowlist
28
28
  *
29
+ * Second branch (token-economy-cache Phase 4.2, advisory-degraded): for
30
+ * commands rtk cannot wrap — an unbounded tree-wide search per the committed
31
+ * OUTPUT_CAP_TABLE below — emit a warn naming the bounded alternative. No
32
+ * rewrite, no truncation; see the table's comment for the degradation record.
33
+ *
29
34
  * Exit codes (dispatcher contract): 0 allow · 2 warn (+ JSON reason on stdout).
30
35
  */
31
36
  import fs from "node:fs";
@@ -175,6 +180,108 @@ export function classify(command: string): Eligibility {
175
180
  return { eligible: true, program };
176
181
  }
177
182
 
183
+ // ── Unbounded-output cap ADVISORY (token-economy-cache Phase 4.2) ──────────
184
+ // The roadmap step asked for a deterministic PreToolUse cap REWRITE; the v1
185
+ // dispatcher contract carries no `updatedInput`, so per the roadmap's
186
+ // pre-registered consequence this degrades to an ADVISORY: a committed
187
+ // per-command cap table (this constant — rows individually removable,
188
+ // per-row opt-out via `enabled`) and a warn naming the bounded alternative.
189
+ // Nothing is ever truncated; ignoring the advisory IS the uncapped re-run,
190
+ // and repeated fires in the dispatcher's warn counters are the demand
191
+ // signal that calibrates the table. Table scope is evidence-bound: only the
192
+ // class the 2026-08-10 corpus run measured (tree-wide search — 302 KB raw
193
+ // vs 21 KB bounded on this repo; internal/bench/rtk-savings/RESULTS.md).
194
+ export interface CapRow {
195
+ enabled: boolean;
196
+ /** long flags whose presence means the output is already bounded */
197
+ bounded_long_flags: readonly string[];
198
+ /** short-option letters (combined forms like `-rln` count) that bound output */
199
+ bounded_short_letters: string;
200
+ /** fire only when a recursive/tree-wide flag is present ("" = always tree-wide) */
201
+ recursive_short_letters: string;
202
+ recursive_long_flags: readonly string[];
203
+ hint: string;
204
+ }
205
+
206
+ export const OUTPUT_CAP_TABLE: Readonly<Record<string, CapRow>> = {
207
+ grep: {
208
+ enabled: true,
209
+ bounded_long_flags: ["--files-with-matches", "--count", "--max-count"],
210
+ bounded_short_letters: "lcm",
211
+ recursive_short_letters: "rR",
212
+ recursive_long_flags: ["--recursive", "--dereference-recursive"],
213
+ hint: "add `-l` (files only) or `-m 5` (per-file cap), or pipe through `| head -n 100`",
214
+ },
215
+ rg: {
216
+ enabled: true,
217
+ bounded_long_flags: ["--files-with-matches", "--count", "--max-count", "--count-matches"],
218
+ bounded_short_letters: "lcm",
219
+ recursive_short_letters: "",
220
+ recursive_long_flags: [],
221
+ hint: "add `-l` (files only) or `--max-count 5`, or pipe through `| head -n 100`",
222
+ },
223
+ };
224
+
225
+ /** Pipeline members that already bound or consume the stream. */
226
+ const _BOUNDING_PROGRAMS: ReadonlySet<string> = new Set(["head", "tail", "wc", "rtk"]);
227
+
228
+ function _shortLetters(token: string): string | null {
229
+ return /^-[A-Za-z]+$/.test(token) ? token.slice(1) : null;
230
+ }
231
+
232
+ function _hasAnyFlag(group: readonly string[], longFlags: readonly string[], shortLetters: string): boolean {
233
+ for (const t of group) {
234
+ if (longFlags.some((f) => t === f || t.startsWith(`${f}=`))) return true;
235
+ const letters = _shortLetters(t);
236
+ if (letters && shortLetters && [...shortLetters].some((c) => letters.includes(c))) return true;
237
+ }
238
+ return false;
239
+ }
240
+
241
+ export interface CapVerdict {
242
+ program: string;
243
+ hint: string;
244
+ }
245
+
246
+ /**
247
+ * An unbounded tree-wide search in the command (any pipeline segment) with no
248
+ * bounding flag and no bounding pipeline member → advisory verdict. Fail-open:
249
+ * parse errors and anything ambiguous return null.
250
+ */
251
+ export function classifyCap(
252
+ command: string,
253
+ table: Readonly<Record<string, CapRow>> = OUTPUT_CAP_TABLE,
254
+ ): CapVerdict | null {
255
+ const cmd = command.trim();
256
+ if (!cmd) return null;
257
+ let tokens: string[];
258
+ try {
259
+ tokens = shlexSplit(cmd);
260
+ } catch (e) {
261
+ if (e instanceof ShlexError) return null;
262
+ throw e;
263
+ }
264
+ const groups = _split_subcommands(tokens);
265
+ let hit: CapVerdict | null = null;
266
+ for (const group of groups) {
267
+ let i = 0;
268
+ while (i < group.length && _is_env_assignment(group[i] as string)) i += 1;
269
+ const program = group[i];
270
+ if (!program) continue;
271
+ // A bounding consumer anywhere in the chain (head/tail/wc/rtk) → bounded.
272
+ if (_BOUNDING_PROGRAMS.has(program)) return null;
273
+ const row = table[program];
274
+ if (!row || !row.enabled) continue;
275
+ const needsRecursive = row.recursive_short_letters !== "" || row.recursive_long_flags.length > 0;
276
+ if (needsRecursive && !_hasAnyFlag(group, row.recursive_long_flags, row.recursive_short_letters)) {
277
+ continue; // single-file / non-tree invocation — not the measured class
278
+ }
279
+ if (_hasAnyFlag(group, row.bounded_long_flags, row.bounded_short_letters)) continue;
280
+ if (hit === null) hit = { program, hint: row.hint };
281
+ }
282
+ return hit;
283
+ }
284
+
178
285
  function _readStdin(): string {
179
286
  return readHookStdin();
180
287
  }
@@ -203,6 +310,9 @@ export function main(): number {
203
310
  : ".";
204
311
  if (!_enabled(root)) return EXIT_ALLOW;
205
312
 
313
+ const command = _extract_command(envelope);
314
+ if (!command) return EXIT_ALLOW;
315
+
206
316
  // Deterministic gate: the live probe, NOT a self-reported flag — and an
207
317
  // IDENTITY probe, not bare presence: the `rtk` binary name collides with
208
318
  // the unrelated Rust Type Kit, and nudging the agent to wrap commands with
@@ -211,21 +321,30 @@ export function main(): number {
211
321
  // NOT — fail closed for behavior, fail open for the command itself.
212
322
  // Identity is cached user-globally keyed on the binary's path+mtime+size,
213
323
  // so the `rtk gain` probe runs once per installed binary, not per command.
214
- if (!rtk_available()) return EXIT_ALLOW;
215
- if (detectRtkCached().identity !== "token-killer") return EXIT_ALLOW;
216
-
217
- const command = _extract_command(envelope);
218
- if (!command) return EXIT_ALLOW;
219
-
220
- const verdict = classify(command);
221
- if (!verdict.eligible) return EXIT_ALLOW;
324
+ if (rtk_available() && detectRtkCached().identity === "token-killer") {
325
+ const verdict = classify(command);
326
+ if (verdict.eligible) {
327
+ const reason =
328
+ `rtk is installed and wraps verbose CLI output (upstream reports 60–90% fewer output tokens). ` +
329
+ `Re-run wrapped: \`rtk ${command.trim()}\`. ` +
330
+ `(Skip for completeness-critical output like \`git diff\`.)`;
331
+ process.stdout.write(`${_jsonReason(reason)}\n`);
332
+ return EXIT_WARN;
333
+ }
334
+ }
222
335
 
223
- const reason =
224
- `rtk is installed and wraps verbose CLI output (upstream reports 60–90% fewer output tokens). ` +
225
- `Re-run wrapped: \`rtk ${command.trim()}\`. ` +
226
- `(Skip for completeness-critical output like \`git diff\`.)`;
227
- process.stdout.write(`${_jsonReason(reason)}\n`);
228
- return EXIT_WARN;
336
+ // Cap advisory needs no rtk — it covers exactly the commands rtk cannot
337
+ // wrap (compound/piped, or outside its allowlist).
338
+ const cap = classifyCap(command);
339
+ if (cap) {
340
+ const reason =
341
+ `Unbounded \`${cap.program}\` output: a tree-wide search can return hundreds of KB into context ` +
342
+ `(measured here: 302 KB raw vs 21 KB bounded — internal/bench/rtk-savings/RESULTS.md). ` +
343
+ `Prefer a bounded form — ${cap.hint}. Re-running unbounded is fine when completeness is required.`;
344
+ process.stdout.write(`${_jsonReason(reason)}\n`);
345
+ return EXIT_WARN;
346
+ }
347
+ return EXIT_ALLOW;
229
348
  }
230
349
 
231
350
  // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-eol — Stop-slot session end-of-life instrument + recycle advisory
4
+ * (road-to-token-economy-recycling Phases 1.1 / 3.2 / 4.2).
5
+ *
6
+ * Phase 1.1 (record-only): per session, maintain counts-only end-of-life
7
+ * state — final main-chain context size in parsed tokens (via
8
+ * `_lib/session_eol.ts` → `cc_transcript.billableInputTokens`), turn count,
9
+ * and every observed compaction event — under
10
+ * `agents/runtime/state/session-eol/<sha256(session)>.json`. Incremental:
11
+ * each Stop reads only the transcript bytes appended since the last scan.
12
+ *
13
+ * Phase 3.2 (advisory, once per session): past the committed recycle
14
+ * threshold (`src/config/recycle-threshold-budget.json`), inject ONE line
15
+ * advising `session:recycle` — the F2 once-per-session pattern of
16
+ * `end_review_nudge_hook.ts`. Absent/unreadable config or transcript is
17
+ * SILENCE, never a block (fail-open); hooks cannot inject `/clear`, so the
18
+ * recycle action itself stays advisory-carried by design (roadmap 5.1).
19
+ *
20
+ * Phase 4.2 (read surface): every Stop also overwrites
21
+ * `agents/runtime/state/context-fill.json` with the machine-readable fill
22
+ * level + threshold state — display substrate for an external statusline;
23
+ * carries counts only, and this hook's behaviour is identical whether or
24
+ * not anything reads it.
25
+ *
26
+ * Never blocks: exit 0 on every silent path; the advisory reports at
27
+ * exit 2 ({decision:"warn", additional_context}) which `host_semantics`
28
+ * reduces to a non-blocking warn on claude. `AGENT_CONFIG_REPLAY=1` → no-op.
29
+ */
30
+
31
+ import * as crypto from 'node:crypto';
32
+ import * as fs from 'node:fs';
33
+ import * as path from 'node:path';
34
+ import process from 'node:process';
35
+ import { fileURLToPath, pathToFileURL } from 'node:url';
36
+
37
+ import recycleThresholdConfig from '../../config/recycle-threshold-budget.json';
38
+
39
+ import {
40
+ emptyCounters,
41
+ readNewCompleteLines,
42
+ scanEolSlice,
43
+ type EolCounters,
44
+ } from '../_lib/session_eol.js';
45
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
46
+ import { readHookStdin } from './hook_stdin.js';
47
+ import { atomic_write_json, is_replay_mode } from './state_io.js';
48
+ import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
49
+
50
+ const EXIT_WARN = 2;
51
+
52
+ /**
53
+ * Per-process threshold override — the test seam and the emergency off
54
+ * switch (`0` or any non-positive value disables the advisory lane while
55
+ * recording continues). The committed value lives in
56
+ * `src/config/recycle-threshold-budget.json` and is statically imported so
57
+ * the hook bundle carries it into consumers — one threshold, one constant.
58
+ */
59
+ export const THRESHOLD_OVERRIDE_ENV = 'AGENT_RECYCLE_THRESHOLD_TOKENS';
60
+
61
+ /** Machine-readable fill-level surface (Phase 4.2). Counts only, overwritten per Stop. */
62
+ export const CONTEXT_FILL_REL = path.join('agents', 'runtime', 'state', 'context-fill.json');
63
+
64
+ export interface SessionEolState {
65
+ schema_version: 1;
66
+ counters: EolCounters;
67
+ /** ISO stamp when the recycle advisory fired for this session (F2), or null. */
68
+ advisory_fired_at: string | null;
69
+ updated_at: string;
70
+ }
71
+
72
+ function str(value: JsonValue | undefined): string {
73
+ return typeof value === 'string' ? value : '';
74
+ }
75
+
76
+ function stateDir(workspaceRoot: string): string {
77
+ return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'session-eol');
78
+ }
79
+
80
+ export function stateFile(workspaceRoot: string, sessionKey: string): string {
81
+ return path.join(stateDir(workspaceRoot), `${sessionKey}.json`);
82
+ }
83
+
84
+ /** Same derivation as `end_review_nudge_hook.deriveSessionKey` — hashed, never raw. */
85
+ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
86
+ const raw =
87
+ str(envelope['session_id'] as JsonValue | undefined) ||
88
+ str((payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined);
89
+ return crypto.createHash('sha256').update(raw || 'unknown-session').digest('hex');
90
+ }
91
+
92
+ /** Load prior per-session state; any read/shape error reads as "fresh session". */
93
+ export function readState(file: string): SessionEolState {
94
+ try {
95
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8')) as Partial<SessionEolState>;
96
+ if (
97
+ parsed &&
98
+ typeof parsed === 'object' &&
99
+ parsed.schema_version === 1 &&
100
+ parsed.counters &&
101
+ typeof parsed.counters.scanned_bytes === 'number'
102
+ ) {
103
+ return {
104
+ schema_version: 1,
105
+ counters: parsed.counters,
106
+ advisory_fired_at:
107
+ typeof parsed.advisory_fired_at === 'string' ? parsed.advisory_fired_at : null,
108
+ updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : '',
109
+ };
110
+ }
111
+ } catch {
112
+ // fall through — fresh state
113
+ }
114
+ return {
115
+ schema_version: 1,
116
+ counters: emptyCounters(),
117
+ advisory_fired_at: null,
118
+ updated_at: '',
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Resolve the recycle threshold (Phase 3.1): the per-process override wins
124
+ * (tests; `0` = advisory lane off), else the committed constant from
125
+ * `recycle-threshold-budget.json`. Returns `null` when the lane is
126
+ * disabled or the value is not a positive number — recording continues
127
+ * either way (fail-open, never a block).
128
+ */
129
+ export function readThresholdTokens(): number | null {
130
+ const override = process.env[THRESHOLD_OVERRIDE_ENV];
131
+ if (override !== undefined) {
132
+ const value = Number(override);
133
+ return Number.isFinite(value) && value > 0 ? value : null;
134
+ }
135
+ const value = (recycleThresholdConfig as Record<string, unknown>)['recycle_threshold_tokens'];
136
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
137
+ }
138
+
139
+ /** The exactly-one advisory line injected past threshold (Phase 3.2). */
140
+ export function buildAdvisoryLine(tokens: number, threshold: number): string {
141
+ return (
142
+ `context past recycle threshold (${tokens.toLocaleString('en-US')} of ` +
143
+ `${threshold.toLocaleString('en-US')} tokens): run \`agent-config session:recycle\` to ` +
144
+ `write the recycle envelope, then /clear — the successor session resumes from the ` +
145
+ `envelope at session_start (road-to-token-economy-recycling)`
146
+ );
147
+ }
148
+
149
+ /** Overwrite the Phase 4.2 read surface. Counts only; failures are swallowed. */
150
+ function writeContextFill(
151
+ workspaceRoot: string,
152
+ tokens: number | null,
153
+ threshold: number | null,
154
+ ts: string,
155
+ ): void {
156
+ try {
157
+ atomic_write_json(path.join(workspaceRoot, CONTEXT_FILL_REL), {
158
+ schema_version: 1,
159
+ final_context_tokens: tokens,
160
+ recycle_threshold_tokens: threshold,
161
+ past_threshold: tokens !== null && threshold !== null && tokens >= threshold,
162
+ updated_at: ts,
163
+ });
164
+ } catch {
165
+ // a state-write failure must never block the Stop path
166
+ }
167
+ }
168
+
169
+ export function main(): number {
170
+ const [envelope, payload] = unwrap(readHookStdin(), 'claude');
171
+
172
+ const event = String(envelope['event'] ?? '');
173
+ if (event !== '' && event !== 'stop') {
174
+ return 0;
175
+ }
176
+ if (is_replay_mode()) {
177
+ return 0; // replay fixtures never mutate session state
178
+ }
179
+
180
+ const workspaceRoot = String(envelope['workspace_root'] ?? '').trim() || process.cwd();
181
+
182
+ const transcriptPath = str(
183
+ (payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
184
+ ).trim();
185
+ if (!transcriptPath || !isSafeTranscriptPath(transcriptPath)) {
186
+ return 0; // no readable ledger — silence, never a block
187
+ }
188
+
189
+ const sessionKey = deriveSessionKey(envelope, payload);
190
+ const file = stateFile(workspaceRoot, sessionKey);
191
+ const state = readState(file);
192
+
193
+ let counters = state.counters;
194
+ const read = readNewCompleteLines(transcriptPath, counters.scanned_bytes);
195
+ if (read.truncated) {
196
+ const full = readNewCompleteLines(transcriptPath, 0);
197
+ counters = scanEolSlice(full.text, emptyCounters());
198
+ counters.scanned_bytes = full.nextOffset;
199
+ } else if (read.text.length > 0) {
200
+ counters = scanEolSlice(read.text, counters);
201
+ counters.scanned_bytes = read.nextOffset;
202
+ } else {
203
+ counters = { ...counters, scanned_bytes: read.nextOffset };
204
+ }
205
+
206
+ const now = new Date().toISOString();
207
+ const threshold = readThresholdTokens();
208
+ const tokens = counters.final_context_tokens;
209
+
210
+ const shouldAdvise =
211
+ threshold !== null &&
212
+ tokens !== null &&
213
+ tokens >= threshold &&
214
+ state.advisory_fired_at === null;
215
+
216
+ const nextState: SessionEolState = {
217
+ schema_version: 1,
218
+ counters,
219
+ advisory_fired_at: shouldAdvise ? now : state.advisory_fired_at,
220
+ updated_at: now,
221
+ };
222
+ try {
223
+ atomic_write_json(file, nextState);
224
+ } catch {
225
+ // recording is best-effort; never block the Stop path
226
+ }
227
+
228
+ writeContextFill(workspaceRoot, tokens, threshold, now);
229
+
230
+ if (shouldAdvise && threshold !== null && tokens !== null) {
231
+ process.stdout.write(
232
+ `${JSON.stringify({
233
+ decision: 'warn',
234
+ reason: `session-eol: context ${tokens} >= recycle threshold ${threshold}`,
235
+ additional_context: buildAdvisoryLine(tokens, threshold),
236
+ })}\n`,
237
+ );
238
+ return EXIT_WARN;
239
+ }
240
+ return 0;
241
+ }
242
+
243
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
244
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
245
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
246
+ function _isCliEntry(): boolean {
247
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
248
+ return false;
249
+ }
250
+ if (process.argv[1] === undefined) {
251
+ return false;
252
+ }
253
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
254
+ if (import.meta.url === argvUrl) {
255
+ return true;
256
+ }
257
+ try {
258
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
259
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
260
+ return here === argv;
261
+ } catch {
262
+ return false;
263
+ }
264
+ }
265
+
266
+ if (_isCliEntry()) {
267
+ process.exit(main());
268
+ }