@geraldmaron/construct 1.0.23 → 1.1.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 (106) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +189 -207
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/beads-client.mjs +9 -0
  7. package/lib/beads-optimistic.mjs +23 -71
  8. package/lib/bridges/copilot-proxy.mjs +116 -0
  9. package/lib/cli-commands.mjs +5 -1
  10. package/lib/comment-lint.mjs +1 -1
  11. package/lib/config/schema.mjs +1 -1
  12. package/lib/document-extract/docling-client.mjs +16 -6
  13. package/lib/document-extract/docling-sidecar.py +32 -2
  14. package/lib/document-extract.mjs +37 -10
  15. package/lib/document-ingest.mjs +90 -5
  16. package/lib/embed/inbox.mjs +6 -3
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/embed/roadmap.mjs +16 -1
  20. package/lib/engine/consolidate.mjs +160 -3
  21. package/lib/engine/contradiction-judge.mjs +71 -0
  22. package/lib/engine/contradiction.mjs +74 -0
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/session-reflect.mjs +5 -1
  25. package/lib/host-capabilities.mjs +30 -0
  26. package/lib/ingest/docling-remote.mjs +90 -0
  27. package/lib/ingest/strategy.mjs +1 -1
  28. package/lib/init-unified.mjs +9 -13
  29. package/lib/intake/git-queue.mjs +195 -0
  30. package/lib/intake/queue.mjs +9 -16
  31. package/lib/logging/rotate.mjs +18 -0
  32. package/lib/mcp/server.mjs +124 -12
  33. package/lib/mcp/tool-budget.mjs +53 -0
  34. package/lib/mcp/tools/storage.mjs +2 -3
  35. package/lib/mcp-catalog.json +4 -4
  36. package/lib/mcp-manager.mjs +59 -3
  37. package/lib/observation-store.mjs +38 -166
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration/runtime.mjs +3 -2
  43. package/lib/orchestration-policy.mjs +41 -6
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +12 -8
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reconcile/index.mjs +0 -2
  48. package/lib/reflect/extractor.mjs +14 -1
  49. package/lib/reflect/salience.mjs +65 -0
  50. package/lib/rules-delivery.mjs +122 -0
  51. package/lib/runtime/uv-bootstrap.mjs +32 -17
  52. package/lib/service-manager.mjs +79 -259
  53. package/lib/setup.mjs +44 -425
  54. package/lib/specialists/prompt-schema.mjs +162 -0
  55. package/lib/specialists/scaffold.mjs +109 -0
  56. package/lib/status.mjs +3 -6
  57. package/lib/storage/admin.mjs +48 -325
  58. package/lib/storage/backend.mjs +10 -57
  59. package/lib/storage/embeddings-engine.mjs +19 -5
  60. package/lib/storage/hybrid-query.mjs +15 -196
  61. package/lib/storage/sync.mjs +36 -177
  62. package/lib/storage/vector-client.mjs +256 -235
  63. package/lib/strategy-store.mjs +35 -286
  64. package/lib/telemetry/beads-fallback.mjs +40 -0
  65. package/lib/telemetry/hook-calls.mjs +138 -0
  66. package/lib/worker/entrypoint.mjs +6 -14
  67. package/package.json +6 -5
  68. package/personas/construct.md +1 -1
  69. package/platforms/capabilities.json +76 -0
  70. package/platforms/claude/settings.template.json +0 -7
  71. package/platforms/opencode/sync-config.mjs +121 -25
  72. package/rules/common/neurodivergent-output.md +1 -1
  73. package/rules/web/coding-style.md +8 -0
  74. package/rules/web/design-quality.md +8 -0
  75. package/rules/web/hooks.md +8 -0
  76. package/rules/web/patterns.md +8 -0
  77. package/rules/web/performance.md +8 -0
  78. package/rules/web/security.md +8 -0
  79. package/rules/web/testing.md +8 -0
  80. package/scripts/sync-specialists.mjs +174 -40
  81. package/specialists/prompts/cx-architect.md +20 -0
  82. package/specialists/prompts/cx-qa.md +1 -1
  83. package/specialists/prompts/cx-test-automation.md +12 -0
  84. package/specialists/registry.json +0 -8
  85. package/templates/docs/construct_guide.md +1 -1
  86. package/db/schema/001_init.sql +0 -40
  87. package/db/schema/002_pgvector.sql +0 -182
  88. package/db/schema/003_intake.sql +0 -47
  89. package/db/schema/003_observation_reconciliation.sql +0 -14
  90. package/db/schema/004_recommendations.sql +0 -46
  91. package/db/schema/005_strategy.sql +0 -21
  92. package/db/schema/006_graph.sql +0 -24
  93. package/db/schema/007_tags.sql +0 -30
  94. package/db/schema/008_skill_usage.sql +0 -24
  95. package/db/schema/009_scheduler.sql +0 -14
  96. package/db/schema/010_cx_scores.sql +0 -51
  97. package/lib/intake/postgres-queue.mjs +0 -240
  98. package/lib/reconcile/postgres-namespace.mjs +0 -102
  99. package/lib/services/local-postgres.mjs +0 -15
  100. package/lib/storage/backup.mjs +0 -347
  101. package/lib/storage/migrations.mjs +0 -187
  102. package/lib/storage/postgres-backup.mjs +0 -124
  103. package/lib/storage/sql-store.mjs +0 -45
  104. package/lib/storage/store-version.mjs +0 -115
  105. package/lib/storage/unified-storage.mjs +0 -550
  106. package/lib/storage/vector-store.mjs +0 -100
@@ -4,31 +4,36 @@
4
4
  * Stores distilled insights that specialists learn during work:
5
5
  * - patterns, anti-patterns, dependency relationships, decisions, insights
6
6
  * - each scoped to a role (cx-engineer, cx-architect, etc.)
7
- * - vector-indexed for semantic search via hashing-bow-v1
7
+ * - vector-indexed for semantic search via embedded LanceDB
8
8
  *
9
9
  * Storage layout:
10
10
  * .cx/observations/index.json — lightweight listing for fast filtering
11
11
  * .cx/observations/<id>.json — full observation record
12
- * .cx/observations/vectors.json — local vector index for semantic search
13
12
  */
14
13
  import crypto from 'node:crypto';
15
14
  import fs from 'node:fs';
16
15
  import path from 'node:path';
17
- import { cosineSimilarity, rankByBm25 } from './storage/embeddings.mjs';
18
- import { embedSync as embedText } from './storage/embeddings-legacy.mjs';
19
16
  import { embedText as embedTextEngine } from './storage/embeddings-engine.mjs';
20
17
  import { VectorClient } from './storage/vector-client.mjs';
21
- import { withFileLockSync } from './storage/file-lock.mjs';
22
18
  import { ensureCxDir } from './project-init-shared.mjs';
23
19
 
24
20
  const OBS_DIR = '.cx/observations';
25
21
  const INDEX_FILE = 'index.json';
26
- const VECTORS_FILE = 'vectors.json';
27
22
  const MAX_INDEX = 1000;
28
23
 
29
- // The exact text fed to the embedder, and its content hash. addObservation and
30
- // the reconciliation pass MUST derive both the same way, so the (content_hash,
31
- // model) fingerprint they compare is computed identically.
24
+ // VectorClient resolves its store from CONSTRUCT_LANCEDB_PATH and otherwise
25
+ // falls back to process.cwd(). Observations are scoped to rootDir, so when no
26
+ // explicit path is configured (tests, unmanaged checkouts) the vector store
27
+ // must follow rootDir — not the caller's cwd — or a reader in a different cwd
28
+ // than the writer sees an empty table. A configured path (managed installs)
29
+ // is left untouched so the home-global store keeps working.
30
+
31
+ function vectorClientFor(rootDir) {
32
+ const env = process.env.CONSTRUCT_LANCEDB_PATH
33
+ ? process.env
34
+ : { ...process.env, CONSTRUCT_LANCEDB_PATH: path.join(rootDir, '.cx', 'lancedb') };
35
+ return new VectorClient({ env });
36
+ }
32
37
 
33
38
  export function observationSearchText(obs) {
34
39
  return [obs.summary, obs.content, ...(obs.tags || [])].filter(Boolean).join(' ');
@@ -40,9 +45,6 @@ export function observationContentHash(text) {
40
45
  const MAX_SUMMARY = 500;
41
46
  const MAX_CONTENT = 2000;
42
47
  const MAX_TAGS = 10;
43
- // Size guardrail: surfaces in `construct doctor` when the observation tree
44
- // exceeds this byte cap. Doesn't drop data — the operator decides whether to
45
- // run `construct memory consolidate` (which prunes archive) or raise the cap.
46
48
  const MAX_BYTES = 50 * 1024 * 1024;
47
49
 
48
50
  const VALID_CATEGORIES = new Set([
@@ -50,8 +52,6 @@ const VALID_CATEGORIES = new Set([
50
52
  ]);
51
53
 
52
54
  function ensureDir(dir, rootDir = null) {
53
- // Preserves the construct context invariant: any code that creates a .cx/
54
- // subdirectory also initializes context.md from the project template.
55
55
  if (rootDir) ensureCxDir(rootDir);
56
56
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
57
57
  }
@@ -64,10 +64,6 @@ function indexPath(rootDir) {
64
64
  return path.join(obsDir(rootDir), INDEX_FILE);
65
65
  }
66
66
 
67
- function vectorsPath(rootDir) {
68
- return path.join(obsDir(rootDir), VECTORS_FILE);
69
- }
70
-
71
67
  function generateId() {
72
68
  const ts = Date.now().toString(36);
73
69
  const rand = crypto.randomBytes(4).toString('hex');
@@ -132,30 +128,6 @@ function writeIndex(rootDir, entries) {
132
128
  return { dropped };
133
129
  }
134
130
 
135
- function readVectors(rootDir) {
136
- const p = vectorsPath(rootDir);
137
- if (!fs.existsSync(p)) return [];
138
- try {
139
- return JSON.parse(fs.readFileSync(p, 'utf8'));
140
- } catch {
141
- return [];
142
- }
143
- }
144
-
145
- function writeVectors(rootDir, records) {
146
- ensureDir(obsDir(rootDir), rootDir);
147
- const dropped = Math.max(0, records.length - MAX_INDEX);
148
- if (dropped > 0) {
149
- logCapDrop(rootDir, 'observation-vectors', dropped, records.length);
150
- }
151
- fs.writeFileSync(vectorsPath(rootDir), JSON.stringify(records, null, 2) + '\n');
152
- return { dropped };
153
- }
154
-
155
- /**
156
- * Add a new observation and vectorize it for semantic search.
157
- * Uses SQL + neural embeddings when available, falls back to local JSON + hashing.
158
- */
159
131
  export async function addObservation(rootDir, {
160
132
  role = 'unknown',
161
133
  category = 'insight',
@@ -175,10 +147,9 @@ export async function addObservation(rootDir, {
175
147
  const clampedSummary = clamp(String(summary), MAX_SUMMARY);
176
148
  const clampedContent = clamp(String(content), MAX_CONTENT);
177
149
  const clampedTags = (Array.isArray(tags) ? tags : []).slice(0, MAX_TAGS).map(String);
178
- // Structured metadata: opt-in. Capped at 2 KB stringified so a misuse can't
179
- // bloat the per-id JSON. Plain object only; rejects arrays and functions.
180
150
  const clampedExtras = sanitizeExtras(extras);
181
151
 
152
+ const effectiveProject = project ? String(project) : rootDir.split('/').pop();
182
153
  const record = {
183
154
  id,
184
155
  role: String(role),
@@ -186,7 +157,7 @@ export async function addObservation(rootDir, {
186
157
  summary: clampedSummary,
187
158
  content: clampedContent,
188
159
  tags: clampedTags,
189
- project: project ? String(project) : null,
160
+ project: effectiveProject,
190
161
  confidence: Math.max(0, Math.min(1, Number(confidence) || 0.8)),
191
162
  source: source || null,
192
163
  gitSha: gitSha ? String(gitSha).slice(0, 40) : null,
@@ -195,37 +166,30 @@ export async function addObservation(rootDir, {
195
166
  updatedAt: now,
196
167
  };
197
168
 
198
- // Lock the per-store index so concurrent writers (CLI + hooks) cannot
199
- // corrupt the index/vectors JSON pair. The observation JSON file is
200
- // unique-per-id and doesn't need the lock; the shared structures do.
201
169
  ensureDir(obsDir(rootDir), rootDir);
202
170
  fs.writeFileSync(
203
171
  path.join(obsDir(rootDir), `${id}.json`),
204
172
  JSON.stringify(record, null, 2) + '\n',
205
173
  );
206
174
 
207
- let indexDropped = 0;
208
- withFileLockSync(indexPath(rootDir), () => {
209
- const index = readIndex(rootDir);
210
- index.unshift({
211
- id,
212
- role: record.role,
213
- category: record.category,
214
- summary: record.summary,
215
- project: record.project,
216
- createdAt: now,
217
- });
218
- indexDropped = writeIndex(rootDir, index).dropped;
175
+ const index = readIndex(rootDir);
176
+ index.unshift({
177
+ id,
178
+ role: record.role,
179
+ category: record.category,
180
+ summary: record.summary,
181
+ project: record.project,
182
+ createdAt: now,
219
183
  });
184
+ const indexDropped = writeIndex(rootDir, index).dropped;
220
185
  if (indexDropped > 0) {
221
186
  record.capDropped = indexDropped;
222
187
  process.stderr.write(`[observation-store] observation cap reached: ${indexDropped} oldest entries evicted (cap=${MAX_INDEX})\n`);
223
188
  }
224
189
 
225
- // Try SQL storage with neural embeddings
226
190
  try {
227
- const client = new VectorClient();
228
- if (await client.isHealthy() && await client.isPgvectorEnabled()) {
191
+ const client = vectorClientFor(rootDir);
192
+ if (await client.isHealthy()) {
229
193
  const searchText = observationSearchText(record);
230
194
  const { embedding, model } = await embedTextEngine(searchText);
231
195
  await client.storeObservation({
@@ -234,34 +198,15 @@ export async function addObservation(rootDir, {
234
198
  contentHash: observationContentHash(searchText),
235
199
  model,
236
200
  });
237
- await client.close();
238
- return record;
239
201
  }
240
202
  await client.close();
241
203
  } catch (err) {
242
- // A reachable backend that still fails to store (dimension drift, auth, transient
243
- // network) must not look identical to "no backend configured" — surface it, then
244
- // preserve the observation in the local index so the write never silently vanishes.
245
-
246
- process.stderr.write(`[observation-store] SQL store failed; kept observation in local index: ${err?.message || err}\n`);
204
+ process.stderr.write(`[observation-store] vector store failed; kept observation in local index: ${err?.message || err}\n`);
247
205
  }
248
206
 
249
- // Fallback: local JSON vector index with hashing-bow-v1.
250
- const searchText = [record.summary, record.content, ...record.tags].filter(Boolean).join(' ');
251
- const embedding = embedText(searchText);
252
- withFileLockSync(vectorsPath(rootDir), () => {
253
- const vectors = readVectors(rootDir);
254
- vectors.push({ id, embedding, role: record.role, category: record.category, project: record.project });
255
- writeVectors(rootDir, vectors.slice(-MAX_INDEX));
256
- });
257
-
258
207
  return record;
259
208
  }
260
209
 
261
- /**
262
- * Search observations using SQL + neural embeddings when available,
263
- * falling back to hybrid cosine + BM25 on local JSON.
264
- */
265
210
  export async function searchObservations(rootDir, query, {
266
211
  role = null,
267
212
  category = null,
@@ -270,16 +215,15 @@ export async function searchObservations(rootDir, query, {
270
215
  } = {}) {
271
216
  if (!query) return [];
272
217
 
273
- // Try SQL + neural embeddings first
274
218
  try {
275
- const client = new VectorClient();
276
- if (await client.isHealthy() && await client.isPgvectorEnabled()) {
219
+ const client = vectorClientFor(rootDir);
220
+ if (await client.isHealthy()) {
277
221
  const { embedding } = await embedTextEngine(String(query));
278
222
  const results = await client.searchObservations({
279
223
  project: project || rootDir.split('/').pop(),
280
224
  queryEmbedding: embedding,
281
225
  limit,
282
- minSimilarity: 0.1,
226
+ minSimilarity: 0.01,
283
227
  role,
284
228
  category,
285
229
  });
@@ -288,6 +232,7 @@ export async function searchObservations(rootDir, query, {
288
232
  id: r.id,
289
233
  role: r.role,
290
234
  category: r.category,
235
+ project: r.project,
291
236
  summary: r.summary,
292
237
  content: r.content,
293
238
  tags: r.tags || [],
@@ -299,65 +244,12 @@ export async function searchObservations(rootDir, query, {
299
244
  }
300
245
  await client.close();
301
246
  } catch {
302
- // Fall through to local JSON search
247
+ // If Vector search fails, return empty since fallback is removed.
303
248
  }
304
249
 
305
- // Fallback: local JSON + hashing-bow-v1
306
- const queryEmbedding = embedText(String(query));
307
- let vectors = readVectors(rootDir);
308
-
309
- if (role) vectors = vectors.filter((v) => v.role === role);
310
- if (category) vectors = vectors.filter((v) => v.category === category);
311
- if (project) vectors = vectors.filter((v) => v.project === project);
312
-
313
- // Cosine pass
314
- const cosineScored = vectors
315
- .map((v) => ({
316
- id: v.id,
317
- score: cosineSimilarity(queryEmbedding, v.embedding || []),
318
- }))
319
- .filter(({ score }) => score > 0.05);
320
-
321
- // Load full records for BM25 pass
322
- const candidateIds = new Set(cosineScored.map((v) => v.id));
323
- const recentIndex = readIndex(rootDir);
324
- const filtered = role ? recentIndex.filter((e) => e.role === role) : recentIndex;
325
- const filteredCat = category ? filtered.filter((e) => e.category === category) : filtered;
326
- const filteredProj = project ? filteredCat.filter((e) => e.project === project) : filteredCat;
327
- for (const entry of filteredProj.slice(0, Math.max(limit * 3, 30))) candidateIds.add(entry.id);
328
-
329
- const candidateRecords = [...candidateIds]
330
- .map((id) => getObservation(rootDir, id))
331
- .filter(Boolean)
332
- .map((r) => ({ ...r, text: [r.summary, r.content, ...(r.tags || [])].filter(Boolean).join(' ') }));
333
-
334
- const bm25Scored = rankByBm25(candidateRecords, query, { limit: limit * 2 });
335
-
336
- // Merge: highest score wins per id
337
- const scoreMap = new Map();
338
- for (const { id, score } of cosineScored) {
339
- scoreMap.set(id, score);
340
- }
341
- for (const item of bm25Scored) {
342
- const prev = scoreMap.get(item.id) || 0;
343
- const bm25Max = bm25Scored[0]?.score || 1;
344
- const normalized = Math.min(item.score / bm25Max, 1);
345
- scoreMap.set(item.id, Math.max(prev, normalized));
346
- }
347
-
348
- return [...scoreMap.entries()]
349
- .sort((a, b) => b[1] - a[1])
350
- .slice(0, limit)
351
- .map(([id, score]) => {
352
- const record = getObservation(rootDir, id);
353
- return record ? { ...record, score } : null;
354
- })
355
- .filter(Boolean);
250
+ return [];
356
251
  }
357
252
 
358
- /**
359
- * List observations from the index with optional filters.
360
- */
361
253
  export function listObservations(rootDir, {
362
254
  role = null,
363
255
  category = null,
@@ -371,9 +263,6 @@ export function listObservations(rootDir, {
371
263
  return entries.slice(0, limit);
372
264
  }
373
265
 
374
- /**
375
- * Load a full observation record by ID.
376
- */
377
266
  export function getObservation(rootDir, id) {
378
267
  const filePath = path.join(obsDir(rootDir), `${id}.json`);
379
268
  if (!fs.existsSync(filePath)) return null;
@@ -384,31 +273,17 @@ export function getObservation(rootDir, id) {
384
273
  }
385
274
  }
386
275
 
387
- /**
388
- * Delete an observation by id.
389
- */
390
276
  export function deleteObservation(rootDir, id) {
391
277
  const filePath = path.join(obsDir(rootDir), `${id}.json`);
392
278
  if (fs.existsSync(filePath)) fs.rmSync(filePath, { force: true });
393
279
 
394
- withFileLockSync(indexPath(rootDir), () => {
395
- const index = readIndex(rootDir);
396
- const filtered = index.filter((e) => e.id !== id);
397
- if (filtered.length !== index.length) writeIndex(rootDir, filtered);
398
- });
399
-
400
- withFileLockSync(vectorsPath(rootDir), () => {
401
- const vectors = readVectors(rootDir);
402
- const filteredVec = vectors.filter((v) => v.id !== id);
403
- if (filteredVec.length !== vectors.length) writeVectors(rootDir, filteredVec);
404
- });
280
+ const index = readIndex(rootDir);
281
+ const filtered = index.filter((e) => e.id !== id);
282
+ if (filtered.length !== index.length) writeIndex(rootDir, filtered);
405
283
 
406
284
  return true;
407
285
  }
408
286
 
409
- /**
410
- * Count observations, optionally filtered.
411
- */
412
287
  export function countObservations(rootDir, { role = null, project = null } = {}) {
413
288
  let entries = readIndex(rootDir);
414
289
  if (role) entries = entries.filter((e) => e.role === role);
@@ -416,9 +291,6 @@ export function countObservations(rootDir, { role = null, project = null } = {})
416
291
  return entries.length;
417
292
  }
418
293
 
419
- // On-disk byte size of the observation tree (live + archive) at rootDir.
420
- // Backs the `Observation size` doctor surface so size pressure shows up
421
- // independently of count-based caps.
422
294
  export function getObservationsSize(rootDir) {
423
295
  const root = obsDir(rootDir);
424
296
  if (!fs.existsSync(root)) return 0;
@@ -439,4 +311,4 @@ export function getObservationsSize(rootDir) {
439
311
  export function checkObservationsSize(rootDir, { cap = MAX_BYTES } = {}) {
440
312
  const size = getObservationsSize(rootDir);
441
313
  return { size, cap, ok: size <= cap };
442
- }
314
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * lib/ollama/capability-store.mjs — durable record of local-model agentic coherence.
3
+ *
4
+ * `construct doctor --probe-local` measures whether each Ollama model can drive
5
+ * the agentic loop or collapses into word salad, and writes the verdict here.
6
+ * sync and doctor READ this store (they never probe — probing loads each model and
7
+ * costs minutes) to skip Modelfile provisioning for collapsed models, inform the
8
+ * MCP-trim decision, and warn when a configured default model cannot do agentic
9
+ * work. Verdicts are keyed by the model's `ollama list` digest so a re-pulled tag
10
+ * goes stale and is re-probed rather than trusted blindly.
11
+ *
12
+ * Lives at ~/.cx/local-models.json because local-model capability is a property of
13
+ * the machine's Ollama install, not of any one project.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { cxDir } from "../paths.mjs";
18
+
19
+ const STORE_VERSION = 1;
20
+
21
+ export function localModelsPath() {
22
+ return path.join(cxDir(), "local-models.json");
23
+ }
24
+
25
+ export function readCapabilityStore() {
26
+ try {
27
+ const raw = JSON.parse(fs.readFileSync(localModelsPath(), "utf8"));
28
+ if (raw && raw.version === STORE_VERSION && raw.models && typeof raw.models === "object") return raw;
29
+ } catch { /* missing or malformed — treat as empty */ }
30
+ return { version: STORE_VERSION, models: {} };
31
+ }
32
+
33
+ // Persist one probe result. probeResult is the object probeAgenticCoherence
34
+ // returns ({ ok, coherent, repeatRatio, uniqueRatio, ... }); a probe that errored
35
+ // (ok=false) is not recorded, so a transient failure never overwrites a real verdict.
36
+
37
+ export function recordProbeResult(model, probeResult, digest = null) {
38
+ if (!probeResult || probeResult.ok !== true) return readCapabilityStore();
39
+ const store = readCapabilityStore();
40
+ store.models[model] = {
41
+ verdict: probeResult.coherent ? "COHERENT" : "COLLAPSED",
42
+ coherent: probeResult.coherent === true,
43
+ calledTool: probeResult.calledTool === true,
44
+ repeatRatio: probeResult.repeatRatio ?? null,
45
+ uniqueRatio: probeResult.uniqueRatio ?? null,
46
+ digest: digest ?? null,
47
+ probedAt: new Date().toISOString(),
48
+ };
49
+ const dir = path.dirname(localModelsPath());
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ fs.writeFileSync(localModelsPath(), `${JSON.stringify(store, null, 2)}\n`);
52
+ return store;
53
+ }
54
+
55
+ export function getModelVerdict(model) {
56
+ return readCapabilityStore().models[model] ?? null;
57
+ }
58
+
59
+ // A verdict is stale when no digest is recorded for it or the current digest
60
+ // differs from the one it was measured against — callers re-probe rather than
61
+ // trust a verdict about different model bytes.
62
+
63
+ export function isVerdictStale(model, currentDigest) {
64
+ const entry = getModelVerdict(model);
65
+ if (!entry) return true;
66
+ if (!currentDigest || !entry.digest) return true;
67
+ return entry.digest !== currentDigest;
68
+ }
69
+
70
+ // COLLAPSED only counts when the digest still matches: a stale or unknown verdict
71
+ // must not strand a model. Callers warn-and-allow-override rather than hide.
72
+
73
+ export function isKnownCollapsed(model, currentDigest = null) {
74
+ const entry = getModelVerdict(model);
75
+ if (!entry) return false;
76
+ if (currentDigest && isVerdictStale(model, currentDigest)) return false;
77
+ return entry.verdict === "COLLAPSED";
78
+ }