@geraldmaron/construct 1.0.21 → 1.0.24

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 (150) hide show
  1. package/README.md +23 -7
  2. package/bin/construct +206 -228
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +31 -10
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/features.mjs +11 -11
  20. package/lib/git-hooks-path.mjs +61 -0
  21. package/lib/home-namespace.mjs +60 -0
  22. package/lib/hooks/ci-status-check.mjs +62 -40
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/session-reflect.mjs +5 -1
  27. package/lib/hooks/stop-notify.mjs +32 -17
  28. package/lib/hooks/stop-typecheck.mjs +7 -2
  29. package/lib/host-capabilities.mjs +24 -8
  30. package/lib/host-disposition.mjs +76 -0
  31. package/lib/ingest/provider-extract.mjs +1 -1
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intake/git-queue.mjs +195 -0
  38. package/lib/intake/queue.mjs +9 -16
  39. package/lib/intent-classifier.mjs +1 -1
  40. package/lib/knowledge/search.mjs +52 -3
  41. package/lib/mcp/server.mjs +57 -14
  42. package/lib/mcp/tools/memory.mjs +2 -2
  43. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  44. package/lib/mcp/tools/storage.mjs +2 -3
  45. package/lib/mcp-catalog.json +3 -3
  46. package/lib/mcp-manager.mjs +59 -3
  47. package/lib/model-registry.mjs +40 -33
  48. package/lib/observation-store.mjs +39 -163
  49. package/lib/opencode-config.mjs +1 -1
  50. package/lib/orchestration/events.mjs +66 -0
  51. package/lib/orchestration/runtime.mjs +72 -10
  52. package/lib/orchestration/worker.mjs +69 -21
  53. package/lib/orchestration-policy.mjs +27 -3
  54. package/lib/parity.mjs +46 -24
  55. package/lib/policy/unified-gates.mjs +96 -0
  56. package/lib/project-init-shared.mjs +0 -173
  57. package/lib/reconcile/adapter-prune.mjs +105 -0
  58. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  59. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  60. package/lib/reconcile/index.mjs +169 -0
  61. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  62. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  63. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  64. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  65. package/lib/runtime/uv-bootstrap.mjs +27 -3
  66. package/lib/schema-infer.mjs +16 -2
  67. package/lib/server/csrf.mjs +14 -2
  68. package/lib/server/index.mjs +95 -0
  69. package/lib/service-manager.mjs +59 -252
  70. package/lib/setup-prompts.mjs +2 -1
  71. package/lib/setup.mjs +104 -480
  72. package/lib/status.mjs +3 -6
  73. package/lib/storage/admin.mjs +48 -325
  74. package/lib/storage/backend.mjs +10 -43
  75. package/lib/storage/hybrid-query.mjs +15 -196
  76. package/lib/storage/sync.mjs +36 -177
  77. package/lib/storage/vector-client.mjs +256 -235
  78. package/lib/strategy-store.mjs +35 -286
  79. package/lib/template-registry.mjs +73 -0
  80. package/lib/term-format.mjs +75 -0
  81. package/lib/uninstall/uninstall.mjs +180 -7
  82. package/lib/worker/entrypoint.mjs +6 -14
  83. package/package.json +7 -6
  84. package/personas/construct.md +7 -8
  85. package/platforms/claude/settings.template.json +30 -11
  86. package/platforms/opencode/config.template.json +2 -2
  87. package/rules/common/neurodivergent-output.md +66 -0
  88. package/rules/common/tool-invisibility.md +37 -0
  89. package/scripts/sync-specialists.mjs +427 -107
  90. package/skills/operating/orchestration-reference.md +2 -16
  91. package/specialists/policy-inventory.json +14 -0
  92. package/specialists/prompts/cx-accessibility.md +2 -6
  93. package/specialists/prompts/cx-ai-engineer.md +0 -4
  94. package/specialists/prompts/cx-architect.md +3 -5
  95. package/specialists/prompts/cx-business-strategist.md +0 -5
  96. package/specialists/prompts/cx-data-analyst.md +0 -4
  97. package/specialists/prompts/cx-data-engineer.md +0 -4
  98. package/specialists/prompts/cx-debugger.md +2 -6
  99. package/specialists/prompts/cx-designer.md +0 -8
  100. package/specialists/prompts/cx-devil-advocate.md +2 -2
  101. package/specialists/prompts/cx-docs-keeper.md +0 -13
  102. package/specialists/prompts/cx-engineer.md +0 -13
  103. package/specialists/prompts/cx-evaluator.md +2 -2
  104. package/specialists/prompts/cx-explorer.md +4 -5
  105. package/specialists/prompts/cx-legal-compliance.md +4 -5
  106. package/specialists/prompts/cx-operations.md +0 -5
  107. package/specialists/prompts/cx-orchestrator.md +0 -4
  108. package/specialists/prompts/cx-platform-engineer.md +0 -8
  109. package/specialists/prompts/cx-product-manager.md +0 -8
  110. package/specialists/prompts/cx-qa.md +3 -12
  111. package/specialists/prompts/cx-rd-lead.md +0 -5
  112. package/specialists/prompts/cx-release-manager.md +0 -8
  113. package/specialists/prompts/cx-researcher.md +5 -29
  114. package/specialists/prompts/cx-reviewer.md +2 -6
  115. package/specialists/prompts/cx-security.md +2 -11
  116. package/specialists/prompts/cx-sre.md +0 -15
  117. package/specialists/prompts/cx-test-automation.md +0 -4
  118. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  119. package/specialists/prompts/cx-ux-researcher.md +0 -4
  120. package/specialists/registry.json +28 -36
  121. package/templates/distribution/run.mjs +36 -7
  122. package/templates/docs/accessibility-audit.md +56 -0
  123. package/templates/docs/architecture-review.md +59 -0
  124. package/templates/docs/code-review-report.md +46 -0
  125. package/templates/docs/construct_guide.md +15 -15
  126. package/templates/docs/debug-investigation.md +53 -0
  127. package/templates/docs/qa-report.md +48 -0
  128. package/templates/docs/security-audit-report.md +48 -0
  129. package/templates/docs/task-packet.md +49 -0
  130. package/templates/docs/verdict.md +40 -0
  131. package/db/schema/001_init.sql +0 -40
  132. package/db/schema/002_pgvector.sql +0 -182
  133. package/db/schema/003_intake.sql +0 -47
  134. package/db/schema/003_observation_reconciliation.sql +0 -14
  135. package/db/schema/004_recommendations.sql +0 -46
  136. package/db/schema/005_strategy.sql +0 -21
  137. package/db/schema/006_graph.sql +0 -24
  138. package/db/schema/007_tags.sql +0 -30
  139. package/db/schema/008_skill_usage.sql +0 -24
  140. package/db/schema/009_scheduler.sql +0 -14
  141. package/db/schema/010_cx_scores.sql +0 -51
  142. package/lib/intake/postgres-queue.mjs +0 -240
  143. package/lib/server/static/index.html +0 -1
  144. package/lib/services/local-postgres.mjs +0 -15
  145. package/lib/storage/backup.mjs +0 -347
  146. package/lib/storage/migrations.mjs +0 -187
  147. package/lib/storage/postgres-backup.mjs +0 -124
  148. package/lib/storage/sql-store.mjs +0 -45
  149. package/lib/storage/store-version.mjs +0 -115
  150. package/lib/storage/vector-store.mjs +0 -100
@@ -4,31 +4,36 @@
4
4
  * Stores distilled insights that specialists learn during work:
5
5
  * - patterns, anti-patterns, dependency relationships, decisions, insights
6
6
  * - each scoped to a role (cx-engineer, cx-architect, etc.)
7
- * - vector-indexed for semantic search via hashing-bow-v1
7
+ * - vector-indexed for semantic search via embedded LanceDB
8
8
  *
9
9
  * Storage layout:
10
10
  * .cx/observations/index.json — lightweight listing for fast filtering
11
11
  * .cx/observations/<id>.json — full observation record
12
- * .cx/observations/vectors.json — local vector index for semantic search
13
12
  */
14
13
  import crypto from 'node:crypto';
15
14
  import fs from 'node:fs';
16
15
  import path from 'node:path';
17
- import { cosineSimilarity, rankByBm25 } from './storage/embeddings.mjs';
18
- import { embedSync as embedText } from './storage/embeddings-legacy.mjs';
19
16
  import { embedText as embedTextEngine } from './storage/embeddings-engine.mjs';
20
17
  import { VectorClient } from './storage/vector-client.mjs';
21
- import { withFileLockSync } from './storage/file-lock.mjs';
22
18
  import { ensureCxDir } from './project-init-shared.mjs';
23
19
 
24
20
  const OBS_DIR = '.cx/observations';
25
21
  const INDEX_FILE = 'index.json';
26
- const VECTORS_FILE = 'vectors.json';
27
22
  const MAX_INDEX = 1000;
28
23
 
29
- // The exact text fed to the embedder, and its content hash. addObservation and
30
- // the reconciliation pass MUST derive both the same way, so the (content_hash,
31
- // model) fingerprint they compare is computed identically.
24
+ // VectorClient resolves its store from CONSTRUCT_LANCEDB_PATH and otherwise
25
+ // falls back to process.cwd(). Observations are scoped to rootDir, so when no
26
+ // explicit path is configured (tests, unmanaged checkouts) the vector store
27
+ // must follow rootDir — not the caller's cwd — or a reader in a different cwd
28
+ // than the writer sees an empty table. A configured path (managed installs)
29
+ // is left untouched so the home-global store keeps working.
30
+
31
+ function vectorClientFor(rootDir) {
32
+ const env = process.env.CONSTRUCT_LANCEDB_PATH
33
+ ? process.env
34
+ : { ...process.env, CONSTRUCT_LANCEDB_PATH: path.join(rootDir, '.cx', 'lancedb') };
35
+ return new VectorClient({ env });
36
+ }
32
37
 
33
38
  export function observationSearchText(obs) {
34
39
  return [obs.summary, obs.content, ...(obs.tags || [])].filter(Boolean).join(' ');
@@ -40,9 +45,6 @@ export function observationContentHash(text) {
40
45
  const MAX_SUMMARY = 500;
41
46
  const MAX_CONTENT = 2000;
42
47
  const MAX_TAGS = 10;
43
- // Size guardrail: surfaces in `construct doctor` when the observation tree
44
- // exceeds this byte cap. Doesn't drop data — the operator decides whether to
45
- // run `construct memory consolidate` (which prunes archive) or raise the cap.
46
48
  const MAX_BYTES = 50 * 1024 * 1024;
47
49
 
48
50
  const VALID_CATEGORIES = new Set([
@@ -50,8 +52,6 @@ const VALID_CATEGORIES = new Set([
50
52
  ]);
51
53
 
52
54
  function ensureDir(dir, rootDir = null) {
53
- // Preserves the construct context invariant: any code that creates a .cx/
54
- // subdirectory also initializes context.md from the project template.
55
55
  if (rootDir) ensureCxDir(rootDir);
56
56
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
57
57
  }
@@ -64,10 +64,6 @@ function indexPath(rootDir) {
64
64
  return path.join(obsDir(rootDir), INDEX_FILE);
65
65
  }
66
66
 
67
- function vectorsPath(rootDir) {
68
- return path.join(obsDir(rootDir), VECTORS_FILE);
69
- }
70
-
71
67
  function generateId() {
72
68
  const ts = Date.now().toString(36);
73
69
  const rand = crypto.randomBytes(4).toString('hex');
@@ -132,30 +128,6 @@ function writeIndex(rootDir, entries) {
132
128
  return { dropped };
133
129
  }
134
130
 
135
- function readVectors(rootDir) {
136
- const p = vectorsPath(rootDir);
137
- if (!fs.existsSync(p)) return [];
138
- try {
139
- return JSON.parse(fs.readFileSync(p, 'utf8'));
140
- } catch {
141
- return [];
142
- }
143
- }
144
-
145
- function writeVectors(rootDir, records) {
146
- ensureDir(obsDir(rootDir), rootDir);
147
- const dropped = Math.max(0, records.length - MAX_INDEX);
148
- if (dropped > 0) {
149
- logCapDrop(rootDir, 'observation-vectors', dropped, records.length);
150
- }
151
- fs.writeFileSync(vectorsPath(rootDir), JSON.stringify(records, null, 2) + '\n');
152
- return { dropped };
153
- }
154
-
155
- /**
156
- * Add a new observation and vectorize it for semantic search.
157
- * Uses SQL + neural embeddings when available, falls back to local JSON + hashing.
158
- */
159
131
  export async function addObservation(rootDir, {
160
132
  role = 'unknown',
161
133
  category = 'insight',
@@ -175,10 +147,9 @@ export async function addObservation(rootDir, {
175
147
  const clampedSummary = clamp(String(summary), MAX_SUMMARY);
176
148
  const clampedContent = clamp(String(content), MAX_CONTENT);
177
149
  const clampedTags = (Array.isArray(tags) ? tags : []).slice(0, MAX_TAGS).map(String);
178
- // Structured metadata: opt-in. Capped at 2 KB stringified so a misuse can't
179
- // bloat the per-id JSON. Plain object only; rejects arrays and functions.
180
150
  const clampedExtras = sanitizeExtras(extras);
181
151
 
152
+ const effectiveProject = project ? String(project) : rootDir.split('/').pop();
182
153
  const record = {
183
154
  id,
184
155
  role: String(role),
@@ -186,7 +157,7 @@ export async function addObservation(rootDir, {
186
157
  summary: clampedSummary,
187
158
  content: clampedContent,
188
159
  tags: clampedTags,
189
- project: project ? String(project) : null,
160
+ project: effectiveProject,
190
161
  confidence: Math.max(0, Math.min(1, Number(confidence) || 0.8)),
191
162
  source: source || null,
192
163
  gitSha: gitSha ? String(gitSha).slice(0, 40) : null,
@@ -195,37 +166,30 @@ export async function addObservation(rootDir, {
195
166
  updatedAt: now,
196
167
  };
197
168
 
198
- // Lock the per-store index so concurrent writers (CLI + hooks) cannot
199
- // corrupt the index/vectors JSON pair. The observation JSON file is
200
- // unique-per-id and doesn't need the lock; the shared structures do.
201
169
  ensureDir(obsDir(rootDir), rootDir);
202
170
  fs.writeFileSync(
203
171
  path.join(obsDir(rootDir), `${id}.json`),
204
172
  JSON.stringify(record, null, 2) + '\n',
205
173
  );
206
174
 
207
- let indexDropped = 0;
208
- withFileLockSync(indexPath(rootDir), () => {
209
- const index = readIndex(rootDir);
210
- index.unshift({
211
- id,
212
- role: record.role,
213
- category: record.category,
214
- summary: record.summary,
215
- project: record.project,
216
- createdAt: now,
217
- });
218
- indexDropped = writeIndex(rootDir, index).dropped;
175
+ const index = readIndex(rootDir);
176
+ index.unshift({
177
+ id,
178
+ role: record.role,
179
+ category: record.category,
180
+ summary: record.summary,
181
+ project: record.project,
182
+ createdAt: now,
219
183
  });
184
+ const indexDropped = writeIndex(rootDir, index).dropped;
220
185
  if (indexDropped > 0) {
221
186
  record.capDropped = indexDropped;
222
187
  process.stderr.write(`[observation-store] observation cap reached: ${indexDropped} oldest entries evicted (cap=${MAX_INDEX})\n`);
223
188
  }
224
189
 
225
- // Try SQL storage with neural embeddings
226
190
  try {
227
- const client = new VectorClient();
228
- if (await client.isHealthy() && await client.isPgvectorEnabled()) {
191
+ const client = vectorClientFor(rootDir);
192
+ if (await client.isHealthy()) {
229
193
  const searchText = observationSearchText(record);
230
194
  const { embedding, model } = await embedTextEngine(searchText);
231
195
  await client.storeObservation({
@@ -234,30 +198,15 @@ export async function addObservation(rootDir, {
234
198
  contentHash: observationContentHash(searchText),
235
199
  model,
236
200
  });
237
- await client.close();
238
- return record;
239
201
  }
240
202
  await client.close();
241
- } catch {
242
- // Fall through to local vector storage
203
+ } catch (err) {
204
+ process.stderr.write(`[observation-store] vector store failed; kept observation in local index: ${err?.message || err}\n`);
243
205
  }
244
206
 
245
- // Fallback: local JSON vector index with hashing-bow-v1.
246
- const searchText = [record.summary, record.content, ...record.tags].filter(Boolean).join(' ');
247
- const embedding = embedText(searchText);
248
- withFileLockSync(vectorsPath(rootDir), () => {
249
- const vectors = readVectors(rootDir);
250
- vectors.push({ id, embedding, role: record.role, category: record.category, project: record.project });
251
- writeVectors(rootDir, vectors.slice(-MAX_INDEX));
252
- });
253
-
254
207
  return record;
255
208
  }
256
209
 
257
- /**
258
- * Search observations using SQL + neural embeddings when available,
259
- * falling back to hybrid cosine + BM25 on local JSON.
260
- */
261
210
  export async function searchObservations(rootDir, query, {
262
211
  role = null,
263
212
  category = null,
@@ -266,16 +215,15 @@ export async function searchObservations(rootDir, query, {
266
215
  } = {}) {
267
216
  if (!query) return [];
268
217
 
269
- // Try SQL + neural embeddings first
270
218
  try {
271
- const client = new VectorClient();
272
- if (await client.isHealthy() && await client.isPgvectorEnabled()) {
219
+ const client = vectorClientFor(rootDir);
220
+ if (await client.isHealthy()) {
273
221
  const { embedding } = await embedTextEngine(String(query));
274
222
  const results = await client.searchObservations({
275
223
  project: project || rootDir.split('/').pop(),
276
224
  queryEmbedding: embedding,
277
225
  limit,
278
- minSimilarity: 0.1,
226
+ minSimilarity: 0.01,
279
227
  role,
280
228
  category,
281
229
  });
@@ -284,6 +232,7 @@ export async function searchObservations(rootDir, query, {
284
232
  id: r.id,
285
233
  role: r.role,
286
234
  category: r.category,
235
+ project: r.project,
287
236
  summary: r.summary,
288
237
  content: r.content,
289
238
  tags: r.tags || [],
@@ -295,65 +244,12 @@ export async function searchObservations(rootDir, query, {
295
244
  }
296
245
  await client.close();
297
246
  } catch {
298
- // Fall through to local JSON search
247
+ // If Vector search fails, return empty since fallback is removed.
299
248
  }
300
249
 
301
- // Fallback: local JSON + hashing-bow-v1
302
- const queryEmbedding = embedText(String(query));
303
- let vectors = readVectors(rootDir);
304
-
305
- if (role) vectors = vectors.filter((v) => v.role === role);
306
- if (category) vectors = vectors.filter((v) => v.category === category);
307
- if (project) vectors = vectors.filter((v) => v.project === project);
308
-
309
- // Cosine pass
310
- const cosineScored = vectors
311
- .map((v) => ({
312
- id: v.id,
313
- score: cosineSimilarity(queryEmbedding, v.embedding || []),
314
- }))
315
- .filter(({ score }) => score > 0.05);
316
-
317
- // Load full records for BM25 pass
318
- const candidateIds = new Set(cosineScored.map((v) => v.id));
319
- const recentIndex = readIndex(rootDir);
320
- const filtered = role ? recentIndex.filter((e) => e.role === role) : recentIndex;
321
- const filteredCat = category ? filtered.filter((e) => e.category === category) : filtered;
322
- const filteredProj = project ? filteredCat.filter((e) => e.project === project) : filteredCat;
323
- for (const entry of filteredProj.slice(0, Math.max(limit * 3, 30))) candidateIds.add(entry.id);
324
-
325
- const candidateRecords = [...candidateIds]
326
- .map((id) => getObservation(rootDir, id))
327
- .filter(Boolean)
328
- .map((r) => ({ ...r, text: [r.summary, r.content, ...(r.tags || [])].filter(Boolean).join(' ') }));
329
-
330
- const bm25Scored = rankByBm25(candidateRecords, query, { limit: limit * 2 });
331
-
332
- // Merge: highest score wins per id
333
- const scoreMap = new Map();
334
- for (const { id, score } of cosineScored) {
335
- scoreMap.set(id, score);
336
- }
337
- for (const item of bm25Scored) {
338
- const prev = scoreMap.get(item.id) || 0;
339
- const bm25Max = bm25Scored[0]?.score || 1;
340
- const normalized = Math.min(item.score / bm25Max, 1);
341
- scoreMap.set(item.id, Math.max(prev, normalized));
342
- }
343
-
344
- return [...scoreMap.entries()]
345
- .sort((a, b) => b[1] - a[1])
346
- .slice(0, limit)
347
- .map(([id, score]) => {
348
- const record = getObservation(rootDir, id);
349
- return record ? { ...record, score } : null;
350
- })
351
- .filter(Boolean);
250
+ return [];
352
251
  }
353
252
 
354
- /**
355
- * List observations from the index with optional filters.
356
- */
357
253
  export function listObservations(rootDir, {
358
254
  role = null,
359
255
  category = null,
@@ -367,9 +263,6 @@ export function listObservations(rootDir, {
367
263
  return entries.slice(0, limit);
368
264
  }
369
265
 
370
- /**
371
- * Load a full observation record by ID.
372
- */
373
266
  export function getObservation(rootDir, id) {
374
267
  const filePath = path.join(obsDir(rootDir), `${id}.json`);
375
268
  if (!fs.existsSync(filePath)) return null;
@@ -380,31 +273,17 @@ export function getObservation(rootDir, id) {
380
273
  }
381
274
  }
382
275
 
383
- /**
384
- * Delete an observation by id.
385
- */
386
276
  export function deleteObservation(rootDir, id) {
387
277
  const filePath = path.join(obsDir(rootDir), `${id}.json`);
388
278
  if (fs.existsSync(filePath)) fs.rmSync(filePath, { force: true });
389
279
 
390
- withFileLockSync(indexPath(rootDir), () => {
391
- const index = readIndex(rootDir);
392
- const filtered = index.filter((e) => e.id !== id);
393
- if (filtered.length !== index.length) writeIndex(rootDir, filtered);
394
- });
395
-
396
- withFileLockSync(vectorsPath(rootDir), () => {
397
- const vectors = readVectors(rootDir);
398
- const filteredVec = vectors.filter((v) => v.id !== id);
399
- if (filteredVec.length !== vectors.length) writeVectors(rootDir, filteredVec);
400
- });
280
+ const index = readIndex(rootDir);
281
+ const filtered = index.filter((e) => e.id !== id);
282
+ if (filtered.length !== index.length) writeIndex(rootDir, filtered);
401
283
 
402
284
  return true;
403
285
  }
404
286
 
405
- /**
406
- * Count observations, optionally filtered.
407
- */
408
287
  export function countObservations(rootDir, { role = null, project = null } = {}) {
409
288
  let entries = readIndex(rootDir);
410
289
  if (role) entries = entries.filter((e) => e.role === role);
@@ -412,9 +291,6 @@ export function countObservations(rootDir, { role = null, project = null } = {})
412
291
  return entries.length;
413
292
  }
414
293
 
415
- // On-disk byte size of the observation tree (live + archive) at rootDir.
416
- // Backs the `Observation size` doctor surface so size pressure shows up
417
- // independently of count-based caps.
418
294
  export function getObservationsSize(rootDir) {
419
295
  const root = obsDir(rootDir);
420
296
  if (!fs.existsSync(root)) return 0;
@@ -435,4 +311,4 @@ export function getObservationsSize(rootDir) {
435
311
  export function checkObservationsSize(rootDir, { cap = MAX_BYTES } = {}) {
436
312
  const size = getObservationsSize(rootDir);
437
313
  return { size, cap, ok: size <= cap };
438
- }
314
+ }
@@ -48,7 +48,7 @@ export function sanitizeOpenCodeConfig(config) {
48
48
 
49
49
  export function writeOpenCodeConfig(config, file = findOpenCodeConfigPath()) {
50
50
  // Treat the canonical home config as the default target; any explicit path
51
- // outside the canonical home dir (e.g. project-scoped .opencode/config.json)
51
+ // outside the canonical home dir (e.g. project-scoped .opencode/opencode.json)
52
52
  // is honored as-is so per-project sync writes to the right tree.
53
53
 
54
54
  const canonical = getCanonicalOpenCodeConfigPath();
@@ -0,0 +1,66 @@
1
+ /**
2
+ * lib/orchestration/events.mjs — in-process run lifecycle event bus.
3
+ *
4
+ * The orchestration runtime persists runs to a store and appends durable JSONL
5
+ * traces, but has no live pub/sub a server can stream from. This is that
6
+ * primitive: a process-local EventEmitter keyed by runId so an HTTP SSE handler
7
+ * can subscribe to a single run's transitions (planned → running → per-task →
8
+ * completed) and forward them to a thin client. Events are lightweight status
9
+ * deltas — never task output or any credential — so the stream stays cheap and
10
+ * secret-free; clients fetch full run records (envelope-guarded) for outputs.
11
+ *
12
+ * Process-local by design: it streams runs executing in THIS process. Durable,
13
+ * cross-process history lives in the run store and the trace log.
14
+ */
15
+
16
+ import { EventEmitter } from 'node:events';
17
+
18
+ const bus = new EventEmitter();
19
+ bus.setMaxListeners(0);
20
+
21
+ function channel(runId) {
22
+ return `run:${runId}`;
23
+ }
24
+
25
+ /**
26
+ * Emit a run lifecycle event. Stamps runId + an ISO timestamp; callers pass a
27
+ * small status delta (type, status, taskId, role, executor, error).
28
+ *
29
+ * @param {string} runId
30
+ * @param {object} event
31
+ */
32
+ export function emitRunEvent(runId, event = {}) {
33
+ if (!runId) return;
34
+ bus.emit(channel(runId), { runId, at: new Date().toISOString(), ...event });
35
+ }
36
+
37
+ /**
38
+ * Subscribe to one run's events.
39
+ *
40
+ * @param {string} runId
41
+ * @param {(event:object)=>void} handler
42
+ * @returns {() => void} unsubscribe
43
+ */
44
+ export function onRunEvent(runId, handler) {
45
+ const ch = channel(runId);
46
+ bus.on(ch, handler);
47
+ return () => bus.off(ch, handler);
48
+ }
49
+
50
+ // Cooperative, between-task cancellation. executeRun checks this before each
51
+ // task and stops cleanly if set — an honest soft cancel (it cannot abort a
52
+ // model call already in flight), not a forced kill.
53
+
54
+ const cancelled = new Set();
55
+
56
+ export function requestCancel(runId) {
57
+ if (runId) cancelled.add(runId);
58
+ }
59
+
60
+ export function isCancelRequested(runId) {
61
+ return cancelled.has(runId);
62
+ }
63
+
64
+ export function clearCancel(runId) {
65
+ cancelled.delete(runId);
66
+ }
@@ -35,12 +35,16 @@ import { routeRequest } from '../orchestration-policy.mjs';
35
35
  import { resolveExecution } from '../embedded-contract/execution.mjs';
36
36
  import { detectHostCapabilities } from '../host-capabilities.mjs';
37
37
  import { loadProjectConfig } from '../config/project-config.mjs';
38
+ import { CHAIN_OF_THOUGHT_MODES } from '../config/schema.mjs';
38
39
  import { newTraceId, newSpanId, emitTraceEvent } from '../worker/trace.mjs';
39
40
  import { resolveRunStore } from './store.mjs';
40
41
  import { runTaskViaProvider, INLINE, PROVIDER, WORKER_BACKEND_SET } from './worker.mjs';
42
+ import { emitRunEvent, isCancelRequested, clearCancel } from './events.mjs';
41
43
 
42
44
  export const WORKER_BACKENDS = WORKER_BACKEND_SET;
43
45
  export const DEFAULT_WORKER_BACKEND = INLINE;
46
+ export const CHAIN_OF_THOUGHT = CHAIN_OF_THOUGHT_MODES;
47
+ export const DEFAULT_CHAIN_OF_THOUGHT = 'hidden';
44
48
 
45
49
  const RUNTIME_SEMANTICS = 'The inline worker backend owns planning, sequencing, handoff state, persistence, and observability, and prepares each specialist task for a downstream executor. It does not perform specialist LLM reasoning itself; tasks reaching status "prepared" are ready for a worker backend or host to execute. The provider worker backend executes specialist reasoning via the configured model and records real output as task.output.';
46
50
 
@@ -97,6 +101,7 @@ function buildTasks(route) {
97
101
  status: 'queued',
98
102
  executor: null,
99
103
  output: null,
104
+ reasoning: null,
100
105
  error: null,
101
106
  startedAt: null,
102
107
  finishedAt: null,
@@ -112,6 +117,16 @@ function resolveWorkerBackend({ explicit, run, config }) {
112
117
  return WORKER_BACKENDS.includes(candidate) ? candidate : DEFAULT_WORKER_BACKEND;
113
118
  }
114
119
 
120
+ // Chain-of-thought disclosure is a deployment concern (ADR-0030): an explicit
121
+ // option wins, then the run's recorded mode, then config
122
+ // (orchestration.chainOfThought), then the hidden default. An unknown value
123
+ // falls back to hidden — specialist reasoning is never surfaced by guess.
124
+
125
+ function resolveChainOfThought({ explicit, run, config }) {
126
+ const candidate = explicit || run?.chainOfThought || config?.orchestration?.chainOfThought || DEFAULT_CHAIN_OF_THOUGHT;
127
+ return CHAIN_OF_THOUGHT.includes(candidate) ? candidate : DEFAULT_CHAIN_OF_THOUGHT;
128
+ }
129
+
115
130
  function loadConfig(cwd, env) {
116
131
  try {
117
132
  return loadProjectConfig(cwd, env).config || {};
@@ -132,6 +147,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
132
147
  const {
133
148
  request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
134
149
  host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
150
+ workerBackend: explicitWorkerBackend = null,
135
151
  } = request;
136
152
 
137
153
  const config = loadConfig(cwd, env);
@@ -146,7 +162,8 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
146
162
  const traceId = newTraceId();
147
163
  const runId = newRunId();
148
164
  const now = new Date().toISOString();
149
- const workerBackend = resolveWorkerBackend({ config });
165
+ const workerBackend = resolveWorkerBackend({ explicit: explicitWorkerBackend, config });
166
+ const chainOfThought = resolveChainOfThought({ config });
150
167
 
151
168
  // Construct owns a specialist task sequence only when the contract resolves to
152
169
  // orchestrated execution; prompt-only and host-direct own none, and the run
@@ -162,6 +179,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
162
179
  request: { summary: truncate(text, 200), workflowType, requestedStrategy },
163
180
  hostRole: resolveHostRole(host),
164
181
  workerBackend,
182
+ chainOfThought,
165
183
  execution: pickExecution(execData),
166
184
  plan: {
167
185
  intent: route.intent,
@@ -182,6 +200,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
182
200
  rootDir: cwd, env, traceId, spanId: newSpanId(), eventType: 'task_graph.created',
183
201
  metadata: { runId, executionMode: run.execution.executionMode, specialists: run.plan.specialists, workerBackend: run.workerBackend },
184
202
  });
203
+ emitRunEvent(runId, { type: 'planned', status: 'planned', executionMode: run.execution.executionMode, taskCount: tasks.length });
185
204
  return run;
186
205
  }
187
206
 
@@ -198,25 +217,29 @@ function prepareTaskInline(task) {
198
217
  // task is recorded (status `failed`, task.error) and does not abort the run, so
199
218
  // one specialist failure cannot lose the work of the others.
200
219
 
201
- async function executeTaskViaProvider(task, run, env, fetchImpl) {
220
+ async function executeTaskViaProvider(task, run, env, fetchImpl, chainOfThought) {
202
221
  try {
203
222
  const result = await runTaskViaProvider({
204
223
  task, run,
205
224
  model: run.execution?.selectedModel,
206
225
  provider: run.execution?.selectedProvider,
207
- env, fetchImpl,
226
+ env, fetchImpl, chainOfThought,
208
227
  });
209
228
  task.output = result.output;
210
229
  task.executor = `provider:${result.provider}:${result.model}`;
211
230
  task.status = 'done';
212
231
  task.finishedAt = new Date().toISOString();
213
- return true;
232
+
233
+ // `surface` attaches reasoning to the task so every display surface renders
234
+ // it; `telemetry_only` keeps it off the task (recorded only in the trace).
235
+ if (chainOfThought === 'surface' && result.reasoning) task.reasoning = result.reasoning;
236
+ return { ok: true, reasoning: result.reasoning || '' };
214
237
  } catch (err) {
215
238
  task.executor = `provider:error`;
216
239
  task.error = { code: err.code || 'PROVIDER_EXECUTION_FAILED', message: err.message };
217
240
  task.status = 'failed';
218
241
  task.finishedAt = new Date().toISOString();
219
- return false;
242
+ return { ok: false, reasoning: '' };
220
243
  }
221
244
  }
222
245
 
@@ -240,33 +263,51 @@ export async function executeRun(cwd, runId, { env = process.env, workerBackend
240
263
  }
241
264
 
242
265
  const backend = resolveWorkerBackend({ explicit: workerBackend, run, config });
266
+ const chainOfThought = resolveChainOfThought({ run, config });
243
267
  run.workerBackend = backend;
268
+ run.chainOfThought = chainOfThought;
244
269
  run.status = 'running';
245
270
  run.updatedAt = new Date().toISOString();
246
271
  await store.saveRun(run);
272
+ emitRunEvent(runId, { type: 'running', status: 'running', workerBackend: backend });
247
273
 
248
274
  let anyFailed = false;
275
+ let cancelled = false;
249
276
  for (const task of run.tasks) {
277
+ if (isCancelRequested(runId)) { cancelled = true; break; }
250
278
  task.status = 'running';
251
279
  task.startedAt = new Date().toISOString();
252
280
  emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId, workerBackend: backend } });
281
+ emitRunEvent(runId, { type: 'task', taskId: task.id, role: task.role, status: 'running' });
253
282
 
283
+ let taskReasoning = '';
254
284
  if (backend === PROVIDER) {
255
- const ok = await executeTaskViaProvider(task, run, env, fetchImpl);
256
- if (!ok) anyFailed = true;
285
+ const res = await executeTaskViaProvider(task, run, env, fetchImpl, chainOfThought);
286
+ if (!res.ok) anyFailed = true;
287
+ taskReasoning = res.reasoning;
257
288
  } else {
258
289
  prepareTaskInline(task);
259
290
  }
260
291
 
261
- emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: { runId, status: task.status } });
292
+ // telemetry_only records reasoning to the trace without ever surfacing it to
293
+ // a display; surface keeps it off the trace (it rides on task.reasoning).
294
+ const completedMeta = { runId, status: task.status };
295
+ if (chainOfThought === 'telemetry_only' && taskReasoning) {
296
+ completedMeta.reasoning = taskReasoning;
297
+ completedMeta.reasoningChars = taskReasoning.length;
298
+ }
299
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: completedMeta });
300
+ emitRunEvent(runId, { type: 'task', taskId: task.id, role: task.role, status: task.status, executor: task.executor, ...(task.reasoning ? { reasoning: task.reasoning } : {}), ...(task.error ? { error: task.error } : {}) });
262
301
  run.updatedAt = new Date().toISOString();
263
302
  await store.saveRun(run);
264
303
  }
265
304
 
266
- run.status = anyFailed ? 'completed-with-failures' : 'completed';
305
+ run.status = cancelled ? 'cancelled' : (anyFailed ? 'completed-with-failures' : 'completed');
267
306
  run.updatedAt = new Date().toISOString();
268
307
  await store.saveRun(run);
308
+ clearCancel(runId);
269
309
  emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
310
+ emitRunEvent(runId, { type: 'completed', status: run.status });
270
311
  return run;
271
312
  }
272
313
 
@@ -278,6 +319,26 @@ export async function runOrchestration(request = {}, { env = process.env, cwd =
278
319
  return executeRun(cwd, planned.runId, { env, workerBackend, fetchImpl });
279
320
  }
280
321
 
322
+ /**
323
+ * Start a run for a thin client: plan + persist synchronously, then execute in
324
+ * the BACKGROUND (not awaited) so the caller gets a runId immediately and tracks
325
+ * progress via the event stream / run store. Execution failures are caught and
326
+ * surfaced as a terminal `error` run event rather than an unhandled rejection.
327
+ *
328
+ * @param {object} request
329
+ * @param {object} [opts] { env, cwd, workerBackend, fetchImpl }
330
+ * @returns {Promise<object>} the planned run (status `planned`)
331
+ */
332
+ export async function startRun(request = {}, { env = process.env, cwd = process.cwd(), workerBackend = null, fetchImpl } = {}) {
333
+ const planned = await planRun(request, { env, cwd });
334
+ Promise.resolve()
335
+ .then(() => executeRun(cwd, planned.runId, { env, workerBackend, fetchImpl }))
336
+ .catch((err) => {
337
+ emitRunEvent(planned.runId, { type: 'error', status: 'error', error: { code: err.code || 'RUN_FAILED', message: err.message } });
338
+ });
339
+ return planned;
340
+ }
341
+
281
342
  /**
282
343
  * The structured metadata a host adapter consumes for runtime-backed integration.
283
344
  */
@@ -292,12 +353,13 @@ export function hostAdapterMetadata(run) {
292
353
  executionMode: e.executionMode,
293
354
  constructCapabilitiesActive: e.constructCapabilitiesActive,
294
355
  workerBackend: run.workerBackend,
356
+ chainOfThought: run.chainOfThought ?? null,
295
357
  hostRole: run.hostRole,
296
358
  degraded: e.degraded,
297
359
  degradationReason: e.degradationReason,
298
360
  selectedProvider: e.selectedProvider,
299
361
  selectedModel: e.selectedModel,
300
- tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor })),
362
+ tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null, output: t.output ?? null, error: t.error ?? null })),
301
363
  warnings: run.warnings || [],
302
364
  semantics: run.semantics,
303
365
  executionSemantics: run.executionSemantics,