@geraldmaron/construct 1.0.18 → 1.0.20

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 (93) hide show
  1. package/README.md +8 -4
  2. package/bin/construct +105 -3
  3. package/db/schema/003_observation_reconciliation.sql +14 -0
  4. package/lib/bootstrap/resources.mjs +0 -1
  5. package/lib/cli-commands.mjs +57 -6
  6. package/lib/comment-lint.mjs +44 -0
  7. package/lib/config/schema.mjs +31 -1
  8. package/lib/contracts/validate.mjs +106 -0
  9. package/lib/decisions/enforced-baseline.json +25 -0
  10. package/lib/decisions/golden.mjs +87 -0
  11. package/lib/decisions/precedence.mjs +46 -0
  12. package/lib/decisions/registry.mjs +469 -0
  13. package/lib/deployment/parity-contract.mjs +148 -0
  14. package/lib/document-ingest.mjs +71 -6
  15. package/lib/embed/cli.mjs +11 -0
  16. package/lib/embed/conflict-detection.mjs +4 -4
  17. package/lib/embed/customer-profiles.mjs +1 -1
  18. package/lib/embed/reconcile.mjs +60 -0
  19. package/lib/embedded-contract/capability.mjs +3 -3
  20. package/lib/embedded-contract/contract-version.mjs +1 -1
  21. package/lib/embedded-contract/execution.mjs +184 -0
  22. package/lib/embedded-contract/index.mjs +16 -0
  23. package/lib/embedded-contract/ingest.mjs +61 -7
  24. package/lib/gates-audit.mjs +2 -2
  25. package/lib/hooks/_lib/output-mode.mjs +101 -0
  26. package/lib/hooks/config-protection.mjs +22 -3
  27. package/lib/hooks/guard-bash.mjs +1 -1
  28. package/lib/hooks/session-start.mjs +13 -1
  29. package/lib/ingest/provider-extract.mjs +200 -0
  30. package/lib/ingest/strategy.mjs +95 -0
  31. package/lib/init-docs.mjs +1 -0
  32. package/lib/mcp/server.mjs +72 -4
  33. package/lib/mcp/tools/embedded-contract.mjs +17 -1
  34. package/lib/mode-commands.mjs +6 -8
  35. package/lib/observation-store.mjs +16 -2
  36. package/lib/opencode-telemetry.mjs +1 -1
  37. package/lib/orchestration/run-store.mjs +82 -0
  38. package/lib/orchestration/runtime.mjs +240 -0
  39. package/lib/roles/cli.mjs +10 -2
  40. package/lib/roles/gateway.mjs +50 -1
  41. package/lib/scheduler/index.mjs +31 -0
  42. package/lib/server/index.mjs +13 -3
  43. package/lib/server/static/index.html +1 -1
  44. package/lib/setup.mjs +6 -0
  45. package/lib/storage/hybrid-query.mjs +49 -38
  46. package/lib/storage/rrf.mjs +42 -0
  47. package/lib/storage/vector-client.mjs +18 -3
  48. package/lib/telemetry/backends/local.mjs +1 -1
  49. package/lib/telemetry/skill-calls.mjs +1 -1
  50. package/lib/templates/visual-requirements.mjs +84 -0
  51. package/package.json +10 -1
  52. package/rules/common/comments.md +3 -0
  53. package/rules/common/no-fabrication.md +3 -0
  54. package/rules/common/precedence.md +19 -0
  55. package/rules/common/research-sources.md +32 -0
  56. package/rules/common/research.md +59 -2
  57. package/skills/roles/data-engineer.pipeline.md +13 -1
  58. package/skills/roles/debugger.md +9 -0
  59. package/skills/roles/designer.accessibility.md +13 -3
  60. package/skills/roles/designer.md +10 -0
  61. package/skills/roles/engineer.platform.md +8 -0
  62. package/skills/roles/operator.md +10 -1
  63. package/skills/roles/operator.release.md +8 -0
  64. package/skills/roles/operator.sre.md +10 -1
  65. package/skills/roles/orchestrator.md +9 -2
  66. package/skills/roles/product-manager.business-strategy.md +10 -1
  67. package/skills/roles/researcher.explorer.md +12 -1
  68. package/skills/roles/researcher.ux.md +13 -1
  69. package/skills/roles/reviewer.devil-advocate.md +14 -2
  70. package/skills/roles/reviewer.evaluator.md +17 -4
  71. package/skills/roles/reviewer.trace.md +12 -1
  72. package/skills/roles/security.legal-compliance.md +8 -0
  73. package/skills/roles/security.md +11 -0
  74. package/specialists/contracts.json +18 -0
  75. package/specialists/prompts/cx-researcher.md +4 -2
  76. package/templates/docs/backlog-proposal.md +1 -1
  77. package/templates/docs/customer-profile.md +1 -1
  78. package/templates/docs/evidence-brief.md +5 -1
  79. package/templates/docs/incident-report.md +37 -21
  80. package/templates/docs/prfaq.md +2 -2
  81. package/templates/docs/product-intelligence-report.md +1 -1
  82. package/templates/docs/research-brief.md +8 -6
  83. package/templates/docs/research-finding.md +32 -7
  84. package/templates/docs/rfc.md +13 -1
  85. package/templates/docs/runbook.md +20 -1
  86. package/templates/docs/signal-brief.md +4 -1
  87. package/templates/docs/skill-artifact.md +27 -7
  88. package/templates/docs/strategy.md +23 -2
  89. package/lib/bootstrap/lazy-install.mjs +0 -161
  90. package/lib/embed/jobs/vector-sync.mjs +0 -198
  91. package/lib/knowledge/postgres-search.mjs +0 -132
  92. package/lib/services/pattern-promotion-service.mjs +0 -167
  93. package/lib/storage/unified-storage.mjs +0 -550
@@ -0,0 +1,148 @@
1
+ /**
2
+ * lib/deployment/parity-contract.mjs — capability parity across deployment modes.
3
+ *
4
+ * Declares, for every dimension of lib/deployment-mode.mjs RESOURCE_TOPOLOGY,
5
+ * whether the capability is `parity` (present in every mode, backend may differ)
6
+ * or `mode-specific` (present only in some modes, by design). validateParityContract
7
+ * cross-checks the declaration against the live topology so the two cannot drift:
8
+ * a new topology dimension with no declaration, or a `parity` capability absent in
9
+ * a mode, fails the check. See ADR-0016.
10
+ *
11
+ * Scope is the static guard only. A live dual-run harness (run a capability in
12
+ * solo and team, compare) is the stronger complement, tracked separately — it
13
+ * needs a provisioned database and so cannot gate every change.
14
+ */
15
+
16
+ import { DEPLOYMENT_MODES, resolveResourceMode } from '../deployment-mode.mjs';
17
+
18
+ export const PARITY_CLASSES = ['parity', 'mode-specific'];
19
+
20
+ // Backend value 'optional' means the capability is present but may run on a
21
+ // degraded local backend (e.g. file BM25 in place of pgvector); it still counts
22
+ // as present for parity purposes.
23
+
24
+ export const PARITY_CONTRACT = {
25
+ queue: {
26
+ parityClass: 'parity',
27
+ capability: 'Work intake queue (enqueue, list pending, mark processed)',
28
+ rationale: 'Filesystem queue (solo) and Postgres queue (team) implement one interface via lib/intake/queue.mjs.',
29
+ evidence: 'tests/intake-queue-factory.test.mjs',
30
+ },
31
+ memory: {
32
+ parityClass: 'parity',
33
+ capability: 'Observation/memory store',
34
+ rationale: 'Local index (solo) and shared Postgres (team) both satisfy the observation schema; Postgres is a secondary store.',
35
+ evidence: 'lib/observation-store.mjs',
36
+ },
37
+ database: {
38
+ parityClass: 'parity',
39
+ capability: 'Knowledge retrieval and durable state',
40
+ rationale: 'Present in every mode; solo degrades to file BM25 + local vectors when Postgres is absent.',
41
+ evidence: 'lib/storage/hybrid-query.mjs',
42
+ },
43
+ telemetry: {
44
+ parityClass: 'parity',
45
+ capability: 'Trace capture',
46
+ rationale: 'Local JSONL is the default in every mode; remote export (OTel/Langfuse) is opt-in, not required for the capability.',
47
+ evidence: 'lib/telemetry/client.mjs',
48
+ },
49
+ workers: {
50
+ parityClass: 'mode-specific',
51
+ capability: 'Background worker pool that claims queued work',
52
+ rationale: 'Solo runs work in-process in the user session; the async Docker/isolated worker pool exists only in team and enterprise.',
53
+ evidence: 'lib/deployment-mode.mjs',
54
+ },
55
+ policy: {
56
+ parityClass: 'mode-specific',
57
+ capability: 'Server-side policy enforcement',
58
+ rationale: 'Solo enforces via local hooks (lightweight); server-side/enforceable policy is a team and enterprise capability.',
59
+ evidence: 'lib/deployment-mode.mjs',
60
+ },
61
+ mcp: {
62
+ parityClass: 'mode-specific',
63
+ capability: 'Brokered MCP with policy, audit, and rate limiting',
64
+ rationale: 'Solo calls MCP tools directly; the broker (and its audit/rate-limit/policy) exists only in team and enterprise.',
65
+ evidence: 'lib/mcp/broker.mjs',
66
+ },
67
+ };
68
+
69
+ function topologyByDimension() {
70
+ const byMode = {};
71
+ for (const mode of DEPLOYMENT_MODES) byMode[mode] = resolveResourceMode(mode);
72
+ const dimensions = new Set();
73
+ for (const mode of DEPLOYMENT_MODES) for (const dim of Object.keys(byMode[mode])) dimensions.add(dim);
74
+ return { byMode, dimensions: [...dimensions] };
75
+ }
76
+
77
+ /**
78
+ * Cross-check the declared parity contract against the live resource topology.
79
+ * Returns { ok, errors }. Failures: an undeclared topology dimension, a declared
80
+ * capability with no topology dimension, an invalid parityClass, a `parity`
81
+ * capability missing from some mode, or a `mode-specific` capability whose backend
82
+ * is identical across all modes (then it is not actually mode-specific).
83
+ */
84
+ export function validateParityContract() {
85
+ const { byMode, dimensions } = topologyByDimension();
86
+ const errors = [];
87
+
88
+ for (const dim of dimensions) {
89
+ if (!PARITY_CONTRACT[dim]) {
90
+ errors.push(`topology dimension "${dim}" has no parity declaration (declare it parity or mode-specific in PARITY_CONTRACT)`);
91
+ }
92
+ }
93
+
94
+ for (const [dim, decl] of Object.entries(PARITY_CONTRACT)) {
95
+ if (!dimensions.includes(dim)) {
96
+ errors.push(`parity declaration "${dim}" has no matching topology dimension`);
97
+ continue;
98
+ }
99
+ if (!PARITY_CLASSES.includes(decl.parityClass)) {
100
+ errors.push(`"${dim}": invalid parityClass "${decl.parityClass}"`);
101
+ continue;
102
+ }
103
+ const values = DEPLOYMENT_MODES.map((m) => byMode[m][dim]);
104
+ if (decl.parityClass === 'parity') {
105
+ const missing = DEPLOYMENT_MODES.filter((m) => !byMode[m][dim]);
106
+ if (missing.length > 0) {
107
+ errors.push(`"${dim}" is declared parity but absent in mode(s): ${missing.join(', ')}`);
108
+ }
109
+ } else if (new Set(values).size === 1) {
110
+ errors.push(`"${dim}" is declared mode-specific but its backend is identical across all modes (${values[0]})`);
111
+ }
112
+ if (!decl.rationale) errors.push(`"${dim}": mode-specific and parity declarations both require a rationale`);
113
+ }
114
+
115
+ return { ok: errors.length === 0, errors };
116
+ }
117
+
118
+ export function describeParityContract() {
119
+ const { byMode } = topologyByDimension();
120
+ const rows = Object.entries(PARITY_CONTRACT).map(([dim, decl]) => {
121
+ const backends = DEPLOYMENT_MODES.map((m) => `${m}:${byMode[m][dim]}`).join(' ');
122
+ return { dimension: dim, parityClass: decl.parityClass, backends, capability: decl.capability, rationale: decl.rationale };
123
+ });
124
+ return rows;
125
+ }
126
+
127
+ export async function runDeploymentParityCli(args = []) {
128
+ if (args.includes('--json')) {
129
+ const { ok, errors } = validateParityContract();
130
+ process.stdout.write(JSON.stringify({ ok, errors, contract: describeParityContract() }, null, 2) + '\n');
131
+ if (!ok) process.exit(1);
132
+ return;
133
+ }
134
+
135
+ const { ok, errors } = validateParityContract();
136
+ for (const row of describeParityContract()) {
137
+ const mark = row.parityClass === 'parity' ? '=' : '≠';
138
+ process.stdout.write(`${mark} ${row.dimension.padEnd(10)} [${row.parityClass}] ${row.backends}\n ${row.capability}\n`);
139
+ }
140
+ process.stdout.write('\n');
141
+ if (ok) {
142
+ process.stdout.write('✓ parity contract reconciled with deployment topology\n');
143
+ return;
144
+ }
145
+ process.stderr.write(`✗ parity contract has ${errors.length} error(s):\n`);
146
+ for (const e of errors) process.stderr.write(` - ${e}\n`);
147
+ process.exit(1);
148
+ }
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Reuses the shared extraction backends from lib/document-extract.mjs, writes
5
5
  * markdown outputs into retrieval-friendly project paths, and can optionally
6
- * trigger storage sync for SQL/vector indexing.
6
+ * trigger storage sync for SQL/vector indexing. The extraction step honors a
7
+ * resolved ingest strategy (adapter | provider) with an explicit fallback policy
8
+ * and records the selected strategy/model in the returned `ingestion` block.
7
9
  */
8
10
  import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
9
11
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from 'node:path';
@@ -11,6 +13,9 @@ import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata,
11
13
  import { syncFileStateToSql } from './storage/sync.mjs';
12
14
  import { stampFrontmatter } from './doc-stamp.mjs';
13
15
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
16
+ import { loadProjectConfig } from './config/project-config.mjs';
17
+ import { resolveIngestStrategy, INGEST_STRATEGIES } from './ingest/strategy.mjs';
18
+ import { extractViaProvider } from './ingest/provider-extract.mjs';
14
19
 
15
20
  const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
16
21
 
@@ -121,6 +126,38 @@ function resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target }) {
121
126
  return join(resolvedDir, `${basename(sourcePath)}.md`);
122
127
  }
123
128
 
129
+ function extractViaAdapter(sourcePath, { highFidelity, maxChars }) {
130
+ return highFidelity
131
+ ? extractDocumentTextAsync(sourcePath, { maxChars })
132
+ : extractDocumentText(sourcePath, { maxChars });
133
+ }
134
+
135
+ async function extractWithStrategy(sourcePath, { strategy, fallback, model, provider, highFidelity, maxChars, env }) {
136
+ const primary = strategy === 'provider' ? 'provider' : 'adapter';
137
+ const run = (mode) => (mode === 'provider'
138
+ ? extractViaProvider({ filePath: sourcePath, model, provider, maxChars, env })
139
+ : extractViaAdapter(sourcePath, { highFidelity, maxChars }));
140
+
141
+ try {
142
+ const extracted = await run(primary);
143
+ return { extracted, strategyUsed: primary, fallbackApplied: null, error: null };
144
+ } catch (primaryErr) {
145
+ if (fallback === 'none' || fallback === primary) {
146
+ const err = new Error(`ingest ${primary} strategy failed and fallback is "${fallback}": ${primaryErr.message}`);
147
+ err.code = primaryErr.code || 'INGEST_STRATEGY_FAILED';
148
+ err.cause = primaryErr;
149
+ throw err;
150
+ }
151
+ const extracted = await run(fallback);
152
+ return {
153
+ extracted,
154
+ strategyUsed: fallback,
155
+ fallbackApplied: { from: primary, to: fallback, reason: primaryErr.message, code: primaryErr.code || 'INGEST_STRATEGY_FAILED' },
156
+ error: null,
157
+ };
158
+ }
159
+ }
160
+
124
161
  export async function ingestDocuments(inputPaths, {
125
162
  cwd = process.cwd(),
126
163
  outputPath = null,
@@ -129,6 +166,7 @@ export async function ingestDocuments(inputPaths, {
129
166
  sync = false,
130
167
  strict = false,
131
168
  highFidelity = true,
169
+ strategy = null,
132
170
  env = process.env,
133
171
  } = {}) {
134
172
  if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
@@ -143,12 +181,24 @@ export async function ingestDocuments(inputPaths, {
143
181
  throw new Error('No supported document files found');
144
182
  }
145
183
 
184
+ const { config } = loadProjectConfig(cwd, env);
185
+ const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy });
186
+
146
187
  const results = [];
147
188
  let totalDrops = 0;
189
+ let fallbackApplied = null;
148
190
  for (const sourcePath of files) {
149
- const extracted = highFidelity
150
- ? await extractDocumentTextAsync(sourcePath, { maxChars: 200_000 })
151
- : extractDocumentText(sourcePath, { maxChars: 200_000 });
191
+ const routed = await extractWithStrategy(sourcePath, {
192
+ strategy: resolvedStrategy.strategy,
193
+ fallback: resolvedStrategy.fallback,
194
+ model: resolvedStrategy.model,
195
+ provider: resolvedStrategy.provider,
196
+ highFidelity,
197
+ maxChars: 200_000,
198
+ env,
199
+ });
200
+ const extracted = routed.extracted;
201
+ if (routed.fallbackApplied) fallbackApplied = routed.fallbackApplied;
152
202
  totalDrops += (extracted.droppedInfo ?? []).reduce((a, d) => a + (Number(d.count) || 1), 0);
153
203
  const metadata = extractDocumentMetadata(sourcePath);
154
204
  const targetPath = resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target });
@@ -213,6 +263,13 @@ export async function ingestDocuments(inputPaths, {
213
263
  droppedInfo: results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath }))),
214
264
  droppedCount: totalDrops,
215
265
  highFidelity,
266
+ ingestion: {
267
+ strategy: resolvedStrategy.strategy,
268
+ fallback: resolvedStrategy.fallback,
269
+ model: resolvedStrategy.model,
270
+ provider: resolvedStrategy.provider,
271
+ fallbackApplied,
272
+ },
216
273
  };
217
274
  }
218
275
 
@@ -224,26 +281,34 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
224
281
  let sync = false;
225
282
  let strict = false;
226
283
  let highFidelity = true;
284
+ let strategy = null;
227
285
 
228
286
  for (const arg of argv) {
229
287
  if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
230
288
  else if (arg.startsWith('--out-dir=')) outputDir = arg.split('=').slice(1).join('=');
231
289
  else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
290
+ else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
291
+ else if (arg === '--strategy') strategy = '';
232
292
  else if (arg === '--sync') sync = true;
233
293
  else if (arg === '--strict') strict = true;
234
294
  else if (arg === '--legacy-extractor') highFidelity = false;
295
+ else if (strategy === '') strategy = arg;
235
296
  else inputs.push(arg);
236
297
  }
237
298
 
238
299
  if (inputs.length === 0) {
239
300
  throw new Error(
240
301
  `Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
241
- `[--target=sibling|knowledge/<subdir>] [--sync] [--strict] [--legacy-extractor]\n` +
302
+ `[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--sync] [--strict] [--legacy-extractor]\n` +
303
+ ` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
242
304
  ` --strict: exit non-zero if any extraction drops occur\n` +
243
305
  ` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
244
306
  ` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
245
307
  );
246
308
  }
309
+ if (strategy !== null && strategy !== '' && !INGEST_STRATEGIES.includes(strategy)) {
310
+ throw new Error(`Unsupported strategy: ${strategy}. Valid strategies: ${INGEST_STRATEGIES.join(', ')}`);
311
+ }
247
312
  const knowledgeTargets = KNOWLEDGE_SUBDIRS.map((s) => `knowledge/${s}`);
248
313
  if (!['sibling', ...knowledgeTargets].includes(target)) {
249
314
  throw new Error(
@@ -251,5 +316,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
251
316
  );
252
317
  }
253
318
 
254
- return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, env });
319
+ return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, env });
255
320
  }
package/lib/embed/cli.mjs CHANGED
@@ -533,6 +533,17 @@ async function cmdEmbedMigrateModel(args) {
533
533
  process.stdout.write(
534
534
  `Done. embeddingsSynced=${result.embeddingsSynced} model=${result.embeddingModel}\n`
535
535
  );
536
+
537
+ // Observations share the embedding model/dimension. Re-dimension their
538
+ // column too, then reconcile re-embeds them from the local source so both
539
+ // corpora live in one vector space after a model change.
540
+ await client.unsafe(
541
+ `BEGIN; TRUNCATE TABLE construct_observations; ` +
542
+ `ALTER TABLE construct_observations ALTER COLUMN embedding TYPE vector(${targetDim}); COMMIT;`
543
+ );
544
+ const { reconcileObservationEmbeddings } = await import('./reconcile.mjs');
545
+ const recon = await reconcileObservationEmbeddings(process.cwd());
546
+ process.stdout.write(`Observations reconciled: re-embedded ${recon.reembedded}.\n`);
536
547
  } catch (err) {
537
548
  process.stderr.write(`migrate-model failed: ${err.message}\n`);
538
549
  process.exit(1);
@@ -15,7 +15,7 @@
15
15
  * Rebuilt on first use or when artifact file count changes.
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, renameSync } from 'node:fs';
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
  import { embed, cosineSimilarity, extractTextFromPacket } from './semantic.mjs';
21
21
  import { listArtifacts } from './artifact.mjs';
@@ -253,9 +253,9 @@ export async function detectInterItemConflicts(pendingPackets, { threshold = 0.7
253
253
  */
254
254
  export function resetArtifactIndex() {
255
255
  _artifactIndex = null;
256
- if (existsSync(INDEX_FILE)) {
257
- const { unlinkSync } = require('node:fs');
258
- try { unlinkSync(INDEX_FILE); } catch { /* ignore */ }
256
+ const { indexFile } = indexPaths();
257
+ if (existsSync(indexFile)) {
258
+ try { unlinkSync(indexFile); } catch { /* ignore */ }
259
259
  }
260
260
  }
261
261
 
@@ -273,7 +273,7 @@ export function searchCustomerProfiles(query) {
273
273
  id: entry.id,
274
274
  name: entry.name,
275
275
  status: entry.status,
276
- path: join(PROFILES_DIR, `${slugify(entry.name)}-${entry.id}.md`),
276
+ path: join(profilesDir, `${slugify(entry.name)}-${entry.id}.md`),
277
277
  });
278
278
  }
279
279
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * lib/embed/reconcile.mjs — bring the Postgres observation index back in sync
3
+ * with the local source of truth.
4
+ *
5
+ * Observations are embedded inline on write (lib/observation-store.mjs), but
6
+ * the inline write is skipped whenever Postgres is unreachable at creation time
7
+ * (the local hashing-bow fallback is used instead), and a model change leaves
8
+ * every prior row embedded in a stale vector space. This pass is the safety
9
+ * net: for each local observation it compares the (content_hash, model) the
10
+ * pg row was embedded with against the live content + current model, and
11
+ * re-embeds ONLY the rows that are missing or stale. Idempotent — a second run
12
+ * with no changes re-embeds nothing.
13
+ *
14
+ * Reconciliation-on-sync is preferred over a fixed-interval polling cron (see
15
+ * docs/research/vector-search-best-practices.md): the pass runs where Construct
16
+ * already touches storage (the model-change re-embed, the storage CLI), keys
17
+ * idempotency on a content hash, and re-embeds on model change.
18
+ */
19
+
20
+ import { VectorClient } from '../storage/vector-client.mjs';
21
+ import { embedText as embedTextEngine, getEmbeddingModelInfo } from '../storage/embeddings-engine.mjs';
22
+ import { listObservations, getObservation, observationSearchText, observationContentHash } from '../observation-store.mjs';
23
+
24
+ export async function reconcileObservationEmbeddings(rootDir, { env = process.env, limit = Infinity, embed = embedTextEngine, modelId = null } = {}) {
25
+ const client = new VectorClient({ env });
26
+ const result = { checked: 0, reembedded: 0, model: null, skipped: null };
27
+
28
+ try {
29
+ if (!(await client.isHealthy()) || !(await client.isPgvectorEnabled())) {
30
+ result.skipped = 'no-pg';
31
+ return result;
32
+ }
33
+
34
+ const currentModel = modelId || (await getEmbeddingModelInfo({ env })).model;
35
+ result.model = currentModel;
36
+
37
+ const ids = (listObservations(rootDir, { limit: Number.MAX_SAFE_INTEGER }) || []).map((e) => e.id);
38
+ const fingerprints = await client.getObservationFingerprints(ids);
39
+
40
+ for (const id of ids) {
41
+ if (result.reembedded >= limit) break;
42
+ const obs = getObservation(rootDir, id);
43
+ if (!obs) continue;
44
+ result.checked += 1;
45
+
46
+ const searchText = observationSearchText(obs);
47
+ const hash = observationContentHash(searchText);
48
+ const fp = fingerprints.get(id);
49
+ if (fp && fp.contentHash === hash && fp.model === currentModel) continue;
50
+
51
+ const { embedding, model } = await embed(searchText, { env });
52
+ await client.storeObservation({ ...obs, embedding, contentHash: hash, model: model || currentModel });
53
+ result.reembedded += 1;
54
+ }
55
+
56
+ return result;
57
+ } finally {
58
+ await client.close();
59
+ }
60
+ }
@@ -32,9 +32,9 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
32
32
 
33
33
  function buildInterfaces() {
34
34
  return [
35
- { surface: 'cli', contractVersion: CONTRACT_VERSION, entrypoints: ['construct capability describe --json', 'construct models resolve --json', 'construct intake classify --json', 'construct graph recommend --json', 'construct workflow invoke --json'] },
36
- { surface: 'mcp', contractVersion: CONTRACT_VERSION, tools: ['capability_describe', 'model_resolve', 'triage_recommend', 'workflow_invoke'] },
37
- { surface: 'sdk', contractVersion: CONTRACT_VERSION, module: 'construct/embedded-contract', exports: ['describeCapabilities', 'resolveEmbeddedModel', 'recommendPlan', 'invokeWorkflow'] },
35
+ { surface: 'cli', contractVersion: CONTRACT_VERSION, entrypoints: ['construct capability describe --json', 'construct models resolve --json', 'construct execution resolve --json', 'construct intake classify --json', 'construct graph recommend --json', 'construct workflow invoke --json'] },
36
+ { surface: 'mcp', contractVersion: CONTRACT_VERSION, tools: ['capability_describe', 'model_resolve', 'construct_execution_resolve', 'triage_recommend', 'workflow_invoke'] },
37
+ { surface: 'sdk', contractVersion: CONTRACT_VERSION, module: 'construct/embedded-contract', exports: ['describeCapabilities', 'resolveEmbeddedModel', 'resolveExecution', 'recommendPlan', 'invokeWorkflow'] },
38
38
  ];
39
39
  }
40
40
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { parseSemver, compareSemver } from '../version.mjs';
17
17
 
18
- export const CONTRACT_VERSION = '1.0.0';
18
+ export const CONTRACT_VERSION = '1.1.0';
19
19
 
20
20
  // A client built against an older minor of the same major still works, because
21
21
  // minor bumps are additive-only; a different major is incompatible either way.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * lib/embedded-contract/execution.mjs — embedded execution-capability contract.
3
+ *
4
+ * An embedded host can request Construct orchestration but cannot otherwise tell
5
+ * whether a run actually engaged personas/skills/routing or degraded to a
6
+ * prompt-only envelope — and if it degraded, why. This contract answers that
7
+ * before or at workflow start: given a requested strategy and the host's model
8
+ * context, it derives an `executionMode`, the Construct capabilities that mode
9
+ * contributes, and an explicit degradation reason.
10
+ *
11
+ * Honesty boundary (ADR-0019): orchestration in the embedded layer is
12
+ * DESCRIPTIVE, not enforced. Construct returns a plan; the host runtime performs
13
+ * the reasoning. So this contract reports what Construct PLANNED and can RESOLVE
14
+ * a model for — never an observation that the host executed personas. Every
15
+ * response carries a `semantics` disclaimer to that effect, and
16
+ * `constructCapabilitiesActive` describes Construct's contribution, not proof of
17
+ * host execution. Claiming observed orchestration would violate the
18
+ * no-fabrication rule. A `same-family-fallback` (host model unavailable) and a
19
+ * `config-error` (no model) both surface as `degraded: true`.
20
+ *
21
+ * Pure and synchronous; reuses resolveEmbeddedModel (no second model surface)
22
+ * and returns a result object carrying `warnings`, lifted into the envelope by
23
+ * the calling surface.
24
+ */
25
+
26
+ import { getDeploymentMode } from '../deployment-mode.mjs';
27
+ import { resolveEmbeddedModel } from './model-resolve.mjs';
28
+ import { getWorkflowDef } from './workflow-defs.mjs';
29
+
30
+ export const EXECUTION_MODES = ['construct-orchestrated', 'construct-prompt-only', 'host-direct', 'same-family-fallback'];
31
+ export const CONSTRUCT_STRATEGIES = ['orchestrated', 'prompt-only', 'auto'];
32
+ export const CONSTRUCT_CAPABILITIES = ['personas', 'skills', 'workflow-routing', 'prompt-envelope'];
33
+
34
+ export const EXECUTION_SEMANTICS = 'Reports Construct-planned capability and model-resolvability before/at workflow start; does not observe host execution. constructCapabilitiesActive describes what Construct contributes, not proof the host ran personas/skills.';
35
+
36
+ const ORCHESTRATED_CAPS = ['personas', 'skills', 'workflow-routing', 'prompt-envelope'];
37
+ const PROMPT_ONLY_CAPS = ['prompt-envelope'];
38
+
39
+ function normalizeStrategy(value) {
40
+ const lowered = typeof value === 'string' ? value.trim().toLowerCase() : '';
41
+ return CONSTRUCT_STRATEGIES.includes(lowered) ? lowered : null;
42
+ }
43
+
44
+ /**
45
+ * Resolve the execution-capability contract for an embedded workflow.
46
+ *
47
+ * @param {object} request
48
+ * @param {string} [request.workflowType] workflow whose orchestration plan is being weighed
49
+ * @param {string} [request.requestedStrategy] orchestrated | prompt-only | auto (default auto)
50
+ * @param {boolean} [request.useConstruct=true] false ⇒ host-direct (host runs without Construct)
51
+ * @param {string} [request.host]
52
+ * @param {string} [request.hostModel]
53
+ * @param {string} [request.hostProvider]
54
+ * @param {string} [request.requestedTier]
55
+ * @param {string[]} [request.capabilities]
56
+ * @param {boolean} [request.allowCrossProviderFallback=false]
57
+ * @param {object} [opts]
58
+ * @param {Record<string,string>} [opts.env]
59
+ * @param {string} [opts.cwd]
60
+ * @param {string} [opts.registryPath]
61
+ * @returns {object}
62
+ */
63
+ export function resolveExecution(request = {}, { env = process.env, cwd = process.cwd(), registryPath = null } = {}) {
64
+ const {
65
+ workflowType = null,
66
+ requestedStrategy: rawStrategy,
67
+ useConstruct = true,
68
+ host, hostModel, hostProvider, requestedTier, capabilities, allowCrossProviderFallback = false,
69
+ } = request;
70
+
71
+ const warnings = [];
72
+ const requestedStrategy = normalizeStrategy(rawStrategy) || 'auto';
73
+ if (rawStrategy != null && !normalizeStrategy(rawStrategy)) {
74
+ warnings.push(`Unknown requestedStrategy "${rawStrategy}"; defaulted to "auto".`);
75
+ }
76
+
77
+ const deploymentMode = getDeploymentMode(env, { cwd });
78
+
79
+ // Model resolution is the single source of provider/model truth; its warnings
80
+ // are namespaced so an embedder can tell model concerns from execution ones.
81
+ const modelResolution = resolveEmbeddedModel(
82
+ { workflowType, requestedTier, host, hostModel, hostProvider, capabilities, allowCrossProviderFallback },
83
+ { env, registryPath },
84
+ );
85
+ for (const w of modelResolution.warnings || []) warnings.push(`model-resolution: ${w}`);
86
+
87
+ const { resolutionSource, selectedModel, selectedProvider } = modelResolution;
88
+ const orchestrationAvailable = resolutionSource !== 'config-error';
89
+
90
+ // An explicit-but-unknown workflowType has no plan; absent a workflowType,
91
+ // Construct's generic router can still orchestrate, so a plan is available.
92
+ const knownWorkflow = workflowType ? Boolean(getWorkflowDef(workflowType)) : true;
93
+ if (workflowType && !knownWorkflow) {
94
+ warnings.push(`Unknown workflowType "${workflowType}"; no orchestration plan is available for it.`);
95
+ }
96
+ const orchestrationPlanned = knownWorkflow;
97
+
98
+ const base = {
99
+ requestedStrategy,
100
+ selectedProvider,
101
+ selectedModel,
102
+ resolutionSource,
103
+ orchestrationPlanned,
104
+ orchestrationAvailable,
105
+ deploymentMode,
106
+ modelResolution: stripWarnings(modelResolution),
107
+ semantics: EXECUTION_SEMANTICS,
108
+ warnings,
109
+ };
110
+
111
+ if (useConstruct === false) {
112
+ return {
113
+ ...base,
114
+ executionMode: 'host-direct',
115
+ effectiveStrategy: 'host-direct',
116
+ constructCapabilitiesActive: [],
117
+ degraded: false,
118
+ degradationReason: null,
119
+ };
120
+ }
121
+
122
+ if (requestedStrategy === 'prompt-only') {
123
+ return {
124
+ ...base,
125
+ executionMode: 'construct-prompt-only',
126
+ effectiveStrategy: 'prompt-only',
127
+ constructCapabilitiesActive: [...PROMPT_ONLY_CAPS],
128
+ degraded: false,
129
+ degradationReason: null,
130
+ };
131
+ }
132
+
133
+ // orchestrated or auto: deliver orchestration only when a plan exists AND a
134
+ // model resolves. A same-family fallback runs but did not honor the host's
135
+ // exact model, so it is reported as its own mode and as degraded.
136
+ const canOrchestrate = orchestrationPlanned && orchestrationAvailable;
137
+ if (canOrchestrate) {
138
+ if (resolutionSource === 'same-family-fallback') {
139
+ return {
140
+ ...base,
141
+ executionMode: 'same-family-fallback',
142
+ effectiveStrategy: 'orchestrated',
143
+ constructCapabilitiesActive: [...ORCHESTRATED_CAPS],
144
+ degraded: true,
145
+ degradationReason: `Host model unavailable; resolved a same-family tier model — ${modelResolution.fallbackReason || 'same-family fallback applied'}.`,
146
+ };
147
+ }
148
+ return {
149
+ ...base,
150
+ executionMode: 'construct-orchestrated',
151
+ effectiveStrategy: 'orchestrated',
152
+ constructCapabilitiesActive: [...ORCHESTRATED_CAPS],
153
+ degraded: false,
154
+ degradationReason: null,
155
+ };
156
+ }
157
+
158
+ // Could not orchestrate. Degradation is asserted only when orchestration was
159
+ // explicitly requested, or when no model resolved at all (auto still needs a
160
+ // model to do anything useful).
161
+ let degraded = false;
162
+ let degradationReason = null;
163
+ if (!orchestrationAvailable) {
164
+ degraded = true;
165
+ degradationReason = `Model could not be resolved; orchestration requires a runnable model — ${modelResolution.error?.reason || 'no model available'}.`;
166
+ } else if (requestedStrategy === 'orchestrated') {
167
+ degraded = true;
168
+ degradationReason = `Orchestration was requested but no orchestration plan is available${workflowType ? ` for workflowType "${workflowType}"` : ''}.`;
169
+ }
170
+
171
+ return {
172
+ ...base,
173
+ executionMode: 'construct-prompt-only',
174
+ effectiveStrategy: 'prompt-only',
175
+ constructCapabilitiesActive: [...PROMPT_ONLY_CAPS],
176
+ degraded,
177
+ degradationReason,
178
+ };
179
+ }
180
+
181
+ function stripWarnings(result) {
182
+ const { warnings, ...rest } = result || {};
183
+ return rest;
184
+ }
@@ -12,9 +12,11 @@ import { resolveEmbeddedModel as resolveModelCore } from './model-resolve.mjs';
12
12
  import { recommendPlan as recommendPlanCore } from './triage.mjs';
13
13
  import { invokeWorkflow as invokeWorkflowCore } from './workflow-invoke.mjs';
14
14
  import { buildCapabilityContract } from './capability.mjs';
15
+ import { resolveExecution as resolveExecutionCore } from './execution.mjs';
15
16
 
16
17
  export { CONTRACT_VERSION, MIN_CLIENT_CONTRACT_VERSION, isClientCompatible } from './contract-version.mjs';
17
18
  export { APPROVAL_MODES } from './audit.mjs';
19
+ export { EXECUTION_MODES, CONSTRUCT_STRATEGIES, CONSTRUCT_CAPABILITIES } from './execution.mjs';
18
20
 
19
21
  // File→text resolution for SDK callers: extract a path through the real
20
22
  // pipeline (docling/whisper/transcript), then pass { text, ingestion } to
@@ -69,3 +71,17 @@ export async function invokeWorkflow(request = {}, { env, cwd } = {}) {
69
71
  export function describeCapabilities({ env, cwd, rootDir } = {}) {
70
72
  return wrapContractResult(buildCapabilityContract({ env, rootDir }), { surface: 'sdk', env, cwd });
71
73
  }
74
+
75
+ /**
76
+ * Resolve the execution-capability contract for an embedded workflow: the
77
+ * executionMode, the contributed Construct capabilities, and any degradation
78
+ * with a reason. Returns a versioned envelope; the resolution is under `.data`.
79
+ * Read-only; performs no model call beyond model resolution.
80
+ *
81
+ * @param {object} request See execution.mjs resolveExecution.
82
+ * @param {object} [opts] { env, cwd, registryPath }
83
+ * @returns {object}
84
+ */
85
+ export function resolveExecution(request = {}, { env, cwd, registryPath } = {}) {
86
+ return wrapContractResult(resolveExecutionCore(request, { env, cwd, registryPath }), { surface: 'sdk', env, cwd });
87
+ }