@event4u/agent-config 9.29.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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +108 -7
  3. package/README.md +1 -0
  4. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  5. package/dist/agent-src/commands/explain-run.md +31 -1
  6. package/dist/agent-src/commands/team/delegate.md +1 -0
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  9. package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
  10. package/dist/agent-src/rules/preservation-guard.md +9 -0
  11. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  12. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  13. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +2 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
  18. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
  19. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
  20. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
  21. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  22. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  23. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  24. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  25. package/dist/cli-delegate/cmd_conformance.js +2798 -95
  26. package/dist/cli-delegate/cmd_doctor.js +2 -1
  27. package/dist/cli-delegate/cmd_export.js +2 -2
  28. package/dist/cli-delegate/cmd_handoff.js +86 -6
  29. package/dist/cli-delegate/cmd_prune.js +2 -2
  30. package/dist/cli-delegate/cmd_route_audit.js +2 -1
  31. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  32. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  33. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  35. package/dist/cli-delegate/cmd_sync.js +2 -2
  36. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  37. package/dist/cli-delegate/cmd_validate.js +2 -2
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +7 -7
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/orphan-report.md +1 -1
  43. package/dist/discovery/packs.json +2 -2
  44. package/dist/discovery/trust-report.md +1 -1
  45. package/dist/discovery/workspaces.json +2 -2
  46. package/dist/hooks/dispatch.js +1904 -575
  47. package/dist/install/install.mjs +14 -0
  48. package/dist/mcp/registry-manifest.json +2 -2
  49. package/dist/mcp/server.json +2 -2
  50. package/dist/mcp/server.mjs +3760 -726
  51. package/dist/server/schemas/settings.js +6 -0
  52. package/dist/server/schemas/settings.js.map +1 -1
  53. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  54. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  55. package/dist/ui/index.html +1 -1
  56. package/docs/contracts/settings-classes.md +21 -5
  57. package/package.json +1 -1
  58. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  59. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  60. package/src/config/agent-settings.template.yml +36 -0
  61. package/src/config/dispatch-economy-metrics.json +61 -0
  62. package/src/config/evaluator-budgets.json +4 -4
  63. package/src/config/hook-token-budget.json +91 -0
  64. package/src/config/recycle-threshold-budget.json +12 -0
  65. package/src/config/rule-enforcement-baseline.json +86 -0
  66. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  67. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  68. package/src/scripts/_dispatch.bash +23 -0
  69. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  70. package/src/scripts/_lib/orchestration_record.ts +41 -10
  71. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  72. package/src/scripts/_lib/self_repair.ts +194 -0
  73. package/src/scripts/_lib/session_eol.ts +230 -0
  74. package/src/scripts/_lib/session_role.ts +42 -0
  75. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  76. package/src/scripts/_lib/subagent_response.ts +75 -0
  77. package/src/scripts/ai_council/clients.ts +50 -1
  78. package/src/scripts/ask_transport.ts +221 -0
  79. package/src/scripts/bench_hook_injection.ts +234 -0
  80. package/src/scripts/check_adr_frontmatter.ts +84 -40
  81. package/src/scripts/check_branch_freshness.ts +1 -0
  82. package/src/scripts/check_corpus_staleness.ts +18 -4
  83. package/src/scripts/check_detector_corpus.ts +17 -0
  84. package/src/scripts/check_gitignore_freshness.ts +42 -8
  85. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  86. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  87. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  88. package/src/scripts/check_static_layer_stability.ts +154 -0
  89. package/src/scripts/check_suppression_hygiene.ts +48 -0
  90. package/src/scripts/dispatch_economy_report.ts +448 -0
  91. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  92. package/src/scripts/explain_run.ts +206 -4
  93. package/src/scripts/generate_settings_reference.ts +1 -0
  94. package/src/scripts/handoff_context_hook.ts +112 -4
  95. package/src/scripts/hook_manifest.yaml +117 -10
  96. package/src/scripts/hooks/concern_registry.ts +8 -0
  97. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  98. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  99. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  100. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  101. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  102. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  103. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  105. package/src/scripts/install-hooks.sh +1 -1
  106. package/src/scripts/language_mirror_hook.ts +89 -0
  107. package/src/scripts/lint_decision_impact.ts +14 -1
  108. package/src/scripts/lint_hook_manifest.ts +82 -0
  109. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  110. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  111. package/src/scripts/measure_turn_end_gate.ts +194 -0
  112. package/src/scripts/orchestration_record.ts +4 -1
  113. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  114. package/src/scripts/report_conformance_funnel.ts +284 -0
  115. package/src/scripts/report_imperative_density.ts +1 -0
  116. package/src/scripts/routing_doctor.ts +97 -0
  117. package/src/scripts/self_repair_cli.ts +51 -3
  118. package/src/scripts/session_eol_report.ts +261 -0
  119. package/src/server/schemas/settings.ts +14 -0
  120. package/src/templates/compact-instructions.md +40 -0
  121. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  122. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -99,6 +99,7 @@ import { load_agent_settings } from "../_lib/agent_settings.js";
99
99
  import { probeHostCapabilities } from "../_lib/host_capability.js";
100
100
  import type { ActivationInputs, Classification, TaskSignals } from "../_lib/auto_dispatch.js";
101
101
  import { classifyLadder, type LadderRung } from "../_lib/judgment_ladder.js";
102
+ import { resolveSessionRole } from "../_lib/session_role.js";
102
103
  import { resolveSubagentRouting, type Tier } from "../_lib/subagent_routing.js";
103
104
  import { isSyntheticPrompt } from "../_lib/prompt_shape.js";
104
105
 
@@ -338,6 +339,17 @@ export function buildNudgeLine(
338
339
  ): string {
339
340
  const unit = sliceCount === 1 ? "slice" : "slices";
340
341
  const rungLabel = rung === null ? "rung-?" : `rung-${rung}`;
342
+ // Rung 0.5 (token-economy-dispatch 4.3): cite the sub-spawn ask path —
343
+ // never a spawn recommendation for a question-shaped slice.
344
+ if (rung === 0.5) {
345
+ return (
346
+ `<delegation-nudge>rung-0.5: ask, est. <1k tokens. This is a single bounded ` +
347
+ `question — answer it via ONE completion ` +
348
+ "(`./scripts-run src/scripts/ask_transport \"<question>\"`) instead of a " +
349
+ `subagent spawn (measured spawn floor: ~251k tokens) — ` +
350
+ `${classification.reason}.</delegation-nudge>`
351
+ );
352
+ }
341
353
  // Rung 1 (single bounded slice) carries no do-in-steps/do-in-parallel
342
354
  // mode of its own — `classification.mode` is `null` for that shape.
343
355
  const modeLabel = classification.mode ?? "single-slice";
@@ -353,7 +365,11 @@ export interface NudgeResult {
353
365
  classification: Classification;
354
366
  sliceCount: number;
355
367
  tier: Tier;
356
- /** The judgment-ladder rung this verdict resolved to (2.4) — 1 or 2 only, this concern's scope. */
368
+ /** The judgment-ladder rung this verdict resolved to (2.4) — 1 or 2 in
369
+ * production today: classifyPrompt returns null for every non-subagent
370
+ * verdict including rung 0.5 (the carrier stays silent on questions);
371
+ * buildNudgeLine's 0.5 branch is vocabulary for orchestrator-facing
372
+ * surfaces and direct callers. */
357
373
  rung: LadderRung;
358
374
  }
359
375
 
@@ -374,9 +390,30 @@ export function classifyPrompt(
374
390
  workspace_root,
375
391
  hostId,
376
392
  );
377
- const ladder = classifyLadder({ taskText: prompt, signals, activation, agentTeams });
393
+ // Recursive-dispatch guard input — the SHARED session-role detector
394
+ // (_lib/session_role.ts, "one detector, two consumers, test-pinned
395
+ // equal"): a session marked worker/reviewer by a suite-owned spawn
396
+ // wrapper never receives a delegation nudge. Unmarked sessions keep
397
+ // the documented gap (no host discriminator exists) — fail-open.
398
+ const sessionRole = resolveSessionRole(process.env);
399
+ const ladder = classifyLadder({
400
+ taskText: prompt,
401
+ signals,
402
+ activation,
403
+ agentTeams,
404
+ insideSubagentSession: sessionRole !== "orchestrator",
405
+ });
406
+ // Rung 0.5 (token-economy-dispatch 4.3): on a USER prompt the carrier
407
+ // stays SILENT for a bounded question — the agent answers in-session at
408
+ // zero marginal cost, and injecting an ask_transport citation here would
409
+ // be the cosmetic-injection failure this repo measured at 24/29 misses.
410
+ // The 0.5 verdict is an ORCHESTRATOR-facing routing datum (slice
411
+ // resolution); buildNudgeLine carries the rung-0.5 vocabulary for those
412
+ // surfaces. Silence here also guarantees a question-shaped prompt can
413
+ // never produce a spawn nudge ("the carrier stops nudging full spawns
414
+ // for question-shaped slices").
378
415
  if (ladder.verdict !== "subagent") {
379
- return null; // rung-0 script, rung-3/4 team/council, or ∅ ask/in-session — out of scope here
416
+ return null; // rung-0 script, rung-0.5 ask-transport, rung-3/4 team/council, or ∅ user-ask/in-session
380
417
  }
381
418
  const tier = recommendSliceTier(downshift, separate_quota_pool);
382
419
  // Rung 1 (single bounded slice) has no explicit slice count of its own —
@@ -48,6 +48,7 @@ import {
48
48
  clearHookStdinOverride,
49
49
  } from "./hook_stdin.js";
50
50
  import { emitFor, type Severity } from "./host_semantics.js";
51
+ import { resolveSessionRole, type SessionRole } from "../_lib/session_role.js";
51
52
 
52
53
  // Free-form JSON values flow through every helper here; a documented
53
54
  // alias keeps the surface honest without `any` (ADR-200 § strict TS).
@@ -305,12 +306,42 @@ export function _concern_matches_tool(concern: JsonObject, tool_name: string): b
305
306
  }
306
307
 
307
308
  /**
308
- * Return the ordered concern definitions for (platform, event).
309
+ * Role axis (road-to-token-economy-dispatch Phase 2): the set of concern
310
+ * names the given role drops. Empty for `orchestrator`, for a role without
311
+ * a manifest entry (fail-open, 2.4), and ALWAYS for `pre_tool_use` — the
312
+ * safety-guard slot is structurally undroppable (2.3); `lint_hook_manifest`
313
+ * additionally rejects a drop entry bound to that slot at CI time.
314
+ */
315
+ export function _role_drop_set(
316
+ manifest: JsonObject,
317
+ role: string,
318
+ event: string,
319
+ ): Set<string> {
320
+ if (!role || role === "orchestrator" || event === "pre_tool_use") {
321
+ return new Set();
322
+ }
323
+ const rolesRaw = manifest["roles"];
324
+ const roleSpec = _isObject(rolesRaw) ? rolesRaw[role] : undefined;
325
+ if (!_isObject(roleSpec)) {
326
+ return new Set(); // unknown role → full chain (fail-open)
327
+ }
328
+ const drop = roleSpec["drop"];
329
+ if (!Array.isArray(drop)) {
330
+ return new Set();
331
+ }
332
+ return new Set(drop.filter((d): d is string => typeof d === "string"));
333
+ }
334
+
335
+ /**
336
+ * Return the ordered concern definitions for (platform, event) under the
337
+ * given session role (default `orchestrator` — byte-identical to the
338
+ * pre-role-axis behaviour).
309
339
  */
310
340
  export function _resolve_concerns(
311
341
  manifest: JsonObject,
312
342
  platform: string,
313
343
  event: string,
344
+ role: SessionRole = "orchestrator",
314
345
  ): ConcernDef[] {
315
346
  const platformsRaw = manifest["platforms"];
316
347
  const platforms = _isObject(platformsRaw) ? platformsRaw : {};
@@ -325,10 +356,14 @@ export function _resolve_concerns(
325
356
  if (!Array.isArray(names)) {
326
357
  return [];
327
358
  }
359
+ const dropped = _role_drop_set(manifest, role, event);
328
360
  const concernsRaw = manifest["concerns"];
329
361
  const concerns_def = _isObject(concernsRaw) ? concernsRaw : {};
330
362
  const out: ConcernDef[] = [];
331
363
  for (const name of names) {
364
+ if (typeof name === "string" && dropped.has(name)) {
365
+ continue; // role-dropped orchestrator-only concern
366
+ }
332
367
  const spec = typeof name === "string" ? concerns_def[name] : undefined;
333
368
  if (!spec || !_isObject(spec)) {
334
369
  process.stderr.write(
@@ -1035,12 +1070,14 @@ export function main(argv?: string[]): number {
1035
1070
 
1036
1071
  const payload_text = process.stdin.isTTY ? "" : _readStdin();
1037
1072
  _maybe_capture_payload(args, payload_text);
1038
- const concerns = _resolve_concerns(manifest, args.platform, args.event);
1073
+ const session_role = resolveSessionRole(process.env);
1074
+ const concerns = _resolve_concerns(manifest, args.platform, args.event, session_role);
1039
1075
 
1040
1076
  if (args.dry_run) {
1041
1077
  const plan = {
1042
1078
  platform: args.platform,
1043
1079
  event: args.event,
1080
+ role: session_role,
1044
1081
  concerns: concerns.map((c) => c["name"]),
1045
1082
  };
1046
1083
  process.stdout.write(_py_json_dumps(plan, 2) + "\n");
@@ -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.