@geraldmaron/construct 1.5.0 → 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.
- package/README.md +3 -0
- package/bin/construct +336 -17
- 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/cli-commands.mjs +44 -4
- package/lib/config/source-target-registry.mjs +1 -0
- package/lib/config/source-targets.mjs +30 -0
- 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/init-unified.mjs +2 -7
- 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/providers/directory/index.mjs +19 -7
- 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/test-corpus-inventory.mjs +1 -0
- package/lib/tracker/contribute.mjs +266 -0
- package/lib/validator.mjs +2 -3
- package/package.json +1 -1
- package/registry/agent-manifest.json +0 -4
- package/registry/capabilities.json +20 -1
- 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/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
|
},
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
package/lib/mcp/tools/skills.mjs
CHANGED
|
@@ -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 (!
|
|
51
|
+
if (!isModelTier(tier)) {
|
|
51
52
|
return { providerId: null, providerLabel: null, modelId: null, configuredProviders: [], rankedList: [] };
|
|
52
53
|
}
|
|
53
54
|
|