@event4u/agent-config 16.0.0 → 16.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +297 -137
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +74 -27
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  40. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  41. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  42. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  43. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  44. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  45. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  46. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  47. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  48. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  49. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  50. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  52. package/dist/agent-src/templates/agent-settings.md +1 -1
  53. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  54. package/dist/agent-src/templates/roadmaps.md +188 -0
  55. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  56. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  58. package/dist/cli-delegate/check_update_banner.js +40 -2
  59. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  60. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  61. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  62. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  63. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  64. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  65. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  66. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  67. package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
  68. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  69. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  70. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  71. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  72. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  73. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  74. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  75. package/dist/cli-delegate/cmd_conformance.js +90 -56
  76. package/dist/cli-delegate/cmd_doctor.js +7 -5
  77. package/dist/cli-delegate/cmd_explain.js +2 -2
  78. package/dist/cli-delegate/cmd_export.js +9 -7
  79. package/dist/cli-delegate/cmd_fleet.js +4 -2
  80. package/dist/cli-delegate/cmd_handoff.js +4 -4
  81. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  82. package/dist/cli-delegate/cmd_migrate.js +2 -2
  83. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  84. package/dist/cli-delegate/cmd_preflight.js +4 -2
  85. package/dist/cli-delegate/cmd_prune.js +10 -8
  86. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  87. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  88. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  89. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  90. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  91. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  92. package/dist/cli-delegate/cmd_sync.js +10 -8
  93. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  94. package/dist/cli-delegate/cmd_update.js +1 -1
  95. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  96. package/dist/cli-delegate/cmd_validate.js +12 -10
  97. package/dist/cli-delegate/cmd_versions.js +1 -1
  98. package/dist/discovery/deprecation-report.md +1 -1
  99. package/dist/discovery/discovery-manifest.json +85 -38
  100. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  101. package/dist/discovery/discovery-manifest.summary.md +4 -4
  102. package/dist/discovery/dormancy-report.md +1 -1
  103. package/dist/discovery/orphan-report.md +1 -1
  104. package/dist/discovery/packs.json +10 -8
  105. package/dist/discovery/trust-report.md +3 -3
  106. package/dist/discovery/workspaces.json +8 -6
  107. package/dist/hooks/dispatch.js +1832 -1007
  108. package/dist/install/claudeRuleRewrite.js +14 -3
  109. package/dist/install/conflict.js +60 -10
  110. package/dist/install/conflictTracker.js +218 -0
  111. package/dist/install/fsPrimitives.js +54 -0
  112. package/dist/install/install.mjs +2007 -1481
  113. package/dist/install/manifestFiles.js +259 -0
  114. package/dist/install/preflight.js +14 -2
  115. package/dist/install/preserve.js +157 -0
  116. package/dist/install/recordedOwnership.js +132 -0
  117. package/dist/install/txlog.js +11 -2
  118. package/dist/mcp/registry-manifest.json +3 -3
  119. package/dist/mcp/server.json +2 -2
  120. package/dist/mcp/server.mjs +398 -39
  121. package/dist/router.json +1 -1
  122. package/dist/scripts/_lib/agent_settings.js +3 -2
  123. package/dist/scripts/_lib/fs_atomic.js +167 -0
  124. package/dist/scripts/_lib/installed_tools.js +460 -0
  125. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  126. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  127. package/dist/server/routes/install.js +8 -0
  128. package/dist/server/routes/wizard.js +9 -3
  129. package/dist/server/schemas/settings.js +17 -5
  130. package/dist/shared/missionExecution.js +97 -0
  131. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  132. package/dist/ui/index.html +1 -1
  133. package/docs/MIGRATION.md +103 -0
  134. package/docs/contracts/settings-classes.md +26 -7
  135. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  136. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  137. package/docs/guidelines/design-fidelity-routing.md +29 -0
  138. package/package.json +1 -1
  139. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  140. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  141. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  142. package/src/config/agent-settings.template.yml +139 -27
  143. package/src/config/auto-rule-budget.json +16 -0
  144. package/src/config/ci-local-parity.yml +18 -5
  145. package/src/config/continuity-surface.json +21 -0
  146. package/src/config/gate-coverage.yml +146 -13
  147. package/src/config/gate-violation-baselines.json +6 -6
  148. package/src/config/hook-token-budget.json +2 -0
  149. package/src/config/loop-surfaces.yaml +215 -0
  150. package/src/config/platform-anchor.json +3 -2
  151. package/src/config/release-gate-locality.yml +11 -0
  152. package/src/config/routing-coverage-seed.json +18 -4
  153. package/src/config/rule-enforcement-baseline.json +0 -1
  154. package/src/install/claudeRuleRewrite.ts +16 -2
  155. package/src/install/conflict.ts +65 -9
  156. package/src/install/conflictTracker.ts +299 -0
  157. package/src/install/fsPrimitives.ts +54 -0
  158. package/src/install/manifestFiles.ts +312 -0
  159. package/src/install/preflight.ts +16 -2
  160. package/src/install/preserve.ts +205 -0
  161. package/src/install/recordedOwnership.ts +149 -0
  162. package/src/install/txlog.ts +11 -2
  163. package/src/install/types.ts +19 -4
  164. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  165. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  166. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  167. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  168. package/src/scripts/_cli/doctor_execution.ts +90 -0
  169. package/src/scripts/_lib/activation_payload.ts +247 -0
  170. package/src/scripts/_lib/agent_settings.ts +4 -2
  171. package/src/scripts/_lib/authority_path.ts +102 -0
  172. package/src/scripts/_lib/cascade_base.ts +94 -0
  173. package/src/scripts/_lib/changelog_eras.ts +7 -2
  174. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  175. package/src/scripts/_lib/council_transport.ts +129 -0
  176. package/src/scripts/_lib/delivery_ready.ts +112 -0
  177. package/src/scripts/_lib/forge_protection.ts +267 -0
  178. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  179. package/src/scripts/_lib/json_python_parity.ts +127 -0
  180. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  181. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  182. package/src/scripts/_lib/mission_record.ts +190 -0
  183. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  184. package/src/scripts/_lib/obligations.ts +402 -0
  185. package/src/scripts/_lib/orchestration_record.ts +11 -20
  186. package/src/scripts/_lib/release_highlights.ts +199 -10
  187. package/src/scripts/_lib/release_holds.ts +493 -0
  188. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  189. package/src/scripts/_lib/rides_along.ts +102 -0
  190. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  191. package/src/scripts/_lib/test_delta.ts +166 -0
  192. package/src/scripts/_lib/test_provenance.ts +171 -0
  193. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  194. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  195. package/src/scripts/ai_council/config.ts +8 -11
  196. package/src/scripts/ai_council/fallback_config.ts +5 -3
  197. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  198. package/src/scripts/ask_block_census.ts +285 -15
  199. package/src/scripts/check_enforcement_matrix.ts +624 -0
  200. package/src/scripts/check_gate_reachability.ts +173 -10
  201. package/src/scripts/check_generator_sync.ts +954 -0
  202. package/src/scripts/check_preamble_payload_budget.ts +75 -2
  203. package/src/scripts/check_release_highlights.ts +33 -1
  204. package/src/scripts/check_release_holds.ts +567 -0
  205. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  206. package/src/scripts/check_test_delta.ts +294 -0
  207. package/src/scripts/check_test_weakening.ts +299 -0
  208. package/src/scripts/closure_scan.ts +333 -0
  209. package/src/scripts/compile_hook_manifest.ts +43 -3
  210. package/src/scripts/council_record_shape.ts +209 -0
  211. package/src/scripts/hook_manifest.json +1 -1
  212. package/src/scripts/hook_manifest.yaml +88 -9
  213. package/src/scripts/hooks/concern_registry.ts +4 -0
  214. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  215. package/src/scripts/hooks/evidence_independence.ts +96 -0
  216. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  217. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  218. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  219. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  220. package/src/scripts/hooks_doctor.ts +85 -0
  221. package/src/scripts/hooks_status.ts +19 -0
  222. package/src/scripts/install.ts +106 -206
  223. package/src/scripts/janitor.ts +7 -0
  224. package/src/scripts/lint_decision_classes.ts +464 -0
  225. package/src/scripts/lint_hook_manifest.ts +11 -0
  226. package/src/scripts/lint_major_migration_sections.ts +450 -0
  227. package/src/scripts/lint_plan_risk_register.ts +47 -3
  228. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  229. package/src/scripts/lint_roadmap_producers.ts +202 -0
  230. package/src/scripts/measure_rule_budget.ts +101 -5
  231. package/src/scripts/new_roadmap.ts +37 -0
  232. package/src/scripts/orchestration_record.ts +0 -5
  233. package/src/scripts/release.ts +14 -98
  234. package/src/scripts/release_drill.ts +267 -1
  235. package/src/scripts/release_publication.ts +51 -0
  236. package/src/scripts/report_enforcement_drift.ts +566 -0
  237. package/src/scripts/report_host_injection_effect.ts +3 -11
  238. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  239. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  240. package/src/scripts/schemas/command.schema.json +4 -0
  241. package/src/scripts/scope_growth.ts +219 -0
  242. package/src/scripts/trigger_eval_grandfather.json +0 -1
  243. package/src/scripts/ui_conformance_probe.ts +622 -0
  244. package/src/server/schemas/settings.ts +31 -7
  245. package/src/shared/missionExecution.ts +126 -0
  246. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -43,6 +43,7 @@
43
43
  *
44
44
  * Exit codes: 0 allow · 2 block (stderr carries the reason).
45
45
  */
46
+ import { spawnSync } from "node:child_process";
46
47
  import crypto from "node:crypto";
47
48
  import fs from "node:fs";
48
49
  import path from "node:path";
@@ -50,6 +51,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
50
51
 
51
52
  import { atomic_write_json } from "./state_io.js";
52
53
  import { readHookStdin } from "./hook_stdin.js";
54
+ import { isCodePath, isTestPath } from "../_lib/test_delta.js";
53
55
 
54
56
  const EXIT_ALLOW = 0;
55
57
  // MUST equal dispatch_hook.EXIT_BLOCK. The dispatcher's internal ladder is
@@ -324,6 +326,79 @@ export function decide(
324
326
  };
325
327
  }
326
328
 
329
+ /**
330
+ * The one-commit test-authorship flag —
331
+ * `road-to-adversarial-verification-and-long-runs` 2.4, third clause.
332
+ *
333
+ * A commit staging BOTH a test and code it could cover is the shape of L0 on the
334
+ * independence scale — the implementer wrote the evaluator — and it is the one
335
+ * case nobody sees, because the diff looks exactly like a well-tested change.
336
+ *
337
+ * **It reads the STAGED SET and the command, and nothing else.** It does NOT
338
+ * read a session id, an author or a timestamp, so it cannot distinguish an
339
+ * implementer who wrote both from one committing a test another session authored
340
+ * — an independent review of this function caught an earlier docstring here
341
+ * claiming a session boundary the code never reads, which is exactly the
342
+ * over-claim `evaluator-independence` exists over. The honest reading is: this
343
+ * is the shape L0 takes in a diff, surfaced so the level is stated rather than
344
+ * inferred.
345
+ *
346
+ * **WARN, never block.** L0 is explicitly permitted as a fallback, so refusing it
347
+ * would forbid a legal state; and a signal this coarse may surface, never refuse.
348
+ *
349
+ * Returns `null` when nothing is worth saying — i.e. this is not a commit.
350
+ */
351
+ export function sameSessionTestFlag(command: string | null): string | null {
352
+ if (command === null || !/\bgit\b[^|;&]*\bcommit\b/.test(command)) return null;
353
+ return SAME_SESSION_TEXT;
354
+ }
355
+
356
+ const SAME_SESSION_TEXT =
357
+ "This commit stages both a test and code it could cover — the shape L0 takes " +
358
+ "in a diff, where the implementer authored the evaluator. This check reads the " +
359
+ "staged set only: it cannot tell who wrote the test, so it is a prompt to STATE " +
360
+ "the level, not a finding that you are at L0. L0 is a permitted FALLBACK, not " +
361
+ "the default: where a second session, a second model or a second provider was " +
362
+ "available, the test should have come from one of them. Say which in the commit " +
363
+ "or the PR body — a diff looks identical either way. See evaluator-independence " +
364
+ "§ Tests are evaluators.";
365
+
366
+ /** Both a test path and a production path in one changed set. */
367
+ export function touchesTestAndCode(paths: readonly string[]): boolean {
368
+ return paths.some(isTestPath) && paths.some(isCodePath);
369
+ }
370
+
371
+ /** The shell command this envelope is about, across the key names hosts use. */
372
+ function _commandOf(envelope: JsonObject): string | null {
373
+ const payload = _isObject(envelope["payload"]) ? envelope["payload"] : envelope;
374
+ const input = _isObject(payload["tool_input"]) ? payload["tool_input"] : payload;
375
+ for (const key of ["command", "cmd", "shell_command"]) {
376
+ const v = input[key];
377
+ if (typeof v === "string" && v !== "") return v;
378
+ }
379
+ return null;
380
+ }
381
+
382
+ /**
383
+ * Staged paths, or `null` when git cannot answer.
384
+ *
385
+ * `null` is not "nothing staged" — it is "no reading", and the caller must not
386
+ * turn an unreadable index into a silent clean bill. A hook that cannot measure
387
+ * says nothing rather than implying a measurement.
388
+ */
389
+ function _stagedPaths(consumer_root: string): string[] | null {
390
+ const r = spawnSync("git", ["diff", "--cached", "--name-only"], {
391
+ cwd: consumer_root,
392
+ encoding: "utf8",
393
+ });
394
+ if (r.status !== 0) return null;
395
+ const out = r.stdout
396
+ .split("\n")
397
+ .map((s) => s.trim())
398
+ .filter((s) => s !== "");
399
+ return out.length === 0 ? null : out;
400
+ }
401
+
327
402
  export function run(stdin_text: string, options: { consumer_root: string }): number {
328
403
  let envelope: JsonObject = {};
329
404
  if (stdin_text.trim()) {
@@ -355,6 +430,27 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
355
430
  }
356
431
 
357
432
  const [tool, prompt] = extractDispatch(envelope);
433
+
434
+ // 2.4 — the one-commit test-authorship flag, checked BEFORE the dispatch
435
+ // branch and returning early. A commit envelope carries no evaluation prompt,
436
+ // so `decide` would classify it as not-an-evaluation and allow it silently;
437
+ // running both would mean the commit path fell through to a counter that is
438
+ // about evaluation dispatches and would be wrong to advance here.
439
+ //
440
+ // CHEAP TEST FIRST, and that ordering is the finding rather than a style
441
+ // preference: `_stagedPaths` spawns `git diff --cached`, and this concern is
442
+ // bound on `pre_tool_use`, so reading the index before the regex made every
443
+ // Read, Edit, Grep and Task dispatch in every session pay a subprocess. The
444
+ // regex answers "is this even a commit" for free.
445
+ const flag = sameSessionTestFlag(_commandOf(envelope));
446
+ if (flag !== null) {
447
+ const staged = _stagedPaths(options.consumer_root);
448
+ if (staged !== null && touchesTestAndCode(staged)) {
449
+ process.stdout.write(`${JSON.stringify({ decision: "warn", reason: flag })}\n`);
450
+ return EXIT_ALLOW;
451
+ }
452
+ }
453
+
358
454
  const decision = decide(tool, prompt, state.evaluations.length);
359
455
 
360
456
  if (decision.evaluations > state.evaluations.length) {
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Memory recall — `pre_tool_use` concern. Delivers a memory the store ALREADY
4
+ * holds, at the moment the tool call it is about would run, instead of after
5
+ * it has gone wrong.
6
+ *
7
+ * The finding it is built from, measured rather than supposed: in one long
8
+ * autonomous run the agent walked into four distinct traps that each already
9
+ * had a memory file, some weeks old. The store's own index records the same
10
+ * shape on an earlier day — "of eleven error classes in one session, nine had
11
+ * a file here already and all nine were read AFTER the failure". So the store
12
+ * is not the gap; retrieval is. `memory_lookup` exists as a CLI and nothing
13
+ * calls it automatically, which makes recall model-carried and therefore
14
+ * weakest exactly when the agent is busy.
15
+ *
16
+ * Per `recurring-criticism`, a criticism that arrives again is evidence about
17
+ * the system. Writing a fifth memory file is the item response; delivering the
18
+ * four that exist, before the action, is the system one.
19
+ *
20
+ * A committed trigger table, never a similarity score. `RECALL_RULES` is a
21
+ * short, hand-authored table. Each row names ONE memory slug and ONE
22
+ * precisely-decidable condition on the tool payload. There is no ranking, no
23
+ * threshold, no lexical match against 467 files: a fuzzy matcher over the
24
+ * whole store would fire on most calls, and the sibling concern that measured
25
+ * this (`skill-route`, silent on ~86% of prompts by design) is why the floor
26
+ * here is a predicate rather than a score. A predicate is also falsifiable —
27
+ * every row is replayed against the real payload shape in
28
+ * `tests/scripts/memory_recall_hook.test.ts`, so a row that cannot fire fails
29
+ * a test instead of degrading quietly into noise.
30
+ *
31
+ * Advisory only — never a block. It binds on the slot that carries the
32
+ * blocking guards and never returns exit 1 on any path. A recall hook that can
33
+ * refuse a tool call is a larger failure mode than the one it closes: a stale
34
+ * trigger row would then deny work over a bookkeeping artefact. Delivery
35
+ * mirrors `reread_guard_hook.ts` exactly — `{decision:"warn", reason,
36
+ * additional_context}` at exit 2, which `host_semantics.emitFor` turns into a
37
+ * host-facing exit 0 unconditionally on the verified `claude` platform.
38
+ *
39
+ * Budget. `pre_tool_use` fires once per tool call, the most expensive slot
40
+ * there is. ONE memory per fire, pointer plus the memory's own one-line
41
+ * description, truncated to `CAP_BYTES` (768 — declared BELOW the 1024 default
42
+ * in `src/config/hook-token-budget.json` deliberately, so a later change that
43
+ * starts injecting memory BODIES fails the build instead of quietly tripling a
44
+ * per-call slot). Injecting a whole memory file would be 1.5-6 kB per fire.
45
+ *
46
+ * Cost of the silent path. The overwhelmingly common case is no match, and it
47
+ * costs a payload parse and a few regex tests — zero filesystem work. An
48
+ * `lstat` runs only after a build-shaped command has already matched; the
49
+ * store is resolved and the latch touched only after a rule has fired.
50
+ *
51
+ * An unreadable store is not the same answer as no match. When a rule matches
52
+ * and the memory directory cannot be resolved, the concern still emits the
53
+ * pointer (the slug is actionable on its own) and writes a one-line diagnostic
54
+ * to stderr naming the directories it tried. Silence there would report
55
+ * "nothing to recall" for a store that was merely unreachable.
56
+ *
57
+ * ROBUSTNESS: malformed payload, any fs error, any throw -> exit 0 silently.
58
+ * No network, no writes outside the session latch under `agents/runtime/`.
59
+ */
60
+ import * as crypto from 'node:crypto';
61
+ import * as fs from 'node:fs';
62
+ import * as path from 'node:path';
63
+ import { fileURLToPath, pathToFileURL } from 'node:url';
64
+
65
+ import { DEFAULT_PROJECTS_ROOT, projectStoreSlug } from '../_lib/cc_transcript.js';
66
+ import { EDIT_TOOLS } from '../minimal_safe_diff_hook.js';
67
+ import { COMMAND_TOOLS } from '../before_complete_hook.js';
68
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
69
+ import { readHookStdin } from './hook_stdin.js';
70
+ import { atomic_write_json } from './state_io.js';
71
+
72
+ const EXIT_ALLOW = 0;
73
+ /**
74
+ * Severity comes from the exit code, not from the `decision` field. See the
75
+ * header: this never blocks, and never returns 1 on any path.
76
+ */
77
+ const EXIT_WARN = 2;
78
+
79
+ /** Hard ceiling on `reason` + `additional_context`, matching the registered row. */
80
+ export const CAP_BYTES = 768;
81
+
82
+ /** How many ancestor directories the store walk inspects before giving up. */
83
+ const MAX_ANCESTOR_WALK = 8;
84
+
85
+ /** One tool call, reduced to the fields a rule may read. */
86
+ export interface ExtractedCall {
87
+ tool: string;
88
+ /** Every target path the payload names, normalized to forward slashes. */
89
+ paths: readonly string[];
90
+ /** The shell command, for command tools; empty string otherwise. */
91
+ command: string;
92
+ /** The session's own working directory, when the host payload carries one. */
93
+ cwd: string;
94
+ }
95
+
96
+ /**
97
+ * Context a rule's environment predicate may inspect. Filesystem reads only.
98
+ *
99
+ * `roots` is a LIST, and that is the whole point. The dispatcher sets
100
+ * `workspace_root` from `--project-dir`, which the Claude hook command fills
101
+ * with `$CLAUDE_PROJECT_DIR` — the PARENT CHECKOUT when the session runs in a
102
+ * git worktree. A parent checkout has a real `node_modules`, so a predicate
103
+ * reading `workspace_root` alone would go silent in exactly the topology the
104
+ * symlink rule exists for. The payload's own `cwd` carries the worktree, so
105
+ * both are offered and a rule fires when EITHER satisfies it.
106
+ */
107
+ export interface RuleContext {
108
+ roots: readonly string[];
109
+ }
110
+
111
+ export interface RecallRule {
112
+ /** Stable id, used in diagnostics and in the test roster. */
113
+ id: string;
114
+ /** The memory file's slug — `<slug>.md` in the store. */
115
+ slug: string;
116
+ /** Tool names this row reacts to. */
117
+ tools: ReadonlySet<string>;
118
+ /** Pure predicate over the extracted call. No I/O. */
119
+ test: (call: ExtractedCall) => boolean;
120
+ /**
121
+ * Optional environment condition, evaluated ONLY after `test` passes, so
122
+ * the silent path never pays for it.
123
+ */
124
+ requires?: (ctx: RuleContext) => boolean;
125
+ /**
126
+ * One clause saying why this memory matters for THIS call. Authored here,
127
+ * never derived from the memory — the memory supplies its own description.
128
+ */
129
+ why: string;
130
+ }
131
+
132
+ /** Commands that produce a bundle whose module paths record their resolution. */
133
+ const BUILD_COMMAND_RE =
134
+ /\bnpm\s+run\s+build\b|\bbuild:(?:install-bundle|cli|hooks)\b|\besbuild\b/;
135
+
136
+ /** A decision record, in either of the two directories this tree has used. */
137
+ const ADR_PATH_RE = /(?:^|\/)docs\/(?:decisions|adr)\/ADR-[^/]*\.md$/i;
138
+
139
+ /** Sources whose build output is committed twice — tsc per-file and esbuild bundle. */
140
+ const INSTALL_SOURCE_RE = /(?:^|\/)src\/install\/|(?:^|\/)src\/scripts\/install\.ts$/;
141
+
142
+ /** The hook-latency bench and its pre-registered budget. */
143
+ const LATENCY_BENCH_RE = /bench_hook_latency|hook-latency-budget/;
144
+
145
+ /** True when `<root>/node_modules` exists and is a symbolic link. */
146
+ export function nodeModulesIsSymlink(root: string): boolean {
147
+ if (root === '') return false;
148
+ try {
149
+ return fs.lstatSync(path.join(root, 'node_modules')).isSymbolicLink();
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /** True when ANY candidate root carries a symlinked `node_modules`. */
156
+ export function anyRootHasSymlinkedNodeModules(ctx: RuleContext): boolean {
157
+ return ctx.roots.some(nodeModulesIsSymlink);
158
+ }
159
+
160
+ /**
161
+ * The committed trigger table. Order is priority order: the first matching row
162
+ * wins and at most one memory is ever emitted.
163
+ *
164
+ * Each row is seeded from a real incident in which the memory existed and was
165
+ * read only after the failure. Adding a row is cheap; adding a row that cannot
166
+ * be shown to fire on a real payload is the failure mode this table exists to
167
+ * avoid, so every row carries a replay case in the test file.
168
+ */
169
+ export const RECALL_RULES: readonly RecallRule[] = [
170
+ {
171
+ id: 'build-in-symlinked-worktree',
172
+ slug: 'worktree-build-poisons-install-bundle',
173
+ tools: COMMAND_TOOLS,
174
+ test: (call) => BUILD_COMMAND_RE.test(call.command),
175
+ requires: anyRootHasSymlinkedNodeModules,
176
+ why:
177
+ 'node_modules here is a SYMLINK, so esbuild resolves every dependency ' +
178
+ 'outside the repo root and bakes ../../../node_modules/ paths into the ' +
179
+ 'TRACKED dist/install/install.mjs',
180
+ },
181
+ {
182
+ id: 'adr-edit',
183
+ slug: 'adding-an-adr-downstream-surface',
184
+ tools: EDIT_TOOLS,
185
+ test: (call) => call.paths.some((p) => ADR_PATH_RE.test(p)),
186
+ why:
187
+ 'an ADR has TWO generated consumers — the INDEX and the evidence census; ' +
188
+ 'census staleness reds the Rule-backstops CI job and no local preflight ' +
189
+ 'gate catches it',
190
+ },
191
+ {
192
+ id: 'install-source-edit',
193
+ slug: 'adding-a-src-install-module-needs-two-builds',
194
+ tools: EDIT_TOOLS,
195
+ test: (call) => call.paths.some((p) => INSTALL_SOURCE_RE.test(p)),
196
+ why:
197
+ 'this source is committed twice — build:cli AND build:install-bundle are ' +
198
+ 'both required, and neither freshness gate is in task preflight',
199
+ },
200
+ {
201
+ id: 'hook-latency-bench',
202
+ slug: 'hook-latency-gate-has-runner-variance',
203
+ tools: COMMAND_TOOLS,
204
+ test: (call) => LATENCY_BENCH_RE.test(call.command),
205
+ why:
206
+ "reading main's own most recent run of the same job is step ONE, not the " +
207
+ 'fallback — measuring locally first answered nothing and cost a CI cycle',
208
+ },
209
+ ];
210
+
211
+ function isObject(v: unknown): v is JsonObject {
212
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
213
+ }
214
+
215
+ function str(v: JsonValue | undefined): string {
216
+ return typeof v === 'string' ? v : '';
217
+ }
218
+
219
+ /** Keys across host spellings that carry the tool's target file path. */
220
+ const PATH_KEYS: readonly string[] = [
221
+ 'file_path',
222
+ 'path',
223
+ 'target_file',
224
+ 'filename',
225
+ 'filePath',
226
+ 'notebook_path',
227
+ ];
228
+
229
+ /** Reduce a `pre_tool_use` payload to the fields a rule may read. */
230
+ export function extractCall(payload: JsonObject): ExtractedCall {
231
+ const tool = str(
232
+ (payload['tool_name'] ?? payload['toolName'] ?? payload['tool']) as JsonValue | undefined,
233
+ );
234
+ const tiRaw = payload['tool_input'] ?? payload['toolInput'] ?? payload['input'];
235
+ const ti: JsonObject = isObject(tiRaw) ? tiRaw : {};
236
+
237
+ const paths: string[] = [];
238
+ for (const key of PATH_KEYS) {
239
+ const v = ti[key];
240
+ if (typeof v === 'string' && v !== '') {
241
+ paths.push(v.replace(/\\/g, '/'));
242
+ }
243
+ }
244
+ // A payload carrying a `paths` array (batch edit shapes) is read too; the
245
+ // single-file keys above already cover MultiEdit, which names one file
246
+ // plus a list of edits.
247
+ const many = ti['paths'];
248
+ if (Array.isArray(many)) {
249
+ for (const v of many) {
250
+ if (typeof v === 'string' && v !== '') paths.push(v.replace(/\\/g, '/'));
251
+ }
252
+ }
253
+
254
+ const command = str((ti['command'] ?? payload['command']) as JsonValue | undefined);
255
+ const cwd = str((payload['cwd'] ?? payload['workspace_root']) as JsonValue | undefined);
256
+ return { tool, paths, command, cwd };
257
+ }
258
+
259
+ /** Candidate roots for an environment predicate, most specific first, deduped. */
260
+ export function ruleRoots(call: ExtractedCall, workspaceRoot: string): RuleContext {
261
+ const roots: string[] = [];
262
+ for (const r of [call.cwd, workspaceRoot]) {
263
+ if (r !== '' && !roots.includes(r)) roots.push(r);
264
+ }
265
+ return { roots };
266
+ }
267
+
268
+ /**
269
+ * First matching rule, or null. Pure over the call except for a matched row's
270
+ * own `requires` predicate, which is deliberately evaluated last.
271
+ */
272
+ export function matchRule(
273
+ call: ExtractedCall,
274
+ ctx: RuleContext,
275
+ rules: readonly RecallRule[] = RECALL_RULES,
276
+ ): RecallRule | null {
277
+ for (const rule of rules) {
278
+ if (!rule.tools.has(call.tool)) continue;
279
+ if (!rule.test(call)) continue;
280
+ if (rule.requires !== undefined && !rule.requires(ctx)) continue;
281
+ return rule;
282
+ }
283
+ return null;
284
+ }
285
+
286
+ export interface StoreResolution {
287
+ /** Absolute path to the memory directory, or null when none was found. */
288
+ dir: string | null;
289
+ /** Every candidate inspected, in order — the stderr diagnostic's content. */
290
+ tried: readonly string[];
291
+ }
292
+
293
+ /**
294
+ * Resolve the host's memory directory for `startDir`.
295
+ *
296
+ * The store lives at `<projectsRoot>/<slugged cwd>/memory`, and in a git
297
+ * worktree the worktree's OWN slug has a project directory with no `memory/`
298
+ * in it — the store belongs to the parent checkout. So this walks ancestors
299
+ * rather than assuming one slug, which also covers a subdirectory cwd.
300
+ *
301
+ * `AGENT_CONFIG_MEMORY_DIR` overrides everything, for tests and for a user
302
+ * whose store is elsewhere.
303
+ */
304
+ export function resolveMemoryDir(
305
+ startDir: string,
306
+ projectsRoot: string = DEFAULT_PROJECTS_ROOT,
307
+ env: NodeJS.ProcessEnv = process.env,
308
+ ): StoreResolution {
309
+ const override = env['AGENT_CONFIG_MEMORY_DIR'];
310
+ if (typeof override === 'string' && override !== '') {
311
+ return { dir: isDirectory(override) ? override : null, tried: [override] };
312
+ }
313
+ const tried: string[] = [];
314
+ let cur = path.resolve(startDir);
315
+ for (let i = 0; i < MAX_ANCESTOR_WALK; i += 1) {
316
+ const candidate = path.join(projectsRoot, projectStoreSlug(cur), 'memory');
317
+ tried.push(candidate);
318
+ if (isDirectory(candidate)) {
319
+ return { dir: candidate, tried };
320
+ }
321
+ const parent = path.dirname(cur);
322
+ if (parent === cur) break;
323
+ cur = parent;
324
+ }
325
+ return { dir: null, tried };
326
+ }
327
+
328
+ function isDirectory(p: string): boolean {
329
+ try {
330
+ return fs.statSync(p).isDirectory();
331
+ } catch {
332
+ return false;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * The memory's own `description:` frontmatter line, or null. Reads at most the
338
+ * file's head — the body is never loaded, and never injected.
339
+ */
340
+ export function readMemoryDescription(memoryDir: string, slug: string): string | null {
341
+ let head: string;
342
+ try {
343
+ const fd = fs.openSync(path.join(memoryDir, `${slug}.md`), 'r');
344
+ try {
345
+ const buf = Buffer.alloc(4096);
346
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
347
+ head = buf.subarray(0, n).toString('utf8');
348
+ } finally {
349
+ fs.closeSync(fd);
350
+ }
351
+ } catch {
352
+ return null;
353
+ }
354
+ const m = /^description:\s*(.*)$/m.exec(head);
355
+ if (m === null) return null;
356
+ let value = (m[1] as string).trim();
357
+ if (
358
+ (value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
359
+ (value.startsWith("'") && value.endsWith("'") && value.length > 1)
360
+ ) {
361
+ value = value.slice(1, -1);
362
+ }
363
+ return value === '' ? null : value;
364
+ }
365
+
366
+ /** Truncate to at most `max` BYTES of UTF-8, on a character boundary. */
367
+ export function truncateBytes(text: string, max: number): string {
368
+ if (Buffer.byteLength(text, 'utf8') <= max) return text;
369
+ const ell = '...';
370
+ const room = Math.max(0, max - Buffer.byteLength(ell, 'utf8'));
371
+ let out = '';
372
+ let used = 0;
373
+ for (const ch of text) {
374
+ const w = Buffer.byteLength(ch, 'utf8');
375
+ if (used + w > room) break;
376
+ out += ch;
377
+ used += w;
378
+ }
379
+ return out + ell;
380
+ }
381
+
382
+ export interface RecallEmission {
383
+ reason: string;
384
+ additional_context: string;
385
+ }
386
+
387
+ /**
388
+ * Build the emission, guaranteed under `CAP_BYTES` for `reason` +
389
+ * `additional_context` combined. The description is the elastic part: the
390
+ * pointer and the why-now are what make the line actionable, so they are
391
+ * reserved first and the description absorbs the truncation.
392
+ */
393
+ export function buildEmission(
394
+ rule: RecallRule,
395
+ store: StoreResolution,
396
+ description: string | null,
397
+ cap: number = CAP_BYTES,
398
+ ): RecallEmission {
399
+ const reason = `memory-recall: ${rule.slug}`;
400
+ const pointer =
401
+ store.dir === null
402
+ ? `memory store not resolvable here — the slug is still the key: grep the store for ${rule.slug}`
403
+ : `read: ${path.join(store.dir, `${rule.slug}.md`)}`;
404
+ const head =
405
+ `memory-recall — you have a memory for this, written before today: ${rule.slug}. ` +
406
+ `Why now: ${rule.why}. ${pointer}`;
407
+ const budget = cap - Buffer.byteLength(reason, 'utf8');
408
+ if (description === null) {
409
+ return { reason, additional_context: truncateBytes(head, budget) };
410
+ }
411
+ const room = budget - Buffer.byteLength(`${head} It says: ""`, 'utf8');
412
+ if (room <= 16) {
413
+ return { reason, additional_context: truncateBytes(head, budget) };
414
+ }
415
+ return { reason, additional_context: `${head} It says: "${truncateBytes(description, room)}"` };
416
+ }
417
+
418
+ /** Hashed session key — mirrors `reread_guard_hook.deriveSessionKey`. */
419
+ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
420
+ const raw =
421
+ str(envelope['session_id'] as JsonValue | undefined) ||
422
+ str((payload['session_id'] ?? payload['sessionId']) as JsonValue | undefined);
423
+ return crypto
424
+ .createHash('sha256')
425
+ .update(raw || 'unknown-session')
426
+ .digest('hex');
427
+ }
428
+
429
+ export function latchFile(workspaceRoot: string, sessionKey: string): string {
430
+ return path.join(
431
+ workspaceRoot,
432
+ 'agents',
433
+ 'runtime',
434
+ 'state',
435
+ 'memory-recall',
436
+ `${sessionKey}.json`,
437
+ );
438
+ }
439
+
440
+ /**
441
+ * Per-session, per-slug latch. A memory delivered once has been delivered; a
442
+ * second copy on the next build command is the noise that gets a nudge tuned
443
+ * out. Slug keys and a timestamp only — the record type has no field able to
444
+ * hold a path, a command, or any free text.
445
+ */
446
+ export function readLatch(workspaceRoot: string, sessionKey: string): Record<string, number> {
447
+ try {
448
+ const parsed = JSON.parse(fs.readFileSync(latchFile(workspaceRoot, sessionKey), 'utf8'));
449
+ return isObject(parsed) ? (parsed as unknown as Record<string, number>) : {};
450
+ } catch {
451
+ return {};
452
+ }
453
+ }
454
+
455
+ function writeLatch(
456
+ workspaceRoot: string,
457
+ sessionKey: string,
458
+ latch: Record<string, number>,
459
+ ): void {
460
+ try {
461
+ atomic_write_json(latchFile(workspaceRoot, sessionKey), latch as unknown as JsonObject);
462
+ } catch {
463
+ // a state-write failure must never affect the tool call
464
+ }
465
+ }
466
+
467
+ export function main(): number {
468
+ try {
469
+ const [envelope, payload] = unwrap(readHookStdin(), 'claude');
470
+
471
+ const event = str(envelope['event'] as JsonValue | undefined);
472
+ if (event !== '' && event !== 'pre_tool_use') return EXIT_ALLOW;
473
+
474
+ const call = extractCall(payload);
475
+ if (call.tool === '') return EXIT_ALLOW;
476
+
477
+ const workspaceRoot =
478
+ str(envelope['workspace_root'] as JsonValue | undefined).trim() || process.cwd();
479
+
480
+ const ctx = ruleRoots(call, workspaceRoot);
481
+ const rule = matchRule(call, ctx);
482
+ if (rule === null) return EXIT_ALLOW;
483
+
484
+ const sessionKey = deriveSessionKey(envelope, payload);
485
+ const latch = readLatch(workspaceRoot, sessionKey);
486
+ if (latch[rule.slug] !== undefined) return EXIT_ALLOW;
487
+
488
+ const store = resolveMemoryDir(workspaceRoot);
489
+ if (store.dir === null) {
490
+ process.stderr.write(
491
+ `memory-recall: rule ${rule.id} matched but no memory store was ` +
492
+ `resolvable; tried ${store.tried.join(', ')}\n`,
493
+ );
494
+ }
495
+ const description = store.dir === null ? null : readMemoryDescription(store.dir, rule.slug);
496
+ if (store.dir !== null && description === null) {
497
+ process.stderr.write(
498
+ `memory-recall: rule ${rule.id} matched but ${rule.slug}.md was ` +
499
+ `unreadable under ${store.dir}\n`,
500
+ );
501
+ }
502
+
503
+ latch[rule.slug] = Date.now();
504
+ writeLatch(workspaceRoot, sessionKey, latch);
505
+
506
+ process.stdout.write(
507
+ `${JSON.stringify({ decision: 'warn', ...buildEmission(rule, store, description) })}\n`,
508
+ );
509
+ return EXIT_WARN;
510
+ } catch {
511
+ return EXIT_ALLOW; // malformed payload / any error — never touch the tool call
512
+ }
513
+ }
514
+
515
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
516
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
517
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
518
+ function _isCliEntry(): boolean {
519
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
520
+ return false;
521
+ }
522
+ if (process.argv[1] === undefined) {
523
+ return false;
524
+ }
525
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
526
+ if (import.meta.url === argvUrl) {
527
+ return true;
528
+ }
529
+ try {
530
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
531
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
532
+ return here === argv;
533
+ } catch {
534
+ return false;
535
+ }
536
+ }
537
+
538
+ if (_isCliEntry()) {
539
+ process.exit(main());
540
+ }