wyrm-mcp 7.2.0 → 7.2.2

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 (156) hide show
  1. package/LICENSE +26 -667
  2. package/NOTICE +14 -33
  3. package/dist/activation.d.ts.map +1 -1
  4. package/dist/activation.js +1 -44
  5. package/dist/activation.js.map +1 -1
  6. package/dist/agent-daemon.js +4 -281
  7. package/dist/agent-loop.js +7 -332
  8. package/dist/analytics.js +13 -236
  9. package/dist/attribution.js +1 -49
  10. package/dist/audit.js +2 -457
  11. package/dist/auto-capture.js +3 -138
  12. package/dist/auto-orchestrator.js +1 -325
  13. package/dist/autoconfig.js +39 -840
  14. package/dist/buddy-runner.js +1 -109
  15. package/dist/buddy.js +14 -564
  16. package/dist/build-flags.js +1 -17
  17. package/dist/capabilities.js +3 -183
  18. package/dist/capture.js +1 -56
  19. package/dist/causality.js +6 -107
  20. package/dist/cli.js +20 -281
  21. package/dist/cloud/cli.js +5 -541
  22. package/dist/cloud/client.js +1 -221
  23. package/dist/cloud/crypto.js +1 -85
  24. package/dist/cloud/machine-id.js +2 -113
  25. package/dist/cloud/recovery.js +1 -60
  26. package/dist/cloud/sync-engine.js +7 -543
  27. package/dist/cloud-backup.js +5 -579
  28. package/dist/cloud-profile.js +1 -138
  29. package/dist/cloud-sync-entrypoint.js +1 -47
  30. package/dist/cloud-sync.js +2 -309
  31. package/dist/constellation.js +12 -168
  32. package/dist/context-build-budgeted.js +4 -144
  33. package/dist/context-ranking.js +1 -69
  34. package/dist/crypto.js +1 -179
  35. package/dist/daemon-write-endpoint.js +1 -290
  36. package/dist/daemon-writer.js +2 -406
  37. package/dist/database.js +43 -1110
  38. package/dist/deprecations.js +2 -162
  39. package/dist/design.js +13 -141
  40. package/dist/event-replication.js +1 -112
  41. package/dist/events-sse.js +7 -43
  42. package/dist/events.js +6 -238
  43. package/dist/failure-patterns.js +42 -659
  44. package/dist/federation.js +12 -236
  45. package/dist/goals.js +13 -101
  46. package/dist/golden.js +3 -355
  47. package/dist/handlers/agent.js +4 -165
  48. package/dist/handlers/alias-adapters.js +1 -129
  49. package/dist/handlers/aliases.js +1 -171
  50. package/dist/handlers/audit.js +1 -87
  51. package/dist/handlers/boundary.js +1 -221
  52. package/dist/handlers/capture.js +73 -1109
  53. package/dist/handlers/causality.js +7 -114
  54. package/dist/handlers/cloud.js +85 -382
  55. package/dist/handlers/companion.js +28 -459
  56. package/dist/handlers/datalake.js +7 -187
  57. package/dist/handlers/dispatch-context.js +0 -22
  58. package/dist/handlers/entity.js +25 -256
  59. package/dist/handlers/events.js +16 -335
  60. package/dist/handlers/failure.js +13 -340
  61. package/dist/handlers/goals.js +4 -296
  62. package/dist/handlers/intelligence.js +126 -674
  63. package/dist/handlers/invoicing.js +1 -70
  64. package/dist/handlers/mcpclient.js +6 -137
  65. package/dist/handlers/orchestration.js +40 -125
  66. package/dist/handlers/output-schemas.js +1 -24
  67. package/dist/handlers/presence.js +3 -99
  68. package/dist/handlers/project.js +28 -182
  69. package/dist/handlers/prompts.js +6 -157
  70. package/dist/handlers/quest.js +4 -224
  71. package/dist/handlers/recall.js +11 -218
  72. package/dist/handlers/registry.js +1 -167
  73. package/dist/handlers/resources.js +1 -288
  74. package/dist/handlers/review.js +11 -74
  75. package/dist/handlers/run.js +17 -487
  76. package/dist/handlers/search.js +15 -326
  77. package/dist/handlers/session.js +28 -615
  78. package/dist/handlers/share.js +8 -184
  79. package/dist/handlers/shims.js +1 -464
  80. package/dist/handlers/skill.js +67 -449
  81. package/dist/handlers/survivors.js +1 -120
  82. package/dist/handlers/symbols.js +8 -109
  83. package/dist/handlers/syncops.js +4 -302
  84. package/dist/handlers/types.js +1 -27
  85. package/dist/harvest.js +5 -191
  86. package/dist/hours.js +7 -156
  87. package/dist/http-auth.js +3 -321
  88. package/dist/http-fast.js +21 -1137
  89. package/dist/icons.js +1 -47
  90. package/dist/index.js +2 -924
  91. package/dist/indexer.js +4 -145
  92. package/dist/intelligence.js +31 -261
  93. package/dist/internal-dispatch.js +3 -212
  94. package/dist/keyset.js +1 -110
  95. package/dist/knowledge-graph.js +12 -176
  96. package/dist/license.d.ts +11 -0
  97. package/dist/license.d.ts.map +1 -1
  98. package/dist/license.js +2 -414
  99. package/dist/license.js.map +1 -1
  100. package/dist/logger.js +2 -199
  101. package/dist/maintenance.js +2 -148
  102. package/dist/mcp-client.js +6 -262
  103. package/dist/memory-artifacts.js +30 -449
  104. package/dist/migrate-prompt.js +2 -124
  105. package/dist/migrations.js +40 -655
  106. package/dist/performance.js +1 -228
  107. package/dist/presence.js +11 -140
  108. package/dist/priority-embed.js +5 -164
  109. package/dist/providers/embedding-provider.js +1 -196
  110. package/dist/readonly-gate.js +1 -29
  111. package/dist/rehydration.js +9 -157
  112. package/dist/reindex.js +1 -88
  113. package/dist/render-target.js +21 -514
  114. package/dist/render.js +4 -280
  115. package/dist/repl-guard.js +1 -173
  116. package/dist/replication-daemon-entrypoint.js +1 -31
  117. package/dist/replication-daemon.js +2 -262
  118. package/dist/resilience.js +1 -591
  119. package/dist/reverse-bridge.js +5 -360
  120. package/dist/security.js +1 -244
  121. package/dist/session-seen.js +3 -51
  122. package/dist/setup.js +1 -260
  123. package/dist/skill-author.js +5 -168
  124. package/dist/spec-kit.js +1 -191
  125. package/dist/sqlite-busy.js +1 -154
  126. package/dist/statusline.js +11 -315
  127. package/dist/sub-agent.js +13 -262
  128. package/dist/summarizer.js +13 -139
  129. package/dist/symbols.js +7 -283
  130. package/dist/sync.js +5 -359
  131. package/dist/tasks-dispatch.js +1 -84
  132. package/dist/tasks.js +1 -282
  133. package/dist/token-budget.js +1 -143
  134. package/dist/tool-analytics.js +7 -129
  135. package/dist/tool-annotations.js +1 -365
  136. package/dist/tool-manifest-v2.json +1 -1
  137. package/dist/tool-manifest.json +1 -1
  138. package/dist/tool-profiles.js +1 -75
  139. package/dist/trace-harvest.js +6 -244
  140. package/dist/types.js +1 -30
  141. package/dist/ui-dashboard.js +41 -50
  142. package/dist/ulid.js +1 -81
  143. package/dist/validate.js +1 -129
  144. package/dist/vault.js +1 -534
  145. package/dist/vectors.js +3 -184
  146. package/dist/version-check.js +4 -136
  147. package/dist/visibility.js +19 -155
  148. package/dist/wyrm-cli.js +98 -2451
  149. package/dist/wyrm-cli.js.map +1 -1
  150. package/dist/wyrm-guard.js +14 -424
  151. package/dist/wyrm-loop.js +3 -150
  152. package/dist/wyrm-manifest.json +1 -1
  153. package/dist/wyrm-statusline-daemon.js +1 -11
  154. package/dist/wyrm-statusline.js +4 -56
  155. package/dist/wyrm-ui.js +9 -77
  156. package/package.json +4 -2
@@ -1,140 +1,21 @@
1
- /**
2
- * Failure Patterns — Counter-pattern detection.
3
- *
4
- * Records edits / commands / prompts that failed so the predictive push
5
- * can BLOCK the same suggestion mid-stream the next time it surfaces.
6
- * Most "memory" tools only learn from success; Wyrm learns from failure too.
7
- *
8
- * Keyed on `(signature, scope)` so semantically-identical failures
9
- * coalesce re-recording the same failure increments `occurrences`
10
- * rather than creating a new row.
11
- *
12
- * @copyright 2026 Ghost Protocol (Pvt) Ltd.
13
- * @license AGPL-3.0-or-later dual-licensed; commercial terms: ghosts.lk@proton.me. See LICENSE.
14
- */
15
- import { sanitizeFtsQuery } from './security.js';
16
- import { emitEvent, isLiveMemoryEnabled } from './events.js';
17
- import { getActor, hasExplicitAttribution } from './handlers/boundary.js';
18
- import { readActor, resolveAttribution } from './attribution.js';
19
- /** Quarantine tier ordering for monotonic promotion: widen-only, never narrow. */
20
- const TIER_RANK = { run: 0, project: 1, global: 2 };
21
- /** targetIdentityMatches: hard bound on rows pulled from the signature-prefix
22
- * range BEFORE the identity filter — a fixed work budget (the guard's ~200ms
23
- * discipline), index-bounded by the range itself. Past it the lookup degrades
24
- * toward fail-open (a miss), never toward a false block. */
25
- const IDENTITY_SCAN_CAP = 200;
26
- /** targetIdentityMatches: result cap AFTER the identity filter (parity with
27
- * query()'s LIMIT 5). Applied post-filter so same-prefix cousins can never
28
- * crowd a genuinely identical-target row out of the verdict. */
29
- const IDENTITY_MATCH_LIMIT = 5;
30
- /**
31
- * Render the failures view as the wyrm_stats prose (v7 F2, T017). Exported
32
- * standalone so the MCP handler AND bench/fleet-demo.mjs print the IDENTICAL
33
- * surface — the demo's output IS the tool's output by construction.
34
- */
35
- export function renderFailureStats(v) {
36
- const scopeLine = (rec) => Object.entries(rec).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(' · ') || 'none';
37
- const bucketLine = (rows) => rows.map((r) => `${'who' in r ? r.who : r.run_id} ×${r.count}`).join(' · ') || 'none';
38
- const thisRun = v.blocked.this_run.run_id === null
39
- ? '- This run: (no run identity — export WYRM_RUN_ID or pass run_id)'
40
- : `- This run (${v.blocked.this_run.run_id}): ${v.blocked.this_run.blocked} repeat(s) blocked`;
41
- return (`󱅝 **Failure Analytics** (${v.project_id === null ? 'all projects' : `project #${v.project_id}`})\n\n` +
42
- `**Recorded:** ${v.recorded.total} failure(s), ${v.recorded.unresolved} unresolved\n` +
43
- `- By quarantine: ${scopeLine(v.recorded.by_quarantine_scope)}\n` +
44
- `- By scope: ${scopeLine(v.recorded.by_scope)}\n` +
45
- `- By agent: ${bucketLine(v.recorded.by_agent)}\n` +
46
- `- By run: ${bucketLine(v.recorded.by_run)}\n\n` +
47
- `**Confirmations:** ${v.confirmations.total} from ${v.confirmations.distinct_agents} distinct agent(s)\n\n` +
48
- `**Repeats blocked:** ${v.blocked.total} total\n` +
49
- `${thisRun}\n` +
50
- `- By agent: ${bucketLine(v.blocked.by_agent)}\n` +
51
- `- By run: ${bucketLine(v.blocked.by_run)}\n` +
52
- `- Top blockers: ${v.blocked.top_blocked.map((t) => `#${t.failure_id} ${t.scope}:${t.target} ×${t.blocks}`).join(' · ') || 'none'}`);
53
- }
54
- /**
55
- * Deterministic match-tier confidence (Article III: never an LLM).
56
- * - 'exact' → 1.0: the coalescing signature itself matched — this IS the
57
- * recorded failure.
58
- * - 'target' → 0.95: scope+target identity (description-independent — the
59
- * wyrm-guard block tier, v7 F2 review fix). Deterministic
60
- * identity, strictly below 'exact' (the description was not
61
- * compared) and strictly above every fuzzy score.
62
- * - 'fuzzy' → normalized FTS5 bm25. SQLite bm25() is "smaller is better"
63
- * (negative for relevant rows; can go ≥0 when a term is in most
64
- * rows). With r = max(0, -bm25): 0.05 + 0.85·r/(r+2),
65
- * rounded to 4dp — monotone in relevance, range [0.05, 0.9),
66
- * always strictly below the exact/target tiers.
67
- */
68
- export function matchConfidence(tier, bm25Rank) {
69
- if (tier === 'exact')
70
- return 1;
71
- if (tier === 'target')
72
- return 0.95;
73
- const r = Math.max(0, -(bm25Rank ?? 0));
74
- return Number((0.05 + 0.85 * (r / (r + 2))).toFixed(4));
75
- }
76
- /**
77
- * The FULL identity normalization: lowercase, whitespace runs collapsed,
78
- * trimmed — NO length cap. This is the truth predicate for target identity
79
- * (targetIdentityMatches and wyrm-guard's block tier): two targets are "the
80
- * same action" only when their FULL normalized strings are equal, however
81
- * long they are. Exported so the guard compares with exactly these semantics
82
- * instead of re-implementing them.
83
- */
84
- export function normalizeIdentity(s) {
85
- return s.toLowerCase().replace(/\s+/g, ' ').trim();
86
- }
87
- /**
88
- * The signature normalization: normalizeIdentity capped at 200 chars. The cap
89
- * is a STORAGE/RECALL bound (it keeps stored signatures and their index keys
90
- * small), NOT an identity predicate: two different long targets can share
91
- * their first 200 normalized chars, so anything deciding "is this the same
92
- * target?" must compare normalizeIdentity (uncapped) forms. Exported (v7 F2,
93
- * T016) so wyrm-guard's range probes use EXACTLY the normalization baked into
94
- * every stored signature — re-implementing it there would drift.
95
- */
96
- export function normalizeSignal(s) {
97
- return normalizeIdentity(s).slice(0, 200);
98
- }
99
- export class FailurePatterns {
100
- db;
101
- constructor(db) {
102
- this.db = db;
103
- }
104
- /** Build a stable signature from scope+target+description, so repeated
105
- * identical failures coalesce instead of creating new rows. */
106
- static signature(scope, target, description) {
107
- return `${scope}:${normalizeSignal(target)}:${normalizeSignal(description).slice(0, 80)}`;
108
- }
109
- /** Record (or increment) a failure. Returns the (new or updated) row.
110
- *
111
- * v7 F2 review fix: the whole write — dedup SELECT, UPDATE-or-INSERT, the
112
- * confirmation row, and any auto-promotion — runs in ONE transaction
113
- * declared IMMEDIATE. This closes two confirmed races at once:
114
- * 1. SELECT-then-INSERT across processes: two agents first-recording the
115
- * same signature could both miss the SELECT — the loser threw an
116
- * unclassified SQLITE_CONSTRAINT_UNIQUE (losing its confirmation, the
117
- * very second-distinct-agent signal T015 promotion needs), and
118
- * project_id-NULL rows (NULLs are distinct under SQLite UNIQUE)
119
- * DUPLICATED, splitting confirmations so neither row ever reached the
120
- * n>=2 promotion bar. BEGIN IMMEDIATE takes the write lock before the
121
- * SELECT, so the dedup read always sees committed truth.
122
- * 2. Multi-statement partial commit: a BUSY between the row write and
123
- * noteConfirmation() left the row committed while the structured
124
- * WYRM_BUSY body told the caller to retry — inflating occurrences.
125
- * Atomic record() makes "retrying is safe" true for this path.
126
- */
127
- record(input) {
128
- const signature = FailurePatterns.signature(input.scope, input.target, input.description);
129
- const projectId = input.project_id ?? null;
130
- const ambient = getActor();
131
- const persist = this.db.transaction(() => {
132
- const existing = this.db.prepare(`
1
+ import{sanitizeFtsQuery as I}from"./security.js";import{emitEvent as b,isLiveMemoryEnabled as A}from"./events.js";import{getActor as E,hasExplicitAttribution as j}from"./handlers/boundary.js";import{readActor as m,resolveAttribution as C}from"./attribution.js";const T={run:0,project:1,global:2},D=200,y=5;function W(a){const e=r=>Object.entries(r).filter(([,o])=>o>0).map(([o,c])=>`${o} ${c}`).join(" \xB7 ")||"none",t=r=>r.map(o=>`${"who"in o?o.who:o.run_id} \xD7${o.count}`).join(" \xB7 ")||"none",n=a.blocked.this_run.run_id===null?"- This run: (no run identity \u2014 export WYRM_RUN_ID or pass run_id)":`- This run (${a.blocked.this_run.run_id}): ${a.blocked.this_run.blocked} repeat(s) blocked`;return`\u{F115D} **Failure Analytics** (${a.project_id===null?"all projects":`project #${a.project_id}`})
2
+
3
+ **Recorded:** ${a.recorded.total} failure(s), ${a.recorded.unresolved} unresolved
4
+ - By quarantine: ${e(a.recorded.by_quarantine_scope)}
5
+ - By scope: ${e(a.recorded.by_scope)}
6
+ - By agent: ${t(a.recorded.by_agent)}
7
+ - By run: ${t(a.recorded.by_run)}
8
+
9
+ **Confirmations:** ${a.confirmations.total} from ${a.confirmations.distinct_agents} distinct agent(s)
10
+
11
+ **Repeats blocked:** ${a.blocked.total} total
12
+ ${n}
13
+ - By agent: ${t(a.blocked.by_agent)}
14
+ - By run: ${t(a.blocked.by_run)}
15
+ - Top blockers: ${a.blocked.top_blocked.map(r=>`#${r.failure_id} ${r.scope}:${r.target} \xD7${r.blocks}`).join(" \xB7 ")||"none"}`}function M(a,e){if(a==="exact")return 1;if(a==="target")return .95;const t=Math.max(0,-(e??0));return Number((.05+.85*(t/(t+2))).toFixed(4))}function h(a){return a.toLowerCase().replace(/\s+/g," ").trim()}function g(a){return h(a).slice(0,200)}class O{db;constructor(e){this.db=e}static signature(e,t,n){return`${e}:${g(t)}:${g(n).slice(0,80)}`}record(e){const t=O.signature(e.scope,e.target,e.description),n=e.project_id??null,r=E(),c=this.db.transaction(()=>{const i=this.db.prepare(`
133
16
  SELECT * FROM failure_patterns
134
17
  WHERE signature = ? AND (project_id IS ? OR project_id = ?)
135
- `).get(signature, projectId, projectId);
136
- if (existing) {
137
- this.db.prepare(`
18
+ `).get(t,n,n);if(i)return this.db.prepare(`
138
19
  UPDATE failure_patterns
139
20
  SET occurrences = occurrences + 1,
140
21
  last_seen = datetime('now'),
@@ -142,202 +23,21 @@ export class FailurePatterns {
142
23
  severity = ?,
143
24
  resolved = 0
144
25
  WHERE id = ?
145
- `).run(input.why_failed ?? null, input.severity ?? existing.severity, existing.id);
146
- // v7 F2 (T015): a re-record by a KNOWN agent is a CONFIRMATION of the
147
- // pattern — the debrief-independent "≥2 distinct agent_id confirmations
148
- // → auto-promote" path lives in noteConfirmation(). Fetch the row AFTER
149
- // so a promotion triggered by this very confirmation is visible to the
150
- // caller.
151
- this.noteConfirmation(existing.id, ambient);
152
- return this.get(existing.id);
153
- }
154
- // v7 F2 (T009): stamp the recording actor. The dedup UPDATE path above
155
- // intentionally does NOT restamp — attribution names the first recorder
156
- // (and keeps the first recorder's quarantine_scope: cross-run
157
- // re-occurrences coalesce but do NOT widen authority — T015's ≥2-agent
158
- // confirmation promotion owns widening).
159
- //
160
- // v7 F2 (T014) quarantine default, documented:
161
- // run_id present → 'run' (born in a fleet run: instantly
162
- // authoritative for same-run siblings,
163
- // quarantined from everyone else until
164
- // T015 promotion)
165
- // no run, project row → 'project' (the 6.x reach: authoritative for
166
- // its whole project at once)
167
- // no run, NULL project → 'global' (6.x check() matched NULL-project
168
- // rows from EVERY project; plain
169
- // 'project' would silently narrow
170
- // them — same rationale as the
171
- // migration-20 backfill)
172
- const quarantine = input.quarantine_scope
173
- ?? (ambient.run_id !== null ? 'run' : projectId === null ? 'global' : 'project');
174
- const info = this.db.prepare(`
26
+ `).run(e.why_failed??null,e.severity??i.severity,i.id),this.noteConfirmation(i.id,r),this.get(i.id);const s=e.quarantine_scope??(r.run_id!==null?"run":n===null?"global":"project"),d=this.db.prepare(`
175
27
  INSERT INTO failure_patterns
176
28
  (project_id, signature, scope, target, description, why_failed, severity, agent_id, run_id, quarantine_scope)
177
29
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
178
- `).run(projectId, signature, input.scope, input.target, input.description, input.why_failed ?? null, input.severity ?? 'medium', ambient.agent_id, ambient.run_id, quarantine);
179
- const created = this.get(info.lastInsertRowid);
180
- // v7 F2 (T015): the first recorder is confirmation #1 (a single agent can
181
- // never auto-promote its own run-quarantined failure — that needs a
182
- // SECOND distinct agent_id).
183
- this.noteConfirmation(created.id, ambient);
184
- return created;
185
- });
186
- const row = persist.immediate();
187
- // Reference-only + private (isShared omitted): failure detail never
188
- // replicates. Emitted AFTER commit (the sweepRunQuarantine pattern) so
189
- // followers re-reading on the event always see the committed row.
190
- emitEvent(this.db, { projectId: row.project_id ?? 0, kind: 'failure', refTable: 'failure_patterns', refId: row.id });
191
- return row;
192
- }
193
- get(id) {
194
- const row = this.db.prepare('SELECT * FROM failure_patterns WHERE id = ?').get(id);
195
- return row ?? null;
196
- }
197
- // ── v7 F2 (T015): run-quarantine promotion (all debrief-INDEPENDENT) ──────
198
- /**
199
- * Confirmation ledger + the ≥2-distinct-agents auto-promotion path.
200
- *
201
- * Every record() of a signature by a KNOWN agent_id files one confirmation
202
- * row in failure_confirmations (migration 21). PK (failure_id, agent_id)
203
- * makes COUNT(*) the DISTINCT-agent count by construction: the same agent
204
- * re-recording N times stays ONE confirmation, and unattributed re-records
205
- * (ambient agent_id NULL) still increment occurrences but can never
206
- * establish distinctness, so they never count.
207
- *
208
- * When a 'run'-quarantined failure reaches ≥2 distinct agents it
209
- * auto-promotes to 'project' ('global' for project-NULL rows — see
210
- * applyPromotion): two independent agents hitting the same wall is a real
211
- * pattern, not single-agent run noise. Works with zero wyrm_run/debrief
212
- * involvement (that tooling arrives in F3) and across runs — record()'s
213
- * dedup matches by signature regardless of quarantine tier, so a sibling
214
- * fleet independently re-hitting the failure confirms it too.
215
- *
216
- * Under-counts safely: two agents that BOTH fall back to the same
217
- * clientInfo name (e.g. 'claude-code') collapse into one confirmation —
218
- * the fallback can delay promotion, never force it.
219
- */
220
- noteConfirmation(failureId, ambient) {
221
- if (ambient.agent_id === null)
222
- return;
223
- this.db.prepare(`
30
+ `).run(n,t,e.scope,e.target,e.description,e.why_failed??null,e.severity??"medium",r.agent_id,r.run_id,s),u=this.get(d.lastInsertRowid);return this.noteConfirmation(u.id,r),u}).immediate();return b(this.db,{projectId:c.project_id??0,kind:"failure",refTable:"failure_patterns",refId:c.id}),c}get(e){return this.db.prepare("SELECT * FROM failure_patterns WHERE id = ?").get(e)??null}noteConfirmation(e,t){if(t.agent_id===null)return;this.db.prepare(`
224
31
  INSERT OR IGNORE INTO failure_confirmations (failure_id, agent_id, run_id)
225
32
  VALUES (?, ?, ?)
226
- `).run(failureId, ambient.agent_id, ambient.run_id);
227
- const row = this.get(failureId);
228
- if (!row || row.quarantine_scope !== 'run')
229
- return;
230
- const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM failure_confirmations WHERE failure_id = ?').get(failureId);
231
- if (n >= 2)
232
- this.applyPromotion(failureId, 'project');
233
- }
234
- /**
235
- * The ungated internal promotion primitive. Monotonic: run → project →
236
- * global, NEVER narrows. 'project' on a project_id-NULL row normalizes to
237
- * 'global' — 6.x check() matches NULL-project rows from every project, so
238
- * labeling one 'project' would be false (same rationale as the migration-20
239
- * backfill). Emits a reference-only failure event on an actual widening so
240
- * live-memory followers re-read the row's new authority (failures stay
241
- * private: is_shared=0, never replicated).
242
- */
243
- applyPromotion(id, tier) {
244
- const row = this.get(id);
245
- if (!row)
246
- return false;
247
- const effective = tier === 'project' && row.project_id === null ? 'global' : tier;
248
- if (TIER_RANK[effective] <= TIER_RANK[row.quarantine_scope])
249
- return false;
250
- this.db.prepare('UPDATE failure_patterns SET quarantine_scope = ? WHERE id = ?').run(effective, id);
251
- emitEvent(this.db, {
252
- projectId: row.project_id ?? 0, kind: 'failure',
253
- refTable: 'failure_patterns', refId: id,
254
- });
255
- return true;
256
- }
257
- /**
258
- * The orchestrator promote flag behind wyrm_failure_record/_resolve
259
- * `promote:'project'|'global'`.
260
- *
261
- * AUTHORITY MODEL (Article VII — the documented rule, enforced by
262
- * boundary.hasExplicitAttribution on the AMBIENT envelope): the flag is
263
- * honored ONLY when the caller carries EXPLICIT identity — a run_id (run
264
- * ids never come from the clientInfo fallback) or an agent_id supplied via
265
- * param/_meta/Wyrm-Actor-header/env. The bare clientInfo fallback that
266
- * every anonymous MCP caller gets for free carries NO promotion authority.
267
- * Denial is visible, never silent: `authorized:false` comes back and the
268
- * tool handler reports the ignored flag in its response text.
269
- */
270
- promote(id, tier) {
271
- if (!hasExplicitAttribution(getActor())) {
272
- return { row: this.get(id), authorized: false, changed: false };
273
- }
274
- const changed = this.applyPromotion(id, tier);
275
- return { row: this.get(id), authorized: true, changed };
276
- }
277
- /**
278
- * The run-quarantine MAINTENANCE SWEEP — the third debrief-independent
279
- * promotion path, wired into wyrm_maintenance (and shipped in the same
280
- * commit as the quarantine, per spec FR-2). One transaction; idempotent —
281
- * a second sweep over a settled DB changes zero rows.
282
- *
283
- * Run lifecycle enforced (works with NO runs rows at all — wyrm_run is F3):
284
- * 1. EXPIRE abandoned-run noise (TTL auto-expiry): unresolved 'run'-scoped
285
- * failures whose runs row says status='abandoned' — an EXPLICIT verdict
286
- * (an orchestrator/operator junked the run, or a previous sweep TTL'd
287
- * it) — are auto-resolved with a note. Noise never reaches project
288
- * memory; history is kept (resolve, not delete).
289
- * 2. PROMOTE unresolved 'run'-scoped failures from runs that are OVER or
290
- * INACTIVE past the TTL — the run ended without resolving or debriefing
291
- * them, and quarantined learning must not die with its run:
292
- * - runs.status IN ('completed','failed'): definitively over;
293
- * - runs.status='running' but BOTH runs.updated_at and the newest
294
- * run-tagged event are older than the TTL: a crashed/stalled fleet;
295
- * - no runs row at all (the common F2 shape: an orchestrator exports
296
- * WYRM_RUN_ID without registering a run) and BOTH the failure's
297
- * last_seen and the newest run-tagged event are older than the TTL.
298
- * Target tier: 'project' ('global' when project_id IS NULL).
299
- * 3. MARK the TTL-stalled 'running' runs of step 2 'abandoned' — AFTER
300
- * their failures were promoted (a crash must not lose learning). Any
301
- * LATE write that re-quarantines against such a dead run is noise and
302
- * expires on the next sweep via step 1.
303
- *
304
- * Activity signal: run-tagged events (idx_events_run) — every attributed
305
- * write emits one, so a live fleet continuously refreshes its run; reads
306
- * (failure_check) never keep a run alive. THE SIGNAL ONLY EXISTS WHILE LIVE
307
- * MEMORY IS ON: emitEvent is WYRM_LIVE_MEMORY-gated, so with it off a fully
308
- * active fleet produces ZERO run-tagged events and "no recent events" is
309
- * silence, not evidence of a dead run. The sweep therefore degrades
310
- * EXPLICITLY in that mode: the inactivity/TTL paths — the stalled-'running'
311
- * clause, the no-runs-row (orphan) clause, and the step-3 abandon — are
312
- * SKIPPED, only the explicit-verdict paths act (status='abandoned' expiry,
313
- * status IN ('completed','failed') promotion), and the result carries
314
- * `degraded` naming the mode so maintenance output can say why nothing
315
- * promoted. Deterministic, zero LLM calls (Article III); every value is a
316
- * bound parameter (the datetime() modifier and the TTL-path gate are bound
317
- * too, never spliced into SQL).
318
- */
319
- sweepRunQuarantine(opts) {
320
- const hours = Math.max(1, Math.floor(opts?.runInactiveHours ?? 24));
321
- const cut = `-${hours} hours`; // datetime('now', @cut) modifier, bound
322
- const cutMs = Date.now() - hours * 3_600_000; // events.created_at is INTEGER ms
323
- // @ttl gate: 1 only when the run-tagged-event heartbeat can exist at all
324
- // (see the doc comment) — the inactivity clauses must not read its
325
- // absence as inactivity when live memory is off.
326
- const eventSignal = isLiveMemoryEnabled();
327
- const sweep = this.db.transaction(() => {
328
- // 1. EXPIRE abandoned-run noise.
329
- const expired = this.db.prepare(`
33
+ `).run(e,t.agent_id,t.run_id);const n=this.get(e);if(!n||n.quarantine_scope!=="run")return;const{n:r}=this.db.prepare("SELECT COUNT(*) AS n FROM failure_confirmations WHERE failure_id = ?").get(e);r>=2&&this.applyPromotion(e,"project")}applyPromotion(e,t){const n=this.get(e);if(!n)return!1;const r=t==="project"&&n.project_id===null?"global":t;return T[r]<=T[n.quarantine_scope]?!1:(this.db.prepare("UPDATE failure_patterns SET quarantine_scope = ? WHERE id = ?").run(r,e),b(this.db,{projectId:n.project_id??0,kind:"failure",refTable:"failure_patterns",refId:e}),!0)}promote(e,t){if(!j(E()))return{row:this.get(e),authorized:!1,changed:!1};const n=this.applyPromotion(e,t);return{row:this.get(e),authorized:!0,changed:n}}sweepRunQuarantine(e){const t=Math.max(1,Math.floor(e?.runInactiveHours??24)),n=`-${t} hours`,r=Date.now()-t*36e5,o=A(),c=this.db.transaction(()=>{const i=this.db.prepare(`
330
34
  UPDATE failure_patterns
331
35
  SET resolved = 1,
332
- resolution_note = COALESCE(resolution_note || ' · ', '')
36
+ resolution_note = COALESCE(resolution_note || ' \xB7 ', '')
333
37
  || 'auto-expired by run-quarantine sweep: run ' || run_id || ' was abandoned'
334
38
  WHERE resolved = 0 AND quarantine_scope = 'run' AND run_id IS NOT NULL
335
39
  AND run_id IN (SELECT run_id FROM runs WHERE status = 'abandoned')
336
- `).run().changes;
337
- // 2. PROMOTE from over/inactive runs. Select-then-update (inside the
338
- // same transaction, so still atomic) — the promoted ids emit
339
- // reference-only events after commit.
340
- const promotedIds = this.db.prepare(`
40
+ `).run().changes,s=this.db.prepare(`
341
41
  SELECT fp.id FROM failure_patterns fp
342
42
  WHERE fp.resolved = 0 AND fp.quarantine_scope = 'run' AND fp.run_id IS NOT NULL
343
43
  AND (
@@ -363,155 +63,18 @@ export class FailurePatterns {
363
63
  )
364
64
  )
365
65
  )
366
- `).all({ cut, cutMs, ttl: eventSignal ? 1 : 0 }).map((r) => r.id);
367
- const widen = this.db.prepare(`
66
+ `).all({cut:n,cutMs:r,ttl:o?1:0}).map(l=>l.id),d=this.db.prepare(`
368
67
  UPDATE failure_patterns
369
68
  SET quarantine_scope = CASE WHEN project_id IS NULL THEN 'global' ELSE 'project' END
370
69
  WHERE id = ?
371
- `);
372
- for (const id of promotedIds)
373
- widen.run(id);
374
- // 3. TTL-abandon the stalled running runs (after step 2 promoted them).
375
- // Event-dependent like the step-2 TTL clauses: without the heartbeat a
376
- // 'running' run cannot be judged dead, so it is left alone.
377
- const abandoned = !eventSignal ? 0 : this.db.prepare(`
70
+ `);for(const l of s)d.run(l);const u=o?this.db.prepare(`
378
71
  UPDATE runs SET status = 'abandoned', updated_at = datetime('now')
379
72
  WHERE status = 'running' AND updated_at < datetime('now', @cut)
380
73
  AND NOT EXISTS (
381
74
  SELECT 1 FROM events e
382
75
  WHERE e.run_id = runs.run_id AND e.created_at >= @cutMs
383
76
  )
384
- `).run({ cut, cutMs }).changes;
385
- return { promotedIds, expired, abandoned };
386
- })();
387
- // Reference-only events for the widened rows. Explicit NULL attribution
388
- // (T009 emitEvent semantics): the sweep is system maintenance — it must
389
- // not inherit whoever happened to call wyrm_maintenance.
390
- for (const id of sweep.promotedIds) {
391
- const row = this.get(id);
392
- emitEvent(this.db, {
393
- projectId: row?.project_id ?? 0, kind: 'failure',
394
- refTable: 'failure_patterns', refId: id, agentId: null, runId: null,
395
- });
396
- }
397
- return {
398
- promoted: sweep.promotedIds.length,
399
- expired: sweep.expired,
400
- runs_abandoned: sweep.abandoned,
401
- // Key ABSENT (not null) on a fully-signalled sweep — the healthy result
402
- // shape stays exactly { promoted, expired, runs_abandoned }.
403
- ...(eventSignal ? {} : {
404
- degraded: 'live memory off (WYRM_LIVE_MEMORY) — run-tagged events do not exist, '
405
- + 'so the run-inactivity TTL paths were skipped; only explicit run '
406
- + 'verdicts acted (completed/failed promote, abandoned expires)',
407
- }),
408
- };
409
- }
410
- /** Check whether a proposed action matches any unresolved failure.
411
- * Stage 1: exact signature match (scope+target+description); stage 2 fallback:
412
- * FTS fuzzy match within the same scope. Both ordered by occurrences DESC,
413
- * last_seen DESC, LIMIT 5 — no multiplicative recency-times-occurrences score.
414
- *
415
- * v7 F2 (T014) run-scope awareness: 'run'-quarantined rows only match callers
416
- * carrying the SAME run_id (ambient envelope by default) — 6.x rows are all
417
- * 'project'/'global' so their behavior is byte-identical to 6.x. */
418
- check(scope, target, description, projectId, opts) {
419
- return this.query(scope, target, description, projectId ?? null, opts)
420
- .map(({ _match: _m, _rank: _r, ...row }) => row);
421
- }
422
- /**
423
- * v7 F2 (T014): the canonical structured verdict for wyrm_failure_check
424
- * (spec FR-2). Same two-stage query and ordering as check() — the verdict is
425
- * a richer VIEW of the same rows, so prose derived from it cannot drift from
426
- * 6.x behavior. `confidence` = max over per-match matchConfidence() scores
427
- * (deterministic bm25/match-tier arithmetic — Article III).
428
- */
429
- checkVerdict(scope, target, description, projectId, opts) {
430
- const rows = this.query(scope, target, description, projectId ?? null, opts);
431
- const matches = rows.map((r) => this.toVerdictMatch(r, r._match, r._rank));
432
- return {
433
- blocked: matches.length > 0,
434
- matches,
435
- recorded_by_agent: matches[0]?.recorded_by_agent ?? null,
436
- run_id: matches[0]?.run_id ?? null,
437
- confidence: matches.reduce((m, x) => Math.max(m, x.confidence), 0),
438
- };
439
- }
440
- /**
441
- * Map a stored row to the canonical verdict-match shape. Shared by
442
- * checkVerdict() and wyrm-guard's target-identity tier (v7 F2 review fix)
443
- * so the two surfaces cannot drift.
444
- */
445
- toVerdictMatch(r, match, bm25Rank = null) {
446
- const att = resolveAttribution(r);
447
- return {
448
- id: r.id,
449
- pattern: r.signature,
450
- scope: r.scope,
451
- quarantine_scope: r.quarantine_scope,
452
- target: r.target,
453
- description: r.description,
454
- why_failed: r.why_failed,
455
- severity: r.severity,
456
- occurrences: r.occurrences,
457
- last_seen: r.last_seen,
458
- recorded_by_agent: att.actor,
459
- run_id: att.run_id,
460
- match,
461
- confidence: matchConfidence(match, bm25Rank),
462
- };
463
- }
464
- /**
465
- * v7 F2 review fix (T016): the deterministic TARGET-IDENTITY lookup behind
466
- * wyrm-guard's block tier.
467
- *
468
- * Production failures always carry a NON-EMPTY description (the
469
- * wyrm_failure_record schema and the daemon endpoint both require one), so
470
- * full-signature equality against the guard's empty-description probe could
471
- * never match a real row — the documented "exact signature match" block rule
472
- * was unreachable outside empty-description test fixtures.
473
- *
474
- * RECALL vs TRUTH — the two invariants this lookup keeps strictly separate:
475
- *
476
- * · RECALL is the signature PREFIX range `scope:normalizeSignal(target):`
477
- * — description-independent, a bound-parameter range scan (no LIKE, no
478
- * template SQL), with the same project filter and run-tier gate as
479
- * query(). The 200-char signature cap and the ':' separator both live
480
- * here, so the range legitimately over-captures: a stored target that
481
- * extends the probe past a colon (probe 'npm run build', stored
482
- * 'npm run build:dev') and any row sharing a truncated 200-char prefix
483
- * both land IN the range. Callers must pass the RAW (pre-normalize,
484
- * uncapped) target so the truth check below has the full string.
485
- *
486
- * · TRUTH is full-string identity: a row is kept only when
487
- * normalizeIdentity(stored target) === normalizeIdentity(probe) — the
488
- * UNCAPPED normalization over the full stored column and the full
489
- * probe. Targets differing only past the signature cap are NOT
490
- * identical. (Pushing this check into SQL via the signature would be
491
- * unsound: the signature is ambiguous at the target/description
492
- * boundary — target 'npm run build:dev' + description 'x' is
493
- * byte-identical to target 'npm run build' + description 'dev:x'.)
494
- *
495
- * The result cap applies AFTER the truth check: prefix-cousins, however
496
- * many and however highly ranked, can never crowd a genuinely identical
497
- * row out of the verdict. The range scan itself pulls at most
498
- * IDENTITY_SCAN_CAP rows (occurrences/recency-ordered) as a fixed work
499
- * budget — past it the lookup degrades toward fail-open (a miss), never
500
- * toward a false block. (query()'s FTS fuzzy stage keeps its own LIMIT 5
501
- * and CAN be crowded — acceptable, because fuzzy results only ever feed
502
- * warn context; the block verdict never depends on FTS recall.)
503
- */
504
- targetIdentityMatches(scope, target, projectId, opts) {
505
- const fullTarget = normalizeIdentity(target);
506
- const normTarget = fullTarget.slice(0, 200); // === normalizeSignal(target): the stored-signature key
507
- if (normTarget.length === 0)
508
- return []; // an empty prefix would match every row in scope
509
- const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
510
- // Half-open prefix range under SQLite's binary collation: every signature
511
- // beginning with `scope:target:` sorts in [`...:`, `...;`) (';' is ':'+1).
512
- const lo = `${scope}:${normTarget}:`;
513
- const hi = `${scope}:${normTarget};`;
514
- const rows = this.db.prepare(`
77
+ `).run({cut:n,cutMs:r}).changes:0;return{promotedIds:s,expired:i,abandoned:u}})();for(const i of c.promotedIds){const s=this.get(i);b(this.db,{projectId:s?.project_id??0,kind:"failure",refTable:"failure_patterns",refId:i,agentId:null,runId:null})}return{promoted:c.promotedIds.length,expired:c.expired,runs_abandoned:c.abandoned,...o?{}:{degraded:"live memory off (WYRM_LIVE_MEMORY) \u2014 run-tagged events do not exist, so the run-inactivity TTL paths were skipped; only explicit run verdicts acted (completed/failed promote, abandoned expires)"}}}check(e,t,n,r,o){return this.query(e,t,n,r??null,o).map(({_match:c,_rank:i,...s})=>s)}checkVerdict(e,t,n,r,o){const i=this.query(e,t,n,r??null,o).map(s=>this.toVerdictMatch(s,s._match,s._rank));return{blocked:i.length>0,matches:i,recorded_by_agent:i[0]?.recorded_by_agent??null,run_id:i[0]?.run_id??null,confidence:i.reduce((s,d)=>Math.max(s,d.confidence),0)}}toVerdictMatch(e,t,n=null){const r=C(e);return{id:e.id,pattern:e.signature,scope:e.scope,quarantine_scope:e.quarantine_scope,target:e.target,description:e.description,why_failed:e.why_failed,severity:e.severity,occurrences:e.occurrences,last_seen:e.last_seen,recorded_by_agent:r.actor,run_id:r.run_id,match:t,confidence:M(t,n)}}targetIdentityMatches(e,t,n,r){const o=h(t),c=o.slice(0,200);if(c.length===0)return[];const i=r?.callerRunId!==void 0?r.callerRunId:E().run_id,s=`${e}:${c}:`,d=`${e}:${c};`;return this.db.prepare(`
515
78
  SELECT * FROM failure_patterns
516
79
  WHERE resolved = 0
517
80
  AND signature >= @lo AND signature < @hi
@@ -519,183 +82,51 @@ export class FailurePatterns {
519
82
  AND (quarantine_scope <> 'run' OR run_id = @run)
520
83
  ORDER BY occurrences DESC, last_seen DESC
521
84
  LIMIT @cap
522
- `).all({
523
- lo, hi, project: projectId ?? null, run: callerRun, cap: IDENTITY_SCAN_CAP,
524
- });
525
- // TRUTH check first, result cap second (see the doc comment): identity is
526
- // re-verified on the FULL stored column against the FULL probe, and only
527
- // then are the top IDENTITY_MATCH_LIMIT kept — capping before filtering
528
- // would let higher-ranked prefix-cousins evict the identical row.
529
- return rows
530
- .filter((r) => normalizeIdentity(r.target) === fullTarget)
531
- .slice(0, IDENTITY_MATCH_LIMIT);
532
- }
533
- // ── v7 F2 (T017): prevented-repeat analytics ───────────────────────────────
534
- /**
535
- * Count one prevented repeat: a blocked:true verdict files ONE row in the
536
- * failure_blocks ledger (migration 22 — the documented storage choice lives
537
- * on that migration), attributed to the CHECKING agent/run from the ambient
538
- * actor envelope — the agent that was about to repeat the failure, not the
539
- * recorder. failure_id is the verdict's top blocker (matches[0]).
540
- *
541
- * Deliberately a SEPARATE method, not a side effect inside checkVerdict():
542
- * checks must stay pure reads — wyrm-guard (T016) runs checkVerdict() over a
543
- * READONLY DB handle and a write inside it would crash every guard probe.
544
- * Counting surfaces: the MCP wyrm_failure_check handler (the logSavings
545
- * precedent — it already writes savings on the same path), and — v7 F2
546
- * review fix — wyrm-guard's block path via a separate short-lived RW
547
- * connection (best-effort, fail-open), so the harness-enforced blocks feed
548
- * the FR-2 ROI number too. Remaining uncounted (documented): the agent-loop
549
- * internalDispatch failure_check.
550
- *
551
- * NEVER throws (the emitEvent contract): the check already succeeded — a
552
- * readonly handle, SQLITE_BUSY, or a pre-migration-22 DB must never turn a
553
- * successful read into a failure for the sake of analytics.
554
- */
555
- recordBlock(verdict, projectId) {
556
- if (!verdict.blocked || verdict.matches.length === 0)
557
- return;
558
- try {
559
- const ambient = getActor();
560
- this.db.prepare(`
85
+ `).all({lo:s,hi:d,project:n??null,run:i,cap:D}).filter(l=>h(l.target)===o).slice(0,y)}recordBlock(e,t){if(!(!e.blocked||e.matches.length===0))try{const n=E();this.db.prepare(`
561
86
  INSERT INTO failure_blocks (failure_id, project_id, agent_id, run_id)
562
87
  VALUES (?, ?, ?, ?)
563
- `).run(verdict.matches[0].id, projectId ?? null, ambient.agent_id, ambient.run_id);
564
- }
565
- catch { /* best-effort analytics — log-and-drop, never break the check */ }
566
- }
567
- /**
568
- * v7 F2 review fix: retention for the failure_blocks ledger (migration 22).
569
- * The ledger is append-only and caller-amplifiable — every blocked
570
- * wyrm_failure_check (a read tool, no rate limit) files one row, and nothing
571
- * pruned it, so a loop probing one known-blocked signature could grow the
572
- * table without bound. wyrm_maintenance calls this with the SAME retention
573
- * knob as the event log (WYRM_EVENT_RETAIN_DAYS, default 90d) — mirroring
574
- * pruneEvents. Bound parameter for the datetime modifier (never spliced);
575
- * best-effort on a pre-migration-22 DB (returns 0).
576
- */
577
- pruneBlocks(olderThanDays) {
578
- const days = Math.max(1, Math.floor(olderThanDays));
579
- try {
580
- return this.db.prepare(`DELETE FROM failure_blocks WHERE blocked_at < datetime('now', @cut)`).run({ cut: `-${days} days` }).changes;
581
- }
582
- catch {
583
- return 0;
584
- }
585
- }
586
- /**
587
- * The `wyrm_stats view=failures` aggregation (spec FR-2 prevented-repeat
588
- * analytics): per-run/per-agent recorded-failure attribution + "N repeats
589
- * blocked this run". Pure indexed COUNT/GROUP BY (idx_failure_run,
590
- * idx_failure_quarantine, idx_failure_blocks_run) with always-bound params —
591
- * deterministic, zero LLM calls (Article III). Project filter is EXACT
592
- * (rows recorded for that project_id); omitted = all rows, the fleet view.
593
- */
594
- failureStats(opts) {
595
- const projectId = opts?.projectId ?? null;
596
- const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
597
- const p = { project: projectId };
598
- const totals = this.db.prepare(`
88
+ `).run(e.matches[0].id,t??null,n.agent_id,n.run_id)}catch{}}pruneBlocks(e){const t=Math.max(1,Math.floor(e));try{return this.db.prepare("DELETE FROM failure_blocks WHERE blocked_at < datetime('now', @cut)").run({cut:`-${t} days`}).changes}catch{return 0}}failureStats(e){const t=e?.projectId??null,n=e?.callerRunId!==void 0?e.callerRunId:E().run_id,r={project:t},o=this.db.prepare(`
599
89
  SELECT COUNT(*) AS total, COALESCE(SUM(resolved = 0), 0) AS unresolved
600
90
  FROM failure_patterns WHERE (@project IS NULL OR project_id = @project)
601
- `).get(p);
602
- const byQuarantine = { run: 0, project: 0, global: 0 };
603
- for (const r of this.db.prepare(`
91
+ `).get(r),c={run:0,project:0,global:0};for(const _ of this.db.prepare(`
604
92
  SELECT quarantine_scope AS k, COUNT(*) AS n FROM failure_patterns
605
93
  WHERE (@project IS NULL OR project_id = @project) GROUP BY quarantine_scope
606
- `).all(p))
607
- byQuarantine[r.k] = r.n;
608
- const byScope = { file: 0, symbol: 0, command: 0, prompt: 0, edit: 0 };
609
- for (const r of this.db.prepare(`
94
+ `).all(r))c[_.k]=_.n;const i={file:0,symbol:0,command:0,prompt:0,edit:0};for(const _ of this.db.prepare(`
610
95
  SELECT scope AS k, COUNT(*) AS n FROM failure_patterns
611
96
  WHERE (@project IS NULL OR project_id = @project) GROUP BY scope
612
- `).all(p))
613
- byScope[r.k] = r.n;
614
- // GROUP BY the raw column (one NULL group), display via readActor —
615
- // 'legacy' is read-time presentation only, never stored (src/attribution.ts).
616
- // Four FIXED query literals (no table-name interpolation — the hygiene
617
- // ratchet's zero-template-SQL floor stays clean); LIMIT 10 is the
618
- // deterministic bucket bound (count DESC, name ASC tiebreak).
619
- const toAgentBuckets = (rows) => rows.map((r) => ({ who: readActor(r.agent_id), count: r.count }));
620
- const recordedByAgent = toAgentBuckets(this.db.prepare(`
97
+ `).all(r))i[_.k]=_.n;const s=_=>_.map(N=>({who:m(N.agent_id),count:N.count})),d=s(this.db.prepare(`
621
98
  SELECT agent_id, COUNT(*) AS count FROM failure_patterns
622
99
  WHERE (@project IS NULL OR project_id = @project)
623
100
  GROUP BY agent_id ORDER BY count DESC, agent_id ASC LIMIT 10
624
- `).all(p));
625
- const recordedByRun = this.db.prepare(`
101
+ `).all(r)),u=this.db.prepare(`
626
102
  SELECT run_id, COUNT(*) AS count FROM failure_patterns
627
103
  WHERE run_id IS NOT NULL AND (@project IS NULL OR project_id = @project)
628
104
  GROUP BY run_id ORDER BY count DESC, run_id ASC LIMIT 10
629
- `).all(p);
630
- const blockedByAgent = toAgentBuckets(this.db.prepare(`
105
+ `).all(r),l=s(this.db.prepare(`
631
106
  SELECT agent_id, COUNT(*) AS count FROM failure_blocks
632
107
  WHERE (@project IS NULL OR project_id = @project)
633
108
  GROUP BY agent_id ORDER BY count DESC, agent_id ASC LIMIT 10
634
- `).all(p));
635
- const blockedByRun = this.db.prepare(`
109
+ `).all(r)),f=this.db.prepare(`
636
110
  SELECT run_id, COUNT(*) AS count FROM failure_blocks
637
111
  WHERE run_id IS NOT NULL AND (@project IS NULL OR project_id = @project)
638
112
  GROUP BY run_id ORDER BY count DESC, run_id ASC LIMIT 10
639
- `).all(p);
640
- const conf = this.db.prepare(`
113
+ `).all(r),R=this.db.prepare(`
641
114
  SELECT COUNT(*) AS total, COUNT(DISTINCT fc.agent_id) AS distinct_agents
642
115
  FROM failure_confirmations fc
643
116
  JOIN failure_patterns fp ON fp.id = fc.failure_id
644
117
  WHERE (@project IS NULL OR fp.project_id = @project)
645
- `).get(p);
646
- const blockedTotal = this.db.prepare(`
118
+ `).get(r),p=this.db.prepare(`
647
119
  SELECT COUNT(*) AS n FROM failure_blocks WHERE (@project IS NULL OR project_id = @project)
648
- `).get(p).n;
649
- // `run_id = NULL` is never true in SQL — a runless caller reads 0, the
650
- // same always-bound-param shape as the query() run gate.
651
- const thisRunBlocked = this.db.prepare(`
120
+ `).get(r).n,S=this.db.prepare(`
652
121
  SELECT COUNT(*) AS n FROM failure_blocks
653
122
  WHERE run_id = @run AND (@project IS NULL OR project_id = @project)
654
- `).get({ ...p, run: callerRun }).n;
655
- const topBlocked = this.db.prepare(`
123
+ `).get({...r,run:n}).n,L=this.db.prepare(`
656
124
  SELECT fb.failure_id, fp.scope, fp.target, COUNT(*) AS blocks
657
125
  FROM failure_blocks fb
658
126
  JOIN failure_patterns fp ON fp.id = fb.failure_id
659
127
  WHERE (@project IS NULL OR fb.project_id = @project)
660
128
  GROUP BY fb.failure_id ORDER BY blocks DESC, fb.failure_id ASC LIMIT 5
661
- `).all(p);
662
- return {
663
- view: 'failures',
664
- project_id: projectId,
665
- recorded: {
666
- total: totals.total,
667
- unresolved: totals.unresolved,
668
- by_quarantine_scope: byQuarantine,
669
- by_scope: byScope,
670
- by_agent: recordedByAgent,
671
- by_run: recordedByRun,
672
- },
673
- confirmations: { total: conf.total, distinct_agents: conf.distinct_agents },
674
- blocked: {
675
- total: blockedTotal,
676
- by_agent: blockedByAgent,
677
- by_run: blockedByRun,
678
- this_run: { run_id: callerRun ?? null, blocked: thisRunBlocked },
679
- top_blocked: topBlocked,
680
- },
681
- };
682
- }
683
- /** The shared two-stage match query behind check()/checkVerdict(). */
684
- query(scope, target, description, projectId, opts) {
685
- // 'run'-tier gate: the caller's run from the ambient envelope unless
686
- // explicitly supplied. `run_id = NULL` is never true in SQL, so a caller
687
- // with no run simply never sees run-quarantined rows. The optional tier
688
- // filter is an always-bound nullable param (@tier IS NULL = 'any') — no
689
- // SQL assembled from values (hygiene ratchet: zero template interpolation).
690
- const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
691
- const runScope = opts?.runScope ?? 'any';
692
- const params = {
693
- project: projectId,
694
- run: callerRun,
695
- tier: runScope === 'any' ? null : runScope,
696
- };
697
- const signature = FailurePatterns.signature(scope, target, description);
698
- const exact = this.db.prepare(`
129
+ `).all(r);return{view:"failures",project_id:t,recorded:{total:o.total,unresolved:o.unresolved,by_quarantine_scope:c,by_scope:i,by_agent:d,by_run:u},confirmations:{total:R.total,distinct_agents:R.distinct_agents},blocked:{total:p,by_agent:l,by_run:f,this_run:{run_id:n??null,blocked:S},top_blocked:L}}}query(e,t,n,r,o){const c=o?.callerRunId!==void 0?o.callerRunId:E().run_id,i=o?.runScope??"any",s={project:r,run:c,tier:i==="any"?null:i},d=O.signature(e,t,n),u=this.db.prepare(`
699
130
  SELECT *, NULL AS _rank FROM failure_patterns
700
131
  WHERE resolved = 0 AND signature = @sig
701
132
  AND (project_id IS @project OR project_id = @project OR project_id IS NULL)
@@ -703,30 +134,7 @@ export class FailurePatterns {
703
134
  AND (@tier IS NULL OR quarantine_scope = @tier)
704
135
  ORDER BY occurrences DESC, last_seen DESC
705
136
  LIMIT 5
706
- `).all({ ...params, sig: signature });
707
- if (exact.length > 0)
708
- return exact.map((r) => ({ ...r, _match: 'exact', _rank: null }));
709
- // Fuzzy fallback: FTS over description + target, bm25-ranked for the
710
- // deterministic confidence score (ordering stays the 6.x occurrences/
711
- // recency order — confidence is reported, not used to reorder).
712
- //
713
- // v7 F2 (T016): each term is quoted into a phrase (implicit AND preserved —
714
- // this stage wants precision, unlike buildFtsMatchQuery's OR). Chars that
715
- // survive sanitizeFtsQuery but are FTS5 operators in bareword position
716
- // ('-', '+', '/', …) previously made MATCH throw "syntax error" — silently
717
- // swallowed into ZERO recall for any probe with a shell flag or path, the
718
- // guard's main input class. Quoting is operator-safe ('"' itself is already
719
- // stripped); tokens with no letter/digit would be zero-token phrases that
720
- // AND-zero the whole query, so they are dropped.
721
- const q = sanitizeFtsQuery(`${target} ${description}`.slice(0, 100));
722
- if (!q)
723
- return [];
724
- const terms = q.split(' ').filter((t) => /[\p{L}\p{N}]/u.test(t));
725
- if (terms.length === 0)
726
- return [];
727
- const probe = terms.map((t) => `"${t.replace(/"/g, '')}"`).join(' ');
728
- try {
729
- const fuzzy = this.db.prepare(`
137
+ `).all({...s,sig:d});if(u.length>0)return u.map(p=>({...p,_match:"exact",_rank:null}));const l=I(`${t} ${n}`.slice(0,100));if(!l)return[];const f=l.split(" ").filter(p=>/[\p{L}\p{N}]/u.test(p));if(f.length===0)return[];const R=f.map(p=>`"${p.replace(/"/g,"")}"`).join(" ");try{return this.db.prepare(`
730
138
  SELECT fp.*, bm25(failure_patterns_fts) AS _rank FROM failure_patterns fp
731
139
  JOIN failure_patterns_fts fts ON fts.rowid = fp.id
732
140
  WHERE failure_patterns_fts MATCH @q
@@ -737,36 +145,11 @@ export class FailurePatterns {
737
145
  AND (@tier IS NULL OR fp.quarantine_scope = @tier)
738
146
  ORDER BY fp.occurrences DESC, fp.last_seen DESC
739
147
  LIMIT 5
740
- `).all({ ...params, q: probe, scope });
741
- return fuzzy.map((r) => ({ ...r, _match: 'fuzzy' }));
742
- }
743
- catch {
744
- return [];
745
- }
746
- }
747
- /** List unresolved failures in a project, most-recent-first. */
748
- list(projectId, limit = 20) {
749
- if (projectId == null) {
750
- return this.db.prepare(`SELECT * FROM failure_patterns WHERE resolved = 0
751
- ORDER BY last_seen DESC LIMIT ?`).all(limit);
752
- }
753
- return this.db.prepare(`SELECT * FROM failure_patterns
148
+ `).all({...s,q:R,scope:e}).map(S=>({...S,_match:"fuzzy"}))}catch{return[]}}list(e,t=20){return e==null?this.db.prepare(`SELECT * FROM failure_patterns WHERE resolved = 0
149
+ ORDER BY last_seen DESC LIMIT ?`).all(t):this.db.prepare(`SELECT * FROM failure_patterns
754
150
  WHERE resolved = 0 AND project_id = ?
755
- ORDER BY last_seen DESC LIMIT ?`).all(projectId, limit);
756
- }
757
- /** Mark a failure as resolved (the underlying issue was fixed). */
758
- resolve(id, note) {
759
- this.db.prepare(`
151
+ ORDER BY last_seen DESC LIMIT ?`).all(e,t)}resolve(e,t){return this.db.prepare(`
760
152
  UPDATE failure_patterns
761
153
  SET resolved = 1, resolution_note = ?
762
154
  WHERE id = ?
763
- `).run(note ?? null, id);
764
- return this.get(id);
765
- }
766
- /** Permanently delete a failure (use sparingly — resolve() is preferred). */
767
- delete(id) {
768
- const info = this.db.prepare('DELETE FROM failure_patterns WHERE id = ?').run(id);
769
- return info.changes > 0;
770
- }
771
- }
772
- //# sourceMappingURL=failure-patterns.js.map
155
+ `).run(t??null,e),this.get(e)}delete(e){return this.db.prepare("DELETE FROM failure_patterns WHERE id = ?").run(e).changes>0}}export{O as FailurePatterns,M as matchConfidence,h as normalizeIdentity,g as normalizeSignal,W as renderFailureStats};