@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.
- package/README.md +0 -2
- package/bin/construct +189 -207
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +4 -4
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration/runtime.mjs +3 -2
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reconcile/index.mjs +0 -2
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/service-manager.mjs +79 -259
- package/lib/setup.mjs +44 -425
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/personas/construct.md +1 -1
- package/platforms/capabilities.json +76 -0
- package/platforms/claude/settings.template.json +0 -7
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +174 -40
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/prompts/cx-test-automation.md +12 -0
- package/specialists/registry.json +0 -8
- package/templates/docs/construct_guide.md +1 -1
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/reconcile/postgres-namespace.mjs +0 -102
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/unified-storage.mjs +0 -550
- package/lib/storage/vector-store.mjs +0 -100
package/lib/embed/reconcile.mjs
CHANGED
|
@@ -26,8 +26,8 @@ export async function reconcileObservationEmbeddings(rootDir, { env = process.en
|
|
|
26
26
|
const result = { checked: 0, reembedded: 0, model: null, skipped: null };
|
|
27
27
|
|
|
28
28
|
try {
|
|
29
|
-
if (!(await client.isHealthy())
|
|
30
|
-
result.skipped = '
|
|
29
|
+
if (!(await client.isHealthy())) {
|
|
30
|
+
result.skipped = 'unhealthy';
|
|
31
31
|
return result;
|
|
32
32
|
}
|
|
33
33
|
|
package/lib/embed/roadmap.mjs
CHANGED
|
@@ -202,6 +202,12 @@ function normalizeStatus(raw) {
|
|
|
202
202
|
return 'planned';
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// Normalize the volatile "Last updated" stamp so two renders can be compared on
|
|
206
|
+
// content alone.
|
|
207
|
+
function stripTimestamp(markdown) {
|
|
208
|
+
return String(markdown || '').replace(/^> Last updated: .*$/m, '> Last updated:');
|
|
209
|
+
}
|
|
210
|
+
|
|
205
211
|
/**
|
|
206
212
|
* Render entries back to roadmap markdown.
|
|
207
213
|
*/
|
|
@@ -324,7 +330,16 @@ export async function generateRoadmap({ targetPath, snapshot, roles }) {
|
|
|
324
330
|
// Render
|
|
325
331
|
const markdown = renderRoadmap(entries, { roles, sources });
|
|
326
332
|
|
|
327
|
-
//
|
|
333
|
+
// Skip the write when only the generated "Last updated" stamp would change —
|
|
334
|
+
// otherwise every render rewrites a tracked file with no semantic diff and
|
|
335
|
+
// leaves the working tree perpetually dirty. The timestamp then reflects when
|
|
336
|
+
// the roadmap content last actually moved, not when generation last ran.
|
|
337
|
+
let existingRaw = null;
|
|
338
|
+
try { existingRaw = readFileSync(roadmapPath, 'utf8'); } catch { existingRaw = null; }
|
|
339
|
+
if (existingRaw !== null && stripTimestamp(existingRaw) === stripTimestamp(markdown)) {
|
|
340
|
+
return { path: roadmapPath, updatedAt, itemCount: entries.length, isNew: false, unchanged: true };
|
|
341
|
+
}
|
|
342
|
+
|
|
328
343
|
mkdirSync(join(targetPath, 'docs'), { recursive: true });
|
|
329
344
|
writeFileSync(roadmapPath, markdown, 'utf8');
|
|
330
345
|
|
|
@@ -8,9 +8,18 @@
|
|
|
8
8
|
* wires in) — when no summariser is provided, the representative observation's
|
|
9
9
|
* own summary is kept verbatim.
|
|
10
10
|
*
|
|
11
|
+
* Supersede (the mem0/Letta decision layer): clustering alone left every
|
|
12
|
+
* near-duplicate live, so search returned N restatements of the same fact. The
|
|
13
|
+
* supersede pass keeps only the highest-salience member of a tight cluster live
|
|
14
|
+
* and archives the rest behind a `supersededBy` pointer — salience (the
|
|
15
|
+
* observation's confidence) decides the winner, the newest breaks a tie. The
|
|
16
|
+
* winner becomes the cluster representative, so the kept insight points at the
|
|
17
|
+
* most valuable member rather than the lexically-first one.
|
|
18
|
+
*
|
|
11
19
|
* Outputs:
|
|
12
20
|
* .cx/observations/consolidated.json list of insight clusters
|
|
13
21
|
* .cx/observations/archive/<id>.json observations demoted to cold archive
|
|
22
|
+
* (superseded members carry supersededBy)
|
|
14
23
|
*
|
|
15
24
|
* The pass is idempotent: re-running it on a stable corpus produces the same
|
|
16
25
|
* cluster set. It is safe to schedule as a cron / launchd job, run in the
|
|
@@ -24,6 +33,7 @@
|
|
|
24
33
|
import fs from 'node:fs';
|
|
25
34
|
import path from 'node:path';
|
|
26
35
|
import { cosineSimilarity } from '../storage/embeddings.mjs';
|
|
36
|
+
import { detectContradiction } from './contradiction.mjs';
|
|
27
37
|
|
|
28
38
|
const OBS_DIR = '.cx/observations';
|
|
29
39
|
const ARCHIVE_DIR = '.cx/observations/archive';
|
|
@@ -35,6 +45,19 @@ const DEFAULTS = {
|
|
|
35
45
|
similarityThreshold: 0.95,
|
|
36
46
|
archiveAfterDays: 60,
|
|
37
47
|
archiveBelowConfidence: 0.5,
|
|
48
|
+
// Supersede only collapses a member that is a true restatement of the winner,
|
|
49
|
+
// a stricter bar than the cluster-forming threshold: cluster-adjacent is not
|
|
50
|
+
// the same as duplicate.
|
|
51
|
+
supersedeDuplicates: true,
|
|
52
|
+
supersedeThreshold: 0.97,
|
|
53
|
+
// Contradiction sits in the band between same-subject and duplicate: low
|
|
54
|
+
// enough that the flipped claim drops cosine out of the duplicate range, high
|
|
55
|
+
// enough that the two are still about the same thing. The O(n²) scan is
|
|
56
|
+
// bounded by contradictionScanMax so a large store degrades to a no-op rather
|
|
57
|
+
// than a stall.
|
|
58
|
+
detectContradictions: true,
|
|
59
|
+
contradictionMinSimilarity: 0.75,
|
|
60
|
+
contradictionScanMax: 1500,
|
|
38
61
|
maxStored: 5000,
|
|
39
62
|
// Archive retention: keep at most this many archived files, and delete
|
|
40
63
|
// anything older than archiveRetainDays. Both bounds apply.
|
|
@@ -56,12 +79,27 @@ function readObservation(rootDir, id) {
|
|
|
56
79
|
return readJsonOrEmpty(filePath, null);
|
|
57
80
|
}
|
|
58
81
|
|
|
59
|
-
function archiveObservation(rootDir, id) {
|
|
82
|
+
function archiveObservation(rootDir, id, supersededBy = null, reason = null) {
|
|
60
83
|
const src = path.join(rootDir, OBS_DIR, `${id}.json`);
|
|
61
84
|
if (!fs.existsSync(src)) return false;
|
|
62
85
|
const archiveDir = path.join(rootDir, ARCHIVE_DIR);
|
|
63
86
|
fs.mkdirSync(archiveDir, { recursive: true });
|
|
64
87
|
const dest = path.join(archiveDir, `${id}.json`);
|
|
88
|
+
|
|
89
|
+
// A superseded member records what replaced it and why before leaving the
|
|
90
|
+
// live store, so the archive stays auditable and a future undo knows both the
|
|
91
|
+
// winner and whether it was a restatement or a contradiction.
|
|
92
|
+
if (supersededBy) {
|
|
93
|
+
const record = readJsonOrEmpty(src, null);
|
|
94
|
+
if (record) {
|
|
95
|
+
record.supersededBy = supersededBy;
|
|
96
|
+
record.supersededAt = new Date().toISOString();
|
|
97
|
+
if (reason) record.supersededReason = reason;
|
|
98
|
+
fs.writeFileSync(dest, JSON.stringify(record, null, 2) + '\n');
|
|
99
|
+
fs.unlinkSync(src);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
65
103
|
fs.renameSync(src, dest);
|
|
66
104
|
return true;
|
|
67
105
|
}
|
|
@@ -71,6 +109,77 @@ function daysAgo(dateStr) {
|
|
|
71
109
|
return (Date.now() - new Date(dateStr).getTime()) / (24 * 60 * 60 * 1000);
|
|
72
110
|
}
|
|
73
111
|
|
|
112
|
+
// The winner of a near-duplicate cluster is the member worth keeping live:
|
|
113
|
+
// highest salience (confidence) first, then the most recent restatement, then a
|
|
114
|
+
// stable id tiebreak so the choice is deterministic across runs.
|
|
115
|
+
function rankForSupersede(a, b) {
|
|
116
|
+
const conf = (b.confidence || 0) - (a.confidence || 0);
|
|
117
|
+
if (conf !== 0) return conf;
|
|
118
|
+
const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
119
|
+
const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
120
|
+
if (bt !== at) return bt - at;
|
|
121
|
+
return String(a.id).localeCompare(String(b.id));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A contradiction resolves the opposite way from a restatement: the world
|
|
125
|
+
// changed, so the most recent claim wins regardless of salience; salience and a
|
|
126
|
+
// stable id only break a same-timestamp tie.
|
|
127
|
+
function rankByRecency(a, b) {
|
|
128
|
+
const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
129
|
+
const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
130
|
+
if (bt !== at) return bt - at;
|
|
131
|
+
const conf = (b.confidence || 0) - (a.confidence || 0);
|
|
132
|
+
if (conf !== 0) return conf;
|
|
133
|
+
return String(a.id).localeCompare(String(b.id));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Scan a record set for same-subject contradictions and archive the older of
|
|
138
|
+
* each contradicting pair behind a supersededBy pointer with reason
|
|
139
|
+
* 'contradiction'. Mutates nothing; returns the loser ids and the supersede
|
|
140
|
+
* entries. Bounded by scanMax — above it the O(n²) scan is skipped wholesale so
|
|
141
|
+
* a large store never stalls consolidation.
|
|
142
|
+
*/
|
|
143
|
+
async function scanContradictions(rootDir, records, settings) {
|
|
144
|
+
const superseded = [];
|
|
145
|
+
const losers = new Set();
|
|
146
|
+
if (records.length > settings.contradictionScanMax) {
|
|
147
|
+
return { superseded, losers, skipped: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ordered = [...records].sort(rankByRecency);
|
|
151
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
152
|
+
const newer = ordered[i];
|
|
153
|
+
if (losers.has(newer.id)) continue;
|
|
154
|
+
for (let j = i + 1; j < ordered.length; j++) {
|
|
155
|
+
const older = ordered[j];
|
|
156
|
+
if (losers.has(older.id)) continue;
|
|
157
|
+
if (!newer.embedding?.length || !older.embedding?.length) continue;
|
|
158
|
+
|
|
159
|
+
const sim = cosineSimilarity(newer.embedding, older.embedding);
|
|
160
|
+
const sameSubject = sim >= settings.contradictionMinSimilarity && sim < settings.supersedeThreshold;
|
|
161
|
+
if (!sameSubject) continue;
|
|
162
|
+
|
|
163
|
+
// The heuristic runs first and for free; the optional judge is consulted
|
|
164
|
+
// only on its misses (the value-swap case it abstains on). Awaiting covers
|
|
165
|
+
// both a sync local judge and a future async one.
|
|
166
|
+
const heuristic = detectContradiction(newer.summary, older.summary);
|
|
167
|
+
let contradicts = heuristic.contradicts;
|
|
168
|
+
if (!contradicts && settings.contradictionJudge?.judge) {
|
|
169
|
+
try { contradicts = !!((await settings.contradictionJudge.judge(newer, older))?.contradicts); }
|
|
170
|
+
catch { contradicts = false; }
|
|
171
|
+
}
|
|
172
|
+
if (!contradicts) continue;
|
|
173
|
+
|
|
174
|
+
if (archiveObservation(rootDir, older.id, newer.id, 'contradiction')) {
|
|
175
|
+
losers.add(older.id);
|
|
176
|
+
superseded.push({ id: older.id, supersededBy: newer.id, reason: 'contradiction' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { superseded, losers, skipped: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
74
183
|
/**
|
|
75
184
|
* Greedy single-pass clustering. Each observation is added to the existing
|
|
76
185
|
* cluster whose centroid has highest cosine similarity, provided that
|
|
@@ -145,9 +254,15 @@ async function buildSummary(cluster, summariser) {
|
|
|
145
254
|
* @param {number} [opts.similarityThreshold=0.95]
|
|
146
255
|
* @param {number} [opts.archiveAfterDays=60]
|
|
147
256
|
* @param {number} [opts.archiveBelowConfidence=0.5]
|
|
257
|
+
* @param {boolean} [opts.supersedeDuplicates=true] - archive tight restatements, keeping the highest-salience member live
|
|
258
|
+
* @param {number} [opts.supersedeThreshold=0.97] - cosine bar a member must clear against the winner to be superseded
|
|
259
|
+
* @param {boolean} [opts.detectContradictions=true] - archive the older of a same-subject contradicting pair (newest wins)
|
|
260
|
+
* @param {number} [opts.contradictionMinSimilarity=0.75] - cosine floor for two observations to count as same-subject
|
|
261
|
+
* @param {number} [opts.contradictionScanMax=1500] - skip the O(n²) contradiction scan above this many live records
|
|
262
|
+
* @param {object} [opts.contradictionJudge] - optional plugin with `judge(a, b) -> { contradicts }` for the value-swap case the heuristic misses
|
|
148
263
|
* @param {number} [opts.maxStored=5000]
|
|
149
264
|
* @param {object} [opts.summariser] - Compressor-shaped plugin
|
|
150
|
-
* @returns {Promise<{ clustersBefore: number, clusters: number, archived: string[], stored: number }>}
|
|
265
|
+
* @returns {Promise<{ clustersBefore: number, clusters: number, archived: string[], superseded: Array<{id: string, supersededBy: string, reason: string}>, contradictionScanSkipped: boolean, stored: number }>}
|
|
151
266
|
*/
|
|
152
267
|
export async function consolidate(rootDir, opts = {}) {
|
|
153
268
|
const settings = { ...DEFAULTS, ...opts };
|
|
@@ -177,7 +292,7 @@ export async function consolidate(rootDir, opts = {}) {
|
|
|
177
292
|
const clustersBefore = records.length;
|
|
178
293
|
|
|
179
294
|
const archived = [];
|
|
180
|
-
|
|
295
|
+
let liveRecords = records.filter((r) => {
|
|
181
296
|
const old = daysAgo(r.createdAt) > settings.archiveAfterDays;
|
|
182
297
|
const lowConfidence = r.confidence < settings.archiveBelowConfidence;
|
|
183
298
|
if (old && lowConfidence) {
|
|
@@ -187,8 +302,47 @@ export async function consolidate(rootDir, opts = {}) {
|
|
|
187
302
|
return true;
|
|
188
303
|
});
|
|
189
304
|
|
|
305
|
+
const superseded = [];
|
|
306
|
+
|
|
307
|
+
// Contradiction runs before clustering: a flipped claim sits below the
|
|
308
|
+
// duplicate threshold, so it would never cluster with what it contradicts and
|
|
309
|
+
// both stale and current statements would stay live. The older loser leaves
|
|
310
|
+
// the set so it is not clustered or returned by search.
|
|
311
|
+
let contradictionScanSkipped = false;
|
|
312
|
+
if (settings.detectContradictions) {
|
|
313
|
+
const result = await scanContradictions(rootDir, liveRecords, settings);
|
|
314
|
+
contradictionScanSkipped = result.skipped;
|
|
315
|
+
for (const id of result.losers) archived.push(id);
|
|
316
|
+
superseded.push(...result.superseded);
|
|
317
|
+
if (result.losers.size > 0) {
|
|
318
|
+
liveRecords = liveRecords.filter((r) => !result.losers.has(r.id));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
190
322
|
const clusters = greedyCluster(liveRecords, settings.similarityThreshold);
|
|
191
323
|
|
|
324
|
+
// Supersede tight restatements: rank each multi-member cluster by salience so
|
|
325
|
+
// the winner leads (it becomes the representative), then archive any member
|
|
326
|
+
// that duplicates the winner closely enough to be the same fact.
|
|
327
|
+
if (settings.supersedeDuplicates) {
|
|
328
|
+
for (const cluster of clusters) {
|
|
329
|
+
if (cluster.members.length < 2) continue;
|
|
330
|
+
cluster.members.sort(rankForSupersede);
|
|
331
|
+
const winner = cluster.members[0];
|
|
332
|
+
cluster.supersededIds = [];
|
|
333
|
+
for (const loser of cluster.members.slice(1)) {
|
|
334
|
+
const tight = winner.embedding?.length && loser.embedding?.length &&
|
|
335
|
+
cosineSimilarity(winner.embedding, loser.embedding) >= settings.supersedeThreshold;
|
|
336
|
+
if (!tight) continue;
|
|
337
|
+
if (archiveObservation(rootDir, loser.id, winner.id, 'restatement')) {
|
|
338
|
+
archived.push(loser.id);
|
|
339
|
+
superseded.push({ id: loser.id, supersededBy: winner.id, reason: 'restatement' });
|
|
340
|
+
cluster.supersededIds.push(loser.id);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
192
346
|
const consolidated = [];
|
|
193
347
|
for (const cluster of clusters) {
|
|
194
348
|
const representative = cluster.members[0];
|
|
@@ -206,6 +360,7 @@ export async function consolidate(rootDir, opts = {}) {
|
|
|
206
360
|
category: representative.category,
|
|
207
361
|
tags: [...new Set(cluster.members.flatMap((m) => m.tags || []))].slice(0, 10),
|
|
208
362
|
memberIds: cluster.members.map((m) => m.id),
|
|
363
|
+
supersededIds: cluster.supersededIds || [],
|
|
209
364
|
avgConfidence:
|
|
210
365
|
cluster.members.reduce((acc, m) => acc + (m.confidence || 0), 0) /
|
|
211
366
|
Math.max(cluster.members.length, 1),
|
|
@@ -247,6 +402,8 @@ export async function consolidate(rootDir, opts = {}) {
|
|
|
247
402
|
clustersBefore,
|
|
248
403
|
clusters: consolidated.length,
|
|
249
404
|
archived,
|
|
405
|
+
superseded,
|
|
406
|
+
contradictionScanSkipped,
|
|
250
407
|
archivePruned,
|
|
251
408
|
stored: consolidated.length,
|
|
252
409
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/contradiction-judge.mjs — optional LLM second opinion for the
|
|
3
|
+
* consolidation contradiction pass.
|
|
4
|
+
*
|
|
5
|
+
* The negation-polarity heuristic (lib/engine/contradiction.mjs) catches a
|
|
6
|
+
* flipped claim but abstains on a value swap with no negation cue — "auth uses
|
|
7
|
+
* RS256" vs "auth uses HS256" share every word yet disagree. Resolving that
|
|
8
|
+
* needs to know the two values are mutually exclusive, which is semantic
|
|
9
|
+
* judgment. This factory builds the judge consolidation consults only when the
|
|
10
|
+
* heuristic says "no".
|
|
11
|
+
*
|
|
12
|
+
* Offline-first: the judge is backed by a local Ollama model and the factory
|
|
13
|
+
* returns null when Ollama is not running or has no model, so consolidation
|
|
14
|
+
* degrades to heuristic-only with no provider and no key required. The status
|
|
15
|
+
* probe and the runner are injectable so the parse logic is testable without a
|
|
16
|
+
* live model.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { checkOllamaStatus, testModel } from '../ollama-manager.mjs';
|
|
20
|
+
|
|
21
|
+
function buildPrompt(a, b) {
|
|
22
|
+
const textA = (a?.summary || a?.content || '').slice(0, 400);
|
|
23
|
+
const textB = (b?.summary || b?.content || '').slice(0, 400);
|
|
24
|
+
return [
|
|
25
|
+
'Two short notes describe the same subject. Decide whether they directly',
|
|
26
|
+
'contradict — assert mutually exclusive facts (e.g. different algorithms,',
|
|
27
|
+
'opposite states, incompatible values). A restatement or an added detail is',
|
|
28
|
+
'NOT a contradiction. Answer with only YES or NO.',
|
|
29
|
+
'',
|
|
30
|
+
`Note A: ${textA}`,
|
|
31
|
+
`Note B: ${textB}`,
|
|
32
|
+
'Answer:',
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseVerdict(response) {
|
|
37
|
+
const match = String(response || '').toLowerCase().match(/\b(yes|no)\b/);
|
|
38
|
+
return { contradicts: !!match && match[1] === 'yes' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a contradiction judge, or null when no local model is available.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {() => {running?: boolean, models?: Array<{name?: string, model?: string}>}} [opts.statusFn]
|
|
46
|
+
* @param {(model: string, prompt: string) => {success?: boolean, response?: string}} [opts.runFn]
|
|
47
|
+
* @param {string} [opts.model] explicit model override (else env, else first available)
|
|
48
|
+
* @returns {{judge: (a: object, b: object) => {contradicts: boolean}} | null}
|
|
49
|
+
*/
|
|
50
|
+
export function createContradictionJudge({ statusFn = checkOllamaStatus, runFn = testModel, model } = {}) {
|
|
51
|
+
const status = statusFn();
|
|
52
|
+
if (!status?.running || !status.models?.length) return null;
|
|
53
|
+
|
|
54
|
+
const chosen = model || process.env.CONSTRUCT_CONTRADICTION_JUDGE_MODEL ||
|
|
55
|
+
status.models[0]?.name || status.models[0]?.model;
|
|
56
|
+
if (!chosen) return null;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
judge(a, b) {
|
|
60
|
+
try {
|
|
61
|
+
const result = runFn(chosen, buildPrompt(a, b));
|
|
62
|
+
if (!result?.success) return { contradicts: false };
|
|
63
|
+
return parseVerdict(result.response);
|
|
64
|
+
} catch {
|
|
65
|
+
return { contradicts: false };
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const __testing = { buildPrompt, parseVerdict };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/contradiction.mjs — deterministic "do these two observations disagree?"
|
|
3
|
+
*
|
|
4
|
+
* Restatement supersede (construct-xh6c) collapses near-duplicates that say the
|
|
5
|
+
* same thing. The open case is contradiction: two observations about the same
|
|
6
|
+
* subject that make opposing claims ("auth is supported" vs "auth is not
|
|
7
|
+
* supported"). A contradiction inherently shares most of its tokens but flips
|
|
8
|
+
* one — so its cosine sits *below* the duplicate threshold, which is why the
|
|
9
|
+
* consolidation scan looks in a suspicious band rather than inside a cluster.
|
|
10
|
+
*
|
|
11
|
+
* The signal this detects without an LLM is negation polarity: same claim
|
|
12
|
+
* words, opposite assertion. A value swap with no negation cue ("RS256" vs
|
|
13
|
+
* "HS256") is not caught here — that needs semantic judgment and is left to an
|
|
14
|
+
* optional `contradictionJudge` plugin wired into the consolidation pass.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Cues that flip an assertion. Apostrophes are stripped during tokenization, so
|
|
18
|
+
// contracted forms appear here without them (don't -> dont, isn't -> isnt).
|
|
19
|
+
const NEGATION_CUES = new Set([
|
|
20
|
+
'not', 'no', 'never', 'none', 'neither', 'nor', 'without', 'cannot',
|
|
21
|
+
'dont', 'doesnt', 'didnt', 'isnt', 'arent', 'wasnt', 'werent', 'wont',
|
|
22
|
+
'cant', 'couldnt', 'shouldnt', 'wouldnt', 'fails', 'failing', 'failed',
|
|
23
|
+
'unsupported', 'disabled', 'broken', 'missing', 'absent',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function tokenize(text) {
|
|
27
|
+
return String(text || '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/['’]/g, '')
|
|
30
|
+
.split(/[^a-z0-9]+/)
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function jaccard(a, b) {
|
|
35
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
36
|
+
let inter = 0;
|
|
37
|
+
for (const t of a) if (b.has(t)) inter += 1;
|
|
38
|
+
return inter / (a.size + b.size - inter);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether two observation texts contradict on negation polarity.
|
|
43
|
+
*
|
|
44
|
+
* Contradiction = the claim words (tokens minus negation cues) overlap heavily
|
|
45
|
+
* AND the two carry a different number of negation cues — one asserts, the other
|
|
46
|
+
* denies the same thing.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} textA
|
|
49
|
+
* @param {string} textB
|
|
50
|
+
* @param {{coreSimilarity?: number}} [opts] coreSimilarity: Jaccard floor on the
|
|
51
|
+
* cue-stripped token sets (default 0.6); below it the two are about different
|
|
52
|
+
* subjects, not a flipped claim.
|
|
53
|
+
* @returns {{contradicts: boolean, coreSimilarity: number, negDelta: number, reason: string}}
|
|
54
|
+
*/
|
|
55
|
+
export function detectContradiction(textA, textB, opts = {}) {
|
|
56
|
+
const floor = opts.coreSimilarity ?? 0.6;
|
|
57
|
+
const tokA = tokenize(textA);
|
|
58
|
+
const tokB = tokenize(textB);
|
|
59
|
+
const negA = tokA.filter((t) => NEGATION_CUES.has(t)).length;
|
|
60
|
+
const negB = tokB.filter((t) => NEGATION_CUES.has(t)).length;
|
|
61
|
+
const coreA = new Set(tokA.filter((t) => !NEGATION_CUES.has(t)));
|
|
62
|
+
const coreB = new Set(tokB.filter((t) => !NEGATION_CUES.has(t)));
|
|
63
|
+
|
|
64
|
+
const coreSimilarity = jaccard(coreA, coreB);
|
|
65
|
+
const negDelta = negA - negB;
|
|
66
|
+
const contradicts =
|
|
67
|
+
coreA.size > 0 && coreB.size > 0 &&
|
|
68
|
+
coreSimilarity >= floor &&
|
|
69
|
+
negDelta !== 0;
|
|
70
|
+
|
|
71
|
+
return { contradicts, coreSimilarity, negDelta, reason: 'negation-polarity' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const __testing = { tokenize, jaccard, NEGATION_CUES };
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* @lifecycle PreToolUse
|
|
23
23
|
* @matcher Write|Edit|MultiEdit
|
|
24
|
-
* @exits 0 =
|
|
24
|
+
* @exits 0 = always, emits audit event on violation
|
|
25
25
|
*/
|
|
26
26
|
import { readFileSync } from 'fs';
|
|
27
27
|
import { execFileSync } from 'child_process';
|
|
@@ -90,10 +90,10 @@ async function emitEvent(type, category, summary) {
|
|
|
90
90
|
const base = filePath.split('/').pop();
|
|
91
91
|
if (PROTECTED.some(r => r.test(base)) && isTrackedInGit(filePath)) {
|
|
92
92
|
process.stderr.write(
|
|
93
|
-
`[config-protection] The code quality rules are protected.
|
|
93
|
+
`[config-protection] The code quality rules are protected. You are editing a tracked config file. Ensure you are not weakening rules just to bypass errors.\nFile: ${filePath}\n`
|
|
94
94
|
);
|
|
95
|
-
await emitEvent('config.protection.violation', 'code-quality-config', `
|
|
96
|
-
process.exit(
|
|
95
|
+
await emitEvent('config.protection.violation', 'code-quality-config', `Edited code-quality-config: ${filePath}`);
|
|
96
|
+
process.exit(0);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
if (META_FILES.some(r => r.test(filePath))) {
|
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
import { readFileSync, existsSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// 500ms keeps the per-turn Stop-hook tax small (ADR-0029): reflection is
|
|
26
|
+
// best-effort and a LanceDB write that overruns the budget is left to the
|
|
27
|
+
// embed daemon's reconcile to backfill. CONSTRUCT_REFLECT_BUDGET_MS raises the
|
|
28
|
+
// ceiling where the write must complete deterministically (slow disks, tests).
|
|
29
|
+
const HARD_BUDGET_MS = Number(process.env.CONSTRUCT_REFLECT_BUDGET_MS) || 500;
|
|
26
30
|
const startedAt = Date.now();
|
|
27
31
|
|
|
28
32
|
// Opt-out before any heavy work — the env check is cheaper than parsing input.
|
|
@@ -118,6 +118,36 @@ function detectCopilotAvailability(homeDir = os.homedir()) {
|
|
|
118
118
|
return { hasFiles };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Detects active host-native sessions (GitHub, Anthropic, etc.).
|
|
123
|
+
* Returns a list of authenticated provider families.
|
|
124
|
+
*/
|
|
125
|
+
export function detectActiveSessions() {
|
|
126
|
+
const active = [];
|
|
127
|
+
|
|
128
|
+
// GitHub Copilot check
|
|
129
|
+
try {
|
|
130
|
+
const ghStatus = execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
131
|
+
if (ghStatus.includes("Logged in to github.com")) {
|
|
132
|
+
active.push("github-copilot");
|
|
133
|
+
}
|
|
134
|
+
} catch { /* not logged in or gh cli missing */ }
|
|
135
|
+
|
|
136
|
+
// Anthropic / Claude Code check
|
|
137
|
+
const home = os.homedir();
|
|
138
|
+
const claudeSettings = path.join(home, ".claude", "settings.json");
|
|
139
|
+
try {
|
|
140
|
+
if (fs.existsSync(claudeSettings)) {
|
|
141
|
+
const settings = JSON.parse(fs.readFileSync(claudeSettings, "utf8"));
|
|
142
|
+
if (settings?.primaryModel || settings?.mcpServers) {
|
|
143
|
+
active.push("anthropic");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch { /* invalid or missing config */ }
|
|
147
|
+
|
|
148
|
+
return active;
|
|
149
|
+
}
|
|
150
|
+
|
|
121
151
|
// `capability` is the honest, normalized functional classification per host:
|
|
122
152
|
// full-native — the host runs a multi-specialist chain itself (native
|
|
123
153
|
// dispatch or config-driven agents): Claude Code, OpenCode.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ingest/docling-remote.mjs — opt-in remote document conversion via Docling Serve.
|
|
3
|
+
*
|
|
4
|
+
* The `docling-remote` ingest strategy (ADR-0036) sends the document to a
|
|
5
|
+
* user-configured Docling Serve API instead of the offline Python sidecar —
|
|
6
|
+
* for zero-local-footprint installs that accept remote conversion. The endpoint
|
|
7
|
+
* contract is Docling Serve's `POST /v1/convert/file` (multipart field `files`,
|
|
8
|
+
* markdown at `document.md_content`, per docling-serve docs/usage.md).
|
|
9
|
+
*
|
|
10
|
+
* The serve URL must be configured (DOCLING_SERVE_URL); a missing URL fails loud —
|
|
11
|
+
* the user explicitly chose remote, so silently degrading to the sidecar would
|
|
12
|
+
* hide the misconfiguration. The call is bounded by the same timeout knob as the
|
|
13
|
+
* sidecar (CONSTRUCT_DOCLING_TIMEOUT_MS, default 600s).
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
19
|
+
|
|
20
|
+
export function resolveDoclingServeUrl(env = process.env) {
|
|
21
|
+
const raw = (env.DOCLING_SERVE_URL || '').trim();
|
|
22
|
+
return raw ? raw.replace(/\/+$/, '') : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert one local file through Docling Serve. Returns the shared extractor
|
|
27
|
+
* contract ({ text, extractionMethod, characters, truncated, droppedInfo }).
|
|
28
|
+
* Throws (fail-loud) on missing URL, HTTP error, non-success status, or timeout.
|
|
29
|
+
*/
|
|
30
|
+
export async function extractViaDoclingRemote({ filePath, maxChars = null, env = process.env, timeoutMs = null } = {}) {
|
|
31
|
+
const baseUrl = resolveDoclingServeUrl(env);
|
|
32
|
+
if (!baseUrl) {
|
|
33
|
+
const err = new Error(
|
|
34
|
+
"ingest.strategy is 'docling-remote' but DOCLING_SERVE_URL is not set. " +
|
|
35
|
+
"Point it at a Docling Serve instance, or switch ingest.strategy back to 'adapter' (offline sidecar).",
|
|
36
|
+
);
|
|
37
|
+
err.code = 'DOCLING_REMOTE_UNCONFIGURED';
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const budget = timeoutMs ?? (Number(env.CONSTRUCT_DOCLING_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS);
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), budget);
|
|
44
|
+
try {
|
|
45
|
+
const form = new FormData();
|
|
46
|
+
const bytes = fs.readFileSync(filePath);
|
|
47
|
+
form.append('files', new Blob([bytes]), path.basename(filePath));
|
|
48
|
+
|
|
49
|
+
const res = await fetch(`${baseUrl}/v1/convert/file`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
body: form,
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const err = new Error(`Docling Serve returned HTTP ${res.status} for ${path.basename(filePath)}`);
|
|
56
|
+
err.code = 'DOCLING_REMOTE_HTTP';
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
const status = data?.status;
|
|
61
|
+
const markdown = data?.document?.md_content;
|
|
62
|
+
if ((status && status !== 'success' && status !== 'partial_success') || typeof markdown !== 'string') {
|
|
63
|
+
const detail = Array.isArray(data?.errors) && data.errors.length ? `: ${JSON.stringify(data.errors).slice(0, 200)}` : '';
|
|
64
|
+
const err = new Error(`Docling Serve conversion ${status || 'returned no markdown'}${detail}`);
|
|
65
|
+
err.code = 'DOCLING_REMOTE_FAILED';
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const truncated = maxChars != null && markdown.length > maxChars;
|
|
70
|
+
const text = truncated ? markdown.slice(0, maxChars) : markdown;
|
|
71
|
+
return {
|
|
72
|
+
text,
|
|
73
|
+
extractionMethod: 'docling-remote',
|
|
74
|
+
characters: text.length,
|
|
75
|
+
truncated,
|
|
76
|
+
droppedInfo: status === 'partial_success'
|
|
77
|
+
? [{ type: 'partial-conversion', count: 1, reason: 'Docling Serve reported partial_success', recoverable: true }]
|
|
78
|
+
: [],
|
|
79
|
+
};
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err.name === 'AbortError') {
|
|
82
|
+
const e = new Error(`Docling Serve conversion timed out after ${budget}ms`);
|
|
83
|
+
e.code = 'DOCLING_REMOTE_TIMEOUT';
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/lib/ingest/strategy.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { resolveSetting } from '../config/project-config.mjs';
|
|
|
17
17
|
import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
|
|
18
18
|
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
19
19
|
|
|
20
|
-
export const INGEST_STRATEGIES = ['adapter', 'provider'];
|
|
20
|
+
export const INGEST_STRATEGIES = ['adapter', 'provider', 'docling-remote'];
|
|
21
21
|
export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
|
|
22
22
|
|
|
23
23
|
// Orchestration is a second, independent axis from extraction (adapter|provider):
|
package/lib/init-unified.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { multiSelect } from './tty-prompts.mjs';
|
|
|
23
23
|
import { execSync, spawnSync } from 'node:child_process';
|
|
24
24
|
import { stageProjectAdapters } from './install/stage-project.mjs';
|
|
25
25
|
import { missingIgnorePatterns, isConstructPackageRepo } from './host-disposition.mjs';
|
|
26
|
+
import { HOST_KEYS, displayNameToKey } from './platforms/capabilities.mjs';
|
|
26
27
|
|
|
27
28
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
28
29
|
const ROOT_DIR = path.join(__dirname, "..");
|
|
@@ -63,8 +64,7 @@ const commitBootstrap = args.includes("--commit-bootstrap");
|
|
|
63
64
|
// one, --all-hosts writes every adapter set. Copilot (`.github/`) is opt-in only
|
|
64
65
|
// — never written by detection — so init never touches a repo's CI directory
|
|
65
66
|
// without --with-copilot.
|
|
66
|
-
const
|
|
67
|
-
const withHostFlags = new Set(HOST_FLAG_KEYS.filter((k) => args.includes(`--with-${k}`)));
|
|
67
|
+
const withHostFlags = new Set(HOST_KEYS.filter((k) => args.includes(`--with-${k}`)));
|
|
68
68
|
const allHosts = args.includes("--all-hosts");
|
|
69
69
|
|
|
70
70
|
// Active profile selector. `--profile=<id>` writes the field into the
|
|
@@ -809,23 +809,19 @@ async function resolveAdapterHosts() {
|
|
|
809
809
|
const detected = new Set();
|
|
810
810
|
try {
|
|
811
811
|
const { detectHostCapabilities } = await import('./host-capabilities.mjs');
|
|
812
|
-
const nameToKey =
|
|
813
|
-
'Claude Code': 'claude',
|
|
814
|
-
'OpenCode': 'opencode',
|
|
815
|
-
'Codex': 'codex',
|
|
816
|
-
'VS Code': 'vscode',
|
|
817
|
-
'Cursor': 'cursor',
|
|
818
|
-
'Copilot': 'copilot',
|
|
819
|
-
};
|
|
812
|
+
const nameToKey = displayNameToKey();
|
|
820
813
|
for (const cap of detectHostCapabilities()) {
|
|
821
814
|
if (cap.availability === 'installed' && nameToKey[cap.host]) detected.add(nameToKey[cap.host]);
|
|
822
815
|
}
|
|
823
816
|
} catch { /* detection is advisory; fall back to flags + baseline */ }
|
|
824
817
|
detected.delete('copilot');
|
|
825
818
|
const selected = new Set([...detected, ...withHostFlags]);
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
819
|
+
|
|
820
|
+
// An empty selection writes no adapters: nothing detected and no --with-<host>
|
|
821
|
+
// flag means the user is guided by docs to run sync with an explicit host,
|
|
822
|
+
// rather than scaffolding a sidecar they did not ask for.
|
|
823
|
+
|
|
824
|
+
return HOST_KEYS.filter((k) => selected.has(k));
|
|
829
825
|
}
|
|
830
826
|
|
|
831
827
|
async function main() {
|