@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
@@ -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()) || !(await client.isPgvectorEnabled())) {
30
- result.skipped = 'no-pg';
29
+ if (!(await client.isHealthy())) {
30
+ result.skipped = 'unhealthy';
31
31
  return result;
32
32
  }
33
33
 
@@ -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
- // Write
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
- const liveRecords = records.filter((r) => {
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 = pass | 2 = block tool call
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. Fix the code to meet the existing standards don't weaken the rules.\nFile: ${filePath}\n`
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', `Blocked edit to code-quality-config: ${filePath}`);
96
- process.exit(2);
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
- const HARD_BUDGET_MS = 500;
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
+ }
@@ -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):
@@ -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 HOST_FLAG_KEYS = ["claude", "codex", "opencode", "vscode", "cursor", "copilot"];
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
- if (selected.has('vscode')) selected.add('claude');
827
- if (selected.size === 0) selected.add('claude');
828
- return HOST_FLAG_KEYS.filter((k) => selected.has(k));
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() {