@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.
- package/README.md +23 -7
- package/bin/construct +206 -228
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +43 -15
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +4 -0
- package/lib/decisions/enforced-baseline.json +2 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +31 -10
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +3 -3
- package/lib/mcp-manager.mjs +59 -3
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +39 -163
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/runtime.mjs +72 -10
- package/lib/orchestration/worker.mjs +69 -21
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +46 -24
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +169 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +59 -252
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +104 -480
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -43
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +7 -6
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -11
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +427 -107
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +3 -12
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +28 -36
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +15 -15
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/server/static/index.html +0 -1
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/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
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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:
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
228
|
-
if (await client.isHealthy()
|
|
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
|
-
|
|
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 =
|
|
272
|
-
if (await client.isHealthy()
|
|
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.
|
|
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
|
-
//
|
|
247
|
+
// If Vector search fails, return empty since fallback is removed.
|
|
299
248
|
}
|
|
300
249
|
|
|
301
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
}
|
package/lib/opencode-config.mjs
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|