@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
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Edit-shape advisory — `post_tool_use` concern
4
+ * (road-to-token-economy-cache Phase 5.1/5.2).
5
+ *
6
+ * Fires when a `Write` tool call REPLACED an existing, git-tracked file of at
7
+ * least `MIN_FILE_LINES` lines while the effective change was at most
8
+ * `MAX_DIFF_RATIO` of the file — the wasteful shape where the agent re-emits
9
+ * a whole file to change a few lines. One advisory line names the cheaper
10
+ * primitive: a targeted Edit pays for the diff, not the file.
11
+ *
12
+ * ## Committed thresholds (roadmap 5.1 — committed in this concern header)
13
+ *
14
+ * `MIN_FILE_LINES = 50` and `MAX_DIFF_RATIO = 0.20` are COMMITTED constants,
15
+ * not tunables: the roadmap requires the thresholds to live in the concern
16
+ * header so a threshold change is a reviewed diff, never a silent drift.
17
+ * Ratio definition: `(added + deleted) / current file line count` from
18
+ * `git diff --numstat` — a 5-line in-place change on a 60-line file measures
19
+ * as 10/60 ≈ 17 % (numstat counts a changed line once on each side).
20
+ *
21
+ * ## Honest mechanism — why git, not the payload
22
+ *
23
+ * At PostToolUse time the target file is ALREADY overwritten on disk, and the
24
+ * PostToolUse payload carries only the NEW content (`tool_input.content`) —
25
+ * the pre-image is not observable from the event itself (verified against the
26
+ * payload shapes `design_slop_hook.ts` / `orchestration_record_hook.ts`
27
+ * read). The pre-image that IS still observable is git's: for a tracked file,
28
+ * `git diff HEAD --numstat -- <path>` diffs the working tree against the last
29
+ * commit, so `added + deleted` is a deterministic effective-diff measure.
30
+ * Two consequences, stated rather than hidden:
31
+ * - The measured diff is CUMULATIVE since HEAD for that path, not the delta
32
+ * of this one Write. A session's second small Write to the same file
33
+ * measures both writes together — an over-count that only makes the
34
+ * ratio LARGER, i.e. biases toward silence, never toward a false fire.
35
+ * - An untracked (brand-new) file has no HEAD pre-image and produces no
36
+ * numstat row → silent. Correct: writing a new file is not a wasteful
37
+ * replace, it is the only primitive that creates one.
38
+ * A path with NO unstaged/staged modification vs HEAD (diff of 0 lines) is
39
+ * also silent — the fire condition per the roadmap is "tracked in git with an
40
+ * observable modification whose ratio is small", and a 0-row diff cannot
41
+ * distinguish a no-op Write from an already-committed state.
42
+ *
43
+ * ## Once-per-session gate (roadmap 5.2)
44
+ *
45
+ * Mirrors `end_review_nudge_hook.ts`'s F2 marker: a JSON marker under
46
+ * `<workspace_root>/agents/runtime/state/edit-shape/<sha256(session)>.json`
47
+ * (gitignored — `agents/runtime/` is blanket-ignored) records that THIS
48
+ * session already fired; later qualifying Writes in the same session return
49
+ * silently. The marker carries a timestamp and nothing else — counts/flags
50
+ * only, never content. The economy nudge must not itself become an economy
51
+ * problem.
52
+ *
53
+ * ## Path exemptions (committed constant — shared list with `reread-guard`)
54
+ *
55
+ * `dist/**`, any path containing `/generated/`, `*.lock`, `*.min.*`, and
56
+ * `agents/runtime/**` are exempt: generated/derived surfaces are legitimately
57
+ * re-emitted wholesale, and nudging there is pure noise.
58
+ *
59
+ * ## Delivery — `{decision:"warn", reason, additional_context}` at exit 2
60
+ *
61
+ * Mirrors `delegation_nudge_hook.ts` exactly. Severity is taken from the
62
+ * EXIT CODE, not the `decision` field: a warn verdict is reported at exit
63
+ * `EXIT_WARN` (2) so `host_semantics.emitFor` reduces it to severity `"warn"`
64
+ * and forwards `additional_context` into
65
+ * `hookSpecificOutput.additionalContext`.
66
+ *
67
+ * WHY EXIT 2 NEVER BLOCKS (copied from `delegation_nudge_hook.ts`, verified
68
+ * there against the real dispatcher): `host_semantics.emitFor`'s
69
+ * `severity === "warn"` branch returns `{exit: 0, stdout:
70
+ * claudeAdditionalContext(event, reason)}` UNCONDITIONALLY — it does not
71
+ * consult `CLAUDE_BLOCK_CAPABLE_EVENTS` at all (that check only gates the
72
+ * `"block"` severity branch). On the verified `claude` platform the real
73
+ * process exit handed back to the host is always 0 for a warn verdict; the
74
+ * internal `EXIT_WARN` is dispatcher-internal bookkeeping, never the byte
75
+ * that reaches Claude Code.
76
+ *
77
+ * ROBUSTNESS: malformed payload → exit 0 silently; every fs/git error is
78
+ * swallowed. This concern never blocks a tool call on any path.
79
+ */
80
+ import { spawnSync } from 'node:child_process';
81
+ import * as crypto from 'node:crypto';
82
+ import * as fs from 'node:fs';
83
+ import * as path from 'node:path';
84
+ import { fileURLToPath, pathToFileURL } from 'node:url';
85
+
86
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
87
+ import { readHookStdin } from './hook_stdin.js';
88
+ import { atomic_write_json } from './state_io.js';
89
+
90
+ const EXIT_ALLOW = 0;
91
+ // Severity is taken from the EXIT CODE, not from the `decision` field in the
92
+ // stdout payload — see the file header for why this never blocks on `claude`.
93
+ const EXIT_WARN = 2;
94
+
95
+ /** Committed threshold (roadmap 5.1): minimum current line count of the
96
+ * replaced file before this concern has anything to say. */
97
+ export const MIN_FILE_LINES = 50;
98
+
99
+ /** Committed threshold (roadmap 5.1): maximum `(added+deleted)/fileLines`
100
+ * ratio for the Write to count as a full-rewrite-with-small-diff. */
101
+ export const MAX_DIFF_RATIO = 0.2;
102
+
103
+ /**
104
+ * Committed exemption list (roadmap 5.2/6.2 — deliberately duplicated in
105
+ * `reread_guard_hook.ts`; the two concerns share the list by convention, and
106
+ * a shared module would couple two independently-rollback-able hooks).
107
+ */
108
+ export const EXEMPT_PATH_PATTERNS: readonly RegExp[] = [
109
+ /(^|\/)dist\//i, // dist/**
110
+ /\/generated\//i, // any path containing /generated/
111
+ /\.lock$/i, // *.lock
112
+ /\.min\.[^/]+$/i, // *.min.*
113
+ /(^|\/)agents\/runtime\//i, // agents/runtime/**
114
+ ];
115
+
116
+ function isObject(v: unknown): v is JsonObject {
117
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
118
+ }
119
+
120
+ function str(v: JsonValue | undefined): string {
121
+ return typeof v === 'string' ? v : '';
122
+ }
123
+
124
+ /** True when `filePath` (absolute or repo-relative) matches the exemption list. */
125
+ export function isExemptPath(filePath: string): boolean {
126
+ const normalized = filePath.replace(/\\/g, '/');
127
+ return EXEMPT_PATH_PATTERNS.some((re) => re.test(normalized));
128
+ }
129
+
130
+ /** One `git diff --numstat` row reduced to its added+deleted line count. */
131
+ export function parseNumstatLines(raw: string): number {
132
+ let total = 0;
133
+ for (const line of raw.split('\n')) {
134
+ const m = /^(\d+|-)\t(\d+|-)\t.+$/.exec(line);
135
+ if (!m) continue;
136
+ const added = m[1] === '-' ? 0 : Number(m[1]);
137
+ const deleted = m[2] === '-' ? 0 : Number(m[2]);
138
+ total += added + deleted;
139
+ }
140
+ return total;
141
+ }
142
+
143
+ /**
144
+ * Effective-diff line count for ONE path: `git diff HEAD --numstat -- <path>`
145
+ * at `cwd` (working tree + index vs the last commit — git still holds the
146
+ * pre-image the PostToolUse payload no longer carries). Fails closed to `0`
147
+ * (silent) — no repo, no HEAD, untracked path, or `git` missing all read as
148
+ * "nothing observable to nudge about".
149
+ */
150
+ export function effectiveDiffLines(cwd: string, relPath: string): number {
151
+ try {
152
+ const r = spawnSync('git', ['diff', 'HEAD', '--numstat', '--', relPath], {
153
+ cwd,
154
+ encoding: 'utf8',
155
+ maxBuffer: 16 * 1024 * 1024,
156
+ });
157
+ if (r.status !== 0) return 0;
158
+ return parseNumstatLines(r.stdout ?? '');
159
+ } catch {
160
+ return 0;
161
+ }
162
+ }
163
+
164
+ /** Current line count of the file on disk. Fails closed to `0` (silent). */
165
+ export function fileLineCount(absPath: string): number {
166
+ try {
167
+ const text = fs.readFileSync(absPath, 'utf8');
168
+ if (text.length === 0) return 0;
169
+ // Trailing newline does not open a new line.
170
+ return text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
171
+ } catch {
172
+ return 0;
173
+ }
174
+ }
175
+
176
+ /** The exactly-one advisory line this concern injects when it fires. */
177
+ export function buildAdvisoryLine(fileLines: number, diffLines: number): string {
178
+ const pct = Math.round((diffLines / fileLines) * 100);
179
+ return (
180
+ `this Write replaced ${fileLines} lines for an effective diff of ~${pct}% ` +
181
+ `(${diffLines} changed lines) — a targeted Edit pays for the diff, not the file.`
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Stable, filesystem-safe once-per-session key — mirrors
187
+ * `end_review_nudge_hook.deriveSessionKey`: the envelope's `session_id`
188
+ * (falling back to the payload's) is HASHED, never stored raw.
189
+ */
190
+ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
191
+ const raw =
192
+ str(envelope['session_id'] as JsonValue | undefined) ||
193
+ str((payload['session_id'] ?? payload['sessionId']) as JsonValue | undefined);
194
+ const material = raw || 'unknown-session';
195
+ return crypto.createHash('sha256').update(material).digest('hex');
196
+ }
197
+
198
+ function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
199
+ return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'edit-shape', `${sessionKey}.json`);
200
+ }
201
+
202
+ /** True when THIS session already fired. Fails closed to `false` (may repeat,
203
+ * never silences over an unreadable state dir). */
204
+ export function hasFiredThisSession(workspaceRoot: string, sessionKey: string): boolean {
205
+ try {
206
+ return fs.existsSync(sessionStateFile(workspaceRoot, sessionKey));
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ function markFiredThisSession(workspaceRoot: string, sessionKey: string): void {
213
+ try {
214
+ atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), {
215
+ fired_at: new Date().toISOString(),
216
+ });
217
+ } catch {
218
+ // a state-write failure must never affect the tool call
219
+ }
220
+ }
221
+
222
+ function extractToolName(payload: JsonObject): string {
223
+ return str((payload['tool_name'] ?? payload['toolName'] ?? payload['tool']) as JsonValue | undefined);
224
+ }
225
+
226
+ function extractFilePath(payload: JsonObject): string {
227
+ const ti = payload['tool_input'] ?? payload['toolInput'] ?? payload['input'];
228
+ if (!isObject(ti)) return '';
229
+ return str((ti['file_path'] ?? ti['path'] ?? ti['filePath']) as JsonValue | undefined);
230
+ }
231
+
232
+ export function main(): number {
233
+ try {
234
+ const [envelope, payload] = unwrap(readHookStdin(), 'claude');
235
+
236
+ const event = str(envelope['event'] as JsonValue | undefined);
237
+ if (event !== '' && event !== 'post_tool_use') return EXIT_ALLOW;
238
+
239
+ const toolName = extractToolName(payload);
240
+ if (toolName !== 'Write') return EXIT_ALLOW; // manifest `tools: [Write]` twin — belt and braces
241
+
242
+ const filePath = extractFilePath(payload);
243
+ if (!filePath) return EXIT_ALLOW;
244
+
245
+ const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined).trim() || process.cwd();
246
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
247
+ const relPath = path.relative(workspaceRoot, absPath);
248
+ if (isExemptPath(relPath) || isExemptPath(absPath)) return EXIT_ALLOW;
249
+
250
+ const fileLines = fileLineCount(absPath);
251
+ if (fileLines < MIN_FILE_LINES) return EXIT_ALLOW;
252
+
253
+ const diffLines = effectiveDiffLines(workspaceRoot, relPath.startsWith('..') ? absPath : relPath);
254
+ // 0 = no observable modification vs HEAD (untracked file, clean path,
255
+ // no repo) — silent per the header's honest-mechanism note.
256
+ if (diffLines === 0) return EXIT_ALLOW;
257
+ if (diffLines / fileLines > MAX_DIFF_RATIO) return EXIT_ALLOW;
258
+
259
+ // Cheap checks first, then the once-per-session gate (same ordering
260
+ // discipline as end_review_nudge's F12).
261
+ const sessionKey = deriveSessionKey(envelope, payload);
262
+ if (hasFiredThisSession(workspaceRoot, sessionKey)) return EXIT_ALLOW;
263
+ markFiredThisSession(workspaceRoot, sessionKey);
264
+
265
+ process.stdout.write(
266
+ `${JSON.stringify({
267
+ decision: 'warn',
268
+ reason: `edit-shape: Write replaced a ${fileLines}-line file for a ${diffLines}-line effective diff`,
269
+ additional_context: buildAdvisoryLine(fileLines, diffLines),
270
+ })}\n`,
271
+ );
272
+ return EXIT_WARN;
273
+ } catch {
274
+ return EXIT_ALLOW; // malformed payload / any error — never touch the tool call
275
+ }
276
+ }
277
+
278
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
279
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
280
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
281
+ function _isCliEntry(): boolean {
282
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
283
+ return false;
284
+ }
285
+ if (process.argv[1] === undefined) {
286
+ return false;
287
+ }
288
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
289
+ if (import.meta.url === argvUrl) {
290
+ return true;
291
+ }
292
+ try {
293
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
294
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
295
+ return here === argv;
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+
301
+ if (_isCliEntry()) {
302
+ process.exit(main());
303
+ }
@@ -131,6 +131,11 @@ export interface DispatchFacts {
131
131
  /** Absolute measured tokens the dispatched slice consumed. `null` = not observed (async). */
132
132
  totalTokens: number | null;
133
133
  totalDurationMs: number | null;
134
+ /** Serialized LENGTH of the tool result — the chars this dispatch returned
135
+ * into the orchestrator context (Phase 6.3 return-channel detector).
136
+ * A COUNT derived from the result object; the content itself is never
137
+ * read into the record (privacy by construction). `null` = async ack. */
138
+ returnChannelChars: number | null;
134
139
  isAsync: boolean;
135
140
  isError: boolean;
136
141
  }
@@ -199,7 +204,19 @@ export function extractDispatchFacts(payload: JsonObject): DispatchFacts {
199
204
  const isAsync = result !== null && (result['isAsync'] === true || result['status'] === 'async_launched');
200
205
  const isError = payload['is_error'] === true || payload['isError'] === true || (result !== null && result['is_error'] === true);
201
206
 
202
- return { subagentType, resolvedModel, totalTokens, totalDurationMs, isAsync, isError };
207
+ // Phase 6.3 return-channel size: measured as the serialized LENGTH of the
208
+ // result object — a count; the content never enters the record. Async
209
+ // acks carry no result payload worth measuring (null, never fabricated).
210
+ let returnChannelChars: number | null = null;
211
+ if (result !== null && !isAsync) {
212
+ try {
213
+ returnChannelChars = JSON.stringify(result).length;
214
+ } catch {
215
+ returnChannelChars = null;
216
+ }
217
+ }
218
+
219
+ return { subagentType, resolvedModel, totalTokens, totalDurationMs, returnChannelChars, isAsync, isError };
203
220
  }
204
221
 
205
222
  /**
@@ -231,6 +248,7 @@ export function buildRecordInput(facts: DispatchFacts, ts: string, id: string):
231
248
  }
232
249
  if (facts.totalTokens !== null) input.dispatch_tokens = facts.totalTokens;
233
250
  if (facts.totalDurationMs !== null) input.wall_clock_ms = facts.totalDurationMs;
251
+ if (facts.returnChannelChars !== null) input.return_channel_chars = facts.returnChannelChars;
234
252
  }
235
253
  // Async launch ack: every metric field stays absent (undefined → the
236
254
  // schema's own null/0 default) rather than fabricated from nothing.
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Re-read guard + post-edit scope hint — `pre_tool_use` concern
4
+ * (road-to-token-economy-cache Phase 6.1/6.2).
5
+ *
6
+ * Two signals, one session-scoped read ledger:
7
+ *
8
+ * 6.1 A full-file `Read` of a path that was ALREADY fully read this session
9
+ * AND is unchanged since (mtime + size compare against the ledger) gets
10
+ * one advisory line naming the earlier read and the ranged-read
11
+ * alternative. A ranged read (any `offset`/`limit` in the tool input)
12
+ * is already the cheap form and never fires — nor is it recorded as a
13
+ * full read.
14
+ * 6.2 A full-file `Read` immediately following THIS session's own
15
+ * `Edit`/`Write` to the same path (last-write tracked in the same
16
+ * ledger — PreToolUse sees those tool calls too) gets the same advisory
17
+ * shape pointing at a ranged re-read of the edited hunk.
18
+ *
19
+ * ## The ledger — paths and numbers only, never content
20
+ *
21
+ * `<workspace_root>/agents/runtime/state/reread-guard/<sha256(session)>.json`
22
+ * (gitignored — `agents/runtime/` is blanket-ignored), keyed by session id
23
+ * the same way `end_review_nudge_hook.deriveSessionKey` does (hashed, never
24
+ * raw). Per path it stores `{mtime, size, ts}` for reads, a `ts` for the last
25
+ * own write, and a per-path fired flag — NO field can hold file content, by
26
+ * construction (same PII/content-exclusion-by-construction discipline as the
27
+ * telemetry schemas).
28
+ *
29
+ * Population: PreToolUse sees the Read BEFORE it happens, so the ledger is
30
+ * populated in this same hook — after the staleness check, the current
31
+ * `{mtime, size}` of the path is recorded (the read is about to happen;
32
+ * recording pre-read is equivalent for every subsequent comparison).
33
+ *
34
+ * ## ADVISORY ONLY — never a block, never exit 1
35
+ *
36
+ * This concern binds on `pre_tool_use`, the slot that carries the blocking
37
+ * guards — but it is advisory by construction and never returns exit 1: a
38
+ * stale-ledger false positive on a BLOCK would corrupt work (the agent would
39
+ * be denied a read it genuinely needs over a bookkeeping artifact). The
40
+ * escalation decision waits on the 6.4 telemetry, per the roadmap.
41
+ *
42
+ * ## Path exemptions (committed constant — shared list with `edit-shape`)
43
+ *
44
+ * `dist/**`, any path containing `/generated/`, `*.lock`, `*.min.*`, and
45
+ * `agents/runtime/**`. Deliberately DUPLICATED from `edit_shape_hook.ts`
46
+ * (noted there too): the two concerns share the list by convention, and a
47
+ * shared module would couple two independently-rollback-able hooks.
48
+ *
49
+ * ## Delivery — `{decision:"warn", reason, additional_context}` at exit 2
50
+ *
51
+ * Mirrors `delegation_nudge_hook.ts` exactly. WHY EXIT 2 NEVER BLOCKS
52
+ * (copied from that hook's verified trace): `host_semantics.emitFor`'s
53
+ * `severity === "warn"` branch returns `{exit: 0, stdout:
54
+ * claudeAdditionalContext(event, reason)}` UNCONDITIONALLY — it does not
55
+ * consult `CLAUDE_BLOCK_CAPABLE_EVENTS` (that check only gates the `"block"`
56
+ * severity branch). On the verified `claude` platform the host-facing exit is
57
+ * always 0 for a warn verdict; `EXIT_WARN` is dispatcher-internal
58
+ * bookkeeping, never the byte that reaches the host.
59
+ *
60
+ * ROBUSTNESS: malformed payload → exit 0 silently; every fs error swallowed;
61
+ * an unreadable/corrupt ledger resets to empty (worst case: one missed or
62
+ * repeated advisory, never a lost read).
63
+ */
64
+ import * as crypto from 'node:crypto';
65
+ import * as fs from 'node:fs';
66
+ import * as path from 'node:path';
67
+ import { fileURLToPath, pathToFileURL } from 'node:url';
68
+
69
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
70
+ import { readHookStdin } from './hook_stdin.js';
71
+ import { atomic_write_json } from './state_io.js';
72
+
73
+ const EXIT_ALLOW = 0;
74
+ // Severity is taken from the EXIT CODE, not from the `decision` field — see
75
+ // the file header for why this never blocks on `claude`. This concern never
76
+ // returns exit 1 on any path.
77
+ const EXIT_WARN = 2;
78
+
79
+ /**
80
+ * Committed exemption list — deliberately duplicated from
81
+ * `edit_shape_hook.ts` (see the header note there and here).
82
+ */
83
+ export const EXEMPT_PATH_PATTERNS: readonly RegExp[] = [
84
+ /(^|\/)dist\//i, // dist/**
85
+ /\/generated\//i, // any path containing /generated/
86
+ /\.lock$/i, // *.lock
87
+ /\.min\.[^/]+$/i, // *.min.*
88
+ /(^|\/)agents\/runtime\//i, // agents/runtime/**
89
+ ];
90
+
91
+ /** Tool names this concern reacts to (manifest `tools:` twin). */
92
+ export const READ_TOOLS: ReadonlySet<string> = new Set(['Read']);
93
+ export const WRITE_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write']);
94
+
95
+ function isObject(v: unknown): v is JsonObject {
96
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
97
+ }
98
+
99
+ function str(v: JsonValue | undefined): string {
100
+ return typeof v === 'string' ? v : '';
101
+ }
102
+
103
+ /** True when `filePath` (absolute or repo-relative) matches the exemption list. */
104
+ export function isExemptPath(filePath: string): boolean {
105
+ const normalized = filePath.replace(/\\/g, '/');
106
+ return EXEMPT_PATH_PATTERNS.some((re) => re.test(normalized));
107
+ }
108
+
109
+ /** Per-path read record — NUMBERS ONLY (mtime ms, byte size, epoch-ms ts). */
110
+ export interface ReadRecord {
111
+ mtime: number;
112
+ size: number;
113
+ ts: number;
114
+ }
115
+
116
+ /** The session ledger — paths as keys, numbers as values, no content field. */
117
+ export interface Ledger {
118
+ reads: Record<string, ReadRecord>;
119
+ /** Last own Edit/Write per path, epoch ms. */
120
+ writes: Record<string, number>;
121
+ /** Per-path once-per-session advisory latch. */
122
+ fired: Record<string, boolean>;
123
+ }
124
+
125
+ function emptyLedger(): Ledger {
126
+ return { reads: {}, writes: {}, fired: {} };
127
+ }
128
+
129
+ /** Hashed session key — mirrors `end_review_nudge_hook.deriveSessionKey`. */
130
+ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
131
+ const raw =
132
+ str(envelope['session_id'] as JsonValue | undefined) ||
133
+ str((payload['session_id'] ?? payload['sessionId']) as JsonValue | undefined);
134
+ const material = raw || 'unknown-session';
135
+ return crypto.createHash('sha256').update(material).digest('hex');
136
+ }
137
+
138
+ export function ledgerFile(workspaceRoot: string, sessionKey: string): string {
139
+ return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'reread-guard', `${sessionKey}.json`);
140
+ }
141
+
142
+ export function readLedger(workspaceRoot: string, sessionKey: string): Ledger {
143
+ try {
144
+ const parsed = JSON.parse(fs.readFileSync(ledgerFile(workspaceRoot, sessionKey), 'utf8'));
145
+ if (!isObject(parsed)) return emptyLedger();
146
+ return {
147
+ reads: isObject(parsed['reads']) ? (parsed['reads'] as unknown as Record<string, ReadRecord>) : {},
148
+ writes: isObject(parsed['writes']) ? (parsed['writes'] as Record<string, number>) : {},
149
+ fired: isObject(parsed['fired']) ? (parsed['fired'] as Record<string, boolean>) : {},
150
+ };
151
+ } catch {
152
+ return emptyLedger(); // missing/corrupt ledger → fresh; never a crash
153
+ }
154
+ }
155
+
156
+ function writeLedger(workspaceRoot: string, sessionKey: string, ledger: Ledger): void {
157
+ try {
158
+ atomic_write_json(ledgerFile(workspaceRoot, sessionKey), ledger);
159
+ } catch {
160
+ // a state-write failure must never affect the tool call
161
+ }
162
+ }
163
+
164
+ function extractToolName(payload: JsonObject): string {
165
+ return str((payload['tool_name'] ?? payload['toolName'] ?? payload['tool']) as JsonValue | undefined);
166
+ }
167
+
168
+ function extractToolInput(payload: JsonObject): JsonObject {
169
+ const ti = payload['tool_input'] ?? payload['toolInput'] ?? payload['input'];
170
+ return isObject(ti) ? ti : {};
171
+ }
172
+
173
+ function extractFilePath(toolInput: JsonObject): string {
174
+ return str((toolInput['file_path'] ?? toolInput['path'] ?? toolInput['filePath']) as JsonValue | undefined);
175
+ }
176
+
177
+ /** True when the incoming Read is already narrowed (offset/limit present). */
178
+ export function isRangedRead(toolInput: JsonObject): boolean {
179
+ for (const key of ['offset', 'limit', 'start_line', 'end_line', 'startLine', 'endLine']) {
180
+ const v = toolInput[key];
181
+ if (typeof v === 'number' || (typeof v === 'string' && v !== '')) return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ /** 6.1 advisory — duplicate full read of an unchanged, already-read path. */
187
+ export function buildDuplicateReadLine(relPath: string, earlierTs: number): string {
188
+ const when = new Date(earlierTs).toISOString();
189
+ return (
190
+ `re-read guard: ${relPath} was already fully read this session (${when}) and is ` +
191
+ `unchanged since (same mtime+size) — the content is still in context; if you only ` +
192
+ `need part of it, a ranged Read (offset/limit) pays for the range, not the file.`
193
+ );
194
+ }
195
+
196
+ /** 6.2 advisory — full re-read right after this session's own edit. */
197
+ export function buildPostEditReadLine(relPath: string): string {
198
+ return (
199
+ `re-read guard: this session just edited ${relPath} — a full-file re-read re-pays ` +
200
+ `for content you already have; a ranged Read (offset/limit) over the edited hunk ` +
201
+ `is enough to verify the change.`
202
+ );
203
+ }
204
+
205
+ export function main(): number {
206
+ try {
207
+ const [envelope, payload] = unwrap(readHookStdin(), 'claude');
208
+
209
+ const event = str(envelope['event'] as JsonValue | undefined);
210
+ if (event !== '' && event !== 'pre_tool_use') return EXIT_ALLOW;
211
+
212
+ const toolName = extractToolName(payload);
213
+ const isRead = READ_TOOLS.has(toolName);
214
+ const isWrite = WRITE_TOOLS.has(toolName);
215
+ if (!isRead && !isWrite) return EXIT_ALLOW;
216
+
217
+ const toolInput = extractToolInput(payload);
218
+ const filePath = extractFilePath(toolInput);
219
+ if (!filePath) return EXIT_ALLOW;
220
+
221
+ const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined).trim() || process.cwd();
222
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
223
+ const relPath = path.relative(workspaceRoot, absPath);
224
+ if (isExemptPath(relPath) || isExemptPath(absPath)) return EXIT_ALLOW;
225
+
226
+ const sessionKey = deriveSessionKey(envelope, payload);
227
+ const ledger = readLedger(workspaceRoot, sessionKey);
228
+ // Ledger keys are workspace-relative where possible (stable across
229
+ // absolute-vs-relative host payloads), absolute otherwise.
230
+ const key = relPath.startsWith('..') ? absPath : relPath;
231
+
232
+ if (isWrite) {
233
+ // 6.2 bookkeeping: remember this session's own write. The write has
234
+ // not happened yet, but for every later comparison "about to write"
235
+ // and "wrote" are equivalent.
236
+ ledger.writes[key] = Date.now();
237
+ writeLedger(workspaceRoot, sessionKey, ledger);
238
+ return EXIT_ALLOW;
239
+ }
240
+
241
+ // ── Read path ────────────────────────────────────────────────────
242
+ if (isRangedRead(toolInput)) {
243
+ return EXIT_ALLOW; // already the cheap form; not recorded as a full read
244
+ }
245
+
246
+ let mtime: number;
247
+ let size: number;
248
+ try {
249
+ const st = fs.statSync(absPath);
250
+ mtime = st.mtimeMs;
251
+ size = st.size;
252
+ } catch {
253
+ return EXIT_ALLOW; // unreadable/missing target — nothing to compare
254
+ }
255
+
256
+ const prior = ledger.reads[key];
257
+ const ownWrite = ledger.writes[key];
258
+ const alreadyFired = ledger.fired[key] === true;
259
+
260
+ let advisory: string | null = null;
261
+ if (!alreadyFired && ownWrite !== undefined) {
262
+ advisory = buildPostEditReadLine(key); // 6.2 wins when both hold
263
+ } else if (!alreadyFired && prior !== undefined && prior.mtime === mtime && prior.size === size) {
264
+ advisory = buildDuplicateReadLine(key, prior.ts);
265
+ }
266
+
267
+ // Record the read that is about to happen (numbers only), consume the
268
+ // one-shot own-write marker, and latch the per-path fire flag.
269
+ ledger.reads[key] = { mtime, size, ts: Date.now() };
270
+ if (ownWrite !== undefined) delete ledger.writes[key];
271
+ if (advisory !== null) ledger.fired[key] = true;
272
+ writeLedger(workspaceRoot, sessionKey, ledger);
273
+
274
+ if (advisory === null) return EXIT_ALLOW;
275
+
276
+ process.stdout.write(
277
+ `${JSON.stringify({
278
+ decision: 'warn',
279
+ reason: `reread-guard: full-file Read of ${key} the session already paid for`,
280
+ additional_context: advisory,
281
+ })}\n`,
282
+ );
283
+ return EXIT_WARN;
284
+ } catch {
285
+ return EXIT_ALLOW; // malformed payload / any error — never touch the tool call
286
+ }
287
+ }
288
+
289
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
290
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
291
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
292
+ function _isCliEntry(): boolean {
293
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
294
+ return false;
295
+ }
296
+ if (process.argv[1] === undefined) {
297
+ return false;
298
+ }
299
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
300
+ if (import.meta.url === argvUrl) {
301
+ return true;
302
+ }
303
+ try {
304
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
305
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
306
+ return here === argv;
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ if (_isCliEntry()) {
313
+ process.exit(main());
314
+ }