@wazir-dev/cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +17 -2
  2. package/docs/research/2026-03-20-agents/a18fb002157904af5.txt +187 -0
  3. package/docs/research/2026-03-20-agents/a1d0ac79ac2f11e6f.txt +2 -0
  4. package/docs/research/2026-03-20-agents/a324079de037abd7c.txt +198 -0
  5. package/docs/research/2026-03-20-agents/a357586bccfafb0e5.txt +256 -0
  6. package/docs/research/2026-03-20-agents/a4365394e4d753105.txt +137 -0
  7. package/docs/research/2026-03-20-agents/a492af28bc52d3613.txt +136 -0
  8. package/docs/research/2026-03-20-agents/a4984db0b6a8eee07.txt +124 -0
  9. package/docs/research/2026-03-20-agents/a5b30e59d34bbb062.txt +214 -0
  10. package/docs/research/2026-03-20-agents/a5cf7829dab911586.txt +165 -0
  11. package/docs/research/2026-03-20-agents/a607157c30dd97c9e.txt +96 -0
  12. package/docs/research/2026-03-20-agents/a60b68b1e19d1e16b.txt +115 -0
  13. package/docs/research/2026-03-20-agents/a722af01c5594aba0.txt +166 -0
  14. package/docs/research/2026-03-20-agents/a787bdc516faa5829.txt +181 -0
  15. package/docs/research/2026-03-20-agents/a7c46d1bba1056ed2.txt +132 -0
  16. package/docs/research/2026-03-20-agents/a7e5abbab2b281a0d.txt +100 -0
  17. package/docs/research/2026-03-20-agents/a8dbadc66cd0d7d5a.txt +95 -0
  18. package/docs/research/2026-03-20-agents/a904d9f45d6b86a6d.txt +75 -0
  19. package/docs/research/2026-03-20-agents/a927659a942ee7f60.txt +102 -0
  20. package/docs/research/2026-03-20-agents/a962cb569191f7583.txt +125 -0
  21. package/docs/research/2026-03-20-agents/aab6decea538aac41.txt +148 -0
  22. package/docs/research/2026-03-20-agents/abd58b853dd938a1b.txt +295 -0
  23. package/docs/research/2026-03-20-agents/ac009da573eff7f65.txt +100 -0
  24. package/docs/research/2026-03-20-agents/ac1bc783364405e5f.txt +190 -0
  25. package/docs/research/2026-03-20-agents/aca5e2b57fde152a0.txt +132 -0
  26. package/docs/research/2026-03-20-agents/ad849b8c0a7e95b8b.txt +176 -0
  27. package/docs/research/2026-03-20-agents/adc2b12a4da32c962.txt +258 -0
  28. package/docs/research/2026-03-20-agents/af97caaaa9a80e4cb.txt +146 -0
  29. package/docs/research/2026-03-20-agents/afc5faceee368b3ca.txt +111 -0
  30. package/docs/research/2026-03-20-agents/afdb282d866e3c1e4.txt +164 -0
  31. package/docs/research/2026-03-20-agents/afe9d1f61c02b1e8d.txt +299 -0
  32. package/docs/research/2026-03-20-agents/b4hmkwril.txt +1856 -0
  33. package/docs/research/2026-03-20-agents/b80ptk89g.txt +1856 -0
  34. package/docs/research/2026-03-20-agents/bf54s1jss.txt +1150 -0
  35. package/docs/research/2026-03-20-agents/bhd6kq2kx.txt +1856 -0
  36. package/docs/research/2026-03-20-agents/bmb2fodyr.txt +988 -0
  37. package/docs/research/2026-03-20-agents/bmmsrij8i.txt +826 -0
  38. package/docs/research/2026-03-20-agents/bn4t2ywpu.txt +2175 -0
  39. package/docs/research/2026-03-20-agents/bu22t9f1z.txt +0 -0
  40. package/docs/research/2026-03-20-agents/bwvl98v2p.txt +738 -0
  41. package/docs/research/2026-03-20-agents/psych-a3697a7fd06eb64fd.txt +135 -0
  42. package/docs/research/2026-03-20-agents/psych-a37776fabc870feae.txt +123 -0
  43. package/docs/research/2026-03-20-agents/psych-a5b1fe05c0589efaf.txt +2 -0
  44. package/docs/research/2026-03-20-agents/psych-a95c15b1f29424435.txt +76 -0
  45. package/docs/research/2026-03-20-agents/psych-a9c26f4d9172dde7c.txt +2 -0
  46. package/docs/research/2026-03-20-agents/psych-aa19c69f0ca2c5ad3.txt +2 -0
  47. package/docs/research/2026-03-20-agents/psych-aa4e4cb70e1be5ecb.txt +95 -0
  48. package/docs/research/2026-03-20-agents/psych-ab5b302f26a554663.txt +102 -0
  49. package/docs/research/2026-03-20-deep-research-complete.md +101 -0
  50. package/docs/research/2026-03-20-deep-research-status.md +38 -0
  51. package/docs/research/2026-03-20-enforcement-research.md +107 -0
  52. package/expertise/composition-map.yaml +27 -8
  53. package/expertise/digests/reviewer/ai-coding-digest.md +83 -0
  54. package/expertise/digests/reviewer/architectural-thinking-digest.md +63 -0
  55. package/expertise/digests/reviewer/architecture-antipatterns-digest.md +49 -0
  56. package/expertise/digests/reviewer/code-smells-digest.md +53 -0
  57. package/expertise/digests/reviewer/coupling-cohesion-digest.md +54 -0
  58. package/expertise/digests/reviewer/ddd-digest.md +60 -0
  59. package/expertise/digests/reviewer/dependency-risk-digest.md +40 -0
  60. package/expertise/digests/reviewer/error-handling-digest.md +55 -0
  61. package/expertise/digests/reviewer/review-methodology-digest.md +49 -0
  62. package/exports/hosts/claude/.claude/commands/learn.md +61 -8
  63. package/exports/hosts/claude/.claude/settings.json +7 -6
  64. package/exports/hosts/claude/export.manifest.json +6 -3
  65. package/exports/hosts/claude/host-package.json +3 -0
  66. package/exports/hosts/codex/export.manifest.json +6 -3
  67. package/exports/hosts/codex/host-package.json +3 -0
  68. package/exports/hosts/cursor/.cursor/hooks.json +6 -6
  69. package/exports/hosts/cursor/export.manifest.json +6 -3
  70. package/exports/hosts/cursor/host-package.json +3 -0
  71. package/exports/hosts/gemini/export.manifest.json +6 -3
  72. package/exports/hosts/gemini/host-package.json +3 -0
  73. package/hooks/definitions/pretooluse_dispatcher.yaml +26 -0
  74. package/hooks/definitions/pretooluse_pipeline_guard.yaml +22 -0
  75. package/hooks/definitions/stop_pipeline_gate.yaml +22 -0
  76. package/hooks/hooks.json +7 -6
  77. package/hooks/pretooluse-dispatcher +84 -0
  78. package/hooks/pretooluse-pipeline-guard +9 -0
  79. package/hooks/stop-pipeline-gate +9 -0
  80. package/package.json +2 -2
  81. package/schemas/decision.schema.json +15 -0
  82. package/schemas/hook.schema.json +4 -1
  83. package/skills/TEMPLATE-3-ZONE.md +160 -0
  84. package/skills/brainstorming/SKILL.md +127 -23
  85. package/skills/clarifier/SKILL.md +175 -18
  86. package/skills/claude-cli/SKILL.md +91 -12
  87. package/skills/codex-cli/SKILL.md +91 -12
  88. package/skills/debugging/SKILL.md +133 -38
  89. package/skills/design/SKILL.md +173 -37
  90. package/skills/dispatching-parallel-agents/SKILL.md +129 -31
  91. package/skills/executing-plans/SKILL.md +113 -25
  92. package/skills/executor/SKILL.md +185 -21
  93. package/skills/finishing-a-development-branch/SKILL.md +107 -18
  94. package/skills/gemini-cli/SKILL.md +91 -12
  95. package/skills/humanize/SKILL.md +92 -13
  96. package/skills/init-pipeline/SKILL.md +90 -17
  97. package/skills/prepare-next/SKILL.md +93 -24
  98. package/skills/receiving-code-review/SKILL.md +90 -16
  99. package/skills/requesting-code-review/SKILL.md +100 -24
  100. package/skills/requesting-code-review/code-reviewer.md +29 -17
  101. package/skills/reviewer/SKILL.md +190 -50
  102. package/skills/run-audit/SKILL.md +92 -15
  103. package/skills/scan-project/SKILL.md +93 -14
  104. package/skills/self-audit/SKILL.md +113 -39
  105. package/skills/skill-research/SKILL.md +94 -7
  106. package/skills/subagent-driven-development/SKILL.md +129 -30
  107. package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +30 -2
  108. package/skills/subagent-driven-development/implementer-prompt.md +40 -27
  109. package/skills/subagent-driven-development/spec-reviewer-prompt.md +25 -12
  110. package/skills/tdd/SKILL.md +125 -20
  111. package/skills/using-git-worktrees/SKILL.md +118 -28
  112. package/skills/using-skills/SKILL.md +116 -29
  113. package/skills/verification/SKILL.md +127 -22
  114. package/skills/wazir/SKILL.md +517 -153
  115. package/skills/writing-plans/SKILL.md +134 -28
  116. package/skills/writing-skills/SKILL.md +91 -13
  117. package/skills/writing-skills/anthropic-best-practices.md +104 -64
  118. package/skills/writing-skills/persuasion-principles.md +100 -34
  119. package/tooling/src/capture/command.js +29 -1
  120. package/tooling/src/capture/decision.js +40 -0
  121. package/tooling/src/capture/store.js +1 -0
  122. package/tooling/src/config/depth-table.js +60 -0
  123. package/tooling/src/export/compiler.js +7 -8
  124. package/tooling/src/guards/guardrail-functions.js +131 -0
  125. package/tooling/src/guards/phase-prerequisite-guard.js +39 -3
  126. package/tooling/src/hooks/pretooluse-dispatcher.js +300 -0
  127. package/tooling/src/hooks/pretooluse-pipeline-guard.js +141 -0
  128. package/tooling/src/hooks/stop-pipeline-gate.js +92 -0
  129. package/tooling/src/learn/pipeline.js +177 -0
  130. package/tooling/src/state/db.js +251 -2
  131. package/tooling/src/state/pipeline-state.js +262 -0
  132. package/wazir.manifest.yaml +3 -0
  133. package/workflows/learn.md +61 -8
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Learning Pipeline — Findings-to-Antipattern Promotion
3
+ *
4
+ * 4-stage pipeline: TALLY → CANDIDATE → PROMOTE → ACTIVE
5
+ *
6
+ * Stage 1 (TALLY): Automatic. Every finding is hashed, categorized, and
7
+ * clustered by canonical pattern. Happens at finding insertion time.
8
+ *
9
+ * Stage 2 (CANDIDATE): Automatic. When a cluster reaches the promotion
10
+ * threshold (3+ occurrences across 2+ runs), it becomes a candidate.
11
+ *
12
+ * Stage 3 (PROMOTE): Human gate. Candidates are proposed for review.
13
+ * User accepts or rejects. Accepted candidates become active antipatterns.
14
+ *
15
+ * Stage 4 (ACTIVE): Automatic. Active antipatterns are loaded into
16
+ * reviewer context for future runs. Hit-rate tracking enables demotion.
17
+ *
18
+ * Drift prevention (from research):
19
+ * - Max 30 active project-level antipatterns
20
+ * - 90-day TTL on candidates (auto-expire if not reviewed)
21
+ * - 5% hit-rate demotion threshold (antipatterns that never trigger get demoted)
22
+ * - Principle consolidation when count exceeds 25
23
+ */
24
+
25
+ import crypto from 'node:crypto';
26
+ import {
27
+ upsertFindingCluster,
28
+ getClustersReadyForPromotion,
29
+ promoteClusterToCandidate,
30
+ insertAntipatternCandidate,
31
+ getActiveLearningsCount,
32
+ expireStaleAntipatternCandidates,
33
+ } from '../state/db.js';
34
+
35
+ const MAX_ACTIVE_ANTIPATTERNS = 30;
36
+ const PROMOTION_THRESHOLD_OCCURRENCES = 3;
37
+ const PROMOTION_THRESHOLD_RUNS = 2;
38
+
39
+ /**
40
+ * Normalize a finding description to a canonical form for clustering.
41
+ * Strips file paths, line numbers, variable names, and normalizes whitespace.
42
+ */
43
+ export function canonicalizeFinding(description) {
44
+ return description
45
+ // Remove file paths
46
+ .replace(/[a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,4}(:\d+)?/g, '<FILE>')
47
+ // Remove line numbers
48
+ .replace(/line \d+/gi, 'line <N>')
49
+ // Remove quoted identifiers
50
+ .replace(/['"`][\w.]+['"`]/g, '<ID>')
51
+ // Remove hex hashes
52
+ .replace(/[0-9a-f]{7,40}/gi, '<HASH>')
53
+ // Normalize whitespace
54
+ .replace(/\s+/g, ' ')
55
+ .trim()
56
+ .toLowerCase();
57
+ }
58
+
59
+ /**
60
+ * Hash a canonicalized finding for dedup and clustering.
61
+ */
62
+ export function hashCanonical(canonicalized) {
63
+ return crypto.createHash('sha256').update(canonicalized).digest('hex').slice(0, 16);
64
+ }
65
+
66
+ /**
67
+ * Stage 1: TALLY — Process a finding and cluster it.
68
+ * Called after each finding is inserted into the findings table.
69
+ *
70
+ * @param {object} db - open state database
71
+ * @param {object} finding - { description, category, finding_hash, run_id }
72
+ * @returns {string} cluster ID
73
+ */
74
+ export function tallyFinding(db, finding) {
75
+ const canonical = canonicalizeFinding(finding.description);
76
+ const canonicalHash = hashCanonical(canonical);
77
+
78
+ return upsertFindingCluster(db, {
79
+ canonical_hash: canonicalHash,
80
+ category: finding.category || '',
81
+ pattern_description: canonical,
82
+ finding_hash: finding.finding_hash,
83
+ run_id: finding.run_id,
84
+ evidence_runs: JSON.stringify([finding.run_id]),
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Stage 2: CANDIDATE — Check clusters that meet the promotion threshold.
90
+ * Returns clusters ready for promotion.
91
+ *
92
+ * @param {object} db - open state database
93
+ * @returns {Array} clusters ready for promotion
94
+ */
95
+ export function identifyCandidates(db) {
96
+ return getClustersReadyForPromotion(
97
+ db,
98
+ PROMOTION_THRESHOLD_OCCURRENCES,
99
+ PROMOTION_THRESHOLD_RUNS,
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Stage 2→3: Promote eligible clusters to candidates and generate
105
+ * antipattern proposals.
106
+ *
107
+ * @param {object} db - open state database
108
+ * @param {Array} clusters - from identifyCandidates()
109
+ * @returns {Array} created candidate IDs
110
+ */
111
+ export function promoteToCandidates(db, clusters) {
112
+ const activeCount = getActiveLearningsCount(db);
113
+ if (activeCount >= MAX_ACTIVE_ANTIPATTERNS) {
114
+ return []; // Drift prevention: don't propose more if at cap
115
+ }
116
+
117
+ const candidateIds = [];
118
+
119
+ for (const cluster of clusters) {
120
+ promoteClusterToCandidate(db, cluster.id);
121
+
122
+ const runIds = JSON.parse(cluster.run_ids || '[]');
123
+ const candidateId = insertAntipatternCandidate(db, {
124
+ cluster_id: cluster.id,
125
+ title: `Recurring: ${cluster.category || 'uncategorized'}`,
126
+ description: cluster.pattern_description,
127
+ detection_signal: `Pattern occurred ${cluster.occurrence_count} times across ${cluster.distinct_runs} runs`,
128
+ severity: cluster.occurrence_count >= 5 ? 'high' : 'medium',
129
+ evidence_runs: runIds,
130
+ evidence_count: cluster.occurrence_count,
131
+ });
132
+
133
+ candidateIds.push(candidateId);
134
+ }
135
+
136
+ return candidateIds;
137
+ }
138
+
139
+ /**
140
+ * Run the full pipeline pass: tally → identify → promote → expire stale.
141
+ * Called by the learn workflow after a run completes.
142
+ *
143
+ * @param {object} db - open state database
144
+ * @param {string} runId - current run ID
145
+ * @param {Array} findings - array of { description, category, severity, source }
146
+ * @returns {object} pipeline results
147
+ */
148
+ export function runLearningPipeline(db, runId, findings) {
149
+ // Stage 1: Tally all findings
150
+ const clusterIds = [];
151
+ for (const finding of findings) {
152
+ const hash = crypto.createHash('sha256').update(finding.description).digest('hex');
153
+ const clusterId = tallyFinding(db, {
154
+ description: finding.description,
155
+ category: finding.category || '',
156
+ finding_hash: hash,
157
+ run_id: runId,
158
+ });
159
+ clusterIds.push(clusterId);
160
+ }
161
+
162
+ // Stage 2: Identify clusters ready for promotion
163
+ const readyClusters = identifyCandidates(db);
164
+
165
+ // Stage 2→3: Promote to candidates
166
+ const newCandidateIds = promoteToCandidates(db, readyClusters);
167
+
168
+ // Housekeeping: expire stale candidates
169
+ expireStaleAntipatternCandidates(db);
170
+
171
+ return {
172
+ findings_tallied: findings.length,
173
+ clusters_touched: new Set(clusterIds).size,
174
+ new_candidates: newCandidateIds.length,
175
+ candidate_ids: newCandidateIds,
176
+ };
177
+ }
@@ -11,6 +11,21 @@ function hashDescription(description) {
11
11
  return crypto.createHash('sha256').update(description).digest('hex');
12
12
  }
13
13
 
14
+ /**
15
+ * Normalize a finding description for clustering.
16
+ * Strips file paths, line numbers, identifiers to produce a canonical pattern.
17
+ */
18
+ function canonicalizeFindingText(description) {
19
+ return description
20
+ .replace(/[a-zA-Z0-9_\-./]+\.[a-zA-Z]{1,4}(:\d+)?/g, '<FILE>')
21
+ .replace(/line \d+/gi, 'line <N>')
22
+ .replace(/['"`][\w.]+['"`]/g, '<ID>')
23
+ .replace(/[0-9a-f]{7,40}/gi, '<HASH>')
24
+ .replace(/\s+/g, ' ')
25
+ .trim()
26
+ .toLowerCase();
27
+ }
28
+
14
29
  function ensureStateSchema(db) {
15
30
  db.exec(`
16
31
  CREATE TABLE IF NOT EXISTS learnings (
@@ -67,7 +82,54 @@ function ensureStateSchema(db) {
67
82
  CREATE INDEX IF NOT EXISTS idx_findings_finding_hash ON findings(finding_hash);
68
83
  CREATE INDEX IF NOT EXISTS idx_audit_history_run_id ON audit_history(run_id);
69
84
  CREATE INDEX IF NOT EXISTS idx_usage_aggregate_run_id ON usage_aggregate(run_id);
85
+
86
+ CREATE TABLE IF NOT EXISTS finding_clusters (
87
+ id TEXT PRIMARY KEY,
88
+ canonical_hash TEXT NOT NULL,
89
+ category TEXT NOT NULL,
90
+ pattern_description TEXT NOT NULL,
91
+ finding_hashes TEXT NOT NULL DEFAULT '[]',
92
+ run_ids TEXT NOT NULL DEFAULT '[]',
93
+ occurrence_count INTEGER DEFAULT 1,
94
+ distinct_runs INTEGER DEFAULT 1,
95
+ first_seen TEXT NOT NULL DEFAULT (datetime('now')),
96
+ last_seen TEXT NOT NULL DEFAULT (datetime('now')),
97
+ status TEXT NOT NULL DEFAULT 'tally' CHECK(status IN ('tally','candidate','promoted','active','demoted')),
98
+ promoted_at TEXT,
99
+ antipattern_id TEXT
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS antipattern_candidates (
103
+ id TEXT PRIMARY KEY,
104
+ cluster_id TEXT NOT NULL REFERENCES finding_clusters(id),
105
+ title TEXT NOT NULL,
106
+ description TEXT NOT NULL,
107
+ detection_signal TEXT NOT NULL,
108
+ severity TEXT NOT NULL CHECK(severity IN ('critical','high','medium','low')),
109
+ scope_roles TEXT DEFAULT 'reviewer',
110
+ scope_stacks TEXT DEFAULT 'all',
111
+ evidence_runs TEXT NOT NULL DEFAULT '[]',
112
+ evidence_count INTEGER DEFAULT 0,
113
+ status TEXT NOT NULL DEFAULT 'proposed' CHECK(status IN ('proposed','accepted','rejected','expired')),
114
+ proposed_at TEXT NOT NULL DEFAULT (datetime('now')),
115
+ reviewed_at TEXT,
116
+ expires_at TEXT
117
+ );
118
+
119
+ CREATE INDEX IF NOT EXISTS idx_finding_clusters_status ON finding_clusters(status);
120
+ CREATE INDEX IF NOT EXISTS idx_finding_clusters_canonical_hash ON finding_clusters(canonical_hash);
121
+ CREATE INDEX IF NOT EXISTS idx_antipattern_candidates_status ON antipattern_candidates(status);
70
122
  `);
123
+
124
+ // Safe migration: add category column to findings if it doesn't exist
125
+ try {
126
+ db.exec(`ALTER TABLE findings ADD COLUMN category TEXT DEFAULT ''`);
127
+ } catch (_) {
128
+ // Column already exists — ignore
129
+ }
130
+
131
+ // Index on findings.category (must run after migration adds the column)
132
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_findings_category ON findings(category)`);
71
133
  }
72
134
 
73
135
  // ---------------------------------------------------------------------------
@@ -171,10 +233,11 @@ export function insertFinding(db, record) {
171
233
  const id = crypto.randomUUID();
172
234
  const findingHash = record.finding_hash ?? hashDescription(record.description);
173
235
  const createdAt = new Date().toISOString();
236
+ const category = record.category || '';
174
237
 
175
238
  db.prepare(`
176
- INSERT INTO findings (id, run_id, phase, source, severity, description, finding_hash, created_at)
177
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
239
+ INSERT INTO findings (id, run_id, phase, source, severity, description, finding_hash, category, created_at)
240
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
178
241
  `).run(
179
242
  id,
180
243
  record.run_id,
@@ -183,9 +246,19 @@ export function insertFinding(db, record) {
183
246
  record.severity,
184
247
  record.description,
185
248
  findingHash,
249
+ category,
186
250
  createdAt,
187
251
  );
188
252
 
253
+ // Auto-tally: cluster the finding for the learning pipeline
254
+ upsertFindingCluster(db, {
255
+ canonical_hash: hashDescription(canonicalizeFindingText(record.description)),
256
+ category,
257
+ pattern_description: canonicalizeFindingText(record.description),
258
+ finding_hash: findingHash,
259
+ run_id: record.run_id,
260
+ });
261
+
189
262
  return id;
190
263
  }
191
264
 
@@ -273,6 +346,179 @@ export function getUsageSummary(db) {
273
346
  return row;
274
347
  }
275
348
 
349
+ // ---------------------------------------------------------------------------
350
+ // Finding Clusters (Learning Pipeline)
351
+ // ---------------------------------------------------------------------------
352
+
353
+ export function upsertFindingCluster(db, record) {
354
+ const existing = db.prepare(`
355
+ SELECT * FROM finding_clusters WHERE canonical_hash = ?
356
+ `).get(record.canonical_hash);
357
+
358
+ if (existing) {
359
+ const hashes = JSON.parse(existing.finding_hashes);
360
+ if (!hashes.includes(record.finding_hash)) {
361
+ hashes.push(record.finding_hash);
362
+ }
363
+ // Track distinct runs from the DB row, not the incoming record
364
+ const existingRuns = new Set(JSON.parse(existing.run_ids || '[]'));
365
+ if (record.run_id) existingRuns.add(record.run_id);
366
+
367
+ db.prepare(`
368
+ UPDATE finding_clusters
369
+ SET finding_hashes = ?,
370
+ run_ids = ?,
371
+ occurrence_count = occurrence_count + 1,
372
+ distinct_runs = ?,
373
+ last_seen = datetime('now'),
374
+ category = COALESCE(NULLIF(?, ''), category)
375
+ WHERE id = ?
376
+ `).run(
377
+ JSON.stringify(hashes),
378
+ JSON.stringify([...existingRuns]),
379
+ existingRuns.size,
380
+ record.category || '',
381
+ existing.id,
382
+ );
383
+
384
+ return existing.id;
385
+ }
386
+
387
+ const id = crypto.randomUUID();
388
+ db.prepare(`
389
+ INSERT INTO finding_clusters (id, canonical_hash, category, pattern_description, finding_hashes, run_ids, occurrence_count, distinct_runs)
390
+ VALUES (?, ?, ?, ?, ?, ?, 1, 1)
391
+ `).run(
392
+ id,
393
+ record.canonical_hash,
394
+ record.category || 'uncategorized',
395
+ record.pattern_description,
396
+ JSON.stringify([record.finding_hash]),
397
+ JSON.stringify(record.run_id ? [record.run_id] : []),
398
+ );
399
+
400
+ return id;
401
+ }
402
+
403
+ export function getClustersByStatus(db, status) {
404
+ return db.prepare(`
405
+ SELECT * FROM finding_clusters
406
+ WHERE status = ?
407
+ ORDER BY occurrence_count DESC
408
+ `).all(status);
409
+ }
410
+
411
+ export function getClustersReadyForPromotion(db, minOccurrences = 3, minRuns = 2) {
412
+ return db.prepare(`
413
+ SELECT * FROM finding_clusters
414
+ WHERE status = 'tally'
415
+ AND occurrence_count >= ?
416
+ AND distinct_runs >= ?
417
+ ORDER BY occurrence_count DESC
418
+ `).all(minOccurrences, minRuns);
419
+ }
420
+
421
+ export function promoteClusterToCandidate(db, clusterId) {
422
+ db.prepare(`
423
+ UPDATE finding_clusters
424
+ SET status = 'candidate',
425
+ promoted_at = datetime('now')
426
+ WHERE id = ?
427
+ `).run(clusterId);
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Antipattern Candidates (Learning Pipeline)
432
+ // ---------------------------------------------------------------------------
433
+
434
+ export function insertAntipatternCandidate(db, record) {
435
+ const id = crypto.randomUUID();
436
+ const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); // 90-day TTL
437
+
438
+ db.prepare(`
439
+ INSERT INTO antipattern_candidates (id, cluster_id, title, description, detection_signal, severity, scope_roles, scope_stacks, evidence_runs, evidence_count, expires_at)
440
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
441
+ `).run(
442
+ id,
443
+ record.cluster_id,
444
+ record.title,
445
+ record.description,
446
+ record.detection_signal,
447
+ record.severity,
448
+ record.scope_roles || 'reviewer',
449
+ record.scope_stacks || 'all',
450
+ JSON.stringify(Array.isArray(record.evidence_runs) ? record.evidence_runs : []),
451
+ record.evidence_count || 0,
452
+ expiresAt,
453
+ );
454
+
455
+ return id;
456
+ }
457
+
458
+ export function getAntipatternCandidatesByStatus(db, status) {
459
+ return db.prepare(`
460
+ SELECT * FROM antipattern_candidates
461
+ WHERE status = ?
462
+ ORDER BY proposed_at DESC
463
+ `).all(status);
464
+ }
465
+
466
+ export function acceptAntipatternCandidate(db, candidateId) {
467
+ const now = new Date().toISOString();
468
+ db.prepare(`
469
+ UPDATE antipattern_candidates
470
+ SET status = 'accepted',
471
+ reviewed_at = ?
472
+ WHERE id = ?
473
+ `).run(now, candidateId);
474
+ }
475
+
476
+ export function rejectAntipatternCandidate(db, candidateId) {
477
+ const now = new Date().toISOString();
478
+ // Get the cluster_id before updating so we can reset the cluster
479
+ const candidate = db.prepare(`SELECT cluster_id FROM antipattern_candidates WHERE id = ?`).get(candidateId);
480
+ db.prepare(`
481
+ UPDATE antipattern_candidates
482
+ SET status = 'rejected',
483
+ reviewed_at = ?
484
+ WHERE id = ?
485
+ `).run(now, candidateId);
486
+ // Reset cluster back to 'tally' so the pattern can be re-proposed if it keeps recurring
487
+ if (candidate) {
488
+ db.prepare(`UPDATE finding_clusters SET status = 'tally' WHERE id = ?`).run(candidate.cluster_id);
489
+ }
490
+ }
491
+
492
+ export function expireStaleAntipatternCandidates(db) {
493
+ const now = new Date().toISOString();
494
+ // Get cluster IDs for candidates about to expire so we can reset them
495
+ const expiring = db.prepare(`
496
+ SELECT cluster_id FROM antipattern_candidates
497
+ WHERE status = 'proposed' AND expires_at < ?
498
+ `).all(now);
499
+
500
+ const result = db.prepare(`
501
+ UPDATE antipattern_candidates
502
+ SET status = 'expired'
503
+ WHERE status = 'proposed'
504
+ AND expires_at < ?
505
+ `).run(now);
506
+
507
+ // Reset clusters back to 'tally' so patterns can be re-proposed
508
+ for (const { cluster_id } of expiring) {
509
+ db.prepare(`UPDATE finding_clusters SET status = 'tally' WHERE id = ?`).run(cluster_id);
510
+ }
511
+
512
+ return result;
513
+ }
514
+
515
+ export function getActiveLearningsCount(db) {
516
+ return db.prepare(`
517
+ SELECT COUNT(*) AS count FROM antipattern_candidates
518
+ WHERE status = 'accepted'
519
+ `).get().count;
520
+ }
521
+
276
522
  // ---------------------------------------------------------------------------
277
523
  // Stats (for CLI)
278
524
  // ---------------------------------------------------------------------------
@@ -283,5 +529,8 @@ export function getStateCounts(db) {
283
529
  finding_count: db.prepare('SELECT COUNT(*) AS count FROM findings').get().count,
284
530
  audit_count: db.prepare('SELECT COUNT(*) AS count FROM audit_history').get().count,
285
531
  usage_count: db.prepare('SELECT COUNT(*) AS count FROM usage_aggregate').get().count,
532
+ cluster_count: db.prepare('SELECT COUNT(*) AS count FROM finding_clusters').get().count,
533
+ candidate_count: db.prepare('SELECT COUNT(*) AS count FROM antipattern_candidates WHERE status = ?').get('proposed').count,
534
+ active_antipattern_count: db.prepare('SELECT COUNT(*) AS count FROM antipattern_candidates WHERE status = ?').get('accepted').count,
286
535
  };
287
536
  }