crewhaus 0.1.8 → 0.2.1

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 (138) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +173 -0
  25. package/dist/dataset-mine.js +404 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11664 -963
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/route.d.ts +19 -0
  107. package/dist/route.js +90 -0
  108. package/dist/scaffold-evals.d.ts +138 -0
  109. package/dist/scaffold-evals.js +410 -0
  110. package/dist/scope-audit-drift.d.ts +139 -0
  111. package/dist/scope-audit-drift.js +260 -0
  112. package/dist/security-corpus.d.ts +237 -0
  113. package/dist/security-corpus.js +516 -0
  114. package/dist/security-digest.d.ts +174 -0
  115. package/dist/security-digest.js +656 -0
  116. package/dist/sessions-index.d.ts +27 -0
  117. package/dist/sessions-index.js +51 -0
  118. package/dist/slo-doctor.d.ts +67 -0
  119. package/dist/slo-doctor.js +119 -0
  120. package/dist/slo-sink.d.ts +96 -0
  121. package/dist/slo-sink.js +107 -0
  122. package/dist/spec-changelog.d.ts +102 -0
  123. package/dist/spec-changelog.js +237 -0
  124. package/dist/state-backup.d.ts +223 -0
  125. package/dist/state-backup.js +648 -0
  126. package/dist/tools-cli.d.ts +170 -0
  127. package/dist/tools-cli.js +300 -0
  128. package/dist/triage.d.ts +202 -0
  129. package/dist/triage.js +403 -0
  130. package/dist/upgrade.d.ts +57 -0
  131. package/dist/upgrade.js +113 -0
  132. package/dist/version.d.ts +6 -0
  133. package/dist/version.js +27 -0
  134. package/dist/voice-eval.d.ts +138 -0
  135. package/dist/voice-eval.js +309 -0
  136. package/dist/watch.d.ts +58 -0
  137. package/dist/watch.js +97 -0
  138. package/package.json +90 -65
@@ -0,0 +1,905 @@
1
+ import { validatePatch } from "@crewhaus/spec-patch";
2
+ /** Parse a JSONL blob into objects, skipping blank/malformed lines —
3
+ * a session log must never abort mining over one corrupt line. */
4
+ export function parseJsonlObjects(text) {
5
+ const out = [];
6
+ for (const line of text.split("\n")) {
7
+ if (line.trim() === "")
8
+ continue;
9
+ try {
10
+ out.push(JSON.parse(line));
11
+ }
12
+ catch {
13
+ // Skip: a malformed line carries no advisory signal.
14
+ }
15
+ }
16
+ return out;
17
+ }
18
+ /** The fixed prefix the runtime stamps on a loop-detection nudge (a
19
+ * `user_message` with `synthetic: true`). Exported so a fixture/test can
20
+ * build a realistic nudge without hard-coding the string twice. */
21
+ export const LOOP_NUDGE_PREFIX = "[runtime] possible loop detected:";
22
+ /** Matches the looping tool name inside a loop-detection nudge string. */
23
+ const LOOP_TOOL_RE = /tool "([^"]+)"/;
24
+ /**
25
+ * Item 19 — extract a loop signature (the looping tool name) from a
26
+ * `user_message` payload's `content` when it is a runtime loop-detection
27
+ * nudge, else undefined. The nudge content is the fixed string
28
+ * `[runtime] possible loop detected: tool "<name>" has been called …`.
29
+ * Returns `tool:<name>` so recurring loops on the same tool cluster; falls
30
+ * back to a generic `loop` signature when the tool name can't be parsed.
31
+ */
32
+ export function loopSignatureOf(content) {
33
+ if (typeof content !== "string" || !content.startsWith(LOOP_NUDGE_PREFIX))
34
+ return undefined;
35
+ const m = LOOP_TOOL_RE.exec(content);
36
+ return m?.[1] !== undefined ? `tool:${m[1]}` : "loop";
37
+ }
38
+ /** Item 21 — UTF-8 byte length of a logged value: strings measured directly,
39
+ * everything else via its JSON encoding (the shape the model actually
40
+ * carries). Undefined/unencodable → 0 (never NaN into the byte tally). */
41
+ function byteLength(value) {
42
+ if (value === undefined)
43
+ return 0;
44
+ const s = typeof value === "string" ? value : JSON.stringify(value);
45
+ return typeof s === "string" ? Buffer.byteLength(s, "utf8") : 0;
46
+ }
47
+ /** Tolerantly read a session-JSONL line's payload when its `kind` matches —
48
+ * the canonical defensive accessor shared with `context-pressure.ts`
49
+ * (`doctor --context-pressure` folds the same persisted events). */
50
+ export function payloadOf(obj, kind) {
51
+ if (obj === null || typeof obj !== "object")
52
+ return undefined;
53
+ const line = obj;
54
+ if (line.kind !== kind)
55
+ return undefined;
56
+ if (line.payload === null || typeof line.payload !== "object")
57
+ return undefined;
58
+ return line.payload;
59
+ }
60
+ /**
61
+ * Fold parsed session objects (and optional audit records) into the
62
+ * aggregates the rules consume. Every field access is defensive: missing
63
+ * payloads, wrong-typed fields, and unknown kinds are skipped, so
64
+ * old-vintage logs (no advisor kinds) and future kinds both pass through
65
+ * cleanly.
66
+ */
67
+ export function buildAdviceContext(sessions, auditObjects = []) {
68
+ const toolStats = new Map();
69
+ const recoveriesByAction = new Map();
70
+ const recoveriesByErrorName = new Map();
71
+ const compactionsBySession = new Map();
72
+ const asksByTool = new Map();
73
+ const stopReasons = new Map();
74
+ const loopSignatures = new Map();
75
+ const perToolBytes = new Map();
76
+ let truncationContinues = 0;
77
+ let modelResponses = 0;
78
+ let totalToolBytes = 0;
79
+ const addToolBytes = (name, bytes) => {
80
+ perToolBytes.set(name, (perToolBytes.get(name) ?? 0) + bytes);
81
+ totalToolBytes += bytes;
82
+ };
83
+ for (const session of sessions) {
84
+ // Item 21 — correlate tool_result bytes back to the tool_use's name via
85
+ // the toolUseId (both live on separate lines). Per-session scope so ids
86
+ // never collide across sessions.
87
+ const toolUseNameById = new Map();
88
+ for (const obj of session.objects) {
89
+ const tool = payloadOf(obj, "tool_stats");
90
+ if (tool !== undefined && typeof tool["toolName"] === "string") {
91
+ const s = toolStats.get(tool["toolName"]) ?? {
92
+ calls: 0,
93
+ errors: 0,
94
+ totalDurationMs: 0,
95
+ };
96
+ s.calls += 1;
97
+ if (tool["isError"] === true)
98
+ s.errors += 1;
99
+ if (typeof tool["durationMs"] === "number")
100
+ s.totalDurationMs += tool["durationMs"];
101
+ toolStats.set(tool["toolName"], s);
102
+ continue;
103
+ }
104
+ const recovery = payloadOf(obj, "recovery");
105
+ if (recovery !== undefined && typeof recovery["action"] === "string") {
106
+ const action = recovery["action"];
107
+ recoveriesByAction.set(action, (recoveriesByAction.get(action) ?? 0) + 1);
108
+ if (action === "continue")
109
+ truncationContinues += 1;
110
+ // Item 19 — cluster by errorName for the failure-taxonomy rule.
111
+ if (typeof recovery["errorName"] === "string" && recovery["errorName"].length > 0) {
112
+ const errorName = recovery["errorName"];
113
+ const stats = recoveriesByErrorName.get(errorName) ?? { count: 0, actions: new Map() };
114
+ stats.count += 1;
115
+ stats.actions.set(action, (stats.actions.get(action) ?? 0) + 1);
116
+ recoveriesByErrorName.set(errorName, stats);
117
+ }
118
+ continue;
119
+ }
120
+ // Item 19 — synthetic loop-detection nudges are `user_message` lines
121
+ // whose content starts with the runtime's fixed prefix; group by the
122
+ // tool signature so a recurring loop on the same tool clusters.
123
+ const userMsg = payloadOf(obj, "user_message");
124
+ if (userMsg !== undefined) {
125
+ const signature = loopSignatureOf(userMsg["content"]);
126
+ if (signature !== undefined) {
127
+ loopSignatures.set(signature, (loopSignatures.get(signature) ?? 0) + 1);
128
+ }
129
+ continue;
130
+ }
131
+ // Item 21 — tool_use input bytes count toward the tool's context spend,
132
+ // and the id→name mapping lets the paired tool_result add its bytes.
133
+ const toolUse = payloadOf(obj, "tool_use");
134
+ if (toolUse !== undefined && typeof toolUse["name"] === "string") {
135
+ const name = toolUse["name"];
136
+ if (typeof toolUse["id"] === "string")
137
+ toolUseNameById.set(toolUse["id"], name);
138
+ addToolBytes(name, byteLength(toolUse["input"]));
139
+ continue;
140
+ }
141
+ const toolResult = payloadOf(obj, "tool_result");
142
+ if (toolResult !== undefined && typeof toolResult["toolUseId"] === "string") {
143
+ const name = toolUseNameById.get(toolResult["toolUseId"]);
144
+ if (name !== undefined)
145
+ addToolBytes(name, byteLength(toolResult["content"]));
146
+ continue;
147
+ }
148
+ const compaction = payloadOf(obj, "compaction");
149
+ if (compaction !== undefined) {
150
+ compactionsBySession.set(session.sessionId, (compactionsBySession.get(session.sessionId) ?? 0) + 1);
151
+ continue;
152
+ }
153
+ const permission = payloadOf(obj, "permission");
154
+ if (permission !== undefined &&
155
+ typeof permission["toolName"] === "string" &&
156
+ permission["decision"] === "ask") {
157
+ const ask = asksByTool.get(permission["toolName"]) ?? {
158
+ asks: 0,
159
+ approved: 0,
160
+ denied: 0,
161
+ };
162
+ ask.asks += 1;
163
+ if (permission["askOutcome"] === "approved")
164
+ ask.approved += 1;
165
+ if (permission["askOutcome"] === "denied")
166
+ ask.denied += 1;
167
+ asksByTool.set(permission["toolName"], ask);
168
+ continue;
169
+ }
170
+ const meta = payloadOf(obj, "model_meta");
171
+ if (meta !== undefined && typeof meta["stopReason"] === "string") {
172
+ modelResponses += 1;
173
+ stopReasons.set(meta["stopReason"], (stopReasons.get(meta["stopReason"]) ?? 0) + 1);
174
+ }
175
+ }
176
+ }
177
+ const auditKindCounts = new Map();
178
+ for (const obj of auditObjects) {
179
+ if (obj === null || typeof obj !== "object")
180
+ continue;
181
+ const kind = obj.kind;
182
+ if (typeof kind !== "string")
183
+ continue;
184
+ auditKindCounts.set(kind, (auditKindCounts.get(kind) ?? 0) + 1);
185
+ }
186
+ return {
187
+ sessionIds: sessions.map((s) => s.sessionId),
188
+ toolStats,
189
+ recoveriesByAction,
190
+ recoveriesByErrorName,
191
+ truncationContinues,
192
+ loopSignatures,
193
+ compactionsBySession,
194
+ asksByTool,
195
+ stopReasons,
196
+ modelResponses,
197
+ perToolBytes,
198
+ totalToolBytes,
199
+ auditKindCounts,
200
+ };
201
+ }
202
+ /**
203
+ * Canonical rule thresholds. runtime-core's in-run digest
204
+ * (observability.ts DIGEST_THRESHOLDS) mirrors these — it cannot import
205
+ * them (apps/cli depends on runtime-core, not vice versa), so keep the two
206
+ * in sync when tuning.
207
+ */
208
+ export const DEFAULT_ADVICE_THRESHOLDS = {
209
+ toolFailureMinCalls: 5,
210
+ toolFailureRate: 0.5,
211
+ truncationContinues: 2,
212
+ compactionsPerSession: 3,
213
+ asksPerTool: 3,
214
+ stopMinResponses: 4,
215
+ stopAnomalyRate: 0.25,
216
+ recoveryClusterMin: 3,
217
+ loopSignatureMin: 2,
218
+ chronicCompactionSessions: 2,
219
+ toolByteDominance: 0.6,
220
+ toolByteMinTotal: 50_000,
221
+ };
222
+ /** Stop reasons that are part of healthy operation; everything else
223
+ * (max_tokens, refusal, pause_turn, …) counts as anomalous. Mirrored by
224
+ * runtime-core's digest tally. */
225
+ export const CLEAN_STOP_REASONS = new Set([
226
+ "end_turn",
227
+ "tool_use",
228
+ "stop_sequence",
229
+ ]);
230
+ function resolveThresholds(opts) {
231
+ return { ...DEFAULT_ADVICE_THRESHOLDS, ...(opts?.thresholds ?? {}) };
232
+ }
233
+ function pct(rate) {
234
+ return `${(rate * 100).toFixed(0)}%`;
235
+ }
236
+ // Narrow accessors over the Spec union — the fields exist on most (not all)
237
+ // targets, so read through an index signature rather than per-target casts.
238
+ function specField(spec, key) {
239
+ if (spec === undefined)
240
+ return undefined;
241
+ return spec[key];
242
+ }
243
+ function agentMaxTokens(spec) {
244
+ const agent = specField(spec, "agent");
245
+ const value = agent?.["max_tokens"];
246
+ return typeof value === "number" ? value : undefined;
247
+ }
248
+ function toolConfigFor(spec, toolName) {
249
+ const cfg = specField(spec, "tool_config");
250
+ return cfg?.[toolName];
251
+ }
252
+ /** Wrap a candidate patch: keep it when `validatePatch` accepts it against
253
+ * the spec, otherwise fall back to the advice text. EVERY patch the rules
254
+ * emit goes through here — the OPTIMIZABLE_PATHS whitelist stays the
255
+ * single safety floor even if a rule drifts. */
256
+ function patchOrAdvice(spec, patch, fallback) {
257
+ if (spec === undefined)
258
+ return { kind: "advice", text: fallback };
259
+ try {
260
+ validatePatch(spec, patch);
261
+ return { kind: "spec-patch", patch };
262
+ }
263
+ catch {
264
+ return { kind: "advice", text: fallback };
265
+ }
266
+ }
267
+ // -------- rules --------
268
+ /**
269
+ * Repeated tool failures: a tool with ≥ minCalls whose error rate crosses
270
+ * the threshold. Advice-only by design — `tool_config` is not in
271
+ * OPTIMIZABLE_PATHS (the whitelist is the autotune safety floor), so the
272
+ * suggestion points at the spec's `tool_config` block when one exists for
273
+ * the tool instead of emitting a patch. NOTE: `tool_stats.isError` includes
274
+ * permission-denied results; the churn rule covers the ask side of that.
275
+ */
276
+ export const ruleRepeatedToolFailures = (ctx, opts) => {
277
+ const t = resolveThresholds(opts);
278
+ const findings = [];
279
+ for (const [tool, s] of ctx.toolStats) {
280
+ if (s.calls < t.toolFailureMinCalls)
281
+ continue;
282
+ const rate = s.errors / s.calls;
283
+ if (rate < t.toolFailureRate)
284
+ continue;
285
+ const hasConfig = toolConfigFor(opts?.spec, tool) !== undefined;
286
+ const configNote = hasConfig
287
+ ? ` The spec has a \`tool_config.${tool}\` block — review its knobs (timeouts, limits) before swapping the tool.`
288
+ : "";
289
+ findings.push({
290
+ id: `repeated-tool-failures:${tool}`,
291
+ severity: "warn",
292
+ summary: `tool ${tool} failed ${s.errors}/${s.calls} calls (${pct(rate)})`,
293
+ evidence: [
294
+ `${s.errors} of ${s.calls} ${tool} calls returned is_error (threshold: ≥${t.toolFailureMinCalls} calls at ≥${pct(t.toolFailureRate)})`,
295
+ `mean duration ${s.calls > 0 ? Math.round(s.totalDurationMs / s.calls) : 0}ms per call`,
296
+ ],
297
+ counts: { calls: s.calls, errors: s.errors },
298
+ suggestion: {
299
+ kind: "advice",
300
+ text: `Investigate why ${tool} keeps failing (bad inputs from the model, an unreachable backend, or permission denials) ` +
301
+ `or swap it for an alternative tool.${configNote}`,
302
+ },
303
+ });
304
+ }
305
+ return findings;
306
+ };
307
+ /**
308
+ * Truncation pressure: `continue` recoveries (always max_output_tokens) at
309
+ * or above the threshold. Suggests a SpecPatch bumping `agent.max_tokens`
310
+ * (whitelisted in OPTIMIZABLE_PATHS): double the spec's current value, or
311
+ * add 16384 (2× the runtime's 8192 default) when the spec doesn't set one.
312
+ */
313
+ export const ruleTruncationPressure = (ctx, opts) => {
314
+ const t = resolveThresholds(opts);
315
+ if (ctx.truncationContinues < t.truncationContinues)
316
+ return [];
317
+ const spec = opts?.spec;
318
+ const current = agentMaxTokens(spec);
319
+ const proposed = current !== undefined ? Math.min(current * 2, 32768) : 16384;
320
+ const adviceText = `Responses are being cut off by the per-turn output-token cap. Raise agent.max_tokens in the spec (e.g. to ${proposed}) so long tool calls and multi-file edits complete in one turn.`;
321
+ const patch = {
322
+ target: spec?.target ?? "cli",
323
+ path: ["agent", "max_tokens"],
324
+ op: current !== undefined ? "replace" : "add",
325
+ value: proposed,
326
+ rationale: `advise: ${ctx.truncationContinues} max_output_tokens truncation recoveries observed`,
327
+ };
328
+ return [
329
+ {
330
+ id: "truncation-pressure",
331
+ severity: "warn",
332
+ summary: `${ctx.truncationContinues} max_output_tokens truncation recoveries`,
333
+ evidence: [
334
+ `${ctx.truncationContinues} recovery events with action=continue (threshold: ≥${t.truncationContinues})`,
335
+ current !== undefined
336
+ ? `spec sets agent.max_tokens: ${current}`
337
+ : "spec does not set agent.max_tokens (runtime default 8192)",
338
+ ],
339
+ counts: { truncationContinues: ctx.truncationContinues },
340
+ suggestion: patchOrAdvice(spec, patch, adviceText),
341
+ },
342
+ ];
343
+ };
344
+ /**
345
+ * Compaction thrash: any single session compacting ≥ threshold times.
346
+ * ADVICE-ONLY — no auto-applicable SpecPatch. `compaction.curate` is listed
347
+ * in OPTIMIZABLE_PATHS and lowers to IR (`packages/ir/src/index.ts`,
348
+ * `IrCompaction.curate`), but no target emitter or runtime-core path
349
+ * actually consumes it yet (see the IR field's doc comment). A patch that
350
+ * flips a runtime no-op would pass the `optimize --from-advice` eval gate
351
+ * trivially (inert change → equal pass rate) and get written into the
352
+ * user's spec while changing nothing, so this rule surfaces the finding as
353
+ * a warning with concrete alternatives instead of proposing `curate: true`.
354
+ * `compaction.threshold` is ALSO whitelisted, but the strict spec schema
355
+ * carries no such key today, so a threshold patch could never apply either.
356
+ */
357
+ export const ruleCompactionThrash = (ctx, opts) => {
358
+ const t = resolveThresholds(opts);
359
+ let worstSession;
360
+ let worstCount = 0;
361
+ for (const [sessionId, count] of ctx.compactionsBySession) {
362
+ if (count > worstCount) {
363
+ worstCount = count;
364
+ worstSession = sessionId;
365
+ }
366
+ }
367
+ if (worstSession === undefined || worstCount < t.compactionsPerSession)
368
+ return [];
369
+ const spec = opts?.spec;
370
+ const compaction = specField(spec, "compaction");
371
+ const curateOn = compaction?.["curate"] === true;
372
+ const suggestion = {
373
+ kind: "advice",
374
+ text: curateOn
375
+ ? "compaction.curate is already set but sessions still thrash — that flag is not yet wired at runtime " +
376
+ "(reserved for a future compaction-curator pass), so it will not help. Reduce compaction thrash by " +
377
+ "widening the compaction window, trimming which tools are available (fewer tools per turn means less " +
378
+ "context to summarize), or splitting the work across a sub-agent so each session carries less history."
379
+ : "Sessions are compacting repeatedly, which burns tokens on re-summarization and loses context each " +
380
+ "pass. `compaction.curate` is not yet wired at runtime, so enabling it would not help today. Reduce " +
381
+ "thrash by widening the compaction window, trimming which tools are available (fewer tools per turn " +
382
+ "means less context to summarize), or splitting the work across a sub-agent so each session carries " +
383
+ "less history.",
384
+ };
385
+ return [
386
+ {
387
+ id: "compaction-thrash",
388
+ severity: "warn",
389
+ summary: `session ${worstSession} compacted ${worstCount} times`,
390
+ evidence: [
391
+ `${worstCount} compaction events in session ${worstSession} (threshold: ≥${t.compactionsPerSession})`,
392
+ `${ctx.compactionsBySession.size} session(s) compacted at least once`,
393
+ ],
394
+ counts: { compactions: worstCount, sessionsWithCompaction: ctx.compactionsBySession.size },
395
+ suggestion,
396
+ },
397
+ ];
398
+ };
399
+ /**
400
+ * Permission churn: a tool prompting ≥ threshold times with 100% approved
401
+ * outcomes. NEVER a patch — permissions are excluded from OPTIMIZABLE_PATHS
402
+ * by design (an optimizer must not be able to widen its own permissions);
403
+ * the human gets the alwaysAllow one-liner with the evidence instead.
404
+ */
405
+ export const rulePermissionChurn = (ctx, opts) => {
406
+ const t = resolveThresholds(opts);
407
+ const findings = [];
408
+ for (const [tool, a] of ctx.asksByTool) {
409
+ if (a.asks < t.asksPerTool)
410
+ continue;
411
+ if (a.denied > 0 || a.approved !== a.asks)
412
+ continue;
413
+ findings.push({
414
+ id: `permission-churn:${tool}`,
415
+ severity: "info",
416
+ summary: `${tool} prompted ${a.asks} times — every ask was approved`,
417
+ evidence: [
418
+ `${a.asks} ask prompts for ${tool}, ${a.approved} approved, 0 denied (threshold: ≥${t.asksPerTool} asks, 100% approved)`,
419
+ ],
420
+ counts: { asks: a.asks, approved: a.approved },
421
+ suggestion: {
422
+ kind: "advice",
423
+ text: `If ${tool} is safe for this agent, add \`{ type: alwaysAllow, pattern: ${tool} }\` to permissions.rules in the spec to stop re-prompting. Permission rules are deliberately NOT auto-patched — review before widening.`,
424
+ },
425
+ });
426
+ }
427
+ return findings;
428
+ };
429
+ /**
430
+ * Stop-reason anomalies: a high rate of stops outside CLEAN_STOP_REASONS
431
+ * (end_turn / tool_use / stop_sequence are healthy; max_tokens, refusal,
432
+ * pause_turn, … are not). Advice-only — the right fix depends on which
433
+ * reason dominates.
434
+ */
435
+ export const ruleStopReasonAnomalies = (ctx, opts) => {
436
+ const t = resolveThresholds(opts);
437
+ if (ctx.modelResponses < t.stopMinResponses)
438
+ return [];
439
+ let anomalous = 0;
440
+ const anomalousReasons = [];
441
+ for (const [reason, count] of ctx.stopReasons) {
442
+ if (CLEAN_STOP_REASONS.has(reason))
443
+ continue;
444
+ anomalous += count;
445
+ anomalousReasons.push(`${reason}×${count}`);
446
+ }
447
+ const rate = anomalous / ctx.modelResponses;
448
+ if (rate < t.stopAnomalyRate)
449
+ return [];
450
+ anomalousReasons.sort();
451
+ return [
452
+ {
453
+ id: "stop-reason-anomalies",
454
+ severity: "warn",
455
+ summary: `${pct(rate)} of model responses stopped abnormally (${anomalousReasons.join(", ")})`,
456
+ evidence: [
457
+ `${anomalous} of ${ctx.modelResponses} responses stopped outside {end_turn, tool_use, stop_sequence} (threshold: ≥${pct(t.stopAnomalyRate)} over ≥${t.stopMinResponses} responses)`,
458
+ `anomalous stop reasons: ${anomalousReasons.join(", ")}`,
459
+ ],
460
+ counts: { anomalous, responses: ctx.modelResponses },
461
+ suggestion: {
462
+ kind: "advice",
463
+ text: "Inspect the dominant abnormal stop reason: max_tokens → raise agent.max_tokens or tighten instructions; " +
464
+ "refusal → review what the sessions are asking the model to do; provider-specific reasons → check the adapter/router config.",
465
+ },
466
+ },
467
+ ];
468
+ };
469
+ // -------- item 19: failure_taxonomy + loop-break rules from telemetry --------
470
+ /** The recovery actions a drafted `failure_taxonomy` entry may declare —
471
+ * exactly the spec's `failureTaxonomyEntrySchema.recovery` enum. */
472
+ const TAXONOMY_RECOVERY_ACTIONS = new Set([
473
+ "retry",
474
+ "compact",
475
+ "continue",
476
+ "tombstone",
477
+ "fail",
478
+ ]);
479
+ /**
480
+ * Item 19 — specificity floor for a drafted taxonomy `pattern`. The pattern is
481
+ * `errorName` verbatim, matched by the recovery engine as a case-insensitive
482
+ * SUBSTRING of `error.message`. A very short or generic name (e.g. "Error")
483
+ * drafts a rule that fires on almost every failure — worse than no entry. Such
484
+ * clusters are surfaced as ADVICE (a human should hand-write a precise pattern)
485
+ * rather than drafted into a patch.
486
+ */
487
+ export const TAXONOMY_MIN_PATTERN_LEN = 4;
488
+ /** Generic error tokens too broad to auto-draft, regardless of length. */
489
+ export const TAXONOMY_GENERIC_TOKENS = new Set(["error", "fail", "err"]);
490
+ /** True when `errorName` is too broad to draft a taxonomy pattern for
491
+ * (too short, or a generic token). Case-insensitive. */
492
+ export function taxonomyPatternTooBroad(errorName) {
493
+ const trimmed = errorName.trim();
494
+ if (trimmed.length < TAXONOMY_MIN_PATTERN_LEN)
495
+ return true;
496
+ return TAXONOMY_GENERIC_TOKENS.has(trimmed.toLowerCase());
497
+ }
498
+ /** The dominant recovery action observed for an error class (ties broken by
499
+ * the enum's declared order → deterministic). Falls back to `retry` when the
500
+ * observed action isn't a valid taxonomy action (e.g. a future action name). */
501
+ function dominantRecoveryAction(actions) {
502
+ let best;
503
+ let bestCount = -1;
504
+ for (const action of TAXONOMY_RECOVERY_ACTIONS) {
505
+ const count = actions.get(action) ?? 0;
506
+ if (count > bestCount) {
507
+ bestCount = count;
508
+ best = action;
509
+ }
510
+ }
511
+ return best ?? "retry";
512
+ }
513
+ /** Read the spec's existing `failure_taxonomy`, defensively — returns the
514
+ * entries' `class` strings already covered so a draft never duplicates one. */
515
+ function existingTaxonomyClasses(spec) {
516
+ const covered = new Set();
517
+ const taxonomy = specField(spec, "failure_taxonomy");
518
+ if (!Array.isArray(taxonomy))
519
+ return covered;
520
+ for (const entry of taxonomy) {
521
+ if (entry !== null && typeof entry === "object") {
522
+ const cls = entry.class;
523
+ if (typeof cls === "string")
524
+ covered.add(cls);
525
+ }
526
+ }
527
+ return covered;
528
+ }
529
+ /**
530
+ * Item 19 — failure-taxonomy learning. Clusters recurring `recovery` events
531
+ * by `errorName`; each class seen ≥ recoveryClusterMin times (and NOT already
532
+ * covered by the spec's taxonomy) becomes a draft `failure_taxonomy` entry
533
+ * whose `recovery` action is the one the runtime actually took most for that
534
+ * class, and whose `pattern` is a case-insensitive substring of the error
535
+ * name.
536
+ *
537
+ * `failure_taxonomy` IS whitelisted in OPTIMIZABLE_PATHS for every target, so
538
+ * the emitted patch rides the eval-gated `optimize --from-advice` apply. The
539
+ * patch REPLACES the whole `failure_taxonomy` array (existing entries +
540
+ * drafts) when the spec already has one, else ADDS the array — a whole-block
541
+ * edit the whitelist's prefix match allows. Budget caps aren't a field on the
542
+ * strict entry schema (`{ class, pattern, recovery, hint? }`), so the drafted
543
+ * `hint` carries the observed count + the "matches error.message" caveat and
544
+ * the recovery-engine's per-turn budget note.
545
+ */
546
+ export const ruleFailureTaxonomy = (ctx, opts) => {
547
+ const t = resolveThresholds(opts);
548
+ const spec = opts?.spec;
549
+ const covered = existingTaxonomyClasses(spec);
550
+ const drafts = [];
551
+ const evidence = [];
552
+ let clustered = 0;
553
+ // Item 19 (F4) — clusters whose errorName is too broad to draft a pattern
554
+ // for (too short/generic). Surfaced as advice, never a patch.
555
+ const tooBroad = [];
556
+ // Deterministic: sort clusters by count desc, then errorName.
557
+ const clusters = [...ctx.recoveriesByErrorName.entries()].sort((a, b) => {
558
+ if (b[1].count !== a[1].count)
559
+ return b[1].count - a[1].count;
560
+ return a[0].localeCompare(b[0]);
561
+ });
562
+ for (const [errorName, stats] of clusters) {
563
+ if (stats.count < t.recoveryClusterMin)
564
+ continue;
565
+ if (covered.has(errorName))
566
+ continue;
567
+ const recovery = dominantRecoveryAction(stats.actions);
568
+ // F4 specificity floor: a too-short/generic errorName drafts a rule that
569
+ // matches almost every error.message. Skip drafting it; collect it for an
570
+ // advice-only nudge so a human writes a precise pattern instead.
571
+ if (taxonomyPatternTooBroad(errorName)) {
572
+ tooBroad.push({ errorName, count: stats.count, recovery });
573
+ continue;
574
+ }
575
+ drafts.push({
576
+ class: errorName,
577
+ // matchNamedFailure matches error.MESSAGE (substring/regex); the class
578
+ // name is the best available proxy — the error message usually contains
579
+ // it. The reviewer refines to a precise pattern.
580
+ pattern: errorName,
581
+ recovery,
582
+ hint: `observed ${stats.count}× → recovery-engine took "${recovery}" most; refine 'pattern' to a substring of the actual error.message, and note recovery actions are budget-capped per turn (repeated failures still fail-fast)`,
583
+ });
584
+ evidence.push(`${errorName}: ${stats.count} recoveries → draft recovery=${recovery}`);
585
+ clustered += stats.count;
586
+ }
587
+ const findings = [];
588
+ if (drafts.length > 0) {
589
+ // Compose the full array: existing entries (verbatim) + the new drafts.
590
+ const existingArray = Array.isArray(specField(spec, "failure_taxonomy"))
591
+ ? specField(spec, "failure_taxonomy")
592
+ : [];
593
+ const value = [...existingArray, ...drafts];
594
+ const op = existingArray.length > 0 ? "replace" : "add";
595
+ const patch = {
596
+ target: spec?.target ?? "cli",
597
+ path: ["failure_taxonomy"],
598
+ op,
599
+ value,
600
+ rationale: `advise: ${drafts.length} error class(es) recurred ≥${t.recoveryClusterMin}× — drafted failure_taxonomy entries`,
601
+ };
602
+ const adviceText = `Recurring error classes were recovered from repeatedly (${evidence.join("; ")}). Add failure_taxonomy entries so the runtime handles them deterministically: ${drafts
603
+ .map((d) => `${d.class} → ${d.recovery}`)
604
+ .join(", ")}. Set each 'pattern' to a substring of the real error.message.`;
605
+ findings.push({
606
+ id: "failure-taxonomy-learned",
607
+ severity: "warn",
608
+ summary: `${drafts.length} recurring error class(es) → draft failure_taxonomy`,
609
+ evidence: [
610
+ ...evidence,
611
+ `threshold: ≥${t.recoveryClusterMin} recoveries per class; ${covered.size} class(es) already in the spec were skipped`,
612
+ ],
613
+ counts: { classes: drafts.length, recoveries: clustered },
614
+ suggestion: patchOrAdvice(spec, patch, adviceText),
615
+ });
616
+ }
617
+ // F4 — advice-only finding for clusters too broad to auto-draft. Never a
618
+ // patch: the errorName is too generic to become a `pattern` safely.
619
+ if (tooBroad.length > 0) {
620
+ const broadClustered = tooBroad.reduce((sum, c) => sum + c.count, 0);
621
+ findings.push({
622
+ id: "failure-taxonomy-too-broad",
623
+ severity: "info",
624
+ summary: `${tooBroad.length} recurring error class(es) too generic to auto-draft failure_taxonomy`,
625
+ evidence: [
626
+ ...tooBroad.map((c) => `${c.errorName}: ${c.count} recoveries → recovery=${c.recovery} (NOT drafted)`),
627
+ `skipped: errorName shorter than ${TAXONOMY_MIN_PATTERN_LEN} chars or a generic token (${[...TAXONOMY_GENERIC_TOKENS].join(", ")}) — a verbatim pattern would match nearly every error.message`,
628
+ ],
629
+ counts: { classes: tooBroad.length, recoveries: broadClustered },
630
+ suggestion: {
631
+ kind: "advice",
632
+ text: `Recurring but generically-named error classes (${tooBroad
633
+ .map((c) => `${c.errorName} ×${c.count}`)
634
+ .join(", ")}) were recovered from repeatedly. Their names are too broad to draft a failure_taxonomy 'pattern' automatically (it is matched as a case-insensitive substring of error.message). Hand-write a precise 'pattern' — a distinctive substring of the real error message — before adding a taxonomy entry.`,
635
+ },
636
+ });
637
+ }
638
+ return findings;
639
+ };
640
+ /**
641
+ * Item 19 — loop-break learning. A `[runtime] possible loop detected` nudge
642
+ * is NOT an error (no exception is thrown), so `matchNamedFailure` — which
643
+ * matches error.message only — can never catch it; a taxonomy entry is the
644
+ * wrong tool. This rule instead drafts an instructions-ADDENDUM as advice
645
+ * text (never a patch: `agent.instructions` IS optimizable, but a
646
+ * mechanically-appended nudge is a prompt edit a human should own, and the
647
+ * optimizer already rewrites instructions on its own signal). Fires per
648
+ * recurring loop signature (same tool looped in ≥ loopSignatureMin sessions/
649
+ * turns).
650
+ */
651
+ export const ruleLoopBreak = (ctx, opts) => {
652
+ const t = resolveThresholds(opts);
653
+ const recurring = [...ctx.loopSignatures.entries()]
654
+ .filter(([, count]) => count >= t.loopSignatureMin)
655
+ .sort((a, b) => (b[1] !== a[1] ? b[1] - a[1] : a[0].localeCompare(b[0])));
656
+ if (recurring.length === 0)
657
+ return [];
658
+ const tools = recurring.map(([sig]) => sig.replace(/^tool:/, ""));
659
+ const evidence = recurring.map(([sig, count]) => `${sig}: ${count} loop nudge(s)`);
660
+ const total = recurring.reduce((sum, [, count]) => sum + count, 0);
661
+ return [
662
+ {
663
+ id: "loop-break",
664
+ severity: "warn",
665
+ summary: `${recurring.length} recurring loop signature(s) (${tools.join(", ")})`,
666
+ evidence: [
667
+ ...evidence,
668
+ `threshold: ≥${t.loopSignatureMin} nudges per signature — loops aren't errors, so a failure_taxonomy entry can't catch them (matchNamedFailure matches error.message only)`,
669
+ ],
670
+ counts: { signatures: recurring.length, nudges: total },
671
+ suggestion: {
672
+ kind: "advice",
673
+ text: `The agent repeatedly looped on: ${tools.join(", ")}. Add an instructions addendum that breaks the loop, e.g.: "If a tool returns the same result twice for the same input, do NOT call it again — change approach or answer from what you already have. Prefer batching related ${tools[0]} calls and reading the results before deciding the next step." This is an instructions edit (a loop isn't an error, so failure_taxonomy can't match it).`,
674
+ },
675
+ },
676
+ ];
677
+ };
678
+ // -------- item 21: sub-agent split under chronic context pressure --------
679
+ /**
680
+ * Targets whose AGENT block carries a `sub_agents:` map, so the drafted
681
+ * fragment (which sits under `agent:`) pastes in verbatim: cli and channel.
682
+ * Crew ALSO has sub_agents, but under each ROLE rather than a single `agent`
683
+ * block, so the paste location differs — it's excluded here rather than emit a
684
+ * fragment that lands in the wrong place. Any other target has no sub_agents
685
+ * block at all, so the rule stays silent (nowhere to paste).
686
+ */
687
+ export const SUB_AGENT_TARGETS = new Set(["cli", "channel"]);
688
+ /** Reverse map from the RegisteredTool PascalCase `.name` back to the
689
+ * camelCase spec/BUILTIN_TOOL_MAP key, for the builtins whose byte spend the
690
+ * rule can attribute to a concern. A tool not here still yields a suggestion
691
+ * (the sub-agent's `tools:` line is just omitted / left for the human). */
692
+ const TOOL_NAME_TO_SPEC_KEY = Object.freeze({
693
+ Read: "read",
694
+ Write: "write",
695
+ Edit: "edit",
696
+ Glob: "glob",
697
+ Grep: "grep",
698
+ Bash: "bash",
699
+ WebFetch: "webFetch",
700
+ WebSearch: "webSearch",
701
+ Fetch: "fetch",
702
+ ReadImage: "readImage",
703
+ IngestDocument: "ingestDocument",
704
+ CodeGraphSearch: "codegraphSearch",
705
+ CodeGraphCallers: "codegraphCallers",
706
+ CodeGraphCallees: "codegraphCallees",
707
+ CodeGraphImpact: "codegraphImpact",
708
+ });
709
+ /** The single most byte-dominant tool over the mined sessions, plus its share
710
+ * of total tool byte-spend. Undefined when no tool bytes were observed. */
711
+ function dominantTool(ctx) {
712
+ if (ctx.totalToolBytes <= 0)
713
+ return undefined;
714
+ let name;
715
+ let bytes = 0;
716
+ for (const [tool, b] of ctx.perToolBytes) {
717
+ if (b > bytes) {
718
+ bytes = b;
719
+ name = tool;
720
+ }
721
+ }
722
+ if (name === undefined)
723
+ return undefined;
724
+ return { name, bytes, share: bytes / ctx.totalToolBytes };
725
+ }
726
+ /** Draft a ready-to-paste `sub_agents:` YAML fragment moving `toolName`'s
727
+ * concern into its own sub-agent with its own context window. Two-space
728
+ * indented to sit under the spec's `agent:` block (cli/channel) — the CLI
729
+ * prints it verbatim in the advice text. */
730
+ export function subAgentFragment(toolName) {
731
+ const specKey = TOOL_NAME_TO_SPEC_KEY[toolName];
732
+ const subName = `${(specKey ?? toolName).toLowerCase()}_worker`;
733
+ const toolsLine = specKey !== undefined ? `\n tools: [${specKey}]` : "";
734
+ return [
735
+ " # paste under the agent block; move the heavy concern into its own window",
736
+ " sub_agents:",
737
+ ` ${subName}:`,
738
+ ` description: Handles the ${toolName}-heavy work in an isolated context`,
739
+ " instructions: |",
740
+ ` You are a focused worker. Do ONLY the ${toolName}-heavy sub-task the`,
741
+ ` parent delegates, then return a concise result — not the full transcript.${toolsLine}`,
742
+ ].join("\n");
743
+ }
744
+ /**
745
+ * Item 21 — suggest a sub-agent split when context pressure is chronic:
746
+ * either the mined sessions ROUTINELY compact (≥ chronicCompactionSessions
747
+ * sessions each compacting ≥ compactionsPerSession times) OR one tool/concern
748
+ * DOMINATES tool byte-spend (≥ toolByteDominance of ≥ toolByteMinTotal total
749
+ * bytes). Moving the heavy concern behind a sub-agent's own context window
750
+ * relieves the parent.
751
+ *
752
+ * STRICTLY suggest+scaffold — NEVER a patch: a structural change (adding a
753
+ * sub-agent + rewriting instructions) isn't eval-safe to auto-apply, so this
754
+ * emits advice text carrying a ready-to-paste `sub_agents:` fragment (the
755
+ * correct spec key — NOT `agents:`). Gated per target: only cli/channel/crew
756
+ * schemas have a `sub_agents` block; on any other target (or with no spec) the
757
+ * rule stays silent, since there's nowhere to paste it.
758
+ */
759
+ export const ruleSubAgentSplit = (ctx, opts) => {
760
+ const t = resolveThresholds(opts);
761
+ const spec = opts?.spec;
762
+ // Gate on target: the fragment is only pasteable where sub_agents exists.
763
+ if (spec === undefined || !SUB_AGENT_TARGETS.has(spec.target))
764
+ return [];
765
+ const chronicSessions = [...ctx.compactionsBySession.values()].filter((c) => c >= t.compactionsPerSession).length;
766
+ const chronicCompaction = chronicSessions >= t.chronicCompactionSessions;
767
+ const dom = dominantTool(ctx);
768
+ const dominates = dom !== undefined &&
769
+ ctx.totalToolBytes >= t.toolByteMinTotal &&
770
+ dom.share >= t.toolByteDominance;
771
+ if (!chronicCompaction && !dominates)
772
+ return [];
773
+ // Prefer the dominant tool as the concern to move; fall back to a generic
774
+ // "heaviest concern" when only the compaction signal tripped.
775
+ const concernTool = dom?.name ?? "the heaviest tool";
776
+ const evidence = [];
777
+ if (chronicCompaction) {
778
+ evidence.push(`${chronicSessions} session(s) each compacted ≥${t.compactionsPerSession}× (threshold: ≥${t.chronicCompactionSessions} sessions)`);
779
+ }
780
+ if (dominates && dom !== undefined) {
781
+ evidence.push(`${dom.name} accounts for ${(dom.share * 100).toFixed(0)}% of ${ctx.totalToolBytes} tool bytes (threshold: ≥${(t.toolByteDominance * 100).toFixed(0)}% of ≥${t.toolByteMinTotal})`);
782
+ }
783
+ const fragment = dom !== undefined ? subAgentFragment(dom.name) : subAgentFragment("Bash");
784
+ return [
785
+ {
786
+ id: "sub-agent-split",
787
+ severity: "warn",
788
+ summary: `chronic context pressure — consider moving ${concernTool} into a sub-agent`,
789
+ evidence,
790
+ counts: {
791
+ chronicSessions,
792
+ dominantShare: dom !== undefined ? Math.round(dom.share * 100) : 0,
793
+ },
794
+ suggestion: {
795
+ kind: "advice",
796
+ text: `Context pressure is chronic. Move the ${concernTool}-heavy concern into a sub-agent so it carries its own context window and the parent stays lean. The runtime already executes sub-agents (the synthetic Task tool). Paste this fragment (note the key is \`sub_agents:\`, NOT \`agents:\`) and delegate the heavy work to it from the parent's instructions:\n${fragment}\n\nThen add to the parent instructions: "For ${concernTool}-heavy work, delegate to the ${(TOOL_NAME_TO_SPEC_KEY[dom?.name ?? ""] ?? "worker").toLowerCase()}_worker sub-agent via the Task tool and use its summarized result." This is a structural change — apply it by hand and re-eval; it is never auto-applied.`,
797
+ },
798
+ },
799
+ ];
800
+ };
801
+ export const ADVICE_RULES = [
802
+ ruleRepeatedToolFailures,
803
+ ruleTruncationPressure,
804
+ ruleCompactionThrash,
805
+ rulePermissionChurn,
806
+ ruleStopReasonAnomalies,
807
+ ruleFailureTaxonomy,
808
+ ruleLoopBreak,
809
+ ruleSubAgentSplit,
810
+ ];
811
+ /**
812
+ * Run every rule and rank the findings: warn before info, then by the
813
+ * magnitude of the primary count, then by id (deterministic output). Each
814
+ * emitted patch was already pre-validated by its rule via `patchOrAdvice`.
815
+ */
816
+ export function runAdviceRules(ctx, opts) {
817
+ const findings = ADVICE_RULES.flatMap((rule) => rule(ctx, opts));
818
+ const magnitude = (f) => Object.values(f.counts).reduce((max, n) => Math.max(max, n), 0);
819
+ return findings.sort((a, b) => {
820
+ if (a.severity !== b.severity)
821
+ return a.severity === "warn" ? -1 : 1;
822
+ const diff = magnitude(b) - magnitude(a);
823
+ if (diff !== 0)
824
+ return diff;
825
+ return a.id.localeCompare(b.id);
826
+ });
827
+ }
828
+ /** The `suggestions.json` payload — the SpecPatch list a future
829
+ * `optimize --from-advice` consumes. Advice-only findings are report-only. */
830
+ export function buildSuggestionsFile(findings, sessionIds, generatedAt) {
831
+ const suggestions = findings.flatMap((f) => f.suggestion.kind === "spec-patch"
832
+ ? [{ findingId: f.id, severity: f.severity, summary: f.summary, patch: f.suggestion.patch }]
833
+ : []);
834
+ return { generatedAt, sessionIds, suggestions };
835
+ }
836
+ // Dependency-free HTML (the eval-report render.ts posture): inline style,
837
+ // no script, everything user-derived escaped.
838
+ const REPORT_STYLE = `
839
+ :root { --bg: #0f1115; --fg: #e6e6e6; --muted: #999; --warn: #ffb74d; --info: #61dafb; --card: #1a1d23; --border: #333; }
840
+ * { box-sizing: border-box; }
841
+ body { font-family: ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--fg); margin: 0; padding: 24px; line-height: 1.5; }
842
+ h1 { margin: 0 0 8px; }
843
+ .meta { color: var(--muted); font-size: 13px; margin-bottom: 24px; }
844
+ .finding { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 16px; margin: 12px 0; }
845
+ .finding h2 { margin: 0 0 8px; font-size: 16px; }
846
+ .sev { display: inline-block; font-size: 11px; font-weight: 700; text-transform: uppercase; border-radius: 4px; padding: 2px 8px; margin-right: 8px; }
847
+ .sev.warn { background: rgba(255, 183, 77, 0.15); color: var(--warn); }
848
+ .sev.info { background: rgba(97, 218, 251, 0.15); color: var(--info); }
849
+ ul.evidence { margin: 8px 0; padding-left: 20px; color: var(--muted); font-size: 13px; }
850
+ pre { background: #0a0c10; padding: 12px; overflow-x: auto; border-radius: 4px; font-size: 12px; line-height: 1.4; }
851
+ .advice { font-size: 14px; }
852
+ .healthy { color: var(--muted); }
853
+ `;
854
+ export function escapeHtml(s) {
855
+ return s
856
+ .replace(/&/g, "&amp;")
857
+ .replace(/</g, "&lt;")
858
+ .replace(/>/g, "&gt;")
859
+ .replace(/"/g, "&quot;")
860
+ .replace(/'/g, "&#39;");
861
+ }
862
+ function findingHtml(f) {
863
+ const evidence = f.evidence.map((e) => `<li>${escapeHtml(e)}</li>`).join("");
864
+ const suggestion = f.suggestion.kind === "spec-patch"
865
+ ? `<pre>${escapeHtml(JSON.stringify(f.suggestion.patch, null, 2))}</pre>`
866
+ : `<p class="advice">${escapeHtml(f.suggestion.text)}</p>`;
867
+ return `<section class="finding">
868
+ <h2><span class="sev ${f.severity}">${f.severity}</span>${escapeHtml(f.id)}</h2>
869
+ <p>${escapeHtml(f.summary)}</p>
870
+ <ul class="evidence">${evidence}</ul>
871
+ ${suggestion}
872
+ </section>`;
873
+ }
874
+ /** Self-contained `report.html` for `crewhaus advise` — ranked findings with
875
+ * evidence and the suggested patch/advice per finding. */
876
+ export function renderAdviceHtml(input) {
877
+ const body = input.findings.length === 0
878
+ ? `<p class="healthy">No findings — the mined sessions look healthy.</p>`
879
+ : input.findings.map(findingHtml).join("\n");
880
+ return `<!doctype html>
881
+ <html><head>
882
+ <meta charset="utf-8">
883
+ <title>crewhaus advise</title>
884
+ <style>${REPORT_STYLE}</style>
885
+ </head>
886
+ <body>
887
+ <h1>crewhaus advise</h1>
888
+ <p class="meta">${input.findings.length} finding(s) across ${input.sessionIds.length} session(s) · generated ${escapeHtml(input.generatedAt)}</p>
889
+ ${body}
890
+ </body></html>`;
891
+ }
892
+ /** One line per finding for the CLI's text mode. */
893
+ export function formatFindingLines(f) {
894
+ const lines = [`[${f.severity}] ${f.id} — ${f.summary}`];
895
+ for (const e of f.evidence)
896
+ lines.push(` · ${e}`);
897
+ if (f.suggestion.kind === "spec-patch") {
898
+ const p = f.suggestion.patch;
899
+ lines.push(` patch: ${p.op} ${p.path.join(".")} → ${JSON.stringify(p.value)}`);
900
+ }
901
+ else {
902
+ lines.push(` advice: ${f.suggestion.text}`);
903
+ }
904
+ return lines;
905
+ }