@geraldmaron/construct 1.0.21 → 1.0.23

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 (117) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +191 -13
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +25 -7
  17. package/lib/features.mjs +11 -11
  18. package/lib/git-hooks-path.mjs +61 -0
  19. package/lib/home-namespace.mjs +60 -0
  20. package/lib/hooks/ci-status-check.mjs +62 -40
  21. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  22. package/lib/hooks/pre-push-gate.mjs +15 -6
  23. package/lib/hooks/stop-notify.mjs +32 -17
  24. package/lib/hooks/stop-typecheck.mjs +7 -2
  25. package/lib/host-capabilities.mjs +24 -8
  26. package/lib/host-disposition.mjs +76 -0
  27. package/lib/ingest/provider-extract.mjs +1 -1
  28. package/lib/init-docs.mjs +1 -1
  29. package/lib/init-unified.mjs +320 -219
  30. package/lib/init-update.mjs +4 -84
  31. package/lib/init.mjs +9 -25
  32. package/lib/install/stage-project.mjs +8 -2
  33. package/lib/intent-classifier.mjs +1 -1
  34. package/lib/knowledge/search.mjs +52 -3
  35. package/lib/mcp/server.mjs +57 -14
  36. package/lib/mcp/tools/memory.mjs +2 -2
  37. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  38. package/lib/model-registry.mjs +40 -33
  39. package/lib/observation-store.mjs +6 -2
  40. package/lib/opencode-config.mjs +1 -1
  41. package/lib/orchestration/events.mjs +66 -0
  42. package/lib/orchestration/runtime.mjs +70 -9
  43. package/lib/orchestration/worker.mjs +69 -21
  44. package/lib/orchestration-policy.mjs +27 -3
  45. package/lib/parity.mjs +46 -24
  46. package/lib/policy/unified-gates.mjs +96 -0
  47. package/lib/project-init-shared.mjs +0 -173
  48. package/lib/reconcile/adapter-prune.mjs +105 -0
  49. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  50. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  51. package/lib/reconcile/index.mjs +171 -0
  52. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  53. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  54. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  55. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  56. package/lib/reconcile/postgres-namespace.mjs +102 -0
  57. package/lib/runtime/uv-bootstrap.mjs +27 -3
  58. package/lib/schema-infer.mjs +16 -2
  59. package/lib/server/csrf.mjs +14 -2
  60. package/lib/server/index.mjs +95 -0
  61. package/lib/service-manager.mjs +39 -14
  62. package/lib/setup-prompts.mjs +2 -1
  63. package/lib/setup.mjs +165 -141
  64. package/lib/storage/backend.mjs +14 -0
  65. package/lib/storage/unified-storage.mjs +550 -0
  66. package/lib/template-registry.mjs +73 -0
  67. package/lib/term-format.mjs +75 -0
  68. package/lib/uninstall/uninstall.mjs +180 -7
  69. package/package.json +2 -2
  70. package/personas/construct.md +7 -8
  71. package/platforms/claude/settings.template.json +30 -4
  72. package/platforms/opencode/config.template.json +2 -2
  73. package/rules/common/neurodivergent-output.md +66 -0
  74. package/rules/common/tool-invisibility.md +37 -0
  75. package/scripts/sync-specialists.mjs +381 -95
  76. package/skills/operating/orchestration-reference.md +2 -16
  77. package/specialists/policy-inventory.json +14 -0
  78. package/specialists/prompts/cx-accessibility.md +2 -6
  79. package/specialists/prompts/cx-ai-engineer.md +0 -4
  80. package/specialists/prompts/cx-architect.md +3 -5
  81. package/specialists/prompts/cx-business-strategist.md +0 -5
  82. package/specialists/prompts/cx-data-analyst.md +0 -4
  83. package/specialists/prompts/cx-data-engineer.md +0 -4
  84. package/specialists/prompts/cx-debugger.md +2 -6
  85. package/specialists/prompts/cx-designer.md +0 -8
  86. package/specialists/prompts/cx-devil-advocate.md +2 -2
  87. package/specialists/prompts/cx-docs-keeper.md +0 -13
  88. package/specialists/prompts/cx-engineer.md +0 -13
  89. package/specialists/prompts/cx-evaluator.md +2 -2
  90. package/specialists/prompts/cx-explorer.md +4 -5
  91. package/specialists/prompts/cx-legal-compliance.md +4 -5
  92. package/specialists/prompts/cx-operations.md +0 -5
  93. package/specialists/prompts/cx-orchestrator.md +0 -4
  94. package/specialists/prompts/cx-platform-engineer.md +0 -8
  95. package/specialists/prompts/cx-product-manager.md +0 -8
  96. package/specialists/prompts/cx-qa.md +2 -11
  97. package/specialists/prompts/cx-rd-lead.md +0 -5
  98. package/specialists/prompts/cx-release-manager.md +0 -8
  99. package/specialists/prompts/cx-researcher.md +5 -29
  100. package/specialists/prompts/cx-reviewer.md +2 -6
  101. package/specialists/prompts/cx-security.md +2 -11
  102. package/specialists/prompts/cx-sre.md +0 -15
  103. package/specialists/prompts/cx-test-automation.md +0 -4
  104. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  105. package/specialists/prompts/cx-ux-researcher.md +0 -4
  106. package/specialists/registry.json +29 -29
  107. package/templates/distribution/run.mjs +36 -7
  108. package/templates/docs/accessibility-audit.md +56 -0
  109. package/templates/docs/architecture-review.md +59 -0
  110. package/templates/docs/code-review-report.md +46 -0
  111. package/templates/docs/construct_guide.md +14 -14
  112. package/templates/docs/debug-investigation.md +53 -0
  113. package/templates/docs/qa-report.md +48 -0
  114. package/templates/docs/security-audit-report.md +48 -0
  115. package/templates/docs/task-packet.md +49 -0
  116. package/templates/docs/verdict.md +40 -0
  117. package/lib/server/static/index.html +0 -1
@@ -21,38 +21,6 @@ const dryRun = args.includes("--dry-run");
21
21
  const cwdArg = args.find((arg) => arg.startsWith("--cwd="));
22
22
  const cwd = cwdArg ? path.resolve(cwdArg.split("=")[1]) : process.cwd();
23
23
 
24
- const REQUIRED_AGENT_SECTIONS = [
25
- "Operating hierarchy",
26
- "Start-of-session rules",
27
- "Maintenance rules",
28
- "End-of-session rules",
29
- "Verification rules",
30
- ];
31
-
32
- async function loadAgentsTemplate(projectName) {
33
- const modulePath = path.join(import.meta.dirname, "project-init-shared.mjs");
34
- const module = await import(modulePath);
35
- if (!module.buildAgentsGuide) {
36
- throw new Error("buildAgentsGuide not available from project-init-shared.mjs");
37
- }
38
- return module.buildAgentsGuide(projectName);
39
- }
40
-
41
- function inferProjectName(targetDir) {
42
- const packagePath = path.join(targetDir, "package.json");
43
- if (fs.existsSync(packagePath)) {
44
- try {
45
- const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
46
- if (pkg.name) return pkg.name;
47
- } catch { /* fall through */ }
48
- }
49
- return path.basename(targetDir);
50
- }
51
-
52
- function findMissingAgentsSections(content) {
53
- return REQUIRED_AGENT_SECTIONS.filter((section) => !content.toLowerCase().includes(section.toLowerCase()));
54
- }
55
-
56
24
  function ensureProposalDir(targetDir) {
57
25
  const dir = path.join(targetDir, ".cx", "proposals");
58
26
  fs.mkdirSync(dir, { recursive: true });
@@ -66,37 +34,6 @@ function writeProposal(targetDir, fileName, content) {
66
34
  return proposalPath;
67
35
  }
68
36
 
69
- function buildAgentsProposal(existingContent, template, missingSections) {
70
- const missingList = missingSections.map((section) => `- ${section}`).join("\n");
71
- return [
72
- "<!--",
73
- "Construct init:update proposal",
74
- "Review this file and merge the needed sections into AGENTS.md manually.",
75
- "The original AGENTS.md was not modified.",
76
- "-->",
77
- "",
78
- "# Proposed AGENTS.md Update",
79
- "",
80
- "## Why this proposal exists",
81
- "",
82
- "Your current `AGENTS.md` is missing these required sections:",
83
- missingList,
84
- "",
85
- "## Current AGENTS.md",
86
- "",
87
- "```md",
88
- existingContent.trimEnd(),
89
- "```",
90
- "",
91
- "## Current Construct template",
92
- "",
93
- "```md",
94
- template.trimEnd(),
95
- "```",
96
- "",
97
- ].join("\n");
98
- }
99
-
100
37
  function buildWorkflowProposal(existingContent) {
101
38
  const updatedContent = existingContent.replace(
102
39
  /(\s+- run: node bin\/construct doctor\s*\n)/,
@@ -207,29 +144,12 @@ export function resolveTemplateConflict(conflict, resolution, targetDir) {
207
144
  async function main() {
208
145
  console.log(`Preparing Construct standards update for ${cwd}`);
209
146
 
210
- const agentsPath = path.join(cwd, "AGENTS.md");
211
- if (!fs.existsSync(agentsPath)) {
212
- console.error("AGENTS.md not found. Run `construct init` first.");
213
- process.exit(1);
214
- }
215
-
216
- const projectName = inferProjectName(cwd);
217
- const existingAgents = fs.readFileSync(agentsPath, "utf8");
218
- const template = await loadAgentsTemplate(projectName);
219
- const missingSections = findMissingAgentsSections(existingAgents);
220
-
221
147
  const planned = [];
222
148
 
223
- if (missingSections.length > 0) {
224
- const proposalPath = path.join(".cx", "proposals", "AGENTS.md.construct-update.md");
225
- planned.push({
226
- label: `AGENTS.md proposal (${missingSections.length} missing section${missingSections.length === 1 ? "" : "s"})`,
227
- write() {
228
- return writeProposal(cwd, "AGENTS.md.construct-update.md", buildAgentsProposal(existingAgents, template, missingSections));
229
- },
230
- relativePath: proposalPath,
231
- });
232
- }
149
+ // Construct's guidance in AGENTS.md/CLAUDE.md is owned by the versioned
150
+ // CONSTRUCT INTEGRATION marker block, kept current by `construct sync`
151
+ // (ADR-0027 §2/§4). init:update scope is opt-in standards a project owner
152
+ // merges by hand: CI checks and template conflicts.
233
153
 
234
154
  const workflowPath = path.join(cwd, ".github", "workflows", "ci.yml");
235
155
  if (fs.existsSync(workflowPath)) {
package/lib/init.mjs CHANGED
@@ -15,7 +15,6 @@ import fs from "node:fs";
15
15
  import path from "node:path";
16
16
 
17
17
  import {
18
- buildAgentsGuide,
19
18
  buildContextJson,
20
19
  buildContextMarkdown,
21
20
  buildPlanTemplate,
@@ -43,14 +42,9 @@ function inferProjectName(targetPath) {
43
42
  function main() {
44
43
  const projectName = inferProjectName(target);
45
44
 
46
- writeStampedIfMissing({
47
- targetRoot: target,
48
- created,
49
- skipped,
50
- filePath: path.join(target, "AGENTS.md"),
51
- content: buildAgentsGuide(projectName),
52
- generator: "construct/init",
53
- });
45
+ // AGENTS.md is user-owned (ADR-0027 §2); Construct contributes only the fenced
46
+ // integration block via injectIntoAgentFile, never a doctrine body here.
47
+
54
48
  writeStampedIfMissing({
55
49
  targetRoot: target,
56
50
  created,
@@ -75,22 +69,12 @@ function main() {
75
69
  content: buildContextMarkdown(),
76
70
  generator: "construct/init",
77
71
  });
78
- writeStampedIfMissing({
79
- targetRoot: target,
80
- created,
81
- skipped,
82
- filePath: path.join(target, ".cx", ".gitkeep"),
83
- content: "",
84
- generator: "construct/init",
85
- });
86
- writeStampedIfMissing({
87
- targetRoot: target,
88
- created,
89
- skipped,
90
- filePath: path.join(target, ".cx", "inbox", ".gitkeep"),
91
- content: "",
92
- generator: "construct/init",
93
- });
72
+ // `.cx/` already exists from the context writes above and is gitignored in full
73
+ // (ADR-0027), so a keep file there could never be tracked; `.cx/inbox/` is the local
74
+ // intake drop zone the watcher polls. Create the directories; write no dead keep file.
75
+
76
+ fs.mkdirSync(path.join(target, ".cx", "inbox"), { recursive: true });
77
+ created.push(".cx/inbox/");
94
78
 
95
79
  process.stdout.write(`\nConstruct init → ${target}\n\n`);
96
80
  if (created.length) {
@@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process';
14
14
  import { existsSync, copyFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
15
15
  import path from 'node:path';
16
16
 
17
- export function stageProjectAdapters({ projectRoot, packageRoot, pkgVersion, log }) {
17
+ export function stageProjectAdapters({ projectRoot, packageRoot, pkgVersion, log, hosts = null }) {
18
18
  if (!projectRoot) throw new Error('stageProjectAdapters: projectRoot is required');
19
19
  if (!packageRoot) throw new Error('stageProjectAdapters: packageRoot is required');
20
20
  const emit = typeof log === 'function' ? log : () => {};
@@ -30,8 +30,14 @@ export function stageProjectAdapters({ projectRoot, packageRoot, pkgVersion, log
30
30
  return { staged: true, synced: false };
31
31
  }
32
32
 
33
+ // A `hosts` array restricts which adapter sets sync writes (construct-4xy6);
34
+ // null/empty leaves sync writing every host, preserving the postinstall path.
35
+
36
+ const syncArgs = [syncScript, '--project'];
37
+ if (Array.isArray(hosts) && hosts.length > 0) syncArgs.push(`--hosts=${hosts.join(',')}`);
38
+
33
39
  emit(`syncing project adapters into ${projectRoot}/.claude/`);
34
- const result = spawnSync(process.execPath, [syncScript, '--project'], {
40
+ const result = spawnSync(process.execPath, syncArgs, {
35
41
  cwd: projectRoot,
36
42
  stdio: 'inherit',
37
43
  env: { ...process.env, CONSTRUCT_PROJECT_ROOT: projectRoot },
@@ -150,7 +150,7 @@ async function defaultModelCaller({ system, user }) {
150
150
  headers: {
151
151
  Authorization: `Bearer ${orKey}`,
152
152
  'content-type': 'application/json',
153
- 'HTTP-Referer': 'https://github.com/construct',
153
+ 'HTTP-Referer': 'https://github.com/geraldmaron/construct',
154
154
  },
155
155
  body: JSON.stringify({
156
156
  model: modelId.replace(/^openrouter\//, ''),
@@ -11,6 +11,9 @@
11
11
  * 3. docs/README.md — docs index + contract
12
12
  * 4. .cx/knowledge/ — operator-written internal docs
13
13
  * 5. Any *.md in docs/cookbook/ — task-oriented recipes
14
+ * 6. <projectRoot>/.cx/knowledge/** — the cwd project's knowledge tree,
15
+ * including `external/research/` written by `construct knowledge add`,
16
+ * so foreign-project queries surface project content alongside bundled docs.
14
17
  *
15
18
  * Retrieval strategy:
16
19
  * Token-based BM25-like scoring over 200-char chunks. Returns top-K chunks
@@ -30,8 +33,17 @@ const REPO_ROOT = join(MODULE_DIR, '..', '..');
30
33
 
31
34
  // ─── Source catalogue ────────────────────────────────────────────────────────
32
35
 
33
- /** Priority-ordered list of source files to search. */
34
- function buildSourceList(repoRoot = REPO_ROOT) {
36
+ /**
37
+ * Priority-ordered list of source files to search.
38
+ *
39
+ * `projectRoot` covers the case where Construct is invoked from a foreign
40
+ * project (cwd != construct repo): the project's own `.cx/knowledge/**` —
41
+ * including the `external/research/` tree written by `construct knowledge add
42
+ * --source=research` — joins the source set, so freshly added project knowledge
43
+ * surfaces alongside (and ahead of) the bundled Construct docs. When
44
+ * projectRoot equals repoRoot or is absent, only the bundled set is searched.
45
+ */
46
+ function buildSourceList(repoRoot = REPO_ROOT, projectRoot = null) {
35
47
  const sources = [];
36
48
 
37
49
  const priority = [
@@ -92,9 +104,45 @@ function buildSourceList(repoRoot = REPO_ROOT) {
92
104
  }
93
105
  }
94
106
 
107
+ // Project knowledge — the cwd project's `.cx/knowledge/**`. Searched only
108
+ // when the project is distinct from the construct repo (the latter's own
109
+ // .cx/knowledge is already covered above as repoRoot's internal knowledge).
110
+ // Priority 1 puts project content on the same scoring tier as core docs so a
111
+ // freshly added project finding outranks bundled framework material on tied
112
+ // matches; the `source: 'project'` tag lets consumers distinguish origin.
113
+
114
+ if (projectRoot && projectRoot !== repoRoot) {
115
+ const projectKnowledgeRoot = join(projectRoot, '.cx', 'knowledge');
116
+ if (existsSync(projectKnowledgeRoot)) {
117
+ for (const file of walkMarkdown(projectKnowledgeRoot)) {
118
+ sources.push({
119
+ path: file,
120
+ rel: relative(projectRoot, file),
121
+ priority: 1,
122
+ source: 'project',
123
+ });
124
+ }
125
+ }
126
+ }
127
+
95
128
  return sources;
96
129
  }
97
130
 
131
+ // Recursive .md walk used for the project-knowledge tree, where `external/`
132
+ // nests one subdirectory deeper than the flat internal/reference/how-tos layout.
133
+
134
+ function walkMarkdown(root) {
135
+ const out = [];
136
+ let entries;
137
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { return out; }
138
+ for (const entry of entries) {
139
+ const full = join(root, entry.name);
140
+ if (entry.isDirectory()) out.push(...walkMarkdown(full));
141
+ else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
142
+ }
143
+ return out;
144
+ }
145
+
98
146
  // ─── Chunking ────────────────────────────────────────────────────────────────
99
147
 
100
148
  const CHUNK_CHARS = 400;
@@ -272,7 +320,7 @@ export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, roo
272
320
 
273
321
  const root = repoRoot ?? REPO_ROOT;
274
322
  const dataDir = rootDir ?? (process.env.CX_DATA_DIR?.trim() || homedir());
275
- const sources = buildSourceList(root);
323
+ const sources = buildSourceList(root, rootDir);
276
324
 
277
325
  // Build corpus from docs + operator knowledge
278
326
  const allChunks = [];
@@ -318,6 +366,7 @@ export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, roo
318
366
  text: chunk.text,
319
367
  heading: chunk.heading,
320
368
  file: chunk.source.rel,
369
+ origin: chunk.source.source || 'bundled',
321
370
  score: Math.round(score * 100) / 100,
322
371
  lineStart: chunk.lineStart,
323
372
  }));
@@ -723,19 +723,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
723
723
  required: ['query'],
724
724
  },
725
725
  },
726
- {
727
- name: 'rovo_search',
728
- description: 'Cross-system semantic search via Atlassian Rovo. Searches Jira, Confluence, and other accessible sources. Returns excerpts with source attribution. Does NOT store results in the observation store. Use for broad queries across all sources, not just focal projects.',
729
- inputSchema: {
730
- type: 'object',
731
- properties: {
732
- query: { type: 'string', description: 'Search query (e.g., "Iverson reliability", "DR failover").' },
733
- top_k: { type: 'number', description: 'Max results (default: 10, max: 50).' },
734
- sources: { type: 'string', description: 'Comma-separated source filter (e.g., "jira,confluence").' },
735
- },
736
- required: ['query'],
737
- },
738
- },
739
726
  {
740
727
  name: 'efficiency_snapshot',
741
728
  description: 'Returns the read-efficiency snapshot for the current session — repeated reads, large reads, hot-spot files, and recommendations. Use this when investigating why a session feels slow or to surface optimization opportunities.',
@@ -939,6 +926,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
939
926
  properties: { cwd: { type: 'string', description: 'Project root (default: server cwd).' } },
940
927
  },
941
928
  },
929
+ {
930
+ name: 'document_export',
931
+ description: 'Convert a markdown file into a distributable PDF, DOCX, or HTML document via Pandoc (DOCX/HTML) and Typst (PDF engine), per ADR-0024. Both engines are optional system binaries discovered at runtime — the tool returns a structured "install <binary>" error when tooling is absent, never crashes. Use `detect_only=true` to check availability without running an export.',
932
+ inputSchema: {
933
+ type: 'object',
934
+ required: ['format'],
935
+ properties: {
936
+ input_path: { type: 'string', description: 'Absolute path to the markdown source file (required unless detect_only=true).' },
937
+ output_path: { type: 'string', description: 'Optional absolute output path; defaults to <input>.<format> next to the source.' },
938
+ format: { type: 'string', enum: ['pdf', 'docx', 'html'], description: 'Target format.' },
939
+ detect_only: { type: 'boolean', description: 'When true, report binary availability without exporting. Default false.' },
940
+ },
941
+ },
942
+ },
942
943
  {
943
944
  name: 'knowledge_search',
944
945
  description: 'Search Construct\'s own documentation, knowledge base, and distilled embed observations. Call this immediately — no approval needed — when the user asks what Construct is, how a feature works, what commands exist, or anything about Construct\'s architecture or configuration. Also searches embed observations from GitHub, Jira, and other configured sources. Returns relevant excerpts with source file and heading.',
@@ -1176,6 +1177,38 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1176
1177
  },
1177
1178
  },
1178
1179
  },
1180
+ {
1181
+ name: 'orchestration_run',
1182
+ description: 'EXECUTE a real multi-specialist orchestration run via the local Construct daemon and return per-specialist output — the executing counterpart to workflow_invoke (which only plans). For MCP hosts with no subagent primitive (VS Code/Copilot, Cursor), this is how you actually run a specialist chain: the engine owns orchestration, this tool is the thin client (ADR-0022). Requires a running daemon (`construct dashboard`); if it is unreachable the tool fails fast with how to start it rather than silently degrading to a single-persona pass. Real specialist output requires the daemon to run the `provider` worker backend (a provider key configured); the default `inline` backend prepares tasks only.',
1183
+ inputSchema: {
1184
+ type: 'object',
1185
+ required: ['request'],
1186
+ properties: {
1187
+ request: { type: 'string', description: 'Natural-language description of the work to orchestrate (e.g. "refactor the auth module and review it for security").' },
1188
+ workflow_type: { type: 'string', description: 'Optional workflow type to shape the plan (e.g. architecture-review, risk-review).' },
1189
+ requested_strategy: { type: 'string', enum: ['orchestrated', 'prompt-only', 'auto'], description: 'Execution strategy (default auto).' },
1190
+ worker_backend: { type: 'string', enum: ['inline', 'provider'], description: 'provider executes specialists (needs a provider key); inline prepares only. Default: daemon config.' },
1191
+ host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
1192
+ host_model: { type: 'string', description: 'Model the host uses, for model resolution.' },
1193
+ host_provider: { type: 'string', description: 'Provider family the host uses, for model resolution.' },
1194
+ file_count: { type: 'number', description: 'Optional planning hint: number of files in scope.' },
1195
+ module_count: { type: 'number', description: 'Optional planning hint: number of modules in scope.' },
1196
+ 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.' },
1197
+ timeout_ms: { type: 'number', description: 'Max time to wait when wait=true (default 120000). On timeout the runId is returned to poll.' },
1198
+ },
1199
+ },
1200
+ },
1201
+ {
1202
+ name: 'orchestration_status',
1203
+ description: 'Inspect orchestration runs on the local Construct daemon: pass run_id for the full record (status, per-task status/executor/output/error), or omit it for a list of recent runs. Fails fast if the daemon is unreachable.',
1204
+ inputSchema: {
1205
+ type: 'object',
1206
+ properties: {
1207
+ run_id: { type: 'string', description: 'Run id to fetch. Omit to list recent runs.' },
1208
+ limit: { type: 'number', description: 'Max runs to list when run_id is omitted (default 20).' },
1209
+ },
1210
+ },
1211
+ },
1179
1212
  ],
1180
1213
  }));
1181
1214
 
@@ -1227,7 +1260,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1227
1260
  else if (name === 'session_search') result = sessionSearch(args);
1228
1261
  else if (name === 'session_save') result = sessionSave(args);
1229
1262
  else if (name === 'memory_search') result = await memorySearch(args);
1230
- else if (name === 'memory_add_observations') result = memoryAddObservations(args);
1263
+ else if (name === 'memory_add_observations') result = await memoryAddObservations(args);
1231
1264
  else if (name === 'memory_create_entities') result = memoryCreateEntities(args);
1232
1265
  else if (name === 'memory_recent') result = memoryRecent(args);
1233
1266
  else if (name === 'rovo_search') result = await rovoSearch(args);
@@ -1241,6 +1274,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1241
1274
  const { knowledgeSearch } = await import('../knowledge/search.mjs');
1242
1275
  result = knowledgeSearch({ query: args.query, topK: args.top_k, repoRoot: args.repo_root, rootDir: args.root_dir });
1243
1276
  }
1277
+ else if (name === 'document_export') {
1278
+ const { detect, exportMarkdown } = await import('../document-export.mjs');
1279
+ if (args.detect_only) {
1280
+ result = detect(args.format);
1281
+ } else {
1282
+ result = exportMarkdown({ inputPath: args.input_path, outputPath: args.output_path, format: args.format });
1283
+ }
1284
+ }
1244
1285
  else if (name === 'profile_show') result = profileShow(args);
1245
1286
  else if (name === 'profile_list') result = profileList();
1246
1287
  else if (name === 'profile_drafts') result = profileDrafts(args);
@@ -1258,6 +1299,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1258
1299
  else if (name === 'workflow_invoke') result = await workflowInvoke(args);
1259
1300
  else if (name === 'capability_describe') result = capabilityDescribe(args);
1260
1301
  else if (name === 'construct_execution_resolve') result = executionResolve(args);
1302
+ else if (name === 'orchestration_run') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationRun(args); }
1303
+ else if (name === 'orchestration_status') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationStatus(args); }
1261
1304
  else result = { error: `Unknown tool: ${name}` };
1262
1305
  } catch (err) {
1263
1306
  result = { error: err.message ?? String(err) };
@@ -56,13 +56,13 @@ export async function memorySearch(args) {
56
56
  };
57
57
  }
58
58
 
59
- export function memoryAddObservations(args) {
59
+ export async function memoryAddObservations(args) {
60
60
  const cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
61
61
  const project = args.project || cwd.split('/').pop() || 'unknown';
62
62
  const observations = (args.observations || []).slice(0, 10);
63
63
  const created = [];
64
64
  for (const obs of observations) {
65
- const record = addObservation(cwd, {
65
+ const record = await addObservation(cwd, {
66
66
  role: obs.role || 'unknown',
67
67
  category: obs.category || 'insight',
68
68
  summary: obs.summary || '',
@@ -0,0 +1,125 @@
1
+ /**
2
+ * lib/mcp/tools/orchestration-run.mjs — MCP client for the local orchestration daemon.
3
+ *
4
+ * Executes a real multi-specialist run by driving the daemon's HTTP API
5
+ * (ADR-0022), so MCP hosts with no subagent primitive — VS Code/Copilot, Cursor —
6
+ * reach orchestrated outcomes through a tool they actually expose: the engine
7
+ * owns orchestration; this tool is the thin client. The daemon URL resolves from
8
+ * dashboard state (else the default loopback port) and the bearer token from
9
+ * ~/.construct/config.env. When the daemon is unreachable the tool fails fast
10
+ * with an actionable error rather than silently degrading to a single-persona
11
+ * pass — the absence of an executable path is made loud, not hidden.
12
+ */
13
+
14
+ import { readDashboardState } from '../../service-manager.mjs';
15
+ import { getDashboardToken } from '../../server/auth.mjs';
16
+
17
+ const TERMINAL = ['completed', 'completed-with-failures', 'cancelled', 'error'];
18
+
19
+ function resolveDaemon(env = process.env) {
20
+ const state = readDashboardState();
21
+ const base = (env.CONSTRUCT_ORCHESTRATION_URL || state?.url || `http://${env.BIND_HOST || '127.0.0.1'}:${env.PORT || '4242'}`).replace(/\/$/, '');
22
+ return { base, token: getDashboardToken(), daemonRunning: Boolean(state) };
23
+ }
24
+
25
+ // A reachability failure is the fail-fast signal MCP hosts need: report how to
26
+ // start the engine instead of letting the host fall back to a manual pass.
27
+
28
+ function unreachable(base, detail) {
29
+ return {
30
+ error: `Orchestration daemon not reachable at ${base}: ${detail}. Start it with \`construct dashboard\`, then retry. The engine runs orchestration; this host is a thin client (ADR-0022).`,
31
+ daemon: base,
32
+ failFast: true,
33
+ };
34
+ }
35
+
36
+ export async function orchestrationRun(args = {}, { fetchImpl = fetch, env = process.env } = {}) {
37
+ const {
38
+ request,
39
+ workflow_type,
40
+ requested_strategy = 'auto',
41
+ worker_backend,
42
+ host,
43
+ host_model,
44
+ host_provider,
45
+ file_count,
46
+ module_count,
47
+ wait = true,
48
+ timeout_ms = 120000,
49
+ } = args;
50
+
51
+ if (!request || typeof request !== 'string') return { error: 'Missing "request" string describing the work to orchestrate.' };
52
+
53
+ const { base, token } = resolveDaemon(env);
54
+ const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
55
+ const body = JSON.stringify({
56
+ request,
57
+ workflowType: workflow_type,
58
+ requestedStrategy: requested_strategy,
59
+ workerBackend: worker_backend,
60
+ host,
61
+ hostModel: host_model,
62
+ hostProvider: host_provider,
63
+ fileCount: file_count,
64
+ moduleCount: module_count,
65
+ });
66
+
67
+ let started;
68
+ try {
69
+ const res = await fetchImpl(`${base}/api/orchestration/runs`, { method: 'POST', headers, body });
70
+ if (res.status === 401 || res.status === 403) {
71
+ return { error: `Daemon rejected the request (HTTP ${res.status}). The dashboard token is missing or wrong — check CONSTRUCT_DASHBOARD_TOKEN in ~/.construct/config.env.`, daemon: base, failFast: true };
72
+ }
73
+ const envelope = await res.json();
74
+ if (!res.ok) return { error: `Daemon error (HTTP ${res.status}): ${envelope.error || 'unknown'}`, daemon: base };
75
+ started = envelope.data;
76
+ } catch (err) {
77
+ return unreachable(base, err.message);
78
+ }
79
+
80
+ if (!wait) {
81
+ return { runId: started.runId, status: started.status, executionMode: started.execution?.executionMode, daemon: base, poll: 'orchestration_status with run_id' };
82
+ }
83
+
84
+ const deadline = Date.now() + timeout_ms;
85
+ let run = started;
86
+ while (!TERMINAL.includes(run.status)) {
87
+ if (Date.now() > deadline) {
88
+ return { runId: started.runId, status: run.status, timedOut: true, daemon: base, note: 'Run is still executing; poll it with orchestration_status.' };
89
+ }
90
+ await new Promise((r) => setTimeout(r, 250));
91
+ try {
92
+ const res = await fetchImpl(`${base}/api/orchestration/runs/${encodeURIComponent(started.runId)}`, { headers });
93
+ const envelope = await res.json();
94
+ if (!res.ok) return { error: `Daemon error while polling (HTTP ${res.status}): ${envelope.error || 'unknown'}`, runId: started.runId, daemon: base };
95
+ run = envelope.data;
96
+ } catch (err) {
97
+ return { error: `Lost connection to the daemon while polling run ${started.runId}: ${err.message}`, runId: started.runId, daemon: base, failFast: true };
98
+ }
99
+ }
100
+
101
+ return {
102
+ runId: run.runId,
103
+ status: run.status,
104
+ executionMode: run.execution?.executionMode,
105
+ degraded: run.execution?.degraded ?? false,
106
+ degradationReason: run.execution?.degradationReason ?? null,
107
+ tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, output: t.output ?? null, reasoning: t.reasoning ?? null, error: t.error ?? null })),
108
+ daemon: base,
109
+ };
110
+ }
111
+
112
+ export async function orchestrationStatus(args = {}, { fetchImpl = fetch, env = process.env } = {}) {
113
+ const { run_id, limit = 20 } = args;
114
+ const { base, token } = resolveDaemon(env);
115
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
116
+ const path = run_id ? `/api/orchestration/runs/${encodeURIComponent(run_id)}` : `/api/orchestration/runs?limit=${encodeURIComponent(limit)}`;
117
+ try {
118
+ const res = await fetchImpl(`${base}${path}`, { headers });
119
+ const envelope = await res.json();
120
+ if (!res.ok) return { error: `Daemon error (HTTP ${res.status}): ${envelope.error || 'unknown'}`, daemon: base };
121
+ return envelope.data;
122
+ } catch (err) {
123
+ return unreachable(base, err.message);
124
+ }
125
+ }