@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.
- package/CHANGELOG.md +17 -2
- package/docs/research/2026-03-20-agents/a18fb002157904af5.txt +187 -0
- package/docs/research/2026-03-20-agents/a1d0ac79ac2f11e6f.txt +2 -0
- package/docs/research/2026-03-20-agents/a324079de037abd7c.txt +198 -0
- package/docs/research/2026-03-20-agents/a357586bccfafb0e5.txt +256 -0
- package/docs/research/2026-03-20-agents/a4365394e4d753105.txt +137 -0
- package/docs/research/2026-03-20-agents/a492af28bc52d3613.txt +136 -0
- package/docs/research/2026-03-20-agents/a4984db0b6a8eee07.txt +124 -0
- package/docs/research/2026-03-20-agents/a5b30e59d34bbb062.txt +214 -0
- package/docs/research/2026-03-20-agents/a5cf7829dab911586.txt +165 -0
- package/docs/research/2026-03-20-agents/a607157c30dd97c9e.txt +96 -0
- package/docs/research/2026-03-20-agents/a60b68b1e19d1e16b.txt +115 -0
- package/docs/research/2026-03-20-agents/a722af01c5594aba0.txt +166 -0
- package/docs/research/2026-03-20-agents/a787bdc516faa5829.txt +181 -0
- package/docs/research/2026-03-20-agents/a7c46d1bba1056ed2.txt +132 -0
- package/docs/research/2026-03-20-agents/a7e5abbab2b281a0d.txt +100 -0
- package/docs/research/2026-03-20-agents/a8dbadc66cd0d7d5a.txt +95 -0
- package/docs/research/2026-03-20-agents/a904d9f45d6b86a6d.txt +75 -0
- package/docs/research/2026-03-20-agents/a927659a942ee7f60.txt +102 -0
- package/docs/research/2026-03-20-agents/a962cb569191f7583.txt +125 -0
- package/docs/research/2026-03-20-agents/aab6decea538aac41.txt +148 -0
- package/docs/research/2026-03-20-agents/abd58b853dd938a1b.txt +295 -0
- package/docs/research/2026-03-20-agents/ac009da573eff7f65.txt +100 -0
- package/docs/research/2026-03-20-agents/ac1bc783364405e5f.txt +190 -0
- package/docs/research/2026-03-20-agents/aca5e2b57fde152a0.txt +132 -0
- package/docs/research/2026-03-20-agents/ad849b8c0a7e95b8b.txt +176 -0
- package/docs/research/2026-03-20-agents/adc2b12a4da32c962.txt +258 -0
- package/docs/research/2026-03-20-agents/af97caaaa9a80e4cb.txt +146 -0
- package/docs/research/2026-03-20-agents/afc5faceee368b3ca.txt +111 -0
- package/docs/research/2026-03-20-agents/afdb282d866e3c1e4.txt +164 -0
- package/docs/research/2026-03-20-agents/afe9d1f61c02b1e8d.txt +299 -0
- package/docs/research/2026-03-20-agents/b4hmkwril.txt +1856 -0
- package/docs/research/2026-03-20-agents/b80ptk89g.txt +1856 -0
- package/docs/research/2026-03-20-agents/bf54s1jss.txt +1150 -0
- package/docs/research/2026-03-20-agents/bhd6kq2kx.txt +1856 -0
- package/docs/research/2026-03-20-agents/bmb2fodyr.txt +988 -0
- package/docs/research/2026-03-20-agents/bmmsrij8i.txt +826 -0
- package/docs/research/2026-03-20-agents/bn4t2ywpu.txt +2175 -0
- package/docs/research/2026-03-20-agents/bu22t9f1z.txt +0 -0
- package/docs/research/2026-03-20-agents/bwvl98v2p.txt +738 -0
- package/docs/research/2026-03-20-agents/psych-a3697a7fd06eb64fd.txt +135 -0
- package/docs/research/2026-03-20-agents/psych-a37776fabc870feae.txt +123 -0
- package/docs/research/2026-03-20-agents/psych-a5b1fe05c0589efaf.txt +2 -0
- package/docs/research/2026-03-20-agents/psych-a95c15b1f29424435.txt +76 -0
- package/docs/research/2026-03-20-agents/psych-a9c26f4d9172dde7c.txt +2 -0
- package/docs/research/2026-03-20-agents/psych-aa19c69f0ca2c5ad3.txt +2 -0
- package/docs/research/2026-03-20-agents/psych-aa4e4cb70e1be5ecb.txt +95 -0
- package/docs/research/2026-03-20-agents/psych-ab5b302f26a554663.txt +102 -0
- package/docs/research/2026-03-20-deep-research-complete.md +101 -0
- package/docs/research/2026-03-20-deep-research-status.md +38 -0
- package/docs/research/2026-03-20-enforcement-research.md +107 -0
- package/expertise/composition-map.yaml +27 -8
- package/expertise/digests/reviewer/ai-coding-digest.md +83 -0
- package/expertise/digests/reviewer/architectural-thinking-digest.md +63 -0
- package/expertise/digests/reviewer/architecture-antipatterns-digest.md +49 -0
- package/expertise/digests/reviewer/code-smells-digest.md +53 -0
- package/expertise/digests/reviewer/coupling-cohesion-digest.md +54 -0
- package/expertise/digests/reviewer/ddd-digest.md +60 -0
- package/expertise/digests/reviewer/dependency-risk-digest.md +40 -0
- package/expertise/digests/reviewer/error-handling-digest.md +55 -0
- package/expertise/digests/reviewer/review-methodology-digest.md +49 -0
- package/exports/hosts/claude/.claude/commands/learn.md +61 -8
- package/exports/hosts/claude/.claude/settings.json +7 -6
- package/exports/hosts/claude/export.manifest.json +6 -3
- package/exports/hosts/claude/host-package.json +3 -0
- package/exports/hosts/codex/export.manifest.json +6 -3
- package/exports/hosts/codex/host-package.json +3 -0
- package/exports/hosts/cursor/.cursor/hooks.json +6 -6
- package/exports/hosts/cursor/export.manifest.json +6 -3
- package/exports/hosts/cursor/host-package.json +3 -0
- package/exports/hosts/gemini/export.manifest.json +6 -3
- package/exports/hosts/gemini/host-package.json +3 -0
- package/hooks/definitions/pretooluse_dispatcher.yaml +26 -0
- package/hooks/definitions/pretooluse_pipeline_guard.yaml +22 -0
- package/hooks/definitions/stop_pipeline_gate.yaml +22 -0
- package/hooks/hooks.json +7 -6
- package/hooks/pretooluse-dispatcher +84 -0
- package/hooks/pretooluse-pipeline-guard +9 -0
- package/hooks/stop-pipeline-gate +9 -0
- package/package.json +2 -2
- package/schemas/decision.schema.json +15 -0
- package/schemas/hook.schema.json +4 -1
- package/skills/TEMPLATE-3-ZONE.md +160 -0
- package/skills/brainstorming/SKILL.md +127 -23
- package/skills/clarifier/SKILL.md +175 -18
- package/skills/claude-cli/SKILL.md +91 -12
- package/skills/codex-cli/SKILL.md +91 -12
- package/skills/debugging/SKILL.md +133 -38
- package/skills/design/SKILL.md +173 -37
- package/skills/dispatching-parallel-agents/SKILL.md +129 -31
- package/skills/executing-plans/SKILL.md +113 -25
- package/skills/executor/SKILL.md +185 -21
- package/skills/finishing-a-development-branch/SKILL.md +107 -18
- package/skills/gemini-cli/SKILL.md +91 -12
- package/skills/humanize/SKILL.md +92 -13
- package/skills/init-pipeline/SKILL.md +90 -17
- package/skills/prepare-next/SKILL.md +93 -24
- package/skills/receiving-code-review/SKILL.md +90 -16
- package/skills/requesting-code-review/SKILL.md +100 -24
- package/skills/requesting-code-review/code-reviewer.md +29 -17
- package/skills/reviewer/SKILL.md +190 -50
- package/skills/run-audit/SKILL.md +92 -15
- package/skills/scan-project/SKILL.md +93 -14
- package/skills/self-audit/SKILL.md +113 -39
- package/skills/skill-research/SKILL.md +94 -7
- package/skills/subagent-driven-development/SKILL.md +129 -30
- package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +30 -2
- package/skills/subagent-driven-development/implementer-prompt.md +40 -27
- package/skills/subagent-driven-development/spec-reviewer-prompt.md +25 -12
- package/skills/tdd/SKILL.md +125 -20
- package/skills/using-git-worktrees/SKILL.md +118 -28
- package/skills/using-skills/SKILL.md +116 -29
- package/skills/verification/SKILL.md +127 -22
- package/skills/wazir/SKILL.md +517 -153
- package/skills/writing-plans/SKILL.md +134 -28
- package/skills/writing-skills/SKILL.md +91 -13
- package/skills/writing-skills/anthropic-best-practices.md +104 -64
- package/skills/writing-skills/persuasion-principles.md +100 -34
- package/tooling/src/capture/command.js +29 -1
- package/tooling/src/capture/decision.js +40 -0
- package/tooling/src/capture/store.js +1 -0
- package/tooling/src/config/depth-table.js +60 -0
- package/tooling/src/export/compiler.js +7 -8
- package/tooling/src/guards/guardrail-functions.js +131 -0
- package/tooling/src/guards/phase-prerequisite-guard.js +39 -3
- package/tooling/src/hooks/pretooluse-dispatcher.js +300 -0
- package/tooling/src/hooks/pretooluse-pipeline-guard.js +141 -0
- package/tooling/src/hooks/stop-pipeline-gate.js +92 -0
- package/tooling/src/learn/pipeline.js +177 -0
- package/tooling/src/state/db.js +251 -2
- package/tooling/src/state/pipeline-state.js +262 -0
- package/wazir.manifest.yaml +3 -0
- 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
|
+
}
|
package/tooling/src/state/db.js
CHANGED
|
@@ -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
|
}
|