@geraldmaron/construct 1.5.1 → 1.5.3
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 +7 -1
- package/bin/construct +354 -19
- package/lib/adapters-sync.mjs +1 -1
- package/lib/artifact-loop-core.mjs +26 -5
- package/lib/artifact-manifest-overlay.mjs +273 -0
- package/lib/artifact-manifest.mjs +13 -10
- package/lib/auto-docs.mjs +5 -1
- package/lib/cli-commands.mjs +51 -6
- package/lib/config/source-target-registry.mjs +1 -0
- package/lib/config/source-targets.mjs +30 -0
- package/lib/decisions/golden.mjs +17 -18
- package/lib/doc-stamp.mjs +12 -0
- package/lib/doctor/index.mjs +2 -1
- package/lib/doctor/source-target-health.mjs +101 -0
- package/lib/doctor/watchers/source-targets.mjs +44 -0
- package/lib/document-ingest.mjs +25 -3
- package/lib/embed/demand-fetch.mjs +30 -36
- package/lib/embed/providers/directory.mjs +117 -0
- package/lib/embed/providers/registry.mjs +7 -0
- package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
- package/lib/extensions/manifests/directory.manifest.json +24 -1
- package/lib/extensions/manifests/github.manifest.json +10 -0
- package/lib/extensions/manifests/linear.manifest.json +1 -1
- package/lib/hooks/session-start.mjs +18 -11
- package/lib/knowledge/rag.mjs +52 -11
- package/lib/knowledge/search.mjs +69 -6
- package/lib/knowledge/synthesis.mjs +157 -0
- package/lib/mcp/server.mjs +2 -2
- package/lib/mcp/tool-definitions-workflow.mjs +35 -7
- package/lib/mcp/tools/artifact-author.mjs +126 -13
- package/lib/mcp/tools/orchestration-run.mjs +3 -0
- package/lib/mcp/tools/skills.mjs +17 -0
- package/lib/model-cheapest-provider.mjs +2 -1
- package/lib/model-policy.mjs +329 -0
- package/lib/model-router.mjs +10 -9
- package/lib/model-tiers.mjs +27 -0
- package/lib/models/catalog.mjs +5 -4
- package/lib/orchestration/classification.mjs +11 -0
- package/lib/orchestration/context-bindings.mjs +85 -0
- package/lib/orchestration/readiness.mjs +2 -1
- package/lib/orchestration/research-evidence-gate.mjs +104 -0
- package/lib/orchestration/runtime.mjs +35 -1
- package/lib/orchestration/worker.mjs +9 -0
- package/lib/review-pr.mjs +102 -0
- package/lib/setup.mjs +2 -1
- package/lib/sources/content-roots.mjs +147 -0
- package/lib/sources/repo-cache.mjs +142 -0
- package/lib/template-registry.mjs +2 -0
- package/lib/tracker/contribute.mjs +266 -0
- package/lib/validator.mjs +2 -3
- package/package.json +1 -5
- package/registry/agent-manifest.json +0 -4
- package/registry/capabilities.json +20 -1
- package/scripts/sync-specialists.mjs +12 -3
- package/templates/docs/README.md +22 -0
- package/templates/docs/adhoc.md +12 -0
- package/templates/docs/strategy-comparison.md +19 -0
package/lib/knowledge/rag.mjs
CHANGED
|
@@ -68,27 +68,47 @@ function loadObservationChunks(rootDir) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// The host project is the reserved origin: no target id, a `self` project key.
|
|
72
|
+
// Registered content targets carry their own origin (targetId, provider,
|
|
73
|
+
// projectKey, ref); the corpus builder stamps per-file `relPath` onto both.
|
|
74
|
+
|
|
75
|
+
const SELF_ORIGIN = Object.freeze({ targetId: null, provider: 'self', projectKey: 'self', ref: null });
|
|
76
|
+
|
|
77
|
+
function withOrigin(chunk, origin, relPath) {
|
|
78
|
+
return { ...chunk, origin: { ...origin, relPath: relPath ?? chunk.origin?.relPath ?? null } };
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
/**
|
|
72
|
-
* Load markdown files from a directory tree as indexable chunks.
|
|
82
|
+
* Load markdown files from a directory tree as indexable chunks. `origin`, when
|
|
83
|
+
* given, tags every chunk with its source project and per-file relative path so
|
|
84
|
+
* cross-project retrieval can attribute and filter the result; `baseDir`
|
|
85
|
+
* anchors the relative path (defaults to `dir`).
|
|
73
86
|
*/
|
|
74
|
-
function loadMarkdownChunks(dir, source) {
|
|
87
|
+
function loadMarkdownChunks(dir, source, { origin = null, baseDir = dir } = {}) {
|
|
75
88
|
if (!fs.existsSync(dir)) return [];
|
|
76
89
|
const chunks = [];
|
|
77
90
|
const walk = (d) => {
|
|
78
91
|
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
79
92
|
const full = path.join(d, entry.name);
|
|
80
|
-
if (entry.isDirectory()) {
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
95
|
+
walk(full);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
81
98
|
if (!entry.name.endsWith('.md')) continue;
|
|
82
99
|
try {
|
|
83
100
|
const content = fs.readFileSync(full, 'utf8');
|
|
84
101
|
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
102
|
+
const relPath = path.relative(baseDir, full);
|
|
103
|
+
const idScope = origin?.targetId ? `${origin.targetId}:` : '';
|
|
85
104
|
chunks.push({
|
|
86
|
-
id: `${source}:${path.relative(process.cwd(), full)}`,
|
|
105
|
+
id: `${source}:${idScope}${path.relative(process.cwd(), full)}`,
|
|
87
106
|
source,
|
|
88
107
|
title: titleMatch ? titleMatch[1].trim() : entry.name,
|
|
89
108
|
body: content,
|
|
90
109
|
filePath: full,
|
|
91
110
|
createdAt: fs.statSync(full).mtime.toISOString(),
|
|
111
|
+
...(origin ? { origin: { ...origin, relPath } } : {}),
|
|
92
112
|
});
|
|
93
113
|
} catch { /* skip unreadable */ }
|
|
94
114
|
}
|
|
@@ -174,15 +194,33 @@ function loadKnowledgeChunks(rootDir) {
|
|
|
174
194
|
|
|
175
195
|
/**
|
|
176
196
|
* Build a full in-memory corpus from all sources.
|
|
177
|
-
* Each chunk gets an embedding vector for cosine scoring.
|
|
197
|
+
* Each chunk gets an embedding vector for cosine scoring and an `origin` tag.
|
|
198
|
+
*
|
|
199
|
+
* The single-`rootDir` signature is preserved for existing callers: passing a
|
|
200
|
+
* string (or nothing) indexes only the host project, whose chunks carry the
|
|
201
|
+
* reserved self origin. Passing `roots` — content-capable target roots resolved
|
|
202
|
+
* via lib/sources/content-roots.mjs — folds each registered project's markdown
|
|
203
|
+
* into the same corpus, every chunk attributed to its source project so
|
|
204
|
+
* retrieval can cite and filter across projects (bead construct-760c.2).
|
|
205
|
+
*
|
|
206
|
+
* @param {string} [rootDir]
|
|
207
|
+
* @param {object} [opts]
|
|
208
|
+
* @param {{dir: string, origin: object}[]} [opts.roots] — extra content roots
|
|
178
209
|
*/
|
|
179
|
-
export function buildCorpus(rootDir = process.cwd()) {
|
|
180
|
-
const
|
|
210
|
+
export function buildCorpus(rootDir = process.cwd(), { roots = [] } = {}) {
|
|
211
|
+
const hostChunks = [
|
|
181
212
|
...loadObservationChunks(rootDir),
|
|
182
213
|
...loadArtifactChunks(rootDir),
|
|
183
214
|
...loadSnapshotChunks(rootDir),
|
|
184
215
|
...loadKnowledgeChunks(rootDir),
|
|
185
|
-
];
|
|
216
|
+
].map((chunk) => withOrigin(chunk, SELF_ORIGIN, chunk.filePath ? path.relative(rootDir, chunk.filePath) : null));
|
|
217
|
+
|
|
218
|
+
const rootChunks = [];
|
|
219
|
+
for (const { dir, origin } of roots) {
|
|
220
|
+
rootChunks.push(...loadMarkdownChunks(dir, 'target', { origin, baseDir: dir }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const chunks = [...hostChunks, ...rootChunks];
|
|
186
224
|
|
|
187
225
|
// Embed each chunk
|
|
188
226
|
return chunks.map((chunk) => ({
|
|
@@ -261,8 +299,11 @@ export function assembleContext(chunks) {
|
|
|
261
299
|
|
|
262
300
|
for (const chunk of chunks) {
|
|
263
301
|
const preview = chunk.body?.slice(0, CHUNK_PREVIEW) || '';
|
|
302
|
+
const projectKey = chunk.origin?.projectKey;
|
|
264
303
|
const meta = [
|
|
265
304
|
chunk.source && `source:${chunk.source}`,
|
|
305
|
+
projectKey && projectKey !== 'self' && `project:${projectKey}`,
|
|
306
|
+
chunk.origin?.relPath && `path:${chunk.origin.relPath}`,
|
|
266
307
|
chunk.role && `role:${chunk.role}`,
|
|
267
308
|
chunk.category && `category:${chunk.category}`,
|
|
268
309
|
chunk.createdAt && `date:${chunk.createdAt.slice(0, 10)}`,
|
|
@@ -302,7 +343,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
|
|
|
302
343
|
if (dryRun) {
|
|
303
344
|
return {
|
|
304
345
|
answer: null,
|
|
305
|
-
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
|
|
346
|
+
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
|
|
306
347
|
query: question,
|
|
307
348
|
};
|
|
308
349
|
}
|
|
@@ -333,7 +374,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
|
|
|
333
374
|
.join('\n\n');
|
|
334
375
|
return {
|
|
335
376
|
answer: `[Claude CLI unavailable — showing retrieved context]\n\n${fallback}`,
|
|
336
|
-
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
|
|
377
|
+
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
|
|
337
378
|
query: question,
|
|
338
379
|
cliMissing: true,
|
|
339
380
|
};
|
|
@@ -341,7 +382,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
|
|
|
341
382
|
|
|
342
383
|
return {
|
|
343
384
|
answer: (result.stdout || '').trim(),
|
|
344
|
-
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
|
|
385
|
+
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
|
|
345
386
|
query: question,
|
|
346
387
|
};
|
|
347
388
|
}
|
package/lib/knowledge/search.mjs
CHANGED
|
@@ -28,6 +28,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
28
28
|
import { dirname } from 'node:path';
|
|
29
29
|
import { homedir } from 'node:os';
|
|
30
30
|
|
|
31
|
+
import { loadProjectConfig } from '../config/project-config.mjs';
|
|
32
|
+
import { resolveEffectiveSourceTargetsFromConfig } from '../config/source-targets.mjs';
|
|
33
|
+
import { resolveContentRoots, expandProjectsFilter, SELF_PROJECT_KEY } from '../sources/content-roots.mjs';
|
|
34
|
+
|
|
31
35
|
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
32
36
|
const REPO_ROOT = join(MODULE_DIR, '..', '..');
|
|
33
37
|
|
|
@@ -43,7 +47,7 @@ const REPO_ROOT = join(MODULE_DIR, '..', '..');
|
|
|
43
47
|
* surfaces alongside (and ahead of) the bundled Construct docs. When
|
|
44
48
|
* projectRoot equals repoRoot or is absent, only the bundled set is searched.
|
|
45
49
|
*/
|
|
46
|
-
function buildSourceList(repoRoot = REPO_ROOT, projectRoot = null) {
|
|
50
|
+
function buildSourceList(repoRoot = REPO_ROOT, projectRoot = null, { targetRoots = [] } = {}) {
|
|
47
51
|
const sources = [];
|
|
48
52
|
|
|
49
53
|
const priority = [
|
|
@@ -125,6 +129,24 @@ function buildSourceList(repoRoot = REPO_ROOT, projectRoot = null) {
|
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
// Registered content targets (B1): directory targets and synced corpus caches.
|
|
133
|
+
// Each markdown file joins the corpus tagged with its target's structured
|
|
134
|
+
// origin, so retrieval can attribute and filter hits by source project. Priority
|
|
135
|
+
// 1 puts registered project docs on the same tier as the host project's own.
|
|
136
|
+
|
|
137
|
+
for (const root of targetRoots) {
|
|
138
|
+
if (!existsSync(root.dir)) continue;
|
|
139
|
+
for (const file of walkMarkdown(root.dir)) {
|
|
140
|
+
sources.push({
|
|
141
|
+
path: file,
|
|
142
|
+
rel: relative(root.dir, file),
|
|
143
|
+
priority: 1,
|
|
144
|
+
source: 'target',
|
|
145
|
+
origin: { ...root.origin, relPath: relative(root.dir, file), kind: 'target' },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
128
150
|
return sources;
|
|
129
151
|
}
|
|
130
152
|
|
|
@@ -313,14 +335,37 @@ function buildObservationChunks(rootDir) {
|
|
|
313
335
|
* @param {string} [opts.rootDir] — data dir where .cx/observations/ lives (default: homedir())
|
|
314
336
|
* @returns {KnowledgeSearchResult}
|
|
315
337
|
*/
|
|
316
|
-
export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, rootDir, tags, tagMatch = 'any' } = {}) {
|
|
338
|
+
export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, rootDir, tags, tagMatch = 'any', projects, env = process.env } = {}) {
|
|
317
339
|
if (!query || typeof query !== 'string') {
|
|
318
340
|
return { ok: false, query: query ?? '', totalChunks: 0, hits: [], sources: [], message: 'query is required' };
|
|
319
341
|
}
|
|
320
342
|
|
|
321
343
|
const root = repoRoot ?? REPO_ROOT;
|
|
322
344
|
const dataDir = rootDir ?? (process.env.CX_DATA_DIR?.trim() || homedir());
|
|
323
|
-
|
|
345
|
+
|
|
346
|
+
// Registered content targets contribute to the corpus whenever the project has
|
|
347
|
+
// any (resolved from its config). A `projects` filter narrows retrieval to the
|
|
348
|
+
// named source projects; `self` is the reserved host-project key, `all` every
|
|
349
|
+
// content target. An unknown project id is a hard error (R3), never a silent
|
|
350
|
+
// empty result.
|
|
351
|
+
let targetRoots = [];
|
|
352
|
+
let projectFilter = null;
|
|
353
|
+
if (rootDir) {
|
|
354
|
+
const { config } = loadProjectConfig(rootDir, env);
|
|
355
|
+
const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
|
|
356
|
+
targetRoots = resolveContentRoots(targets, { projectRoot: rootDir });
|
|
357
|
+
if (projects !== undefined && projects !== null && String(projects).trim() !== '') {
|
|
358
|
+
try {
|
|
359
|
+
projectFilter = expandProjectsFilter(projects, targets);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
return { ok: false, query, totalChunks: 0, hits: [], sources: [], message: err.message };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} else if (projects !== undefined && projects !== null && String(projects).trim() !== '') {
|
|
365
|
+
return { ok: false, query, totalChunks: 0, hits: [], sources: [], message: 'projects filter requires a project root (rootDir)' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const sources = buildSourceList(root, rootDir, { targetRoots });
|
|
324
369
|
|
|
325
370
|
// Build corpus from docs + operator knowledge
|
|
326
371
|
const allChunks = [];
|
|
@@ -356,17 +401,35 @@ export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, roo
|
|
|
356
401
|
|
|
357
402
|
const idf = buildIdf(queryTokens, filteredChunks);
|
|
358
403
|
|
|
404
|
+
// A chunk's structured origin: registered targets carry it directly from
|
|
405
|
+
// buildSourceList; host/bundled chunks resolve to the reserved self project.
|
|
406
|
+
const originFor = (chunk) => chunk.source.origin ?? {
|
|
407
|
+
targetId: null,
|
|
408
|
+
provider: 'self',
|
|
409
|
+
projectKey: SELF_PROJECT_KEY,
|
|
410
|
+
relPath: chunk.source.rel,
|
|
411
|
+
ref: null,
|
|
412
|
+
kind: chunk.source.source || 'bundled',
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const inFilter = (origin) => {
|
|
416
|
+
if (!projectFilter) return true;
|
|
417
|
+
if (origin.targetId == null) return projectFilter.includeSelf;
|
|
418
|
+
return projectFilter.ids.has(origin.targetId);
|
|
419
|
+
};
|
|
420
|
+
|
|
359
421
|
const scored = filteredChunks
|
|
360
|
-
.map(chunk => ({ chunk, score: scoreChunk(chunk, queryTokens, idf) }))
|
|
422
|
+
.map(chunk => ({ chunk, origin: originFor(chunk), score: scoreChunk(chunk, queryTokens, idf) }))
|
|
361
423
|
.filter(({ score }) => score >= minScore)
|
|
424
|
+
.filter(({ origin }) => inFilter(origin))
|
|
362
425
|
.sort((a, b) => b.score - a.score)
|
|
363
426
|
.slice(0, topK);
|
|
364
427
|
|
|
365
|
-
const hits = scored.map(({ chunk, score }) => ({
|
|
428
|
+
const hits = scored.map(({ chunk, origin, score }) => ({
|
|
366
429
|
text: chunk.text,
|
|
367
430
|
heading: chunk.heading,
|
|
368
431
|
file: chunk.source.rel,
|
|
369
|
-
origin
|
|
432
|
+
origin,
|
|
370
433
|
score: Math.round(score * 100) / 100,
|
|
371
434
|
lineStart: chunk.lineStart,
|
|
372
435
|
}));
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/knowledge/synthesis.mjs — cross-project synthesis (bead construct-760c.4→.3).
|
|
3
|
+
*
|
|
4
|
+
* Map-reduce over the B2 multi-root corpus so a user can ask one question across
|
|
5
|
+
* several registered projects — "summarize each project's docs", "how do these
|
|
6
|
+
* strategies converge" — and get an answer that names its sources (no
|
|
7
|
+
* fabrication: every cross-project claim traces to an origin the reader can
|
|
8
|
+
* re-verify).
|
|
9
|
+
*
|
|
10
|
+
* MAP — per project, retrieve the chunks most relevant to the ask from that
|
|
11
|
+
* project's content root only, and extract them as an attributed
|
|
12
|
+
* section. This pass is retrieval-only (no model call), so it is
|
|
13
|
+
* deterministic and free — the cheap-tier goal taken to its limit.
|
|
14
|
+
* REDUCE — one synthesis pass over the per-project sections that draws the
|
|
15
|
+
* convergence/divergence across projects, each claim carrying an
|
|
16
|
+
* origin citation. This is the single model call.
|
|
17
|
+
*
|
|
18
|
+
* `dryRun` stops after MAP and returns the fully assembled context (per-project
|
|
19
|
+
* sections + a citation table + the reduce prompt) with zero model output — the
|
|
20
|
+
* deterministic, CI-testable preview surface. An unknown project id is a hard
|
|
21
|
+
* error before any model call (R3).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
|
|
26
|
+
import { loadProjectConfig } from '../config/project-config.mjs';
|
|
27
|
+
import { resolveEffectiveSourceTargetsFromConfig } from '../config/source-targets.mjs';
|
|
28
|
+
import { resolveContentRoots, expandProjectsFilter } from '../sources/content-roots.mjs';
|
|
29
|
+
import { getTemplate } from '../mcp/tools/skills.mjs';
|
|
30
|
+
import { buildCorpus, retrieve } from './rag.mjs';
|
|
31
|
+
|
|
32
|
+
const PER_PROJECT_TOPK = 6;
|
|
33
|
+
const SECTION_PREVIEW = 500;
|
|
34
|
+
|
|
35
|
+
function citationLabel(origin) {
|
|
36
|
+
const rel = origin?.relPath || '(unknown)';
|
|
37
|
+
return `${origin?.projectKey || origin?.targetId || 'project'}:${rel}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Retrieval-only map: a single-root corpus filtered to this project's own
|
|
41
|
+
// chunks, then top-K for the ask. Sorting the roots and keying each corpus to a
|
|
42
|
+
// single origin keeps the assembly deterministic across runs (AC3).
|
|
43
|
+
async function mapProject(root, ask, { cwd, topK }) {
|
|
44
|
+
const corpus = buildCorpus(cwd, { roots: [root] })
|
|
45
|
+
.filter((c) => c.origin?.targetId === root.origin.targetId);
|
|
46
|
+
const hits = await retrieve(ask, corpus, { topK });
|
|
47
|
+
return { root, hits };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderProjectSection({ root, hits }) {
|
|
51
|
+
const id = root.origin.targetId;
|
|
52
|
+
if (!hits.length) {
|
|
53
|
+
return `## Project: ${id}\n\n_No content matched the query in this project._`;
|
|
54
|
+
}
|
|
55
|
+
const blocks = hits.map((h) => {
|
|
56
|
+
const preview = (h.body || '').slice(0, SECTION_PREVIEW).trim();
|
|
57
|
+
return `- **${h.title}** — \`${citationLabel(h.origin)}\`\n\n ${preview.replace(/\n+/g, ' ')}`;
|
|
58
|
+
});
|
|
59
|
+
return `## Project: ${id}\n\n${blocks.join('\n\n')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderCitationTable(mapped) {
|
|
63
|
+
const rows = [];
|
|
64
|
+
for (const { root, hits } of mapped) {
|
|
65
|
+
for (const h of hits) {
|
|
66
|
+
rows.push(`| ${root.origin.targetId} | ${h.origin?.relPath || '(unknown)'} | ${h.title} |`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!rows.length) return '## Citations\n\n_No sources matched._';
|
|
70
|
+
return `## Citations\n\n| Project | Path | Title |\n|---|---|---|\n${rows.join('\n')}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Template shapes the convergence section only (AC2): when one resolves, its
|
|
74
|
+
// section headings scaffold the reduce; otherwise a default convergence shape.
|
|
75
|
+
function convergenceHeadings(template, rootDir) {
|
|
76
|
+
if (!template) return ['Convergence', 'Divergence', 'Recommendation'];
|
|
77
|
+
const resolved = getTemplate({ name: template }, { ROOT_DIR: rootDir });
|
|
78
|
+
if (resolved?.error || !resolved?.content) return ['Convergence', 'Divergence', 'Recommendation'];
|
|
79
|
+
const headings = [...resolved.content.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim());
|
|
80
|
+
return headings.length ? headings : ['Convergence', 'Divergence', 'Recommendation'];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildReducePrompt({ ask, sections, headings }) {
|
|
84
|
+
const headingBlock = headings.map((h) => `## ${h}`).join('\n');
|
|
85
|
+
return `You are synthesizing across multiple projects. Answer the question using ONLY the per-project context below. Every cross-project claim MUST cite its source as \`project:path\` (the labels shown). If a project lacks relevant content, say so — do not invent.
|
|
86
|
+
|
|
87
|
+
Question: ${ask}
|
|
88
|
+
|
|
89
|
+
${sections}
|
|
90
|
+
|
|
91
|
+
Produce a synthesis with these sections (keep the per-project attribution above intact):
|
|
92
|
+
${headingBlock}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Assemble the multi-project context and, unless dryRun, synthesize an answer.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} opts
|
|
99
|
+
* @param {string} opts.projects CSV / array project filter (all | self | ids)
|
|
100
|
+
* @param {string} opts.ask the synthesis question
|
|
101
|
+
* @param {string} [opts.cwd]
|
|
102
|
+
* @param {object} [opts.env]
|
|
103
|
+
* @param {string} [opts.template] optional template name shaping the reduce
|
|
104
|
+
* @param {boolean} [opts.dryRun] stop after MAP, return assembled context only
|
|
105
|
+
* @param {number} [opts.topK] per-project retrieval depth
|
|
106
|
+
* @param {string} [opts.rootDir] construct repo root (for template resolution)
|
|
107
|
+
* @returns {Promise<{ok, ask, projects, context, citations, prompt, answer, sources}>}
|
|
108
|
+
*/
|
|
109
|
+
export async function synthesize({ projects, ask, cwd = process.cwd(), env = process.env, template = null, dryRun = false, topK = PER_PROJECT_TOPK, rootDir } = {}) {
|
|
110
|
+
if (!ask || typeof ask !== 'string' || !ask.trim()) {
|
|
111
|
+
return { ok: false, message: 'a synthesis question (--ask) is required' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { config } = loadProjectConfig(cwd, env);
|
|
115
|
+
const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
|
|
116
|
+
const allRoots = resolveContentRoots(targets, { projectRoot: cwd });
|
|
117
|
+
|
|
118
|
+
let selectedIds;
|
|
119
|
+
try {
|
|
120
|
+
const filter = expandProjectsFilter(projects ?? 'all', targets);
|
|
121
|
+
selectedIds = filter.ids;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { ok: false, message: err.message };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const roots = allRoots
|
|
127
|
+
.filter((r) => selectedIds.has(r.origin.targetId))
|
|
128
|
+
.sort((a, b) => a.origin.targetId.localeCompare(b.origin.targetId));
|
|
129
|
+
|
|
130
|
+
if (!roots.length) {
|
|
131
|
+
return { ok: false, message: `no content-capable projects resolved for "${projects ?? 'all'}" — register a directory or synced corpus target first` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const mapped = [];
|
|
135
|
+
for (const root of roots) {
|
|
136
|
+
mapped.push(await mapProject(root, ask, { cwd, topK }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sections = mapped.map(renderProjectSection).join('\n\n');
|
|
140
|
+
const citations = renderCitationTable(mapped);
|
|
141
|
+
const headings = convergenceHeadings(template, rootDir || cwd);
|
|
142
|
+
const prompt = buildReducePrompt({ ask, sections: `${sections}\n\n${citations}`, headings });
|
|
143
|
+
|
|
144
|
+
const context = `# Cross-project synthesis: ${ask}\n\n${sections}\n\n${citations}`;
|
|
145
|
+
const sourceList = mapped.flatMap(({ root, hits }) =>
|
|
146
|
+
hits.map((h) => ({ project: root.origin.targetId, path: h.origin?.relPath || null, title: h.title })));
|
|
147
|
+
|
|
148
|
+
if (dryRun) {
|
|
149
|
+
return { ok: true, ask, projects: roots.map((r) => r.origin.targetId), context, citations, prompt, answer: null, sources: sourceList };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = spawnSync('claude', ['--print', prompt], { encoding: 'utf8', timeout: 120_000, env: { ...env } });
|
|
153
|
+
if (result.error || result.status !== 0) {
|
|
154
|
+
return { ok: true, ask, projects: roots.map((r) => r.origin.targetId), context, citations, prompt, answer: `[claude CLI unavailable — showing assembled context]\n\n${context}`, sources: sourceList, cliMissing: true };
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, ask, projects: roots.map((r) => r.origin.targetId), context, citations, prompt, answer: (result.stdout || '').trim(), sources: sourceList };
|
|
157
|
+
}
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -215,7 +215,7 @@ const CORE_TOOL_NAMES = new Set([
|
|
|
215
215
|
'orchestration_policy', 'orchestration_run', 'get_skill', 'get_template', 'search_skills', 'knowledge_search',
|
|
216
216
|
'memory_search', 'project_context', 'summarize_diff', 'find_tool',
|
|
217
217
|
'author_artifact', 'document_export', 'publish_run', 'artifact_workflow',
|
|
218
|
-
'
|
|
218
|
+
'triage_recommend', 'orchestration_readiness',
|
|
219
219
|
]);
|
|
220
220
|
|
|
221
221
|
const KNOWN_TOOL_NAMES = new Set(ALL_TOOL_DEFS.map((t) => t.name));
|
|
@@ -325,7 +325,7 @@ export async function dispatchToolByName(name, args = {}) {
|
|
|
325
325
|
else if (name === 'provider_write') result = await providerWrite(args, { rootDir: opts.ROOT_DIR });
|
|
326
326
|
else if (name === 'knowledge_search') {
|
|
327
327
|
const { knowledgeSearch } = await import('../knowledge/search.mjs');
|
|
328
|
-
result = knowledgeSearch({ query: args.query, topK: args.top_k, repoRoot: args.repo_root, rootDir: args.root_dir });
|
|
328
|
+
result = knowledgeSearch({ query: args.query, topK: args.top_k, repoRoot: args.repo_root, rootDir: args.root_dir, projects: args.projects });
|
|
329
329
|
}
|
|
330
330
|
else if (name === 'document_export') {
|
|
331
331
|
const { detect, exportMarkdown } = await import('../document-export.mjs');
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* lib/mcp/server.mjs concatenates every slice and applies
|
|
10
10
|
* withSafetyEnvelope (lib/mcp/tool-safety.mjs) at load time.
|
|
11
11
|
*/
|
|
12
|
+
import { MODEL_TIERS } from '../model-tiers.mjs';
|
|
13
|
+
|
|
12
14
|
export const TOOL_DEFS_WORKFLOW = [
|
|
13
15
|
{
|
|
14
16
|
name: 'publish_detect',
|
|
@@ -56,7 +58,8 @@ export const TOOL_DEFS_WORKFLOW = [
|
|
|
56
58
|
query: { type: 'string', description: 'Natural-language question or keyword (e.g. "what is construct", "how does embed mode work", "provider authority guard", "slack configuration", "open Jira issues").' },
|
|
57
59
|
top_k: { type: 'number', description: 'Max excerpts to return (default: 5).' },
|
|
58
60
|
repo_root: { type: 'string', description: 'Repo root override (default: auto-detected from server location).' },
|
|
59
|
-
root_dir: { type: 'string', description: 'Data directory where .cx/observations/ lives (default: home directory). Pass this to search embed observations from a custom data dir.' },
|
|
61
|
+
root_dir: { type: 'string', description: 'Data directory where .cx/observations/ lives (default: home directory). Pass this to search embed observations from a custom data dir, and to resolve registered content targets for cross-project search.' },
|
|
62
|
+
projects: { type: 'string', description: 'Restrict retrieval to specific registered source projects: a comma-separated list of target ids, "all" for every content target, or "self" for the host project. Requires root_dir. An unknown id is a hard error, not an empty result. Each hit carries a structured origin {targetId, provider, projectKey, relPath, ref, kind} for attribution.' },
|
|
60
63
|
},
|
|
61
64
|
},
|
|
62
65
|
},
|
|
@@ -231,15 +234,28 @@ export const TOOL_DEFS_WORKFLOW = [
|
|
|
231
234
|
{
|
|
232
235
|
name: 'author_artifact',
|
|
233
236
|
outputSchema: { type: 'object' },
|
|
234
|
-
description: 'Materialize a typed Construct artifact you have drafted (prd, prd-platform, prd-business, meta-prd, adr, rfc, research-brief, evidence-brief, runbook) to disk and run the release gate. YOU draft the full markdown — start with a single # title and include the type\'s required ## sections (call get_template first for the shape) — and pass it as draft_markdown; the canonical file is written and the gate verdict + errors are returned so you can fix and re-call. This is the Construct author→materialize→validate pass for supported hosts.',
|
|
237
|
+
description: 'Materialize a typed Construct artifact you have drafted (prd, prd-platform, prd-business, meta-prd, adr, rfc, research-brief, evidence-brief, runbook, a project-registered custom class, or adhoc) to disk and run the release gate. YOU draft the full markdown — start with a single # title and include the type\'s required ## sections (call get_template first for the shape) — and pass it as draft_markdown; the canonical file is written and the gate verdict + errors are returned so you can fix and re-call. For a one-off with no fixed shape, pass artifact_type "adhoc" with title + instructions (no draft_markdown needed). An unknown, unregistered non-adhoc class returns a classification/registration prompt instead of a draft. This is the Construct author→materialize→validate pass for supported hosts.',
|
|
235
238
|
inputSchema: {
|
|
236
239
|
type: 'object',
|
|
237
240
|
properties: {
|
|
238
|
-
draft_markdown: { type: 'string', description: 'The complete artifact markdown you authored. Must start with one # title line and contain the required ## sections for the type.' },
|
|
239
|
-
artifact_type: { type: 'string', description: 'Artifact type (prd, meta-prd, adr, rfc, research-brief, evidence-brief, runbook,
|
|
241
|
+
draft_markdown: { type: 'string', description: 'The complete artifact markdown you authored. Must start with one # title line and contain the required ## sections for the type. Required for a normal author pass; omit for adhoc or dry_run.' },
|
|
242
|
+
artifact_type: { type: 'string', description: 'Artifact type (prd, meta-prd, adr, rfc, research-brief, evidence-brief, runbook, a registered custom class, or adhoc). Defaults to prd; inferred from subject when omitted.' },
|
|
240
243
|
subject: { type: 'string', description: 'Short subject/title hint (e.g. "OIDC integration") used for the filename and type inference.' },
|
|
244
|
+
title: { type: 'string', description: 'Required for adhoc: the artifact title.' },
|
|
245
|
+
instructions: { type: 'string', description: 'Required for adhoc: what the one-off document should cover. Its structure follows these instructions; the release gate still applies.' },
|
|
246
|
+
for_type: { type: 'string', description: 'Optional adhoc hint: if this names a registered class, the call is redirected to that class instead of authoring adhoc.' },
|
|
247
|
+
dry_run: { type: 'boolean', description: 'Preview mode: draft from the resolved template (or the adhoc scaffold) without a supplied draft, then run the gate. Default false.' },
|
|
248
|
+
context_targets: {
|
|
249
|
+
type: 'array',
|
|
250
|
+
description: 'Optional registered source-target ids (or {id, role}) to bind this author pass to for cross-project context (B3). Each id must exist in sources.targets[] — an unknown id is a hard error before authoring. The multi-project synthesis context is assembled deterministically and woven into the authoring input so the artifact can draw on and cite each project (project:path).',
|
|
251
|
+
items: {
|
|
252
|
+
oneOf: [
|
|
253
|
+
{ type: 'string' },
|
|
254
|
+
{ type: 'object', required: ['id'], properties: { id: { type: 'string' }, role: { type: 'string' } } },
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
},
|
|
241
258
|
},
|
|
242
|
-
required: ['draft_markdown'],
|
|
243
259
|
},
|
|
244
260
|
},
|
|
245
261
|
{
|
|
@@ -250,7 +266,7 @@ export const TOOL_DEFS_WORKFLOW = [
|
|
|
250
266
|
type: 'object',
|
|
251
267
|
properties: {
|
|
252
268
|
workflow_type: { type: 'string', description: 'Workflow type hint (e.g. evidence-ingest, prd-draft, architecture-review). Selects a tier when requested_tier is absent.' },
|
|
253
|
-
requested_tier: { type: 'string', enum: [
|
|
269
|
+
requested_tier: { type: 'string', enum: [...MODEL_TIERS], description: 'Desired tier; overrides the workflow-type hint.' },
|
|
254
270
|
host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
|
|
255
271
|
host_model: { type: 'string', description: 'Model the host is currently using (e.g. anthropic/claude-sonnet-4-6).' },
|
|
256
272
|
host_provider: { type: 'string', description: 'Provider family the host uses, when no host_model is given.' },
|
|
@@ -323,7 +339,7 @@ export const TOOL_DEFS_WORKFLOW = [
|
|
|
323
339
|
host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
|
|
324
340
|
host_model: { type: 'string', description: 'Model the host is currently using, for model resolution.' },
|
|
325
341
|
host_provider: { type: 'string', description: 'Provider family the host uses, when no host_model is given.' },
|
|
326
|
-
requested_tier: { type: 'string', enum: [
|
|
342
|
+
requested_tier: { type: 'string', enum: [...MODEL_TIERS], description: 'Desired model tier; overrides the workflow-type hint.' },
|
|
327
343
|
capabilities: { type: 'array', items: { type: 'string' }, description: 'Optional required capabilities; unverifiable ones are returned as warnings.' },
|
|
328
344
|
allow_cross_provider_fallback: { type: 'boolean', description: 'Permit model fallback outside the host provider family (default false).' },
|
|
329
345
|
},
|
|
@@ -346,6 +362,18 @@ export const TOOL_DEFS_WORKFLOW = [
|
|
|
346
362
|
host_provider: { type: 'string', description: 'Provider family the host uses, for model resolution.' },
|
|
347
363
|
file_count: { type: 'number', description: 'Optional planning hint: number of files in scope.' },
|
|
348
364
|
module_count: { type: 'number', description: 'Optional planning hint: number of modules in scope.' },
|
|
365
|
+
context_targets: {
|
|
366
|
+
type: 'array',
|
|
367
|
+
description: 'Optional registered source targets to bind this run to for context (e.g. [{"id":"proj-app"},{"id":"jira-core","role":"tracker"}]). Each id must exist in sources.targets[] — an unknown id is rejected at plan time before any task is built, never a silent skip. `role` is an optional free-form hint. Resolved bindings are persisted on the run record and returned as contextBindings. Omit for today\'s implicit source resolution.',
|
|
368
|
+
items: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
required: ['id'],
|
|
371
|
+
properties: {
|
|
372
|
+
id: { type: 'string', description: 'A registered source-target id.' },
|
|
373
|
+
role: { type: 'string', description: 'Optional free-form role hint (e.g. "tracker", "reference").' },
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
349
377
|
wait: { type: 'boolean', description: 'Wait for the run to reach a terminal state and return task output (default true). false returns the runId immediately to poll with orchestration_status.' },
|
|
350
378
|
timeout_ms: { type: 'number', description: 'Max time to wait when wait=true (default 120000). On timeout the runId is returned to poll.' },
|
|
351
379
|
},
|