@geraldmaron/construct 1.5.1 → 1.5.2

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 (52) hide show
  1. package/README.md +3 -0
  2. package/bin/construct +336 -17
  3. package/lib/artifact-loop-core.mjs +26 -5
  4. package/lib/artifact-manifest-overlay.mjs +273 -0
  5. package/lib/artifact-manifest.mjs +13 -10
  6. package/lib/cli-commands.mjs +44 -4
  7. package/lib/config/source-target-registry.mjs +1 -0
  8. package/lib/config/source-targets.mjs +30 -0
  9. package/lib/doc-stamp.mjs +12 -0
  10. package/lib/doctor/index.mjs +2 -1
  11. package/lib/doctor/source-target-health.mjs +101 -0
  12. package/lib/doctor/watchers/source-targets.mjs +44 -0
  13. package/lib/document-ingest.mjs +25 -3
  14. package/lib/embed/demand-fetch.mjs +30 -36
  15. package/lib/embed/providers/directory.mjs +117 -0
  16. package/lib/embed/providers/registry.mjs +7 -0
  17. package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
  18. package/lib/extensions/manifests/directory.manifest.json +24 -1
  19. package/lib/extensions/manifests/github.manifest.json +10 -0
  20. package/lib/extensions/manifests/linear.manifest.json +1 -1
  21. package/lib/hooks/session-start.mjs +18 -11
  22. package/lib/knowledge/rag.mjs +52 -11
  23. package/lib/knowledge/search.mjs +69 -6
  24. package/lib/knowledge/synthesis.mjs +157 -0
  25. package/lib/mcp/server.mjs +2 -2
  26. package/lib/mcp/tool-definitions-workflow.mjs +35 -7
  27. package/lib/mcp/tools/artifact-author.mjs +126 -13
  28. package/lib/mcp/tools/orchestration-run.mjs +3 -0
  29. package/lib/mcp/tools/skills.mjs +17 -0
  30. package/lib/model-cheapest-provider.mjs +2 -1
  31. package/lib/model-policy.mjs +329 -0
  32. package/lib/model-router.mjs +10 -9
  33. package/lib/model-tiers.mjs +27 -0
  34. package/lib/models/catalog.mjs +5 -4
  35. package/lib/orchestration/classification.mjs +11 -0
  36. package/lib/orchestration/context-bindings.mjs +85 -0
  37. package/lib/orchestration/readiness.mjs +2 -1
  38. package/lib/orchestration/research-evidence-gate.mjs +104 -0
  39. package/lib/orchestration/runtime.mjs +35 -1
  40. package/lib/orchestration/worker.mjs +9 -0
  41. package/lib/setup.mjs +2 -1
  42. package/lib/sources/content-roots.mjs +147 -0
  43. package/lib/sources/repo-cache.mjs +142 -0
  44. package/lib/template-registry.mjs +2 -0
  45. package/lib/tracker/contribute.mjs +266 -0
  46. package/lib/validator.mjs +2 -3
  47. package/package.json +1 -1
  48. package/registry/agent-manifest.json +0 -4
  49. package/registry/capabilities.json +20 -1
  50. package/templates/docs/README.md +22 -0
  51. package/templates/docs/adhoc.md +12 -0
  52. package/templates/docs/strategy-comparison.md +19 -0
@@ -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
  },
@@ -5,23 +5,48 @@
5
5
  * the drafted typed-artifact markdown,
6
6
  * which gets materialized to its canonical path and run through the release gate.
7
7
  * Returns the path and a structured verdict so the agent can fix and re-call.
8
- * allowScaffold stays off — a real draft is required, never an empty scaffold.
8
+ *
9
+ * Type resolution respects the manifest gate. A registered class (builtin,
10
+ * user/project overlay, or the sanctioned `adhoc`) proceeds; an unknown
11
+ * non-adhoc class returns a classification/registration result instead of
12
+ * silently becoming a PRD. `adhoc` needs an explicit title + instructions and
13
+ * is not a bypass for a known class — naming a registered type through adhoc
14
+ * is redirected. allowScaffold stays off for a real author pass (a real draft
15
+ * is required); `dry_run` flips it on to preview the resolved template or the
16
+ * adhoc scaffold through the same gate without a live model.
9
17
  */
10
18
  import { runConstructArtifactLoop } from '../../artifact-loop-core.mjs';
19
+ import { resolveArtifactType } from '../../artifact-manifest.mjs';
20
+ import { ADHOC_TYPE } from '../../artifact-manifest-overlay.mjs';
21
+ import { loadProjectConfig } from '../../config/project-config.mjs';
22
+ import { resolveContextBindings } from '../../orchestration/context-bindings.mjs';
23
+ import { synthesize } from '../../knowledge/synthesis.mjs';
11
24
 
12
- export async function authorArtifact(args = {}, { ROOT_DIR } = {}) {
13
- const draftMarkdown = args.draft_markdown || args.draft || '';
14
- const cwd = args.cwd || process.cwd();
15
- const result = await runConstructArtifactLoop({
16
- draftMarkdown,
17
- artifactType: args.artifact_type,
18
- text: args.subject || args.text || '',
19
- cwd,
20
- rootDir: ROOT_DIR,
21
- explicit: true,
22
- allowScaffold: false,
23
- });
25
+ // context_targets binds an author pass to registered source projects (B3): the
26
+ // ids are validated the same way orchestration runs validate theirs (unknown id
27
+ // hard error before any authoring), and the multi-project synthesis context is
28
+ // assembled deterministically (dry-run map) and woven into the authoring input so
29
+ // the artifact draws on — and cites — every named project.
30
+ async function resolveContextBlock(args, cwd) {
31
+ const contextTargets = args.context_targets;
32
+ if (contextTargets == null || (Array.isArray(contextTargets) && contextTargets.length === 0)) {
33
+ return { ok: true, block: '', bindings: [] };
34
+ }
35
+ const { config } = loadProjectConfig(cwd);
36
+ let bindings;
37
+ try {
38
+ bindings = resolveContextBindings(contextTargets, { config, cwd });
39
+ } catch (err) {
40
+ return { ok: false, error: err.message };
41
+ }
42
+ const ask = String(args.subject || args.text || args.instructions || args.title || 'synthesize across the bound projects').trim();
43
+ const projects = bindings.map((b) => b.id).join(',');
44
+ const synth = await synthesize({ projects, ask, cwd, dryRun: true });
45
+ const block = synth.ok ? `\n\n## Cross-project context (cite as project:path)\n\n${synth.context}` : '';
46
+ return { ok: true, block, bindings };
47
+ }
24
48
 
49
+ function toResult(result) {
25
50
  return {
26
51
  ok: Boolean(result.ok),
27
52
  artifact_type: result.artifactType,
@@ -34,3 +59,91 @@ export async function authorArtifact(args = {}, { ROOT_DIR } = {}) {
34
59
  summary: result.summary,
35
60
  };
36
61
  }
62
+
63
+ async function authorAdhoc(args, { ROOT_DIR, cwd, dryRun, contextBlock = '' }) {
64
+ const title = String(args.title || '').trim();
65
+ const instructions = String(args.instructions || '').trim();
66
+ if (!title || !instructions) {
67
+ return {
68
+ ok: false,
69
+ artifact_type: ADHOC_TYPE,
70
+ status: 'invalid-request',
71
+ errors: ['adhoc requires an explicit title and instructions'],
72
+ guidance: 'Call author_artifact with {type:"adhoc", title, instructions}. For a registered class, pass its type instead.',
73
+ };
74
+ }
75
+
76
+ // Guard R3: adhoc is for genuinely unstructured one-offs. When the caller
77
+ // names a registered class (via `for_type` or an exact-match title), redirect
78
+ // to that class so adhoc never becomes a bypass for a gated type.
79
+ const namedType = String(args.for_type || '').trim().toLowerCase() || title.toLowerCase();
80
+ const named = resolveArtifactType(namedType, { rootDir: ROOT_DIR, cwd });
81
+ if (named.status === 'registered' && named.type !== ADHOC_TYPE) {
82
+ return {
83
+ ok: false,
84
+ artifact_type: ADHOC_TYPE,
85
+ status: 'redirect',
86
+ redirect_to: named.type,
87
+ warnings: [`'${named.type}' is a registered class; author it directly instead of via adhoc.`],
88
+ guidance: `Call author_artifact with {type:"${named.type}", ...} to use its template and release gate.`,
89
+ };
90
+ }
91
+
92
+ const draftMarkdown = args.draft_markdown || args.draft || '';
93
+ const result = await runConstructArtifactLoop({
94
+ draftMarkdown,
95
+ artifactType: ADHOC_TYPE,
96
+ titleOverride: title,
97
+ instructions,
98
+ text: `${args.subject || args.text || instructions}${contextBlock}`,
99
+ cwd,
100
+ rootDir: ROOT_DIR,
101
+ explicit: true,
102
+ allowScaffold: dryRun || !draftMarkdown,
103
+ });
104
+ return toResult(result);
105
+ }
106
+
107
+ export async function authorArtifact(args = {}, { ROOT_DIR } = {}) {
108
+ const cwd = args.cwd || process.cwd();
109
+ const dryRun = args.dry_run === true || args.scaffold === true;
110
+ const requestedType = String(args.artifact_type || '').trim().toLowerCase();
111
+
112
+ const ctx = await resolveContextBlock(args, cwd);
113
+ if (!ctx.ok) {
114
+ return { ok: false, artifact_type: requestedType || null, status: 'invalid-context-target', errors: [ctx.error] };
115
+ }
116
+
117
+ if (requestedType === ADHOC_TYPE || requestedType === 'ad-hoc' || requestedType === 'free-form' || requestedType === 'freeform') {
118
+ return authorAdhoc(args, { ROOT_DIR, cwd, dryRun, contextBlock: ctx.block });
119
+ }
120
+
121
+ // Gate intact (R4): an explicitly requested class that resolves to nothing
122
+ // registered gets the classification/registration answer, not a PRD.
123
+ if (requestedType) {
124
+ const resolved = resolveArtifactType(requestedType, { rootDir: ROOT_DIR, cwd });
125
+ if (resolved.status !== 'registered') {
126
+ return {
127
+ ok: false,
128
+ artifact_type: requestedType,
129
+ status: 'unrecognized',
130
+ classification_required: true,
131
+ errors: [`Document class '${requestedType}' is not registered.`],
132
+ guidance: `${resolved.guidance} Register it with \`construct templates register ${requestedType}\`, or author a one-off with {type:"adhoc", title, instructions}.`,
133
+ };
134
+ }
135
+ }
136
+
137
+ const draftMarkdown = args.draft_markdown || args.draft || '';
138
+ const result = await runConstructArtifactLoop({
139
+ draftMarkdown,
140
+ artifactType: args.artifact_type,
141
+ text: `${args.subject || args.text || ''}${ctx.block}`,
142
+ cwd,
143
+ rootDir: ROOT_DIR,
144
+ explicit: true,
145
+ allowScaffold: dryRun,
146
+ });
147
+
148
+ return toResult(result);
149
+ }
@@ -73,6 +73,7 @@ export function shapeRun(run) {
73
73
  suggestedWorkflowType: run.plan?.suggestedWorkflowType ?? null,
74
74
  researchExecutionPolicy: run.plan?.researchExecutionPolicy ?? null,
75
75
  specialists: run.plan?.specialists ?? [],
76
+ contextBindings: run.contextBindings ?? [],
76
77
  tasks: (run.tasks || []).map((t) => ({
77
78
  id: t.id, role: t.role, status: t.status, executor: t.executor,
78
79
  output: t.output ?? null, reasoning: t.reasoning ?? null, error: t.error ?? null,
@@ -85,6 +86,7 @@ export function shapeRun(run) {
85
86
  // never be confused with a construct-verified provider execution.
86
87
  ...(t.hostPrompt ? { system: t.hostPrompt.system, user: t.hostPrompt.user } : {}),
87
88
  ...(t.provenanceSource ? { provenanceSource: t.provenanceSource } : {}),
89
+ ...(t.evidenceGate ? { evidenceGate: t.evidenceGate } : {}),
88
90
  })),
89
91
  };
90
92
  }
@@ -118,6 +120,7 @@ function toRequest(args) {
118
120
  hostProvider: args.host_provider,
119
121
  fileCount: args.file_count,
120
122
  moduleCount: args.module_count,
123
+ contextTargets: args.context_targets,
121
124
  };
122
125
  }
123
126
 
@@ -290,11 +290,28 @@ export async function orchestrationPolicy(args) {
290
290
  }
291
291
  }
292
292
 
293
+ // A plan is not a tool. Weak models otherwise infer a tool name from
294
+ // suggestedWorkflowType (e.g. research-synthesis → an invented
295
+ // `research_synthesis` tool) or freelance raw host tools, bypassing the
296
+ // governed specialist chain and its anti-fabrication persona. Hand back an
297
+ // explicit, machine-actionable next step so the caller executes the plan
298
+ // through orchestration_run instead of guessing. Omitted for the immediate
299
+ // track, which needs no orchestration. worker_backend is left unset so the
300
+ // project's configured backend (free/host/inline) is honored — never forced.
301
+ const nextAction = route.track !== 'immediate'
302
+ ? {
303
+ tool: 'orchestration_run',
304
+ arguments: { request: args?.request || '' },
305
+ instruction: 'Execute this plan by calling the orchestration_run tool with the request above. suggestedWorkflowType is a plan label, not a tool — do not call it, and do not freelance raw web/file tools in its place.',
306
+ }
307
+ : null;
308
+
293
309
  return {
294
310
  ...route,
295
311
  specialistCatalog: buildSpecialistCatalog(),
296
312
  approvalRequired,
297
313
  terminalStates: TERMINAL_STATES,
314
+ nextAction,
298
315
  draftTask,
299
316
  handoffPacket,
300
317
  ...(contextPackets ? { contextPackets } : {}),
@@ -22,6 +22,7 @@ import path from 'node:path';
22
22
  import { getProviderModelCatalog, PROVIDER_FAMILY_TIERS } from './model-router.mjs';
23
23
  import { getPricingForModels, formatPricingLabel } from './model-pricing.mjs';
24
24
  import { configDir } from './config/xdg.mjs';
25
+ import { isModelTier } from './model-tiers.mjs';
25
26
 
26
27
  const CHEAPEST_PREF_KEY = 'CHEAPEST_PROVIDER_ENABLED';
27
28
  const CHEAPEST_CHECKED_KEY = 'CHEAPEST_PROVIDER_CHECKED';
@@ -47,7 +48,7 @@ const ENV_PATH = path.join(configDir(), 'config.env');
47
48
  * }>}
48
49
  */
49
50
  export async function selectCheapestProvider(tier, opts = {}) {
50
- if (!['reasoning', 'standard', 'fast'].includes(tier)) {
51
+ if (!isModelTier(tier)) {
51
52
  return { providerId: null, providerLabel: null, modelId: null, configuredProviders: [], rankedList: [] };
52
53
  }
53
54