@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.
Files changed (57) hide show
  1. package/README.md +7 -1
  2. package/bin/construct +354 -19
  3. package/lib/adapters-sync.mjs +1 -1
  4. package/lib/artifact-loop-core.mjs +26 -5
  5. package/lib/artifact-manifest-overlay.mjs +273 -0
  6. package/lib/artifact-manifest.mjs +13 -10
  7. package/lib/auto-docs.mjs +5 -1
  8. package/lib/cli-commands.mjs +51 -6
  9. package/lib/config/source-target-registry.mjs +1 -0
  10. package/lib/config/source-targets.mjs +30 -0
  11. package/lib/decisions/golden.mjs +17 -18
  12. package/lib/doc-stamp.mjs +12 -0
  13. package/lib/doctor/index.mjs +2 -1
  14. package/lib/doctor/source-target-health.mjs +101 -0
  15. package/lib/doctor/watchers/source-targets.mjs +44 -0
  16. package/lib/document-ingest.mjs +25 -3
  17. package/lib/embed/demand-fetch.mjs +30 -36
  18. package/lib/embed/providers/directory.mjs +117 -0
  19. package/lib/embed/providers/registry.mjs +7 -0
  20. package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
  21. package/lib/extensions/manifests/directory.manifest.json +24 -1
  22. package/lib/extensions/manifests/github.manifest.json +10 -0
  23. package/lib/extensions/manifests/linear.manifest.json +1 -1
  24. package/lib/hooks/session-start.mjs +18 -11
  25. package/lib/knowledge/rag.mjs +52 -11
  26. package/lib/knowledge/search.mjs +69 -6
  27. package/lib/knowledge/synthesis.mjs +157 -0
  28. package/lib/mcp/server.mjs +2 -2
  29. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  30. package/lib/mcp/tools/artifact-author.mjs +126 -13
  31. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  32. package/lib/mcp/tools/skills.mjs +17 -0
  33. package/lib/model-cheapest-provider.mjs +2 -1
  34. package/lib/model-policy.mjs +329 -0
  35. package/lib/model-router.mjs +10 -9
  36. package/lib/model-tiers.mjs +27 -0
  37. package/lib/models/catalog.mjs +5 -4
  38. package/lib/orchestration/classification.mjs +11 -0
  39. package/lib/orchestration/context-bindings.mjs +85 -0
  40. package/lib/orchestration/readiness.mjs +2 -1
  41. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  42. package/lib/orchestration/runtime.mjs +35 -1
  43. package/lib/orchestration/worker.mjs +9 -0
  44. package/lib/review-pr.mjs +102 -0
  45. package/lib/setup.mjs +2 -1
  46. package/lib/sources/content-roots.mjs +147 -0
  47. package/lib/sources/repo-cache.mjs +142 -0
  48. package/lib/template-registry.mjs +2 -0
  49. package/lib/tracker/contribute.mjs +266 -0
  50. package/lib/validator.mjs +2 -3
  51. package/package.json +1 -5
  52. package/registry/agent-manifest.json +0 -4
  53. package/registry/capabilities.json +20 -1
  54. package/scripts/sync-specialists.mjs +12 -3
  55. package/templates/docs/README.md +22 -0
  56. package/templates/docs/adhoc.md +12 -0
  57. package/templates/docs/strategy-comparison.md +19 -0
@@ -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()) { walk(full); continue; }
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 chunks = [
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
  }
@@ -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
- const sources = buildSourceList(root, rootDir);
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: chunk.source.source || 'bundled',
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
+ }
@@ -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
- 'workflow_invoke', 'triage_recommend', 'orchestration_readiness',
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, ). Defaults to prd; inferred from subject when omitted.' },
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: ['reasoning', 'standard', 'fast'], description: 'Desired tier; overrides the workflow-type hint.' },
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: ['reasoning', 'standard', 'fast'], description: 'Desired model tier; overrides the workflow-type hint.' },
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
  },