wyrm-mcp 7.3.2 → 7.3.3

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 (184) hide show
  1. package/README.md +8 -14
  2. package/dist/activation.js +59 -1
  3. package/dist/agent-daemon.js +281 -4
  4. package/dist/agent-loop.js +332 -7
  5. package/dist/analytics.js +236 -13
  6. package/dist/attribution.js +49 -1
  7. package/dist/audit.js +457 -2
  8. package/dist/auto-capture.js +138 -3
  9. package/dist/auto-orchestrator.js +325 -1
  10. package/dist/autoconfig.d.ts +50 -0
  11. package/dist/autoconfig.d.ts.map +1 -1
  12. package/dist/autoconfig.js +1115 -39
  13. package/dist/autoconfig.js.map +1 -1
  14. package/dist/buddy-runner.js +109 -1
  15. package/dist/buddy.js +564 -14
  16. package/dist/build-flags.js +15 -1
  17. package/dist/capabilities.js +183 -3
  18. package/dist/capture.js +56 -1
  19. package/dist/causality.js +148 -8
  20. package/dist/cli.js +281 -20
  21. package/dist/cloud/cli.js +541 -5
  22. package/dist/cloud/client.js +221 -1
  23. package/dist/cloud/crypto.js +85 -1
  24. package/dist/cloud/machine-id.js +113 -2
  25. package/dist/cloud/recovery.js +60 -1
  26. package/dist/cloud/sync-engine.js +543 -7
  27. package/dist/cloud-backup.js +579 -5
  28. package/dist/cloud-profile.js +138 -1
  29. package/dist/cloud-sync-entrypoint.js +47 -1
  30. package/dist/cloud-sync.js +309 -2
  31. package/dist/connectors/bridge-source.d.ts +46 -0
  32. package/dist/connectors/bridge-source.d.ts.map +1 -0
  33. package/dist/connectors/bridge-source.js +77 -0
  34. package/dist/connectors/bridge-source.js.map +1 -0
  35. package/dist/connectors/index.d.ts +24 -0
  36. package/dist/connectors/index.d.ts.map +1 -0
  37. package/dist/connectors/index.js +69 -0
  38. package/dist/connectors/index.js.map +1 -0
  39. package/dist/connectors/ingest.d.ts +16 -0
  40. package/dist/connectors/ingest.d.ts.map +1 -0
  41. package/dist/connectors/ingest.js +116 -0
  42. package/dist/connectors/ingest.js.map +1 -0
  43. package/dist/connectors/types.d.ts +99 -0
  44. package/dist/connectors/types.d.ts.map +1 -0
  45. package/dist/connectors/types.js +17 -0
  46. package/dist/connectors/types.js.map +1 -0
  47. package/dist/constellation.js +168 -12
  48. package/dist/content-signature.js +45 -1
  49. package/dist/context-build-budgeted.js +144 -4
  50. package/dist/context-ranking.js +69 -1
  51. package/dist/crypto.js +179 -1
  52. package/dist/daemon-write-endpoint.js +290 -1
  53. package/dist/daemon-writer.js +406 -2
  54. package/dist/database.js +1278 -53
  55. package/dist/deprecations.js +162 -2
  56. package/dist/design.js +141 -13
  57. package/dist/event-replication.js +112 -1
  58. package/dist/events-sse.js +43 -7
  59. package/dist/events.js +238 -6
  60. package/dist/failure-patterns.d.ts +107 -0
  61. package/dist/failure-patterns.d.ts.map +1 -1
  62. package/dist/failure-patterns.js +924 -43
  63. package/dist/failure-patterns.js.map +1 -1
  64. package/dist/federation.js +236 -12
  65. package/dist/goals.js +101 -13
  66. package/dist/golden.js +355 -3
  67. package/dist/handlers/agent.js +165 -4
  68. package/dist/handlers/alias-adapters.js +129 -1
  69. package/dist/handlers/aliases.js +171 -1
  70. package/dist/handlers/audit.js +87 -1
  71. package/dist/handlers/boundary.js +221 -1
  72. package/dist/handlers/capture.js +1114 -73
  73. package/dist/handlers/causality.js +119 -9
  74. package/dist/handlers/cloud.js +382 -85
  75. package/dist/handlers/companion.js +459 -28
  76. package/dist/handlers/datalake.js +187 -7
  77. package/dist/handlers/dispatch-context.js +22 -0
  78. package/dist/handlers/entity.js +256 -25
  79. package/dist/handlers/events.js +335 -16
  80. package/dist/handlers/failure.d.ts.map +1 -1
  81. package/dist/handlers/failure.js +408 -13
  82. package/dist/handlers/failure.js.map +1 -1
  83. package/dist/handlers/goals.js +296 -4
  84. package/dist/handlers/intelligence.js +681 -126
  85. package/dist/handlers/invoicing.js +70 -1
  86. package/dist/handlers/mcpclient.js +137 -6
  87. package/dist/handlers/orchestration.js +125 -40
  88. package/dist/handlers/output-schemas.js +24 -1
  89. package/dist/handlers/presence.js +99 -3
  90. package/dist/handlers/project.js +182 -28
  91. package/dist/handlers/prompts.js +157 -6
  92. package/dist/handlers/quest.js +224 -4
  93. package/dist/handlers/recall.js +237 -13
  94. package/dist/handlers/registry.js +167 -1
  95. package/dist/handlers/resources.js +288 -1
  96. package/dist/handlers/review.js +74 -11
  97. package/dist/handlers/run.js +498 -16
  98. package/dist/handlers/search.js +338 -15
  99. package/dist/handlers/session.js +643 -31
  100. package/dist/handlers/share.js +184 -8
  101. package/dist/handlers/shims.js +464 -1
  102. package/dist/handlers/skill.js +449 -67
  103. package/dist/handlers/survivors.js +120 -1
  104. package/dist/handlers/symbols.js +109 -8
  105. package/dist/handlers/syncops.js +302 -4
  106. package/dist/handlers/types.js +27 -1
  107. package/dist/harvest.js +191 -5
  108. package/dist/hours.js +156 -7
  109. package/dist/http-auth.js +321 -3
  110. package/dist/http-fast.js +1302 -22
  111. package/dist/icons.js +47 -1
  112. package/dist/importers.js +268 -1
  113. package/dist/index.js +840 -2
  114. package/dist/indexer.js +145 -4
  115. package/dist/intelligence.js +261 -31
  116. package/dist/internal-dispatch.js +212 -3
  117. package/dist/keyset.js +110 -1
  118. package/dist/knowledge-graph.js +176 -12
  119. package/dist/license.js +441 -2
  120. package/dist/logger.js +199 -2
  121. package/dist/maintenance.js +148 -2
  122. package/dist/mcp-client.js +262 -6
  123. package/dist/memory-artifacts.js +596 -32
  124. package/dist/migrate-prompt.js +124 -2
  125. package/dist/migrations.d.ts.map +1 -1
  126. package/dist/migrations.js +799 -42
  127. package/dist/migrations.js.map +1 -1
  128. package/dist/performance.js +228 -1
  129. package/dist/presence.js +140 -11
  130. package/dist/priority-embed.js +164 -5
  131. package/dist/providers/embedding-provider.js +196 -1
  132. package/dist/readonly-gate.js +29 -1
  133. package/dist/receipt.js +43 -1
  134. package/dist/rehydration.js +157 -9
  135. package/dist/reindex.js +88 -1
  136. package/dist/render-target.js +544 -21
  137. package/dist/render.js +280 -4
  138. package/dist/repl-guard.js +173 -1
  139. package/dist/replication-daemon-entrypoint.js +31 -1
  140. package/dist/replication-daemon.js +262 -2
  141. package/dist/rerank.js +142 -1
  142. package/dist/resilience.js +591 -1
  143. package/dist/reverse-bridge.js +360 -5
  144. package/dist/security.js +244 -1
  145. package/dist/session-seen.js +51 -3
  146. package/dist/setup.js +260 -1
  147. package/dist/skill-author.js +168 -5
  148. package/dist/spec-kit.js +191 -1
  149. package/dist/sqlite-busy.js +154 -1
  150. package/dist/statusline.js +315 -11
  151. package/dist/sub-agent.js +262 -13
  152. package/dist/summarizer.js +139 -13
  153. package/dist/symbols.js +283 -7
  154. package/dist/sync.js +359 -5
  155. package/dist/tasks-dispatch.js +84 -1
  156. package/dist/tasks.js +282 -1
  157. package/dist/token-budget.js +143 -1
  158. package/dist/tool-analytics.js +129 -7
  159. package/dist/tool-annotations.js +365 -1
  160. package/dist/tool-manifest-v2.json +1 -1
  161. package/dist/tool-manifest.json +1 -1
  162. package/dist/tool-profiles.js +75 -1
  163. package/dist/trace-harvest.js +244 -6
  164. package/dist/types.js +30 -1
  165. package/dist/ui-dashboard.js +50 -41
  166. package/dist/ulid.js +81 -1
  167. package/dist/usage-tracker.js +66 -1
  168. package/dist/validate.js +129 -1
  169. package/dist/vault.js +534 -1
  170. package/dist/vector-init.js +67 -1
  171. package/dist/vectors.js +184 -3
  172. package/dist/version-check.js +136 -4
  173. package/dist/visibility.js +155 -19
  174. package/dist/wyrm-cli.js +2845 -101
  175. package/dist/wyrm-cli.js.map +1 -1
  176. package/dist/wyrm-guard.d.ts.map +1 -1
  177. package/dist/wyrm-guard.js +475 -14
  178. package/dist/wyrm-guard.js.map +1 -1
  179. package/dist/wyrm-loop.js +150 -3
  180. package/dist/wyrm-manifest.json +1 -1
  181. package/dist/wyrm-statusline-daemon.js +11 -1
  182. package/dist/wyrm-statusline.js +56 -4
  183. package/dist/wyrm-ui.js +77 -9
  184. package/package.json +1 -1
@@ -1,21 +1,171 @@
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";import{normalizeContentSignature as D,containsFailedSignature as y}from"./content-signature.js";const M=new Set(["a","an","the","this","that","these","those","and","or","but","if","then","than","so","to","of","in","on","at","by","for","from","with","without","no","not","as","is","are","was","were","be","been","being","will","would","can","could","should","it","its","i","we","you","my","our","your","again","just","now","please","let","lets","do","does","did","into","over","about"]),T={run:0,project:1,global:2},U=200,k=5;function q(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 w(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 O(a){return a.toLowerCase().replace(/\s+/g," ").trim()}function L(a){return O(a).slice(0,200)}class g{db;constructor(e){this.db=e}static signature(e,t,n){return`${e}:${L(t)}:${L(n).slice(0,80)}`}record(e){const t=g.signature(e.scope,e.target,e.description),n=e.project_id??null,r=e.content&&D(e.content)||null,o=E(),s=this.db.transaction(()=>{const i=this.db.prepare(`
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 Proprietary — (c) 2026 Ghost Protocol (Pvt) Ltd. All rights reserved. See LICENSE.
14
+ */
15
+ import { readFileSync, statSync } from 'node:fs';
16
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
17
+ import { sanitizeFtsQuery } from './security.js';
18
+ import { WyrmCrypto } from './crypto.js';
19
+ import { emitEvent, isLiveMemoryEnabled } from './events.js';
20
+ import { getActor, hasExplicitAttribution } from './handlers/boundary.js';
21
+ import { readActor, resolveAttribution } from './attribution.js';
22
+ import { normalizeContentSignature, containsFailedSignature } from './content-signature.js';
23
+ /**
24
+ * Function-word filler dropped from the fuzzy-match probe. A reworded repeat
25
+ * ("run the migration that dropped the table AGAIN") carries incidental words
26
+ * that aren't in the terse recorded failure; left in, they break the conjunctive
27
+ * AND and the repeat slips through. Removing them lets the AND span only CONTENT
28
+ * words — precision is unchanged (content terms are still all required), recall
29
+ * on natural-language restatements rises. (Falls back to the full term set when
30
+ * filtering would empty the probe, e.g. an all-stopword description.)
31
+ */
32
+ const FUZZY_STOPWORDS = new Set([
33
+ 'a', 'an', 'the', 'this', 'that', 'these', 'those', 'and', 'or', 'but', 'if',
34
+ 'then', 'than', 'so', 'to', 'of', 'in', 'on', 'at', 'by', 'for', 'from',
35
+ 'with', 'without', 'no', 'not', 'as', 'is', 'are', 'was', 'were', 'be',
36
+ 'been', 'being', 'will', 'would', 'can', 'could', 'should', 'it', 'its',
37
+ 'i', 'we', 'you', 'my', 'our', 'your', 'again', 'just', 'now', 'please',
38
+ 'let', 'lets', 'do', 'does', 'did', 'into', 'over', 'about',
39
+ ]);
40
+ /** Quarantine tier ordering for monotonic promotion: widen-only, never narrow. */
41
+ const TIER_RANK = { run: 0, project: 1, global: 2 };
42
+ // ── Staleness oracle (migration 30) ─────────────────────────────────────────
43
+ /** Hard cap on DISTINCT anchored files (rel_paths) per failure, enforced at
44
+ * WRITE time (recordAnchors). Refreshing an already-anchored path never
45
+ * consumes a slot — one row per (failure_id, rel_path) by construction. */
46
+ export const ANCHOR_CAP = 8;
47
+ /** The advisory flag a downgraded match carries (PROTOCOL.md §10.2.5). Both
48
+ * surfaces — the MCP failure_check handler and the wyrm-guard block tiers —
49
+ * emit EXACTLY this string, so consumers can key on it. */
50
+ export const STALE_ANCHOR_FLAG = 'source changed since this failure was recorded — re-verify';
51
+ /** targetIdentityMatches: hard bound on rows pulled from the signature-prefix
52
+ * range BEFORE the identity filter — a fixed work budget (the guard's ~200ms
53
+ * discipline), index-bounded by the range itself. Past it the lookup degrades
54
+ * toward fail-open (a miss), never toward a false block. */
55
+ const IDENTITY_SCAN_CAP = 200;
56
+ /** targetIdentityMatches: result cap AFTER the identity filter (parity with
57
+ * query()'s LIMIT 5). Applied post-filter so same-prefix cousins can never
58
+ * crowd a genuinely identical-target row out of the verdict. */
59
+ const IDENTITY_MATCH_LIMIT = 5;
60
+ /**
61
+ * Render the failures view as the wyrm_stats prose (v7 F2, T017). Exported
62
+ * standalone so the MCP handler AND bench/fleet-demo.mjs print the IDENTICAL
63
+ * surface — the demo's output IS the tool's output by construction.
64
+ */
65
+ export function renderFailureStats(v) {
66
+ const scopeLine = (rec) => Object.entries(rec).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(' · ') || 'none';
67
+ const bucketLine = (rows) => rows.map((r) => `${'who' in r ? r.who : r.run_id} ×${r.count}`).join(' · ') || 'none';
68
+ const thisRun = v.blocked.this_run.run_id === null
69
+ ? '- This run: (no run identity — export WYRM_RUN_ID or pass run_id)'
70
+ : `- This run (${v.blocked.this_run.run_id}): ${v.blocked.this_run.blocked} repeat(s) blocked`;
71
+ return (`󱅝 **Failure Analytics** (${v.project_id === null ? 'all projects' : `project #${v.project_id}`})\n\n` +
72
+ `**Recorded:** ${v.recorded.total} failure(s), ${v.recorded.unresolved} unresolved\n` +
73
+ `- By quarantine: ${scopeLine(v.recorded.by_quarantine_scope)}\n` +
74
+ `- By scope: ${scopeLine(v.recorded.by_scope)}\n` +
75
+ `- By agent: ${bucketLine(v.recorded.by_agent)}\n` +
76
+ `- By run: ${bucketLine(v.recorded.by_run)}\n\n` +
77
+ `**Confirmations:** ${v.confirmations.total} from ${v.confirmations.distinct_agents} distinct agent(s)\n\n` +
78
+ `**Repeats blocked:** ${v.blocked.total} total\n` +
79
+ `${thisRun}\n` +
80
+ `- By agent: ${bucketLine(v.blocked.by_agent)}\n` +
81
+ `- By run: ${bucketLine(v.blocked.by_run)}\n` +
82
+ `- Top blockers: ${v.blocked.top_blocked.map((t) => `#${t.failure_id} ${t.scope}:${t.target} ×${t.blocks}`).join(' · ') || 'none'}`);
83
+ }
84
+ /**
85
+ * Deterministic match-tier confidence (Article III: never an LLM).
86
+ * - 'exact' → 1.0: the coalescing signature itself matched — this IS the
87
+ * recorded failure.
88
+ * - 'target' → 0.95: scope+target identity (description-independent — the
89
+ * wyrm-guard block tier, v7 F2 review fix). Deterministic
90
+ * identity, strictly below 'exact' (the description was not
91
+ * compared) and strictly above every fuzzy score.
92
+ * - 'fuzzy' → normalized FTS5 bm25. SQLite bm25() is "smaller is better"
93
+ * (negative for relevant rows; can go ≥0 when a term is in most
94
+ * rows). With r = max(0, -bm25): 0.05 + 0.85·r/(r+2),
95
+ * rounded to 4dp — monotone in relevance, range [0.05, 0.9),
96
+ * always strictly below the exact/target tiers.
97
+ */
98
+ export function matchConfidence(tier, bm25Rank) {
99
+ if (tier === 'exact')
100
+ return 1;
101
+ if (tier === 'target')
102
+ return 0.95;
103
+ const r = Math.max(0, -(bm25Rank ?? 0));
104
+ return Number((0.05 + 0.85 * (r / (r + 2))).toFixed(4));
105
+ }
106
+ /**
107
+ * The FULL identity normalization: lowercase, whitespace runs collapsed,
108
+ * trimmed — NO length cap. This is the truth predicate for target identity
109
+ * (targetIdentityMatches and wyrm-guard's block tier): two targets are "the
110
+ * same action" only when their FULL normalized strings are equal, however
111
+ * long they are. Exported so the guard compares with exactly these semantics
112
+ * instead of re-implementing them.
113
+ */
114
+ export function normalizeIdentity(s) {
115
+ return s.toLowerCase().replace(/\s+/g, ' ').trim();
116
+ }
117
+ /**
118
+ * The signature normalization: normalizeIdentity capped at 200 chars. The cap
119
+ * is a STORAGE/RECALL bound (it keeps stored signatures and their index keys
120
+ * small), NOT an identity predicate: two different long targets can share
121
+ * their first 200 normalized chars, so anything deciding "is this the same
122
+ * target?" must compare normalizeIdentity (uncapped) forms. Exported (v7 F2,
123
+ * T016) so wyrm-guard's range probes use EXACTLY the normalization baked into
124
+ * every stored signature — re-implementing it there would drift.
125
+ */
126
+ export function normalizeSignal(s) {
127
+ return normalizeIdentity(s).slice(0, 200);
128
+ }
129
+ export class FailurePatterns {
130
+ db;
131
+ constructor(db) {
132
+ this.db = db;
133
+ }
134
+ /** Build a stable signature from scope+target+description, so repeated
135
+ * identical failures coalesce instead of creating new rows. */
136
+ static signature(scope, target, description) {
137
+ return `${scope}:${normalizeSignal(target)}:${normalizeSignal(description).slice(0, 80)}`;
138
+ }
139
+ /** Record (or increment) a failure. Returns the (new or updated) row.
140
+ *
141
+ * v7 F2 review fix: the whole write — dedup SELECT, UPDATE-or-INSERT, the
142
+ * confirmation row, and any auto-promotion — runs in ONE transaction
143
+ * declared IMMEDIATE. This closes two confirmed races at once:
144
+ * 1. SELECT-then-INSERT across processes: two agents first-recording the
145
+ * same signature could both miss the SELECT — the loser threw an
146
+ * unclassified SQLITE_CONSTRAINT_UNIQUE (losing its confirmation, the
147
+ * very second-distinct-agent signal T015 promotion needs), and
148
+ * project_id-NULL rows (NULLs are distinct under SQLite UNIQUE)
149
+ * DUPLICATED, splitting confirmations so neither row ever reached the
150
+ * n>=2 promotion bar. BEGIN IMMEDIATE takes the write lock before the
151
+ * SELECT, so the dedup read always sees committed truth.
152
+ * 2. Multi-statement partial commit: a BUSY between the row write and
153
+ * noteConfirmation() left the row committed while the structured
154
+ * WYRM_BUSY body told the caller to retry — inflating occurrences.
155
+ * Atomic record() makes "retrying is safe" true for this path.
156
+ */
157
+ record(input) {
158
+ const signature = FailurePatterns.signature(input.scope, input.target, input.description);
159
+ const projectId = input.project_id ?? null;
160
+ const contentSig = input.content ? (normalizeContentSignature(input.content) || null) : null;
161
+ const ambient = getActor();
162
+ const persist = this.db.transaction(() => {
163
+ const existing = this.db.prepare(`
16
164
  SELECT * FROM failure_patterns
17
165
  WHERE signature = ? AND (project_id IS ? OR project_id = ?)
18
- `).get(t,n,n);if(i)return this.db.prepare(`
166
+ `).get(signature, projectId, projectId);
167
+ if (existing) {
168
+ this.db.prepare(`
19
169
  UPDATE failure_patterns
20
170
  SET occurrences = occurrences + 1,
21
171
  last_seen = datetime('now'),
@@ -24,26 +174,423 @@ ${n}
24
174
  content_sig = COALESCE(?, content_sig),
25
175
  resolved = 0
26
176
  WHERE id = ?
27
- `).run(e.why_failed??null,e.severity??i.severity,r,i.id),this.noteConfirmation(i.id,o),this.get(i.id);const u=e.quarantine_scope??(o.run_id!==null?"run":n===null?"global":"project"),p=this.db.prepare(`
177
+ `).run(input.why_failed ?? null, input.severity ?? existing.severity, contentSig, existing.id);
178
+ // v7 F2 (T015): a re-record by a KNOWN agent is a CONFIRMATION of the
179
+ // pattern — the debrief-independent "≥2 distinct agent_id confirmations
180
+ // → auto-promote" path lives in noteConfirmation(). Fetch the row AFTER
181
+ // so a promotion triggered by this very confirmation is visible to the
182
+ // caller.
183
+ this.noteConfirmation(existing.id, ambient);
184
+ return this.get(existing.id);
185
+ }
186
+ // v7 F2 (T009): stamp the recording actor. The dedup UPDATE path above
187
+ // intentionally does NOT restamp — attribution names the first recorder
188
+ // (and keeps the first recorder's quarantine_scope: cross-run
189
+ // re-occurrences coalesce but do NOT widen authority — T015's ≥2-agent
190
+ // confirmation promotion owns widening).
191
+ //
192
+ // v7 F2 (T014) quarantine default, documented:
193
+ // run_id present → 'run' (born in a fleet run: instantly
194
+ // authoritative for same-run siblings,
195
+ // quarantined from everyone else until
196
+ // T015 promotion)
197
+ // no run, project row → 'project' (the 6.x reach: authoritative for
198
+ // its whole project at once)
199
+ // no run, NULL project → 'global' (6.x check() matched NULL-project
200
+ // rows from EVERY project; plain
201
+ // 'project' would silently narrow
202
+ // them — same rationale as the
203
+ // migration-20 backfill)
204
+ const quarantine = input.quarantine_scope
205
+ ?? (ambient.run_id !== null ? 'run' : projectId === null ? 'global' : 'project');
206
+ const info = this.db.prepare(`
28
207
  INSERT INTO failure_patterns
29
208
  (project_id, signature, scope, target, description, why_failed, severity, agent_id, run_id, quarantine_scope, content_sig)
30
209
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
31
- `).run(n,t,e.scope,e.target,e.description,e.why_failed??null,e.severity??"medium",o.agent_id,o.run_id,u,r),d=this.get(p.lastInsertRowid);return this.noteConfirmation(d.id,o),d}).immediate();return b(this.db,{projectId:s.project_id??0,kind:"failure",refTable:"failure_patterns",refId:s.id}),s}contentMatch(e,t,n){const r=this.db.prepare(`
210
+ `).run(projectId, signature, input.scope, input.target, input.description, input.why_failed ?? null, input.severity ?? 'medium', ambient.agent_id, ambient.run_id, quarantine, contentSig);
211
+ const created = this.get(info.lastInsertRowid);
212
+ // v7 F2 (T015): the first recorder is confirmation #1 (a single agent can
213
+ // never auto-promote its own run-quarantined failure — that needs a
214
+ // SECOND distinct agent_id).
215
+ this.noteConfirmation(created.id, ambient);
216
+ return created;
217
+ });
218
+ const row = persist.immediate();
219
+ // Reference-only + private (isShared omitted): failure detail never
220
+ // replicates. Emitted AFTER commit (the sweepRunQuarantine pattern) so
221
+ // followers re-reading on the event always see the committed row.
222
+ emitEvent(this.db, { projectId: row.project_id ?? 0, kind: 'failure', refTable: 'failure_patterns', refId: row.id });
223
+ return row;
224
+ }
225
+ /**
226
+ * v7 Failure Firewall v2 — content tier. Find an UNRESOLVED failure for this
227
+ * scope (+ project reach) whose recorded code signature is re-introduced by
228
+ * `editContent`. Precision-gated (containsFailedSignature): a too-short stored
229
+ * signature never matches. Returns the matching pattern (for the block +
230
+ * remedy) or null. Read-only, deterministic, no network.
231
+ */
232
+ contentMatch(editContent, scope, projectId) {
233
+ const rows = this.db.prepare(`
32
234
  SELECT * FROM failure_patterns
33
235
  WHERE content_sig IS NOT NULL AND scope = ? AND resolved = 0
34
236
  AND (project_id IS ? OR project_id = ?)
35
237
  ORDER BY occurrences DESC, last_seen DESC
36
- `).all(t,n,n);for(const o of r)if(o.content_sig&&y(e,o.content_sig))return o;return null}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(`
238
+ `).all(scope, projectId, projectId);
239
+ for (const row of rows) {
240
+ if (row.content_sig && containsFailedSignature(editContent, row.content_sig))
241
+ return row;
242
+ }
243
+ return null;
244
+ }
245
+ get(id) {
246
+ const row = this.db.prepare('SELECT * FROM failure_patterns WHERE id = ?').get(id);
247
+ return row ?? null;
248
+ }
249
+ // ── Staleness oracle (migration 30, PROTOCOL.md §10.2.5) ──────────────────
250
+ /** The anchor fingerprint: sha256 over the NORMALIZED content form
251
+ * (normalizeContentSignature — the content-tier normal form), so
252
+ * whitespace/reformat/comment churn can never false-flag staleness. */
253
+ static anchorSha(content) {
254
+ return WyrmCrypto.hash(normalizeContentSignature(content));
255
+ }
256
+ /**
257
+ * Anchor a recorded failure to the source files it is about (the undeclared
258
+ * `files` param of wyrm_failure_record). For each file UNDER the project
259
+ * root: stat + read + hash the normalized content, store the
260
+ * PROJECT-RELATIVE path (anchors replicate across machines whose checkouts
261
+ * live at different roots) with the stat fast-path fields. Paths escaping
262
+ * the project root (traversal, absolute paths outside it) are REJECTED;
263
+ * unreadable files are skipped.
264
+ *
265
+ * UPSERT per (failure_id, rel_path): record() COALESCES repeat failures
266
+ * onto ONE id, so a coalesced re-record that names the same files must
267
+ * REFRESH each path's anchor in place (delete-then-insert — no reliance on
268
+ * the unique index, so a DB that ran an earlier shape of migration 30 still
269
+ * behaves). Appending instead would leave the old-sha row beside the fresh
270
+ * one, and anchorFreshness — 'stale' on ANY drifted row — would keep a
271
+ * failure just re-confirmed against CURRENT source permanently advisory.
272
+ * ANCHOR_CAP counts DISTINCT rel_paths: refreshing an existing anchor never
273
+ * consumes a slot and lands even at the cap. Best-effort by contract:
274
+ * returns how many anchors were written (refreshes included) and NEVER
275
+ * throws — a pre-migration-30 DB or any fs error must not fail the record.
276
+ *
277
+ * SECURITY (allowNewPaths): only the call that FRESHLY created this failure
278
+ * may introduce NEW anchor paths. record() coalesces a repeat signature onto
279
+ * an existing id, so a coalesced re-record (allowNewPaths=false) may only
280
+ * REFRESH paths already anchored — it can never attach a NEW (decoy) anchor
281
+ * to a firm block it does not own and then drift the decoy to collapse that
282
+ * block to advisory. Refreshes are always allowed (a re-confirmed failure
283
+ * must regain its hard block); the gate is on introducing new paths only.
284
+ */
285
+ recordAnchors(failureId, projectRoot, files, allowNewPaths = true) {
286
+ try {
287
+ const root = resolve(projectRoot);
288
+ // Existing DISTINCT rel_paths: the cap denominator AND the refresh set.
289
+ const anchored = new Set(this.db.prepare('SELECT DISTINCT rel_path FROM failure_anchors WHERE failure_id = ?').all(failureId).map((r) => r.rel_path));
290
+ let remaining = ANCHOR_CAP - anchored.size;
291
+ const del = this.db.prepare('DELETE FROM failure_anchors WHERE failure_id = ? AND rel_path = ?');
292
+ const insert = this.db.prepare(`
293
+ INSERT INTO failure_anchors (failure_id, rel_path, sha, size, mtime_ns)
294
+ VALUES (?, ?, ?, ?, ?)
295
+ `);
296
+ const seen = new Set();
297
+ let stored = 0;
298
+ for (const file of files) {
299
+ if (typeof file !== 'string' || file.length === 0)
300
+ continue;
301
+ const abs = isAbsolute(file) ? resolve(file) : resolve(root, file);
302
+ // No traversal: the resolved path must be strictly INSIDE the root
303
+ // (the root itself is a directory, never an anchor).
304
+ if (!abs.startsWith(root + sep))
305
+ continue;
306
+ if (seen.has(abs))
307
+ continue;
308
+ seen.add(abs);
309
+ const rel = relative(root, abs);
310
+ const refresh = anchored.has(rel);
311
+ // The cap gates NEW paths only — a refresh always lands (a failure
312
+ // re-confirmed against current source must regain its hard block).
313
+ // A NEW path also requires allowNewPaths (a coalesced re-record cannot
314
+ // inject a decoy anchor onto a block it does not own — see the doc).
315
+ if (!refresh && (!allowNewPaths || remaining <= 0))
316
+ continue;
317
+ try {
318
+ // stat BEFORE read: if the file mutates between the two, the stored
319
+ // stat is conservatively older — the fast path misses once and the
320
+ // hash path settles it (never a false "fresh").
321
+ const st = statSync(abs, { bigint: true });
322
+ if (!st.isFile())
323
+ continue;
324
+ const sha = FailurePatterns.anchorSha(readFileSync(abs, 'utf-8'));
325
+ del.run(failureId, rel); // refresh: the old-sha row must NOT survive
326
+ insert.run(failureId, rel, sha, st.size, st.mtimeNs);
327
+ stored++;
328
+ if (!refresh)
329
+ remaining--;
330
+ }
331
+ catch {
332
+ // unreadable/vanished file — skip it, anchor the rest
333
+ }
334
+ }
335
+ return stored;
336
+ }
337
+ catch {
338
+ return 0; // pre-migration-30 DB / readonly handle — anchors are best-effort
339
+ }
340
+ }
341
+ /** Stored anchors for one failure. safeIntegers: mtime_ns (INT64 ns) exceeds
342
+ * JS safe-number range — a number round-trip would break the stat fast path.
343
+ * Never throws: a pre-migration-30 DB reads as "no anchors" (fail-open). */
344
+ getAnchors(failureId) {
345
+ try {
346
+ const stmt = this.db.prepare('SELECT rel_path, sha, size, mtime_ns, recorded_at FROM failure_anchors WHERE failure_id = ?');
347
+ stmt.safeIntegers(true);
348
+ return stmt.all(failureId);
349
+ }
350
+ catch {
351
+ return [];
352
+ }
353
+ }
354
+ /**
355
+ * THE ORACLE CORE: has this failure's anchored source POSITIVELY drifted?
356
+ *
357
+ * Fast path first (the guard's latency budget): stat each anchor — equal
358
+ * size AND mtime_ns ⇒ fresh with ZERO hashing. Only on stat drift is the
359
+ * file read and its NORMALIZED content re-hashed (anchorSha), so a
360
+ * whitespace-only reformat stats different but hashes identical ⇒ fresh.
361
+ *
362
+ * FAIL-OPEN (PROTOCOL.md §10.2 verdict contract): a missing/unreadable
363
+ * anchor file (fleet-replicated rows from machines whose checkout this box
364
+ * doesn't have), a rel_path that would escape the root, a pre-migration DB,
365
+ * or ANY fs error contributes NO staleness claim — only a readable file
366
+ * whose normalized hash differs returns 'stale'. Never throws. Read-only
367
+ * (the wyrm-guard handle is readonly).
368
+ */
369
+ anchorFreshness(failureId, projectRoot) {
370
+ try {
371
+ if (!projectRoot)
372
+ return 'unanchored';
373
+ const anchors = this.getAnchors(failureId);
374
+ if (anchors.length === 0)
375
+ return 'unanchored';
376
+ const root = resolve(projectRoot);
377
+ let checkable = 0;
378
+ for (const a of anchors) {
379
+ try {
380
+ const abs = resolve(root, a.rel_path);
381
+ // Defense-in-depth mirror of the write-time rule: a row whose
382
+ // rel_path escapes the root is never read (or judged).
383
+ if (!abs.startsWith(root + sep))
384
+ continue;
385
+ const st = statSync(abs, { bigint: true });
386
+ if (!st.isFile())
387
+ continue;
388
+ if (st.size === a.size && st.mtimeNs === a.mtime_ns) {
389
+ checkable++; // stat fast path: byte-identical file, no hashing
390
+ continue;
391
+ }
392
+ if (FailurePatterns.anchorSha(readFileSync(abs, 'utf-8')) === a.sha) {
393
+ checkable++; // touched/reformatted, semantically unchanged
394
+ continue;
395
+ }
396
+ return 'stale'; // POSITIVE drift: readable file, normalized hash differs
397
+ }
398
+ catch {
399
+ // missing/unreadable — no claim from this anchor (fail-open)
400
+ }
401
+ }
402
+ return checkable > 0 ? 'fresh' : 'unanchored';
403
+ }
404
+ catch {
405
+ return 'unanchored';
406
+ }
407
+ }
408
+ /**
409
+ * The SHARED downgrade rule both firewall surfaces inherit — the MCP
410
+ * wyrm_failure_check handler AND the wyrm-guard block tiers call exactly
411
+ * this, so the two cannot drift. Returns the ids of matches whose hard
412
+ * block must DOWNGRADE to advisory (stale_anchor), i.e. matches that are:
413
+ *
414
+ * · NOT an exact-signature match for the current attempt — an exact
415
+ * command/edit signature match KEEPS its hard block regardless of
416
+ * anchors (code moving on does not unprove a command failure), and
417
+ * · file/symbol-scoped — only matches ABOUT source code are unproven by
418
+ * that source changing; command/edit/prompt scopes key on the ATTEMPT
419
+ * itself and never downgrade, and
420
+ * · positively 'stale' under anchorFreshness above.
421
+ *
422
+ * Fail-open end to end: no project root, no anchors, missing files, any
423
+ * error ⇒ ids stay out of the set ⇒ verdict unchanged. Never throws.
424
+ */
425
+ staleAnchorIds(matches, projectRoot) {
426
+ const stale = new Set();
427
+ if (!projectRoot)
428
+ return stale;
429
+ const judged = new Map();
430
+ for (const m of matches) {
431
+ try {
432
+ if (m.match === 'exact')
433
+ continue;
434
+ if (m.scope !== 'file' && m.scope !== 'symbol')
435
+ continue;
436
+ let verdict = judged.get(m.id);
437
+ if (verdict === undefined) {
438
+ verdict = this.anchorFreshness(m.id, projectRoot);
439
+ judged.set(m.id, verdict);
440
+ }
441
+ if (verdict === 'stale')
442
+ stale.add(m.id);
443
+ }
444
+ catch {
445
+ // staleness is advisory — an error can only mean "no claim"
446
+ }
447
+ }
448
+ return stale;
449
+ }
450
+ // ── v7 F2 (T015): run-quarantine promotion (all debrief-INDEPENDENT) ──────
451
+ /**
452
+ * Confirmation ledger + the ≥2-distinct-agents auto-promotion path.
453
+ *
454
+ * Every record() of a signature by a KNOWN agent_id files one confirmation
455
+ * row in failure_confirmations (migration 21). PK (failure_id, agent_id)
456
+ * makes COUNT(*) the DISTINCT-agent count by construction: the same agent
457
+ * re-recording N times stays ONE confirmation, and unattributed re-records
458
+ * (ambient agent_id NULL) still increment occurrences but can never
459
+ * establish distinctness, so they never count.
460
+ *
461
+ * When a 'run'-quarantined failure reaches ≥2 distinct agents it
462
+ * auto-promotes to 'project' ('global' for project-NULL rows — see
463
+ * applyPromotion): two independent agents hitting the same wall is a real
464
+ * pattern, not single-agent run noise. Works with zero wyrm_run/debrief
465
+ * involvement (that tooling arrives in F3) and across runs — record()'s
466
+ * dedup matches by signature regardless of quarantine tier, so a sibling
467
+ * fleet independently re-hitting the failure confirms it too.
468
+ *
469
+ * Under-counts safely: two agents that BOTH fall back to the same
470
+ * clientInfo name (e.g. 'claude-code') collapse into one confirmation —
471
+ * the fallback can delay promotion, never force it.
472
+ */
473
+ noteConfirmation(failureId, ambient) {
474
+ if (ambient.agent_id === null)
475
+ return;
476
+ this.db.prepare(`
37
477
  INSERT OR IGNORE INTO failure_confirmations (failure_id, agent_id, run_id)
38
478
  VALUES (?, ?, ?)
39
- `).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 s=this.db.prepare(`
479
+ `).run(failureId, ambient.agent_id, ambient.run_id);
480
+ const row = this.get(failureId);
481
+ if (!row || row.quarantine_scope !== 'run')
482
+ return;
483
+ const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM failure_confirmations WHERE failure_id = ?').get(failureId);
484
+ if (n >= 2)
485
+ this.applyPromotion(failureId, 'project');
486
+ }
487
+ /**
488
+ * The ungated internal promotion primitive. Monotonic: run → project →
489
+ * global, NEVER narrows. 'project' on a project_id-NULL row normalizes to
490
+ * 'global' — 6.x check() matches NULL-project rows from every project, so
491
+ * labeling one 'project' would be false (same rationale as the migration-20
492
+ * backfill). Emits a reference-only failure event on an actual widening so
493
+ * live-memory followers re-read the row's new authority (failures stay
494
+ * private: is_shared=0, never replicated).
495
+ */
496
+ applyPromotion(id, tier) {
497
+ const row = this.get(id);
498
+ if (!row)
499
+ return false;
500
+ const effective = tier === 'project' && row.project_id === null ? 'global' : tier;
501
+ if (TIER_RANK[effective] <= TIER_RANK[row.quarantine_scope])
502
+ return false;
503
+ this.db.prepare('UPDATE failure_patterns SET quarantine_scope = ? WHERE id = ?').run(effective, id);
504
+ emitEvent(this.db, {
505
+ projectId: row.project_id ?? 0, kind: 'failure',
506
+ refTable: 'failure_patterns', refId: id,
507
+ });
508
+ return true;
509
+ }
510
+ /**
511
+ * The orchestrator promote flag behind wyrm_failure_record/_resolve
512
+ * `promote:'project'|'global'`.
513
+ *
514
+ * AUTHORITY MODEL (Article VII — the documented rule, enforced by
515
+ * boundary.hasExplicitAttribution on the AMBIENT envelope): the flag is
516
+ * honored ONLY when the caller carries EXPLICIT identity — a run_id (run
517
+ * ids never come from the clientInfo fallback) or an agent_id supplied via
518
+ * param/_meta/Wyrm-Actor-header/env. The bare clientInfo fallback that
519
+ * every anonymous MCP caller gets for free carries NO promotion authority.
520
+ * Denial is visible, never silent: `authorized:false` comes back and the
521
+ * tool handler reports the ignored flag in its response text.
522
+ */
523
+ promote(id, tier) {
524
+ if (!hasExplicitAttribution(getActor())) {
525
+ return { row: this.get(id), authorized: false, changed: false };
526
+ }
527
+ const changed = this.applyPromotion(id, tier);
528
+ return { row: this.get(id), authorized: true, changed };
529
+ }
530
+ /**
531
+ * The run-quarantine MAINTENANCE SWEEP — the third debrief-independent
532
+ * promotion path, wired into wyrm_maintenance (and shipped in the same
533
+ * commit as the quarantine, per spec FR-2). One transaction; idempotent —
534
+ * a second sweep over a settled DB changes zero rows.
535
+ *
536
+ * Run lifecycle enforced (works with NO runs rows at all — wyrm_run is F3):
537
+ * 1. EXPIRE abandoned-run noise (TTL auto-expiry): unresolved 'run'-scoped
538
+ * failures whose runs row says status='abandoned' — an EXPLICIT verdict
539
+ * (an orchestrator/operator junked the run, or a previous sweep TTL'd
540
+ * it) — are auto-resolved with a note. Noise never reaches project
541
+ * memory; history is kept (resolve, not delete).
542
+ * 2. PROMOTE unresolved 'run'-scoped failures from runs that are OVER or
543
+ * INACTIVE past the TTL — the run ended without resolving or debriefing
544
+ * them, and quarantined learning must not die with its run:
545
+ * - runs.status IN ('completed','failed'): definitively over;
546
+ * - runs.status='running' but BOTH runs.updated_at and the newest
547
+ * run-tagged event are older than the TTL: a crashed/stalled fleet;
548
+ * - no runs row at all (the common F2 shape: an orchestrator exports
549
+ * WYRM_RUN_ID without registering a run) and BOTH the failure's
550
+ * last_seen and the newest run-tagged event are older than the TTL.
551
+ * Target tier: 'project' ('global' when project_id IS NULL).
552
+ * 3. MARK the TTL-stalled 'running' runs of step 2 'abandoned' — AFTER
553
+ * their failures were promoted (a crash must not lose learning). Any
554
+ * LATE write that re-quarantines against such a dead run is noise and
555
+ * expires on the next sweep via step 1.
556
+ *
557
+ * Activity signal: run-tagged events (idx_events_run) — every attributed
558
+ * write emits one, so a live fleet continuously refreshes its run; reads
559
+ * (failure_check) never keep a run alive. THE SIGNAL ONLY EXISTS WHILE LIVE
560
+ * MEMORY IS ON: emitEvent is WYRM_LIVE_MEMORY-gated, so with it off a fully
561
+ * active fleet produces ZERO run-tagged events and "no recent events" is
562
+ * silence, not evidence of a dead run. The sweep therefore degrades
563
+ * EXPLICITLY in that mode: the inactivity/TTL paths — the stalled-'running'
564
+ * clause, the no-runs-row (orphan) clause, and the step-3 abandon — are
565
+ * SKIPPED, only the explicit-verdict paths act (status='abandoned' expiry,
566
+ * status IN ('completed','failed') promotion), and the result carries
567
+ * `degraded` naming the mode so maintenance output can say why nothing
568
+ * promoted. Deterministic, zero LLM calls (Article III); every value is a
569
+ * bound parameter (the datetime() modifier and the TTL-path gate are bound
570
+ * too, never spliced into SQL).
571
+ */
572
+ sweepRunQuarantine(opts) {
573
+ const hours = Math.max(1, Math.floor(opts?.runInactiveHours ?? 24));
574
+ const cut = `-${hours} hours`; // datetime('now', @cut) modifier, bound
575
+ const cutMs = Date.now() - hours * 3_600_000; // events.created_at is INTEGER ms
576
+ // @ttl gate: 1 only when the run-tagged-event heartbeat can exist at all
577
+ // (see the doc comment) — the inactivity clauses must not read its
578
+ // absence as inactivity when live memory is off.
579
+ const eventSignal = isLiveMemoryEnabled();
580
+ const sweep = this.db.transaction(() => {
581
+ // 1. EXPIRE abandoned-run noise.
582
+ const expired = this.db.prepare(`
40
583
  UPDATE failure_patterns
41
584
  SET resolved = 1,
42
- resolution_note = COALESCE(resolution_note || ' \xB7 ', '')
585
+ resolution_note = COALESCE(resolution_note || ' · ', '')
43
586
  || 'auto-expired by run-quarantine sweep: run ' || run_id || ' was abandoned'
44
587
  WHERE resolved = 0 AND quarantine_scope = 'run' AND run_id IS NOT NULL
45
588
  AND run_id IN (SELECT run_id FROM runs WHERE status = 'abandoned')
46
- `).run().changes,i=this.db.prepare(`
589
+ `).run().changes;
590
+ // 2. PROMOTE from over/inactive runs. Select-then-update (inside the
591
+ // same transaction, so still atomic) — the promoted ids emit
592
+ // reference-only events after commit.
593
+ const promotedIds = this.db.prepare(`
47
594
  SELECT fp.id FROM failure_patterns fp
48
595
  WHERE fp.resolved = 0 AND fp.quarantine_scope = 'run' AND fp.run_id IS NOT NULL
49
596
  AND (
@@ -69,18 +616,155 @@ ${n}
69
616
  )
70
617
  )
71
618
  )
72
- `).all({cut:n,cutMs:r,ttl:o?1:0}).map(d=>d.id),u=this.db.prepare(`
619
+ `).all({ cut, cutMs, ttl: eventSignal ? 1 : 0 }).map((r) => r.id);
620
+ const widen = this.db.prepare(`
73
621
  UPDATE failure_patterns
74
622
  SET quarantine_scope = CASE WHEN project_id IS NULL THEN 'global' ELSE 'project' END
75
623
  WHERE id = ?
76
- `);for(const d of i)u.run(d);const p=o?this.db.prepare(`
624
+ `);
625
+ for (const id of promotedIds)
626
+ widen.run(id);
627
+ // 3. TTL-abandon the stalled running runs (after step 2 promoted them).
628
+ // Event-dependent like the step-2 TTL clauses: without the heartbeat a
629
+ // 'running' run cannot be judged dead, so it is left alone.
630
+ const abandoned = !eventSignal ? 0 : this.db.prepare(`
77
631
  UPDATE runs SET status = 'abandoned', updated_at = datetime('now')
78
632
  WHERE status = 'running' AND updated_at < datetime('now', @cut)
79
633
  AND NOT EXISTS (
80
634
  SELECT 1 FROM events e
81
635
  WHERE e.run_id = runs.run_id AND e.created_at >= @cutMs
82
636
  )
83
- `).run({cut:n,cutMs:r}).changes:0;return{promotedIds:i,expired:s,abandoned:p}})();for(const s of c.promotedIds){const i=this.get(s);b(this.db,{projectId:i?.project_id??0,kind:"failure",refTable:"failure_patterns",refId:s,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:s,...i})=>i)}checkVerdict(e,t,n,r,o){const s=this.query(e,t,n,r??null,o).map(i=>this.toVerdictMatch(i,i._match,i._rank));return{blocked:s.length>0,matches:s,recorded_by_agent:s[0]?.recorded_by_agent??null,run_id:s[0]?.run_id??null,confidence:s.reduce((i,u)=>Math.max(i,u.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:w(t,n)}}targetIdentityMatches(e,t,n,r){const o=O(t),c=o.slice(0,200);if(c.length===0)return[];const s=r?.callerRunId!==void 0?r.callerRunId:E().run_id,i=`${e}:${c}:`,u=`${e}:${c};`;return this.db.prepare(`
637
+ `).run({ cut, cutMs }).changes;
638
+ return { promotedIds, expired, abandoned };
639
+ })();
640
+ // Reference-only events for the widened rows. Explicit NULL attribution
641
+ // (T009 emitEvent semantics): the sweep is system maintenance — it must
642
+ // not inherit whoever happened to call wyrm_maintenance.
643
+ for (const id of sweep.promotedIds) {
644
+ const row = this.get(id);
645
+ emitEvent(this.db, {
646
+ projectId: row?.project_id ?? 0, kind: 'failure',
647
+ refTable: 'failure_patterns', refId: id, agentId: null, runId: null,
648
+ });
649
+ }
650
+ return {
651
+ promoted: sweep.promotedIds.length,
652
+ expired: sweep.expired,
653
+ runs_abandoned: sweep.abandoned,
654
+ // Key ABSENT (not null) on a fully-signalled sweep — the healthy result
655
+ // shape stays exactly { promoted, expired, runs_abandoned }.
656
+ ...(eventSignal ? {} : {
657
+ degraded: 'live memory off (WYRM_LIVE_MEMORY) — run-tagged events do not exist, '
658
+ + 'so the run-inactivity TTL paths were skipped; only explicit run '
659
+ + 'verdicts acted (completed/failed promote, abandoned expires)',
660
+ }),
661
+ };
662
+ }
663
+ /** Check whether a proposed action matches any unresolved failure.
664
+ * Stage 1: exact signature match (scope+target+description); stage 2 fallback:
665
+ * FTS fuzzy match within the same scope. Both ordered by occurrences DESC,
666
+ * last_seen DESC, LIMIT 5 — no multiplicative recency-times-occurrences score.
667
+ *
668
+ * v7 F2 (T014) run-scope awareness: 'run'-quarantined rows only match callers
669
+ * carrying the SAME run_id (ambient envelope by default) — 6.x rows are all
670
+ * 'project'/'global' so their behavior is byte-identical to 6.x. */
671
+ check(scope, target, description, projectId, opts) {
672
+ return this.query(scope, target, description, projectId ?? null, opts)
673
+ .map(({ _match: _m, _rank: _r, ...row }) => row);
674
+ }
675
+ /**
676
+ * v7 F2 (T014): the canonical structured verdict for wyrm_failure_check
677
+ * (spec FR-2). Same two-stage query and ordering as check() — the verdict is
678
+ * a richer VIEW of the same rows, so prose derived from it cannot drift from
679
+ * 6.x behavior. `confidence` = max over per-match matchConfidence() scores
680
+ * (deterministic bm25/match-tier arithmetic — Article III).
681
+ */
682
+ checkVerdict(scope, target, description, projectId, opts) {
683
+ const rows = this.query(scope, target, description, projectId ?? null, opts);
684
+ const matches = rows.map((r) => this.toVerdictMatch(r, r._match, r._rank));
685
+ return {
686
+ blocked: matches.length > 0,
687
+ matches,
688
+ recorded_by_agent: matches[0]?.recorded_by_agent ?? null,
689
+ run_id: matches[0]?.run_id ?? null,
690
+ confidence: matches.reduce((m, x) => Math.max(m, x.confidence), 0),
691
+ };
692
+ }
693
+ /**
694
+ * Map a stored row to the canonical verdict-match shape. Shared by
695
+ * checkVerdict() and wyrm-guard's target-identity tier (v7 F2 review fix)
696
+ * so the two surfaces cannot drift.
697
+ */
698
+ toVerdictMatch(r, match, bm25Rank = null) {
699
+ const att = resolveAttribution(r);
700
+ return {
701
+ id: r.id,
702
+ pattern: r.signature,
703
+ scope: r.scope,
704
+ quarantine_scope: r.quarantine_scope,
705
+ target: r.target,
706
+ description: r.description,
707
+ why_failed: r.why_failed,
708
+ severity: r.severity,
709
+ occurrences: r.occurrences,
710
+ last_seen: r.last_seen,
711
+ recorded_by_agent: att.actor,
712
+ run_id: att.run_id,
713
+ match,
714
+ confidence: matchConfidence(match, bm25Rank),
715
+ };
716
+ }
717
+ /**
718
+ * v7 F2 review fix (T016): the deterministic TARGET-IDENTITY lookup behind
719
+ * wyrm-guard's block tier.
720
+ *
721
+ * Production failures always carry a NON-EMPTY description (the
722
+ * wyrm_failure_record schema and the daemon endpoint both require one), so
723
+ * full-signature equality against the guard's empty-description probe could
724
+ * never match a real row — the documented "exact signature match" block rule
725
+ * was unreachable outside empty-description test fixtures.
726
+ *
727
+ * RECALL vs TRUTH — the two invariants this lookup keeps strictly separate:
728
+ *
729
+ * · RECALL is the signature PREFIX range `scope:normalizeSignal(target):`
730
+ * — description-independent, a bound-parameter range scan (no LIKE, no
731
+ * template SQL), with the same project filter and run-tier gate as
732
+ * query(). The 200-char signature cap and the ':' separator both live
733
+ * here, so the range legitimately over-captures: a stored target that
734
+ * extends the probe past a colon (probe 'npm run build', stored
735
+ * 'npm run build:dev') and any row sharing a truncated 200-char prefix
736
+ * both land IN the range. Callers must pass the RAW (pre-normalize,
737
+ * uncapped) target so the truth check below has the full string.
738
+ *
739
+ * · TRUTH is full-string identity: a row is kept only when
740
+ * normalizeIdentity(stored target) === normalizeIdentity(probe) — the
741
+ * UNCAPPED normalization over the full stored column and the full
742
+ * probe. Targets differing only past the signature cap are NOT
743
+ * identical. (Pushing this check into SQL via the signature would be
744
+ * unsound: the signature is ambiguous at the target/description
745
+ * boundary — target 'npm run build:dev' + description 'x' is
746
+ * byte-identical to target 'npm run build' + description 'dev:x'.)
747
+ *
748
+ * The result cap applies AFTER the truth check: prefix-cousins, however
749
+ * many and however highly ranked, can never crowd a genuinely identical
750
+ * row out of the verdict. The range scan itself pulls at most
751
+ * IDENTITY_SCAN_CAP rows (occurrences/recency-ordered) as a fixed work
752
+ * budget — past it the lookup degrades toward fail-open (a miss), never
753
+ * toward a false block. (query()'s FTS fuzzy stage keeps its own LIMIT 5
754
+ * and CAN be crowded — acceptable, because fuzzy results only ever feed
755
+ * warn context; the block verdict never depends on FTS recall.)
756
+ */
757
+ targetIdentityMatches(scope, target, projectId, opts) {
758
+ const fullTarget = normalizeIdentity(target);
759
+ const normTarget = fullTarget.slice(0, 200); // === normalizeSignal(target): the stored-signature key
760
+ if (normTarget.length === 0)
761
+ return []; // an empty prefix would match every row in scope
762
+ const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
763
+ // Half-open prefix range under SQLite's binary collation: every signature
764
+ // beginning with `scope:target:` sorts in [`...:`, `...;`) (';' is ':'+1).
765
+ const lo = `${scope}:${normTarget}:`;
766
+ const hi = `${scope}:${normTarget};`;
767
+ const rows = this.db.prepare(`
84
768
  SELECT * FROM failure_patterns
85
769
  WHERE resolved = 0
86
770
  AND signature >= @lo AND signature < @hi
@@ -88,51 +772,188 @@ ${n}
88
772
  AND (quarantine_scope <> 'run' OR run_id = @run)
89
773
  ORDER BY occurrences DESC, last_seen DESC
90
774
  LIMIT @cap
91
- `).all({lo:i,hi:u,project:n??null,run:s,cap:U}).filter(d=>O(d.target)===o).slice(0,k)}recordBlock(e,t){if(!(!e.blocked||e.matches.length===0))try{const n=E();this.db.prepare(`
775
+ `).all({
776
+ lo, hi, project: projectId ?? null, run: callerRun, cap: IDENTITY_SCAN_CAP,
777
+ });
778
+ // TRUTH check first, result cap second (see the doc comment): identity is
779
+ // re-verified on the FULL stored column against the FULL probe, and only
780
+ // then are the top IDENTITY_MATCH_LIMIT kept — capping before filtering
781
+ // would let higher-ranked prefix-cousins evict the identical row.
782
+ return rows
783
+ .filter((r) => normalizeIdentity(r.target) === fullTarget)
784
+ .slice(0, IDENTITY_MATCH_LIMIT);
785
+ }
786
+ // ── v7 F2 (T017): prevented-repeat analytics ───────────────────────────────
787
+ /**
788
+ * Count one prevented repeat: a blocked:true verdict files ONE row in the
789
+ * failure_blocks ledger (migration 22 — the documented storage choice lives
790
+ * on that migration), attributed to the CHECKING agent/run from the ambient
791
+ * actor envelope — the agent that was about to repeat the failure, not the
792
+ * recorder. failure_id is the verdict's top blocker (matches[0]).
793
+ *
794
+ * Deliberately a SEPARATE method, not a side effect inside checkVerdict():
795
+ * checks must stay pure reads — wyrm-guard (T016) runs checkVerdict() over a
796
+ * READONLY DB handle and a write inside it would crash every guard probe.
797
+ * Counting surfaces: the MCP wyrm_failure_check handler (the logSavings
798
+ * precedent — it already writes savings on the same path), and — v7 F2
799
+ * review fix — wyrm-guard's block path via a separate short-lived RW
800
+ * connection (best-effort, fail-open), so the harness-enforced blocks feed
801
+ * the FR-2 ROI number too. Remaining uncounted (documented): the agent-loop
802
+ * internalDispatch failure_check.
803
+ *
804
+ * NEVER throws (the emitEvent contract): the check already succeeded — a
805
+ * readonly handle, SQLITE_BUSY, or a pre-migration-22 DB must never turn a
806
+ * successful read into a failure for the sake of analytics.
807
+ */
808
+ recordBlock(verdict, projectId) {
809
+ if (!verdict.blocked || verdict.matches.length === 0)
810
+ return;
811
+ try {
812
+ const ambient = getActor();
813
+ // Attribute the prevented repeat to the match that ACTUALLY blocked — the
814
+ // first NON-downgraded match. After a staleness downgrade matches[0] can
815
+ // be an advisory (stale_anchor) match while a later firm match holds the
816
+ // block; crediting matches[0] would misattribute the wyrm_stats ledger.
817
+ const firm = verdict.matches.find((m) => !m.stale_anchor) ?? verdict.matches[0];
818
+ this.db.prepare(`
92
819
  INSERT INTO failure_blocks (failure_id, project_id, agent_id, run_id)
93
820
  VALUES (?, ?, ?, ?)
94
- `).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(`
821
+ `).run(firm.id, projectId ?? null, ambient.agent_id, ambient.run_id);
822
+ }
823
+ catch { /* best-effort analytics — log-and-drop, never break the check */ }
824
+ }
825
+ /**
826
+ * v7 F2 review fix: retention for the failure_blocks ledger (migration 22).
827
+ * The ledger is append-only and caller-amplifiable — every blocked
828
+ * wyrm_failure_check (a read tool, no rate limit) files one row, and nothing
829
+ * pruned it, so a loop probing one known-blocked signature could grow the
830
+ * table without bound. wyrm_maintenance calls this with the SAME retention
831
+ * knob as the event log (WYRM_EVENT_RETAIN_DAYS, default 90d) — mirroring
832
+ * pruneEvents. Bound parameter for the datetime modifier (never spliced);
833
+ * best-effort on a pre-migration-22 DB (returns 0).
834
+ */
835
+ pruneBlocks(olderThanDays) {
836
+ const days = Math.max(1, Math.floor(olderThanDays));
837
+ try {
838
+ return this.db.prepare(`DELETE FROM failure_blocks WHERE blocked_at < datetime('now', @cut)`).run({ cut: `-${days} days` }).changes;
839
+ }
840
+ catch {
841
+ return 0;
842
+ }
843
+ }
844
+ /**
845
+ * The `wyrm_stats view=failures` aggregation (spec FR-2 prevented-repeat
846
+ * analytics): per-run/per-agent recorded-failure attribution + "N repeats
847
+ * blocked this run". Pure indexed COUNT/GROUP BY (idx_failure_run,
848
+ * idx_failure_quarantine, idx_failure_blocks_run) with always-bound params —
849
+ * deterministic, zero LLM calls (Article III). Project filter is EXACT
850
+ * (rows recorded for that project_id); omitted = all rows, the fleet view.
851
+ */
852
+ failureStats(opts) {
853
+ const projectId = opts?.projectId ?? null;
854
+ const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
855
+ const p = { project: projectId };
856
+ const totals = this.db.prepare(`
95
857
  SELECT COUNT(*) AS total, COALESCE(SUM(resolved = 0), 0) AS unresolved
96
858
  FROM failure_patterns WHERE (@project IS NULL OR project_id = @project)
97
- `).get(r),c={run:0,project:0,global:0};for(const _ of this.db.prepare(`
859
+ `).get(p);
860
+ const byQuarantine = { run: 0, project: 0, global: 0 };
861
+ for (const r of this.db.prepare(`
98
862
  SELECT quarantine_scope AS k, COUNT(*) AS n FROM failure_patterns
99
863
  WHERE (@project IS NULL OR project_id = @project) GROUP BY quarantine_scope
100
- `).all(r))c[_.k]=_.n;const s={file:0,symbol:0,command:0,prompt:0,edit:0};for(const _ of this.db.prepare(`
864
+ `).all(p))
865
+ byQuarantine[r.k] = r.n;
866
+ const byScope = { file: 0, symbol: 0, command: 0, prompt: 0, edit: 0 };
867
+ for (const r of this.db.prepare(`
101
868
  SELECT scope AS k, COUNT(*) AS n FROM failure_patterns
102
869
  WHERE (@project IS NULL OR project_id = @project) GROUP BY scope
103
- `).all(r))s[_.k]=_.n;const i=_=>_.map(N=>({who:m(N.agent_id),count:N.count})),u=i(this.db.prepare(`
870
+ `).all(p))
871
+ byScope[r.k] = r.n;
872
+ // GROUP BY the raw column (one NULL group), display via readActor —
873
+ // 'legacy' is read-time presentation only, never stored (src/attribution.ts).
874
+ // Four FIXED query literals (no table-name interpolation — the hygiene
875
+ // ratchet's zero-template-SQL floor stays clean); LIMIT 10 is the
876
+ // deterministic bucket bound (count DESC, name ASC tiebreak).
877
+ const toAgentBuckets = (rows) => rows.map((r) => ({ who: readActor(r.agent_id), count: r.count }));
878
+ const recordedByAgent = toAgentBuckets(this.db.prepare(`
104
879
  SELECT agent_id, COUNT(*) AS count FROM failure_patterns
105
880
  WHERE (@project IS NULL OR project_id = @project)
106
881
  GROUP BY agent_id ORDER BY count DESC, agent_id ASC LIMIT 10
107
- `).all(r)),p=this.db.prepare(`
882
+ `).all(p));
883
+ const recordedByRun = this.db.prepare(`
108
884
  SELECT run_id, COUNT(*) AS count FROM failure_patterns
109
885
  WHERE run_id IS NOT NULL AND (@project IS NULL OR project_id = @project)
110
886
  GROUP BY run_id ORDER BY count DESC, run_id ASC LIMIT 10
111
- `).all(r),d=i(this.db.prepare(`
887
+ `).all(p);
888
+ const blockedByAgent = toAgentBuckets(this.db.prepare(`
112
889
  SELECT agent_id, COUNT(*) AS count FROM failure_blocks
113
890
  WHERE (@project IS NULL OR project_id = @project)
114
891
  GROUP BY agent_id ORDER BY count DESC, agent_id ASC LIMIT 10
115
- `).all(r)),R=this.db.prepare(`
892
+ `).all(p));
893
+ const blockedByRun = this.db.prepare(`
116
894
  SELECT run_id, COUNT(*) AS count FROM failure_blocks
117
895
  WHERE run_id IS NOT NULL AND (@project IS NULL OR project_id = @project)
118
896
  GROUP BY run_id ORDER BY count DESC, run_id ASC LIMIT 10
119
- `).all(r),f=this.db.prepare(`
897
+ `).all(p);
898
+ const conf = this.db.prepare(`
120
899
  SELECT COUNT(*) AS total, COUNT(DISTINCT fc.agent_id) AS distinct_agents
121
900
  FROM failure_confirmations fc
122
901
  JOIN failure_patterns fp ON fp.id = fc.failure_id
123
902
  WHERE (@project IS NULL OR fp.project_id = @project)
124
- `).get(r),S=this.db.prepare(`
903
+ `).get(p);
904
+ const blockedTotal = this.db.prepare(`
125
905
  SELECT COUNT(*) AS n FROM failure_blocks WHERE (@project IS NULL OR project_id = @project)
126
- `).get(r).n,h=this.db.prepare(`
906
+ `).get(p).n;
907
+ // `run_id = NULL` is never true in SQL — a runless caller reads 0, the
908
+ // same always-bound-param shape as the query() run gate.
909
+ const thisRunBlocked = this.db.prepare(`
127
910
  SELECT COUNT(*) AS n FROM failure_blocks
128
911
  WHERE run_id = @run AND (@project IS NULL OR project_id = @project)
129
- `).get({...r,run:n}).n,l=this.db.prepare(`
912
+ `).get({ ...p, run: callerRun }).n;
913
+ const topBlocked = this.db.prepare(`
130
914
  SELECT fb.failure_id, fp.scope, fp.target, COUNT(*) AS blocks
131
915
  FROM failure_blocks fb
132
916
  JOIN failure_patterns fp ON fp.id = fb.failure_id
133
917
  WHERE (@project IS NULL OR fb.project_id = @project)
134
918
  GROUP BY fb.failure_id ORDER BY blocks DESC, fb.failure_id ASC LIMIT 5
135
- `).all(r);return{view:"failures",project_id:t,recorded:{total:o.total,unresolved:o.unresolved,by_quarantine_scope:c,by_scope:s,by_agent:u,by_run:p},confirmations:{total:f.total,distinct_agents:f.distinct_agents},blocked:{total:S,by_agent:d,by_run:R,this_run:{run_id:n??null,blocked:h},top_blocked:l}}}query(e,t,n,r,o){const c=o?.callerRunId!==void 0?o.callerRunId:E().run_id,s=o?.runScope??"any",i={project:r,run:c,tier:s==="any"?null:s},u=g.signature(e,t,n),p=this.db.prepare(`
919
+ `).all(p);
920
+ return {
921
+ view: 'failures',
922
+ project_id: projectId,
923
+ recorded: {
924
+ total: totals.total,
925
+ unresolved: totals.unresolved,
926
+ by_quarantine_scope: byQuarantine,
927
+ by_scope: byScope,
928
+ by_agent: recordedByAgent,
929
+ by_run: recordedByRun,
930
+ },
931
+ confirmations: { total: conf.total, distinct_agents: conf.distinct_agents },
932
+ blocked: {
933
+ total: blockedTotal,
934
+ by_agent: blockedByAgent,
935
+ by_run: blockedByRun,
936
+ this_run: { run_id: callerRun ?? null, blocked: thisRunBlocked },
937
+ top_blocked: topBlocked,
938
+ },
939
+ };
940
+ }
941
+ /** The shared two-stage match query behind check()/checkVerdict(). */
942
+ query(scope, target, description, projectId, opts) {
943
+ // 'run'-tier gate: the caller's run from the ambient envelope unless
944
+ // explicitly supplied. `run_id = NULL` is never true in SQL, so a caller
945
+ // with no run simply never sees run-quarantined rows. The optional tier
946
+ // filter is an always-bound nullable param (@tier IS NULL = 'any') — no
947
+ // SQL assembled from values (hygiene ratchet: zero template interpolation).
948
+ const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
949
+ const runScope = opts?.runScope ?? 'any';
950
+ const params = {
951
+ project: projectId,
952
+ run: callerRun,
953
+ tier: runScope === 'any' ? null : runScope,
954
+ };
955
+ const signature = FailurePatterns.signature(scope, target, description);
956
+ const exact = this.db.prepare(`
136
957
  SELECT *, NULL AS _rank FROM failure_patterns
137
958
  WHERE resolved = 0 AND signature = @sig
138
959
  AND (project_id IS @project OR project_id = @project OR project_id IS NULL)
@@ -140,7 +961,35 @@ ${n}
140
961
  AND (@tier IS NULL OR quarantine_scope = @tier)
141
962
  ORDER BY occurrences DESC, last_seen DESC
142
963
  LIMIT 5
143
- `).all({...i,sig:u});if(p.length>0)return p.map(l=>({...l,_match:"exact",_rank:null}));const d=I(`${t} ${n}`.slice(0,100));if(!d)return[];const R=d.split(" ").filter(l=>/[\p{L}\p{N}]/u.test(l)),f=R.filter(l=>!M.has(l.toLowerCase())),S=f.length?f:R;if(S.length===0)return[];const h=S.map(l=>`"${l.replace(/"/g,"")}"`).join(" ");try{return this.db.prepare(`
964
+ `).all({ ...params, sig: signature });
965
+ if (exact.length > 0)
966
+ return exact.map((r) => ({ ...r, _match: 'exact', _rank: null }));
967
+ // Fuzzy fallback: FTS over description + target, bm25-ranked for the
968
+ // deterministic confidence score (ordering stays the 6.x occurrences/
969
+ // recency order — confidence is reported, not used to reorder).
970
+ //
971
+ // v7 F2 (T016): each term is quoted into a phrase (implicit AND preserved —
972
+ // this stage wants precision, unlike buildFtsMatchQuery's OR). Chars that
973
+ // survive sanitizeFtsQuery but are FTS5 operators in bareword position
974
+ // ('-', '+', '/', …) previously made MATCH throw "syntax error" — silently
975
+ // swallowed into ZERO recall for any probe with a shell flag or path, the
976
+ // guard's main input class. Quoting is operator-safe ('"' itself is already
977
+ // stripped); tokens with no letter/digit would be zero-token phrases that
978
+ // AND-zero the whole query, so they are dropped.
979
+ const q = sanitizeFtsQuery(`${target} ${description}`.slice(0, 100));
980
+ if (!q)
981
+ return [];
982
+ const allTerms = q.split(' ').filter((t) => /[\p{L}\p{N}]/u.test(t));
983
+ // Drop function-word filler so a reworded repeat still AND-matches on its
984
+ // CONTENT words (precision-preserving — see FUZZY_STOPWORDS); fall back to the
985
+ // full term set if filtering would empty the probe.
986
+ const content = allTerms.filter((t) => !FUZZY_STOPWORDS.has(t.toLowerCase()));
987
+ const terms = content.length ? content : allTerms;
988
+ if (terms.length === 0)
989
+ return [];
990
+ const probe = terms.map((t) => `"${t.replace(/"/g, '')}"`).join(' ');
991
+ try {
992
+ const fuzzy = this.db.prepare(`
144
993
  SELECT fp.*, bm25(failure_patterns_fts) AS _rank FROM failure_patterns fp
145
994
  JOIN failure_patterns_fts fts ON fts.rowid = fp.id
146
995
  WHERE failure_patterns_fts MATCH @q
@@ -151,11 +1000,43 @@ ${n}
151
1000
  AND (@tier IS NULL OR fp.quarantine_scope = @tier)
152
1001
  ORDER BY fp.occurrences DESC, fp.last_seen DESC
153
1002
  LIMIT 5
154
- `).all({...i,q:h,scope:e}).map(_=>({..._,_match:"fuzzy"}))}catch{return[]}}list(e,t=20){return e==null?this.db.prepare(`SELECT * FROM failure_patterns WHERE resolved = 0
155
- ORDER BY last_seen DESC LIMIT ?`).all(t):this.db.prepare(`SELECT * FROM failure_patterns
1003
+ `).all({ ...params, q: probe, scope });
1004
+ return fuzzy.map((r) => ({ ...r, _match: 'fuzzy' }));
1005
+ }
1006
+ catch {
1007
+ return [];
1008
+ }
1009
+ }
1010
+ /** List unresolved failures in a project, most-recent-first. */
1011
+ list(projectId, limit = 20) {
1012
+ if (projectId == null) {
1013
+ return this.db.prepare(`SELECT * FROM failure_patterns WHERE resolved = 0
1014
+ ORDER BY last_seen DESC LIMIT ?`).all(limit);
1015
+ }
1016
+ return this.db.prepare(`SELECT * FROM failure_patterns
156
1017
  WHERE resolved = 0 AND project_id = ?
157
- ORDER BY last_seen DESC LIMIT ?`).all(e,t)}resolve(e,t){return this.db.prepare(`
1018
+ ORDER BY last_seen DESC LIMIT ?`).all(projectId, limit);
1019
+ }
1020
+ /** Mark a failure as resolved (the underlying issue was fixed). */
1021
+ resolve(id, note) {
1022
+ this.db.prepare(`
158
1023
  UPDATE failure_patterns
159
1024
  SET resolved = 1, resolution_note = ?
160
1025
  WHERE id = ?
161
- `).run(t??null,e),this.get(e)}delete(e){return this.db.prepare("DELETE FROM failure_patterns WHERE id = ?").run(e).changes>0}}export{g as FailurePatterns,w as matchConfidence,O as normalizeIdentity,L as normalizeSignal,q as renderFailureStats};
1026
+ `).run(note ?? null, id);
1027
+ return this.get(id);
1028
+ }
1029
+ /** Permanently delete a failure (use sparingly — resolve() is preferred). */
1030
+ delete(id) {
1031
+ // Anchors go with their failure (SQLite FKs are off by default, so the
1032
+ // REFERENCES clause alone would orphan them). Best-effort: a
1033
+ // pre-migration-30 DB has no table and the delete must still succeed.
1034
+ try {
1035
+ this.db.prepare('DELETE FROM failure_anchors WHERE failure_id = ?').run(id);
1036
+ }
1037
+ catch { /* pre-migration-30 DB */ }
1038
+ const info = this.db.prepare('DELETE FROM failure_patterns WHERE id = ?').run(id);
1039
+ return info.changes > 0;
1040
+ }
1041
+ }
1042
+ //# sourceMappingURL=failure-patterns.js.map