@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
@@ -19,6 +19,14 @@
19
19
  * 4. Subagent dispatches in window — mode, tiers, token deltas,
20
20
  * first_pass_success/escalated. Source: same audit-log-v1 files,
21
21
  * `orchestration` sub-object.
22
+ * 4b. (opt-in via `--decision "<task text>"`) Dispatch-decision trace —
23
+ * the judgment-ladder rung taken, every rung rejected with its
24
+ * detector's own reason (why-not-team / why-not-council /
25
+ * why-no-spawn), and the token/cost estimate from the most recent
26
+ * matching orchestration-telemetry record. No record in window → an
27
+ * honest "no telemetry record" line, never a fabricated estimate.
28
+ * Sources: `_lib/judgment_ladder.ts::explainLadder` + the same
29
+ * audit-log-v1 files as §4.
22
30
  * 5. Hook / loop / freshness state snapshot. Source:
23
31
  * `agents/state/context-hygiene.json` (the code path the hook
24
32
  * actually writes — see `context_hygiene_hook.ts::STATE_FILE`) or
@@ -39,7 +47,10 @@
39
47
  * ./scripts-run src/scripts/explain_run \
40
48
  * [--task <id>] [--since <ISO-8601>] \
41
49
  * [--router <path>] [--audit-dir <path>] [--engagement <path>] \
42
- * [--hygiene <path>] [--output <path>]
50
+ * [--hygiene <path>] [--output <path>] \
51
+ * [--decision "<task text>"] [--size-estimate <n>] [--slices <n>] \
52
+ * [--ordered-plan] [--agent-teams] [--halted] [--no-spawn-primitive] \
53
+ * [--inside-subagent] [--approval-required]
43
54
  *
44
55
  * Defaults: --router dist/router.json · --audit-dir agents/runtime/state/audit
45
56
  * · --engagement .agent-engagement.jsonl · --hygiene tries
@@ -51,6 +62,7 @@ import * as path from 'node:path';
51
62
  import { pathToFileURL } from 'node:url';
52
63
 
53
64
  import { EngagementSchemaError, parse_event, type EngagementEvent } from '../agent-src/templates/scripts/telemetry/engagement.js';
65
+ import { explainLadder, type LadderExplanation, type LadderInputs } from './_lib/judgment_ladder.js';
54
66
  import { readAuditLines } from './orchestration_savings_report.js';
55
67
 
56
68
  // ── CLI ──────────────────────────────────────────────────────────────────
@@ -76,7 +88,48 @@ export interface Options {
76
88
  engagement: string;
77
89
  hygiene: string | null;
78
90
  output: string | null;
79
- }
91
+ /**
92
+ * Dispatch-decision task text (§4b); null/absent → the section is not
93
+ * rendered. Optional (with the flags below) so pre-existing callers
94
+ * constructing `Options` literals stay source-compatible.
95
+ */
96
+ decision?: string | null;
97
+ /** Ladder signal flags — map 1:1 onto `LadderInputs`; nothing is inferred. */
98
+ sizeEstimate?: number;
99
+ slices?: number;
100
+ orderedPlan?: boolean;
101
+ agentTeams?: boolean;
102
+ halted?: boolean;
103
+ noSpawnPrimitive?: boolean;
104
+ insideSubagent?: boolean;
105
+ approvalRequired?: boolean;
106
+ }
107
+
108
+ const USAGE = `usage: explain_run [--task <id>] [--since <ISO-8601>] [--output <path>]
109
+ [--decision "<task text>"] [--size-estimate <int>] [--slices <int>]
110
+ [--ordered-plan] [--agent-teams] [--halted] [--no-spawn-primitive]
111
+ [--inside-subagent] [--approval-required]
112
+
113
+ Window:
114
+ --task <id> restrict to one task id in the audit log
115
+ --since <ISO-8601> cutoff timestamp / date
116
+ --output <path> write the report to a file instead of stdout
117
+
118
+ Dispatch decision (all optional; absent --decision omits the section):
119
+ --decision <text> classify this task text through the judgment ladder and
120
+ render the rung taken, the rungs rejected with their
121
+ detector's reason, and the rungs never reached
122
+ --size-estimate <int> size signal; at or below the floor nothing delegates
123
+ --slices <int> number of independent slices
124
+ --ordered-plan the slices are ordered, not independent
125
+ --agent-teams the host reports the agent_teams capability
126
+ --halted emergency.orchestration_halt is set
127
+ --no-spawn-primitive the host has no subagent_spawn primitive
128
+ --inside-subagent classification runs inside a subagent (recursive guard)
129
+ --approval-required a human decision is pending; resolves before every rung
130
+
131
+ Exit: 0 report rendered · 2 bad argument.
132
+ `;
80
133
 
81
134
  function parseArgs(argv: string[]): Options {
82
135
  const opts: Options = {
@@ -87,6 +140,23 @@ function parseArgs(argv: string[]): Options {
87
140
  engagement: DEFAULT_ENGAGEMENT,
88
141
  hygiene: null,
89
142
  output: null,
143
+ decision: null,
144
+ sizeEstimate: 0,
145
+ slices: 0,
146
+ orderedPlan: false,
147
+ agentTeams: false,
148
+ halted: false,
149
+ noSpawnPrimitive: false,
150
+ insideSubagent: false,
151
+ approvalRequired: false,
152
+ };
153
+ const intValue = (flag: string, raw: string | undefined): number => {
154
+ const n = Number(raw);
155
+ if (raw === undefined || raw === '' || !Number.isInteger(n)) {
156
+ process.stderr.write(`explain_run: ${flag} requires an integer, got: ${raw ?? '(missing)'}\n`);
157
+ process.exit(2);
158
+ }
159
+ return n;
90
160
  };
91
161
  for (let i = 0; i < argv.length; i++) {
92
162
  const a = argv[i];
@@ -106,8 +176,38 @@ function parseArgs(argv: string[]): Options {
106
176
  else if (a.startsWith('--hygiene=')) opts.hygiene = a.slice('--hygiene='.length);
107
177
  else if (a === '--output') opts.output = next() ?? opts.output;
108
178
  else if (a.startsWith('--output=')) opts.output = a.slice('--output='.length);
109
- else {
110
- process.stderr.write(`explain_run: unrecognized argument: ${a}\n`);
179
+ else if (a === '--decision') {
180
+ const v = next();
181
+ // Exit rather than no-op: silently dropping the value produced a full
182
+ // report with the dispatch section absent and exit 0, so a typo read
183
+ // as "this run had no dispatch decision". `--size-estimate` already
184
+ // exits 2 on the same mistake; the flags now behave alike.
185
+ if (v === undefined || v === '') {
186
+ process.stderr.write('explain_run: --decision requires a value (the task text to classify)\n');
187
+ process.exit(2);
188
+ }
189
+ opts.decision = v;
190
+ } else if (a.startsWith('--decision=')) opts.decision = a.slice('--decision='.length);
191
+ else if (a === '--size-estimate') opts.sizeEstimate = intValue('--size-estimate', next());
192
+ else if (a.startsWith('--size-estimate=')) opts.sizeEstimate = intValue('--size-estimate', a.slice('--size-estimate='.length));
193
+ else if (a === '--slices') opts.slices = intValue('--slices', next());
194
+ else if (a.startsWith('--slices=')) opts.slices = intValue('--slices', a.slice('--slices='.length));
195
+ else if (a === '--ordered-plan') opts.orderedPlan = true;
196
+ else if (a === '--agent-teams') opts.agentTeams = true;
197
+ else if (a === '--halted') opts.halted = true;
198
+ else if (a === '--no-spawn-primitive') opts.noSpawnPrimitive = true;
199
+ else if (a === '--inside-subagent') opts.insideSubagent = true;
200
+ else if (a === '--approval-required') opts.approvalRequired = true;
201
+ else if (a === '--help' || a === '-h') {
202
+ process.stdout.write(USAGE);
203
+ process.exit(0);
204
+ } else {
205
+ // The usage text goes to stderr with the error, so a typo shows the
206
+ // whole surface rather than only the rejected token. Nine of these
207
+ // flags landed at once (the dispatch-decision trace); a script whose
208
+ // only discovery path is reading its source is a script whose flags
209
+ // do not exist for the person at the terminal.
210
+ process.stderr.write(`explain_run: unrecognized argument: ${a}\n\n${USAGE}`);
111
211
  process.exit(2);
112
212
  }
113
213
  }
@@ -486,6 +586,104 @@ function renderOrchestrationSection(opts: Options, auditDirExists: boolean, disp
486
586
  return out;
487
587
  }
488
588
 
589
+ // ── Dispatch-decision trace (§4b — judgment ladder + telemetry estimate) ─
590
+
591
+ /** Map the CLI signal flags 1:1 onto `LadderInputs` — nothing inferred. */
592
+ export function ladderInputsFromOptions(opts: Options): LadderInputs {
593
+ return {
594
+ taskText: opts.decision ?? '',
595
+ signals: {
596
+ size_estimate: opts.sizeEstimate ?? 0,
597
+ independent_slices: opts.slices ?? 0,
598
+ ordered_plan: opts.orderedPlan ?? false,
599
+ },
600
+ activation: { halted: opts.halted ?? false, subagent_spawn: !(opts.noSpawnPrimitive ?? false) },
601
+ agentTeams: opts.agentTeams ?? false,
602
+ interactiveApprovalRequired: opts.approvalRequired ?? false,
603
+ insideSubagentSession: opts.insideSubagent ?? false,
604
+ };
605
+ }
606
+
607
+ /** Most recent dispatch line by `ts` (undefined ts sorts oldest). */
608
+ export function latestDispatch(dispatches: ExplainAuditLine[]): ExplainAuditLine | null {
609
+ let latest: ExplainAuditLine | null = null;
610
+ let latestTs = Number.NEGATIVE_INFINITY;
611
+ for (const line of dispatches) {
612
+ const t = line.ts === undefined ? Number.NEGATIVE_INFINITY : Date.parse(line.ts);
613
+ const ts = Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY;
614
+ if (latest === null || ts >= latestTs) {
615
+ latest = line;
616
+ latestTs = ts;
617
+ }
618
+ }
619
+ return latest;
620
+ }
621
+
622
+ function renderDecisionSection(
623
+ opts: Options,
624
+ explanation: LadderExplanation,
625
+ auditDirExists: boolean,
626
+ dispatches: ExplainAuditLine[],
627
+ ): string[] {
628
+ const out: string[] = [];
629
+ out.push('## Dispatch decision (judgment ladder)');
630
+ out.push(
631
+ `_Source: \`src/scripts/_lib/judgment_ladder.ts\` (\`explainLadder\`) over the \`--decision\` text + signal flags; token/cost estimate from \`${opts.auditDir}/*.jsonl\` orchestration telemetry_`,
632
+ );
633
+ out.push('');
634
+ out.push(`Decision text: ${JSON.stringify(opts.decision ?? '')}`);
635
+ const r = explanation.result;
636
+ const rungLabel = r.rung === null ? '∅ (never spawns)' : String(r.rung);
637
+ const modeSuffix = r.mode !== undefined && r.mode !== null ? ` (mode ${r.mode})` : '';
638
+ const degradedSuffix = r.degraded_from !== undefined ? ` [degraded from rung ${r.degraded_from}]` : '';
639
+ out.push(`Resolved: rung ${rungLabel} — verdict \`${r.verdict}\`${modeSuffix}${degradedSuffix} — ${r.reason}`);
640
+ out.push('');
641
+ out.push('### Ladder trail (priority order 0 → 4 → 3 → 2 → 1)');
642
+ out.push('');
643
+ out.push('| rung | resolves to | status | reason |');
644
+ out.push('|---|---|---|---|');
645
+ for (const step of explanation.trail) {
646
+ out.push(`| ${step.rung} | ${step.resolves_to} | ${step.status} | ${step.reason} |`);
647
+ }
648
+ if (explanation.no_spawn_reason !== undefined) {
649
+ out.push('');
650
+ out.push(`Why no spawn: ${explanation.no_spawn_reason}`);
651
+ }
652
+ out.push('');
653
+ out.push('### Token/cost estimate');
654
+ if (!auditDirExists) {
655
+ out.push(`no telemetry record — \`${opts.auditDir}\` absent; no token/cost estimate (a fabricated number would be worse than none)`);
656
+ return out;
657
+ }
658
+ const latest = latestDispatch(dispatches);
659
+ if (latest === null) {
660
+ out.push(`no telemetry record matches ${windowLabel(opts)}; no token/cost estimate (a fabricated number would be worse than none)`);
661
+ return out;
662
+ }
663
+ const o = latest.orchestration ?? {};
664
+ // NOT matched to this decision, and the label must say so. The record shape
665
+ // carries no key that ties a line to a decision text — `latestDispatch` sorts
666
+ // by `ts` alone — so calling it "matching" made an unrelated run's cost read
667
+ // as this decision's estimate, in a section whose own header promises never
668
+ // to fabricate one. Printing it is still useful (it is the freshest real
669
+ // measurement on this host); claiming correspondence was the defect.
670
+ out.push(
671
+ `most recent dispatch in ${windowLabel(opts)} — NOT matched to this decision ` +
672
+ `(the record carries no decision key; sorted by ts alone): ` +
673
+ `ts ${latest.ts ?? '(unset)'}, work_id ${latest.work_id ?? '(unset)'}, ` +
674
+ `token_delta ${o.token_delta ?? 0} (${o.token_delta_provenance ?? 'estimated'}), ` +
675
+ `mode ${o.dispatch_mode ?? '(unset)'}, tiers ${(o.tiers ?? []).join(',') || '(unset)'}`,
676
+ );
677
+ const resolved = explanation.result.verdict;
678
+ const recordMode = o.dispatch_mode;
679
+ if (recordMode !== undefined && resolved === 'in-session') {
680
+ out.push(
681
+ ` ⚠️ this decision resolves to \`${resolved}\` (no dispatch), so the mode above describes a different run.`,
682
+ );
683
+ }
684
+ return out;
685
+ }
686
+
489
687
  function renderHygieneSection(hygiene: HygieneReadResult): string[] {
490
688
  const out: string[] = [];
491
689
  out.push('## Hook / loop state (context-hygiene)');
@@ -568,6 +766,10 @@ export function buildReport(opts: Options): string {
568
766
  out.push('');
569
767
  out.push(...renderOrchestrationSection(opts, auditDirExists, dispatches));
570
768
  out.push('');
769
+ if (opts.decision !== null && opts.decision !== undefined) {
770
+ out.push(...renderDecisionSection(opts, explainLadder(ladderInputsFromOptions(opts)), auditDirExists, dispatches));
771
+ out.push('');
772
+ }
571
773
  out.push(...renderHygieneSection(hygiene));
572
774
  out.push('');
573
775
  out.push(...renderParkedSection());
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Forensic analyzers — hotspot risk + change coupling from `git log` alone
3
+ * (road-to-judgment-and-forensic-evidence Phase 3; pack `forensics`,
4
+ * default-off, read-only, advisory).
5
+ *
6
+ * Two deterministic analyzers over a PINNED revision range:
7
+ *
8
+ * hotspot(file) = norm(change_frequency) × norm(complexity)
9
+ * complexity = LOC + total indentation units (tabs = 1,
10
+ * 4 spaces = 1) of the file in the working tree — a cheap,
11
+ * language-agnostic indentation proxy, documented rather
12
+ * than clever. A hotspot is a place to LOOK, never a code
13
+ * quality verdict (both inputs can be correct for a file).
14
+ *
15
+ * coupling(A,B) = co_changes(A,B) / min(changes(A), changes(B))
16
+ * over commits with ≤ max_commit_files files (bulk sweeps
17
+ * poison coupling; skipped commits are counted in the
18
+ * report, never silently dropped).
19
+ *
20
+ * boundary_contradictions — coupling pairs ABOVE the threshold whose files
21
+ * live in different top-level modules. Recorded as a finding CLASS (the
22
+ * intended boundaries and the actual change pattern disagree), not as a
23
+ * failure — per the roadmap's step 3.6.
24
+ *
25
+ * Determinism bar (step 3.3): byte-stable output on a frozen fixture — same
26
+ * inputs, same bytes. Inputs are the git log for the pinned range plus file
27
+ * metrics; `--log-file` + `--metrics-file` inject both for fixture tests, so
28
+ * the analyzer's whole pipeline is exercised without a live repo. All maps
29
+ * are sorted before emission; scores are rounded to 4 decimals.
30
+ *
31
+ * Advisory wiring (step 3.4): `--findings-out` emits
32
+ * review-findings.schema.json-shaped items with kind `correctness` and
33
+ * severity `low|medium` — non-blocking by construction in
34
+ * `check_finding_dispositions.isBlocking` — for ingestion into the existing
35
+ * release findings ledger (`check_finding_dispositions --ingest`).
36
+ *
37
+ * Gaming risk: an empty scan (dead range, wrong cwd) yielding a green "no
38
+ * hotspots" report — mitigated by the shared scan-scope helper: zero scanned
39
+ * commits throws (DeadScopeError), it never publishes an empty finding set
40
+ * (step 3.2). Residual: a range that is real but trivially small still
41
+ * produces low-signal output; the report publishes commit + file counts so a
42
+ * reader can judge the denominator.
43
+ */
44
+ // ledger-exempt: on-demand advisory analyzer, not a CI gate — the report JSON
45
+ // itself carries the full denominator (scanned commits/files) per run.
46
+ import * as fs from 'node:fs';
47
+ import { createHash } from 'node:crypto';
48
+ import { execFileSync } from 'node:child_process';
49
+
50
+ import { reportScanned } from './_lib/scan_scope.js';
51
+
52
+ interface Params {
53
+ max_commit_files: number;
54
+ coupling_threshold: number;
55
+ min_cochanges: number;
56
+ top_hotspots: number;
57
+ }
58
+ const DEFAULTS: Params = {
59
+ max_commit_files: 50,
60
+ coupling_threshold: 0.5,
61
+ min_cochanges: 3,
62
+ top_hotspots: 25,
63
+ };
64
+
65
+ interface Commit {
66
+ hash: string;
67
+ files: string[];
68
+ }
69
+
70
+ /** Parse `git log --pretty=format:'C %H' --name-only` output. */
71
+ export function parseLog(text: string): Commit[] {
72
+ const commits: Commit[] = [];
73
+ let current: Commit | null = null;
74
+ for (const raw of text.split('\n')) {
75
+ const line = raw.trimEnd();
76
+ if (line.startsWith('C ')) {
77
+ if (current) commits.push(current);
78
+ current = { hash: line.slice(2).trim(), files: [] };
79
+ } else if (line.length > 0 && current) {
80
+ current.files.push(line);
81
+ }
82
+ }
83
+ if (current) commits.push(current);
84
+ return commits;
85
+ }
86
+
87
+ function gitLog(range: string): string {
88
+ return execFileSync('git', ['log', '--pretty=format:C %H', '--name-only', range], {
89
+ encoding: 'utf8',
90
+ maxBuffer: 256 * 1024 * 1024,
91
+ });
92
+ }
93
+
94
+ /** LOC + indentation units — the documented complexity proxy. */
95
+ export function complexityOf(content: string): number {
96
+ const lines = content.split('\n');
97
+ let indent = 0;
98
+ for (const l of lines) {
99
+ const m = /^[\t ]*/.exec(l);
100
+ const ws = m ? m[0] : '';
101
+ let units = 0;
102
+ let spaces = 0;
103
+ for (const ch of ws) {
104
+ if (ch === '\t') units += 1;
105
+ else spaces += 1;
106
+ }
107
+ units += Math.floor(spaces / 4);
108
+ indent += units;
109
+ }
110
+ return lines.length + indent;
111
+ }
112
+
113
+ function liveComplexity(file: string): number | null {
114
+ try {
115
+ const st = fs.statSync(file);
116
+ if (!st.isFile()) return null;
117
+ const buf = fs.readFileSync(file);
118
+ if (buf.includes(0)) return null; // binary
119
+ return complexityOf(buf.toString('utf8'));
120
+ } catch {
121
+ return null; // deleted / renamed away — frequency without complexity
122
+ }
123
+ }
124
+
125
+ function round4(n: number): number {
126
+ return Math.round(n * 10000) / 10000;
127
+ }
128
+
129
+ /** First path segment — the module boundary the router treats as independent. */
130
+ export function moduleOf(file: string): string {
131
+ const parts = file.split('/');
132
+ if (parts.length >= 2 && (parts[0] === 'src' || parts[0] === 'tests' || parts[0] === 'docs')) {
133
+ return `${parts[0]}/${parts[1] ?? ''}`;
134
+ }
135
+ return parts[0] ?? file;
136
+ }
137
+
138
+ interface Hotspot {
139
+ file: string;
140
+ changes: number;
141
+ complexity: number | null;
142
+ score: number;
143
+ }
144
+ interface CouplingPair {
145
+ a: string;
146
+ b: string;
147
+ co_changes: number;
148
+ changes_a: number;
149
+ changes_b: number;
150
+ coupling: number;
151
+ cross_module: boolean;
152
+ }
153
+
154
+ export interface Report {
155
+ mode: string;
156
+ range: string;
157
+ params: Params;
158
+ scanned: { commits: number; commits_skipped_bulk: number; files: number };
159
+ hotspots: Hotspot[];
160
+ coupling: CouplingPair[];
161
+ boundary_contradictions: CouplingPair[];
162
+ }
163
+
164
+ export function analyze(
165
+ commits: Commit[],
166
+ params: Params,
167
+ complexity: (file: string) => number | null,
168
+ range: string,
169
+ mode: string,
170
+ ): Report {
171
+ const changes = new Map<string, number>();
172
+ const co = new Map<string, number>();
173
+ let skipped = 0;
174
+ for (const c of commits) {
175
+ const files = [...new Set(c.files)].sort();
176
+ if (files.length > params.max_commit_files) {
177
+ skipped++;
178
+ continue;
179
+ }
180
+ for (const f of files) changes.set(f, (changes.get(f) ?? 0) + 1);
181
+ for (let i = 0; i < files.length; i++) {
182
+ for (let j = i + 1; j < files.length; j++) {
183
+ const key = `${files[i]}\u0000${files[j]}`;
184
+ co.set(key, (co.get(key) ?? 0) + 1);
185
+ }
186
+ }
187
+ }
188
+
189
+ const files = [...changes.keys()].sort();
190
+ const complexities = new Map<string, number | null>();
191
+ for (const f of files) complexities.set(f, complexity(f));
192
+ let maxChanges = 0;
193
+ for (const v of changes.values()) if (v > maxChanges) maxChanges = v;
194
+ let maxComplexity = 0;
195
+ for (const v of complexities.values()) if (v !== null && v > maxComplexity) maxComplexity = v;
196
+
197
+ const hotspots: Hotspot[] = files
198
+ .map((f) => {
199
+ const ch = changes.get(f) ?? 0;
200
+ const cx = complexities.get(f) ?? null;
201
+ const score =
202
+ cx === null || maxChanges === 0 || maxComplexity === 0
203
+ ? 0
204
+ : round4((ch / maxChanges) * (cx / maxComplexity));
205
+ return { file: f, changes: ch, complexity: cx, score };
206
+ })
207
+ .filter((h) => h.score > 0)
208
+ .sort((a, b) => b.score - a.score || a.file.localeCompare(b.file))
209
+ .slice(0, params.top_hotspots);
210
+
211
+ const coupling: CouplingPair[] = [];
212
+ for (const [key, n] of [...co.entries()].sort()) {
213
+ if (n < params.min_cochanges) continue;
214
+ const [a, b] = key.split('\u0000') as [string, string];
215
+ const ca = changes.get(a) ?? 0;
216
+ const cb = changes.get(b) ?? 0;
217
+ const denom = Math.min(ca, cb);
218
+ if (denom === 0) continue;
219
+ const strength = round4(n / denom);
220
+ if (strength < params.coupling_threshold) continue;
221
+ coupling.push({
222
+ a,
223
+ b,
224
+ co_changes: n,
225
+ changes_a: ca,
226
+ changes_b: cb,
227
+ coupling: strength,
228
+ cross_module: moduleOf(a) !== moduleOf(b),
229
+ });
230
+ }
231
+ coupling.sort((x, y) => y.coupling - x.coupling || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
232
+
233
+ return {
234
+ mode,
235
+ range,
236
+ params,
237
+ scanned: {
238
+ commits: commits.length - skipped,
239
+ commits_skipped_bulk: skipped,
240
+ files: files.length,
241
+ },
242
+ hotspots,
243
+ coupling,
244
+ boundary_contradictions: coupling.filter((p) => p.cross_module),
245
+ };
246
+ }
247
+
248
+ /** Same id convention as self_review_gate.findingId — the schema pins ^[0-9a-f]{12}$. */
249
+ function forensicsFindingId(kind: string, title: string, file: string): string {
250
+ return createHash('sha256').update(`${kind}|${title}|${file}`).digest('hex').slice(0, 12);
251
+ }
252
+
253
+ /** Findings-schema-shaped advisory items — non-blocking by construction. */
254
+ export function toFindings(report: Report): Record<string, unknown> {
255
+ const findings: Record<string, unknown>[] = [];
256
+ for (const h of report.hotspots.slice(0, 10)) {
257
+ const title = `hotspot: ${h.file} (score ${String(h.score)})`;
258
+ findings.push({
259
+ finding_id: forensicsFindingId('correctness', title, h.file),
260
+ severity: 'low',
261
+ kind: 'correctness',
262
+ title,
263
+ detail: `${String(h.changes)} changes in range × complexity ${String(h.complexity ?? 'n/a')} — a place to look, not a quality verdict.`,
264
+ file: h.file,
265
+ });
266
+ }
267
+ for (const p of report.boundary_contradictions) {
268
+ const title = `boundary contradiction: ${p.a} <-> ${p.b} (coupling ${String(p.coupling)})`;
269
+ findings.push({
270
+ finding_id: forensicsFindingId('correctness', title, p.a),
271
+ severity: 'medium',
272
+ kind: 'correctness',
273
+ title,
274
+ detail: `Cross-module co-change ${String(p.co_changes)}/${String(Math.min(p.changes_a, p.changes_b))} above threshold ${String(report.params.coupling_threshold)} — the intended module boundary and the actual change pattern disagree. A finding class, not a failure.`,
275
+ file: p.a,
276
+ });
277
+ }
278
+ return { schema_version: 1, findings };
279
+ }
280
+
281
+ function stringifyReport(report: Report): string {
282
+ return JSON.stringify(report, null, 2) + '\n';
283
+ }
284
+
285
+ function argValue(argv: string[], flag: string): string | null {
286
+ const i = argv.indexOf(flag);
287
+ return i >= 0 ? (argv[i + 1] ?? null) : null;
288
+ }
289
+
290
+ function main(argv: string[]): number {
291
+ const range = argValue(argv, '--range');
292
+ const logFile = argValue(argv, '--log-file');
293
+ const metricsFile = argValue(argv, '--metrics-file');
294
+ const outFile = argValue(argv, '--out');
295
+ const findingsOut = argValue(argv, '--findings-out');
296
+ if (!range && !logFile) {
297
+ process.stderr.write(
298
+ 'usage: forensics_report (--range <rev-range> | --log-file <fixture>) [--metrics-file <json>] [--out <file>] [--findings-out <file>]\n',
299
+ );
300
+ return 2;
301
+ }
302
+ const params: Params = { ...DEFAULTS };
303
+ const text = logFile ? fs.readFileSync(logFile, 'utf8') : gitLog(range as string);
304
+ const commits = parseLog(text);
305
+ let complexity: (file: string) => number | null = liveComplexity;
306
+ if (metricsFile) {
307
+ const metrics = JSON.parse(fs.readFileSync(metricsFile, 'utf8')) as Record<string, number>;
308
+ complexity = (f) => (Object.prototype.hasOwnProperty.call(metrics, f) ? metrics[f]! : null);
309
+ }
310
+ const report = analyze(commits, params, complexity, range ?? `log-file:${logFile ?? ''}`, 'run');
311
+
312
+ // Loud-fail on an empty scan BEFORE anything is written — a report over
313
+ // nothing is not a report, and it must not exist on disk either (throws
314
+ // DeadScopeError when zero commits were analyzed).
315
+ reportScanned({
316
+ gate: 'forensics_report',
317
+ scanned: report.scanned.commits,
318
+ units: 'commit(s)',
319
+ roots: [range ?? (logFile as string)],
320
+ });
321
+
322
+ const json = stringifyReport(report);
323
+ if (outFile) fs.writeFileSync(outFile, json);
324
+ process.stdout.write(json);
325
+ if (findingsOut) fs.writeFileSync(findingsOut, JSON.stringify(toFindings(report), null, 2) + '\n');
326
+ return 0;
327
+ }
328
+
329
+ if (import.meta.url === `file://${process.argv[1]}`) {
330
+ process.exit(main(process.argv.slice(2)));
331
+ }
332
+
333
+ export { main };
@@ -43,6 +43,7 @@ import {
43
43
  } from '../shared/settingsClasses.js';
44
44
  import { flattenSurface, type SurfaceEntry } from '../shared/settingsSurface.js';
45
45
 
46
+ // ledger-exempt: single-artifact generator — renders ONE derived page from the zod schema and byte-compares it against the committed copy in --check mode; the verdict is one aggregate drift comparison over the whole page, so per-target findings do not exist.
46
47
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
47
48
  export const ROOT = path.resolve(_HERE, '..', '..');
48
49