opencodekit 0.23.1 → 0.23.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.
- package/dist/index.js +354 -825
- package/dist/template/.opencode/AGENTS.md +15 -2
- package/dist/template/.opencode/command/init.md +198 -34
- package/dist/template/.opencode/context/fallow.md +137 -0
- package/dist/template/.opencode/opencode.json +12 -315
- package/dist/template/.opencode/plugin/codesearch.ts +730 -0
- package/dist/template/.opencode/plugin/memory/compile.ts +171 -186
- package/dist/template/.opencode/plugin/memory/index-generator.ts +118 -133
- package/dist/template/.opencode/plugin/memory/lint.ts +253 -275
- package/dist/template/.opencode/plugin/memory/tools.ts +224 -268
- package/dist/template/.opencode/plugin/memory/validate.ts +154 -164
- package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search-preview.ts +13 -30
- package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search-shared.ts +25 -0
- package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search.ts +17 -34
- package/dist/template/.opencode/plugin/session-summary.ts +0 -2
- package/dist/template/.opencode/plugin/srcwalk.ts +646 -667
- package/dist/template/.opencode/skill/code-navigation/SKILL.md +10 -10
- package/dist/template/.opencode/skill/code-review-and-quality/SKILL.md +1 -1
- package/dist/template/.opencode/skill/condition-based-waiting/example.ts +15 -2
- package/dist/template/.opencode/skill/debugging-and-error-recovery/SKILL.md +1 -1
- package/dist/template/.opencode/skill/deep-module-design/SKILL.md +1 -1
- package/dist/template/.opencode/skill/fallow/SKILL.md +409 -0
- package/dist/template/.opencode/skill/fallow/references/cli-reference.md +1905 -0
- package/dist/template/.opencode/skill/fallow/references/gotchas.md +644 -0
- package/dist/template/.opencode/skill/fallow/references/patterns.md +791 -0
- package/dist/template/.opencode/skill/planning-and-task-breakdown/SKILL.md +1 -1
- package/dist/template/.opencode/skill/srcwalk/SKILL.md +10 -13
- package/dist/template/.opencode/skill/ubiquitous-language/SKILL.md +1 -1
- package/dist/template/.opencode/tool/grepsearch.ts +92 -103
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ObservationRow } from "./db/types.js";
|
|
12
|
-
import { getMemoryDB } from "./db.js";
|
|
12
|
+
import { getMemoryDB } from "./db/schema.js";
|
|
13
13
|
import { hasWord, parseConcepts } from "./helpers.js";
|
|
14
14
|
|
|
15
15
|
// ============================================================================
|
|
@@ -17,31 +17,31 @@ import { hasWord, parseConcepts } from "./helpers.js";
|
|
|
17
17
|
// ============================================================================
|
|
18
18
|
|
|
19
19
|
export type LintIssueType =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
| "duplicate"
|
|
21
|
+
| "contradiction"
|
|
22
|
+
| "stale"
|
|
23
|
+
| "orphan"
|
|
24
|
+
| "missing-narrative";
|
|
25
25
|
|
|
26
26
|
export interface LintIssue {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
type: LintIssueType;
|
|
28
|
+
severity: "high" | "medium" | "low";
|
|
29
|
+
observation_ids: number[];
|
|
30
|
+
title: string;
|
|
31
|
+
detail: string;
|
|
32
|
+
suggestion: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export interface LintResult {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
issues: LintIssue[];
|
|
37
|
+
stats: {
|
|
38
|
+
total_observations: number;
|
|
39
|
+
duplicates: number;
|
|
40
|
+
contradictions: number;
|
|
41
|
+
stale: number;
|
|
42
|
+
orphans: number;
|
|
43
|
+
missing_narrative: number;
|
|
44
|
+
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// ============================================================================
|
|
@@ -52,42 +52,34 @@ export interface LintResult {
|
|
|
52
52
|
* Run all lint checks and return a consolidated report.
|
|
53
53
|
*/
|
|
54
54
|
export function lintMemory(options: { staleDays?: number } = {}): LintResult {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
total_observations: row.count,
|
|
84
|
-
duplicates: duplicates.length,
|
|
85
|
-
contradictions: contradictions.length,
|
|
86
|
-
stale: stale.length,
|
|
87
|
-
orphans: orphans.length,
|
|
88
|
-
missing_narrative: missing.length,
|
|
89
|
-
},
|
|
90
|
-
};
|
|
55
|
+
const staleDays = options.staleDays ?? 90;
|
|
56
|
+
const issues: LintIssue[] = [];
|
|
57
|
+
|
|
58
|
+
const duplicates = findDuplicates();
|
|
59
|
+
const contradictions = findContradictions();
|
|
60
|
+
const stale = findStaleObservations(staleDays);
|
|
61
|
+
const orphans = findOrphanObservations();
|
|
62
|
+
const missing = findMissingNarratives();
|
|
63
|
+
|
|
64
|
+
issues.push(...duplicates, ...contradictions, ...stale, ...orphans, ...missing);
|
|
65
|
+
|
|
66
|
+
// Count total active observations
|
|
67
|
+
const db = getMemoryDB();
|
|
68
|
+
const row = db
|
|
69
|
+
.query("SELECT COUNT(*) as count FROM observations WHERE superseded_by IS NULL")
|
|
70
|
+
.get() as { count: number };
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
issues,
|
|
74
|
+
stats: {
|
|
75
|
+
total_observations: row.count,
|
|
76
|
+
duplicates: duplicates.length,
|
|
77
|
+
contradictions: contradictions.length,
|
|
78
|
+
stale: stale.length,
|
|
79
|
+
orphans: orphans.length,
|
|
80
|
+
missing_narrative: missing.length,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
91
83
|
}
|
|
92
84
|
|
|
93
85
|
/**
|
|
@@ -95,77 +87,74 @@ export function lintMemory(options: { staleDays?: number } = {}): LintResult {
|
|
|
95
87
|
* Uses normalized title comparison + concept overlap.
|
|
96
88
|
*/
|
|
97
89
|
function findDuplicates(): LintIssue[] {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return issues;
|
|
90
|
+
const db = getMemoryDB();
|
|
91
|
+
const issues: LintIssue[] = [];
|
|
92
|
+
|
|
93
|
+
const observations = db
|
|
94
|
+
.query(
|
|
95
|
+
"SELECT id, type, title, concepts, narrative FROM observations WHERE superseded_by IS NULL ORDER BY created_at_epoch DESC",
|
|
96
|
+
)
|
|
97
|
+
.all() as Pick<ObservationRow, "id" | "type" | "title" | "concepts" | "narrative">[];
|
|
98
|
+
|
|
99
|
+
// Group by normalized title
|
|
100
|
+
const titleMap = new Map<string, typeof observations>();
|
|
101
|
+
for (const obs of observations) {
|
|
102
|
+
const normalized = normalizeTitle(obs.title);
|
|
103
|
+
const group = titleMap.get(normalized) ?? [];
|
|
104
|
+
group.push(obs);
|
|
105
|
+
titleMap.set(normalized, group);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const [normalized, group] of titleMap) {
|
|
109
|
+
if (group.length > 1) {
|
|
110
|
+
issues.push({
|
|
111
|
+
type: "duplicate",
|
|
112
|
+
severity: "medium",
|
|
113
|
+
observation_ids: group.map((o) => o.id),
|
|
114
|
+
title: `Duplicate: "${group[0].title}"`,
|
|
115
|
+
detail: `${group.length} observations with similar title "${normalized}": IDs ${group.map((o) => `#${o.id}`).join(", ")}`,
|
|
116
|
+
suggestion: `Use \`observation({ supersedes: "${group[group.length - 1].id}" })\` to merge, keeping the most recent.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Also check concept overlap for same-type observations
|
|
122
|
+
const byType = new Map<string, typeof observations>();
|
|
123
|
+
for (const obs of observations) {
|
|
124
|
+
if (!obs.concepts) continue;
|
|
125
|
+
const group = byType.get(obs.type) ?? [];
|
|
126
|
+
group.push(obs);
|
|
127
|
+
byType.set(obs.type, group);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const [, group] of byType) {
|
|
131
|
+
for (let i = 0; i < group.length; i++) {
|
|
132
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
133
|
+
const overlap = conceptOverlap(group[i].concepts, group[j].concepts);
|
|
134
|
+
if (overlap > 0.8 && group[i].id !== group[j].id) {
|
|
135
|
+
// Check not already flagged by title
|
|
136
|
+
const alreadyFlagged = issues.some(
|
|
137
|
+
(iss) =>
|
|
138
|
+
iss.type === "duplicate" &&
|
|
139
|
+
iss.observation_ids.includes(group[i].id) &&
|
|
140
|
+
iss.observation_ids.includes(group[j].id),
|
|
141
|
+
);
|
|
142
|
+
if (!alreadyFlagged) {
|
|
143
|
+
issues.push({
|
|
144
|
+
type: "duplicate",
|
|
145
|
+
severity: "low",
|
|
146
|
+
observation_ids: [group[i].id, group[j].id],
|
|
147
|
+
title: `High concept overlap: #${group[i].id} ↔ #${group[j].id}`,
|
|
148
|
+
detail: `"${group[i].title}" and "${group[j].title}" share ${(overlap * 100).toFixed(0)}% concepts`,
|
|
149
|
+
suggestion: `Review if these should be merged with \`supersedes\`.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return issues;
|
|
169
158
|
}
|
|
170
159
|
|
|
171
160
|
/**
|
|
@@ -173,94 +162,86 @@ function findDuplicates(): LintIssue[] {
|
|
|
173
162
|
* Looks for opposing signal words in narratives.
|
|
174
163
|
*/
|
|
175
164
|
function findContradictions(): LintIssue[] {
|
|
176
|
-
|
|
177
|
-
|
|
165
|
+
const db = getMemoryDB();
|
|
166
|
+
const issues: LintIssue[] = [];
|
|
178
167
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
168
|
+
// Get decision-type observations that share concepts
|
|
169
|
+
const decisions = db
|
|
170
|
+
.query(
|
|
171
|
+
`SELECT id, title, concepts, narrative FROM observations
|
|
183
172
|
WHERE type = 'decision' AND superseded_by IS NULL AND concepts IS NOT NULL`,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return issues;
|
|
173
|
+
)
|
|
174
|
+
.all() as Pick<ObservationRow, "id" | "title" | "concepts" | "narrative">[];
|
|
175
|
+
|
|
176
|
+
// Check pairs for contradictory language
|
|
177
|
+
const contradictionPairs = [
|
|
178
|
+
["use", "don't use"],
|
|
179
|
+
["enable", "disable"],
|
|
180
|
+
["add", "remove"],
|
|
181
|
+
["prefer", "avoid"],
|
|
182
|
+
["always", "never"],
|
|
183
|
+
["yes", "no"],
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < decisions.length; i++) {
|
|
187
|
+
for (let j = i + 1; j < decisions.length; j++) {
|
|
188
|
+
const overlap = conceptOverlap(decisions[i].concepts, decisions[j].concepts);
|
|
189
|
+
if (overlap < 0.3) continue; // Unrelated decisions
|
|
190
|
+
|
|
191
|
+
const textA = `${decisions[i].title} ${decisions[i].narrative ?? ""}`.toLowerCase();
|
|
192
|
+
const textB = `${decisions[j].title} ${decisions[j].narrative ?? ""}`.toLowerCase();
|
|
193
|
+
|
|
194
|
+
for (const [wordA, wordB] of contradictionPairs) {
|
|
195
|
+
if (
|
|
196
|
+
(hasWord(textA, wordA) && hasWord(textB, wordB)) ||
|
|
197
|
+
(hasWord(textA, wordB) && hasWord(textB, wordA))
|
|
198
|
+
) {
|
|
199
|
+
issues.push({
|
|
200
|
+
type: "contradiction",
|
|
201
|
+
severity: "high",
|
|
202
|
+
observation_ids: [decisions[i].id, decisions[j].id],
|
|
203
|
+
title: `Potential contradiction: #${decisions[i].id} vs #${decisions[j].id}`,
|
|
204
|
+
detail: `"${decisions[i].title}" and "${decisions[j].title}" share concepts but contain opposing signals ("${wordA}" vs "${wordB}")`,
|
|
205
|
+
suggestion: `Review both and supersede the outdated one.`,
|
|
206
|
+
});
|
|
207
|
+
break; // One contradiction signal per pair is enough
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return issues;
|
|
230
214
|
}
|
|
231
215
|
|
|
232
216
|
/**
|
|
233
217
|
* Find observations older than N days with no references in recent distillations.
|
|
234
218
|
*/
|
|
235
219
|
function findStaleObservations(staleDays: number): LintIssue[] {
|
|
236
|
-
|
|
237
|
-
|
|
220
|
+
const db = getMemoryDB();
|
|
221
|
+
const cutoffEpoch = Date.now() - staleDays * 24 * 60 * 60 * 1000;
|
|
238
222
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
223
|
+
const stale = db
|
|
224
|
+
.query(
|
|
225
|
+
`SELECT id, type, title, created_at, created_at_epoch FROM observations
|
|
242
226
|
WHERE superseded_by IS NULL AND created_at_epoch < ? AND valid_until IS NULL
|
|
243
227
|
ORDER BY created_at_epoch ASC`,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
suggestion: `Review: still relevant? If yes, update it. If no, supersede or set valid_until.`,
|
|
262
|
-
};
|
|
263
|
-
});
|
|
228
|
+
)
|
|
229
|
+
.all(cutoffEpoch) as Pick<
|
|
230
|
+
ObservationRow,
|
|
231
|
+
"id" | "type" | "title" | "created_at" | "created_at_epoch"
|
|
232
|
+
>[];
|
|
233
|
+
|
|
234
|
+
return stale.map((obs) => {
|
|
235
|
+
const ageDays = Math.floor((Date.now() - obs.created_at_epoch) / (1000 * 60 * 60 * 24));
|
|
236
|
+
return {
|
|
237
|
+
type: "stale" as const,
|
|
238
|
+
severity: ageDays > staleDays * 2 ? ("high" as const) : ("medium" as const),
|
|
239
|
+
observation_ids: [obs.id],
|
|
240
|
+
title: `Stale (${ageDays}d): #${obs.id} "${obs.title}"`,
|
|
241
|
+
detail: `[${obs.type}] created ${obs.created_at.slice(0, 10)}, ${ageDays} days old with no valid_until set`,
|
|
242
|
+
suggestion: `Review: still relevant? If yes, update it. If no, supersede or set valid_until.`,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
264
245
|
}
|
|
265
246
|
|
|
266
247
|
/**
|
|
@@ -268,69 +249,67 @@ function findStaleObservations(staleDays: number): LintIssue[] {
|
|
|
268
249
|
* These are "orphan concepts" — knowledge islands with no connections.
|
|
269
250
|
*/
|
|
270
251
|
function findOrphanObservations(): LintIssue[] {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
return issues;
|
|
252
|
+
const db = getMemoryDB();
|
|
253
|
+
const issues: LintIssue[] = [];
|
|
254
|
+
|
|
255
|
+
const observations = db
|
|
256
|
+
.query(
|
|
257
|
+
"SELECT id, title, concepts FROM observations WHERE superseded_by IS NULL AND concepts IS NOT NULL",
|
|
258
|
+
)
|
|
259
|
+
.all() as Pick<ObservationRow, "id" | "title" | "concepts">[];
|
|
260
|
+
|
|
261
|
+
// Build concept → observation IDs map
|
|
262
|
+
const conceptMap = new Map<string, number[]>();
|
|
263
|
+
for (const obs of observations) {
|
|
264
|
+
const concepts = parseConcepts(obs.concepts);
|
|
265
|
+
for (const concept of concepts) {
|
|
266
|
+
const ids = conceptMap.get(concept) ?? [];
|
|
267
|
+
ids.push(obs.id);
|
|
268
|
+
conceptMap.set(concept, ids);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Find observations where ALL concepts are orphans (only appear once)
|
|
273
|
+
for (const obs of observations) {
|
|
274
|
+
const concepts = parseConcepts(obs.concepts);
|
|
275
|
+
if (concepts.length === 0) continue;
|
|
276
|
+
const allOrphan = concepts.every((c) => (conceptMap.get(c)?.length ?? 0) <= 1);
|
|
277
|
+
if (allOrphan && concepts.length >= 2) {
|
|
278
|
+
issues.push({
|
|
279
|
+
type: "orphan",
|
|
280
|
+
severity: "low",
|
|
281
|
+
observation_ids: [obs.id],
|
|
282
|
+
title: `Isolated: #${obs.id} "${obs.title}"`,
|
|
283
|
+
detail: `All concepts [${concepts.join(", ")}] appear in no other observation — this knowledge is disconnected`,
|
|
284
|
+
suggestion: `Consider adding cross-references or broadening concept tags.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return issues;
|
|
311
290
|
}
|
|
312
291
|
|
|
313
292
|
/**
|
|
314
293
|
* Find observations with no narrative (title-only, low value).
|
|
315
294
|
*/
|
|
316
295
|
function findMissingNarratives(): LintIssue[] {
|
|
317
|
-
|
|
296
|
+
const db = getMemoryDB();
|
|
318
297
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
298
|
+
const missing = db
|
|
299
|
+
.query(
|
|
300
|
+
`SELECT id, type, title FROM observations
|
|
322
301
|
WHERE superseded_by IS NULL AND (narrative IS NULL OR narrative = '')`,
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
302
|
+
)
|
|
303
|
+
.all() as Pick<ObservationRow, "id" | "type" | "title">[];
|
|
304
|
+
|
|
305
|
+
return missing.map((obs) => ({
|
|
306
|
+
type: "missing-narrative" as const,
|
|
307
|
+
severity: "low" as const,
|
|
308
|
+
observation_ids: [obs.id],
|
|
309
|
+
title: `No narrative: #${obs.id} "${obs.title}"`,
|
|
310
|
+
detail: `[${obs.type}] has title but no narrative — low-value observation`,
|
|
311
|
+
suggestion: `Add narrative context or remove if the title alone is not useful.`,
|
|
312
|
+
}));
|
|
334
313
|
}
|
|
335
314
|
|
|
336
315
|
// ============================================================================
|
|
@@ -338,22 +317,21 @@ function findMissingNarratives(): LintIssue[] {
|
|
|
338
317
|
// ============================================================================
|
|
339
318
|
|
|
340
319
|
function normalizeTitle(title: string): string {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
320
|
+
return title
|
|
321
|
+
.toLowerCase()
|
|
322
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
323
|
+
.replace(/\s+/g, " ")
|
|
324
|
+
.trim();
|
|
346
325
|
}
|
|
347
326
|
|
|
348
327
|
function conceptOverlap(a: string | null, b: string | null): number {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
328
|
+
const conceptsA = new Set(parseConcepts(a));
|
|
329
|
+
const conceptsB = new Set(parseConcepts(b));
|
|
330
|
+
if (conceptsA.size === 0 || conceptsB.size === 0) return 0;
|
|
331
|
+
|
|
332
|
+
let overlap = 0;
|
|
333
|
+
for (const c of conceptsA) {
|
|
334
|
+
if (conceptsB.has(c)) overlap++;
|
|
335
|
+
}
|
|
336
|
+
return overlap / Math.min(conceptsA.size, conceptsB.size);
|
|
358
337
|
}
|
|
359
|
-
|