@geraldmaron/construct 1.0.21 → 1.0.24

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 (150) hide show
  1. package/README.md +23 -7
  2. package/bin/construct +206 -228
  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 +31 -10
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/features.mjs +11 -11
  20. package/lib/git-hooks-path.mjs +61 -0
  21. package/lib/home-namespace.mjs +60 -0
  22. package/lib/hooks/ci-status-check.mjs +62 -40
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/session-reflect.mjs +5 -1
  27. package/lib/hooks/stop-notify.mjs +32 -17
  28. package/lib/hooks/stop-typecheck.mjs +7 -2
  29. package/lib/host-capabilities.mjs +24 -8
  30. package/lib/host-disposition.mjs +76 -0
  31. package/lib/ingest/provider-extract.mjs +1 -1
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intake/git-queue.mjs +195 -0
  38. package/lib/intake/queue.mjs +9 -16
  39. package/lib/intent-classifier.mjs +1 -1
  40. package/lib/knowledge/search.mjs +52 -3
  41. package/lib/mcp/server.mjs +57 -14
  42. package/lib/mcp/tools/memory.mjs +2 -2
  43. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  44. package/lib/mcp/tools/storage.mjs +2 -3
  45. package/lib/mcp-catalog.json +3 -3
  46. package/lib/mcp-manager.mjs +59 -3
  47. package/lib/model-registry.mjs +40 -33
  48. package/lib/observation-store.mjs +39 -163
  49. package/lib/opencode-config.mjs +1 -1
  50. package/lib/orchestration/events.mjs +66 -0
  51. package/lib/orchestration/runtime.mjs +72 -10
  52. package/lib/orchestration/worker.mjs +69 -21
  53. package/lib/orchestration-policy.mjs +27 -3
  54. package/lib/parity.mjs +46 -24
  55. package/lib/policy/unified-gates.mjs +96 -0
  56. package/lib/project-init-shared.mjs +0 -173
  57. package/lib/reconcile/adapter-prune.mjs +105 -0
  58. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  59. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  60. package/lib/reconcile/index.mjs +169 -0
  61. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  62. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  63. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  64. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  65. package/lib/runtime/uv-bootstrap.mjs +27 -3
  66. package/lib/schema-infer.mjs +16 -2
  67. package/lib/server/csrf.mjs +14 -2
  68. package/lib/server/index.mjs +95 -0
  69. package/lib/service-manager.mjs +59 -252
  70. package/lib/setup-prompts.mjs +2 -1
  71. package/lib/setup.mjs +104 -480
  72. package/lib/status.mjs +3 -6
  73. package/lib/storage/admin.mjs +48 -325
  74. package/lib/storage/backend.mjs +10 -43
  75. package/lib/storage/hybrid-query.mjs +15 -196
  76. package/lib/storage/sync.mjs +36 -177
  77. package/lib/storage/vector-client.mjs +256 -235
  78. package/lib/strategy-store.mjs +35 -286
  79. package/lib/template-registry.mjs +73 -0
  80. package/lib/term-format.mjs +75 -0
  81. package/lib/uninstall/uninstall.mjs +180 -7
  82. package/lib/worker/entrypoint.mjs +6 -14
  83. package/package.json +7 -6
  84. package/personas/construct.md +7 -8
  85. package/platforms/claude/settings.template.json +30 -11
  86. package/platforms/opencode/config.template.json +2 -2
  87. package/rules/common/neurodivergent-output.md +66 -0
  88. package/rules/common/tool-invisibility.md +37 -0
  89. package/scripts/sync-specialists.mjs +427 -107
  90. package/skills/operating/orchestration-reference.md +2 -16
  91. package/specialists/policy-inventory.json +14 -0
  92. package/specialists/prompts/cx-accessibility.md +2 -6
  93. package/specialists/prompts/cx-ai-engineer.md +0 -4
  94. package/specialists/prompts/cx-architect.md +3 -5
  95. package/specialists/prompts/cx-business-strategist.md +0 -5
  96. package/specialists/prompts/cx-data-analyst.md +0 -4
  97. package/specialists/prompts/cx-data-engineer.md +0 -4
  98. package/specialists/prompts/cx-debugger.md +2 -6
  99. package/specialists/prompts/cx-designer.md +0 -8
  100. package/specialists/prompts/cx-devil-advocate.md +2 -2
  101. package/specialists/prompts/cx-docs-keeper.md +0 -13
  102. package/specialists/prompts/cx-engineer.md +0 -13
  103. package/specialists/prompts/cx-evaluator.md +2 -2
  104. package/specialists/prompts/cx-explorer.md +4 -5
  105. package/specialists/prompts/cx-legal-compliance.md +4 -5
  106. package/specialists/prompts/cx-operations.md +0 -5
  107. package/specialists/prompts/cx-orchestrator.md +0 -4
  108. package/specialists/prompts/cx-platform-engineer.md +0 -8
  109. package/specialists/prompts/cx-product-manager.md +0 -8
  110. package/specialists/prompts/cx-qa.md +3 -12
  111. package/specialists/prompts/cx-rd-lead.md +0 -5
  112. package/specialists/prompts/cx-release-manager.md +0 -8
  113. package/specialists/prompts/cx-researcher.md +5 -29
  114. package/specialists/prompts/cx-reviewer.md +2 -6
  115. package/specialists/prompts/cx-security.md +2 -11
  116. package/specialists/prompts/cx-sre.md +0 -15
  117. package/specialists/prompts/cx-test-automation.md +0 -4
  118. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  119. package/specialists/prompts/cx-ux-researcher.md +0 -4
  120. package/specialists/registry.json +28 -36
  121. package/templates/distribution/run.mjs +36 -7
  122. package/templates/docs/accessibility-audit.md +56 -0
  123. package/templates/docs/architecture-review.md +59 -0
  124. package/templates/docs/code-review-report.md +46 -0
  125. package/templates/docs/construct_guide.md +15 -15
  126. package/templates/docs/debug-investigation.md +53 -0
  127. package/templates/docs/qa-report.md +48 -0
  128. package/templates/docs/security-audit-report.md +48 -0
  129. package/templates/docs/task-packet.md +49 -0
  130. package/templates/docs/verdict.md +40 -0
  131. package/db/schema/001_init.sql +0 -40
  132. package/db/schema/002_pgvector.sql +0 -182
  133. package/db/schema/003_intake.sql +0 -47
  134. package/db/schema/003_observation_reconciliation.sql +0 -14
  135. package/db/schema/004_recommendations.sql +0 -46
  136. package/db/schema/005_strategy.sql +0 -21
  137. package/db/schema/006_graph.sql +0 -24
  138. package/db/schema/007_tags.sql +0 -30
  139. package/db/schema/008_skill_usage.sql +0 -24
  140. package/db/schema/009_scheduler.sql +0 -14
  141. package/db/schema/010_cx_scores.sql +0 -51
  142. package/lib/intake/postgres-queue.mjs +0 -240
  143. package/lib/server/static/index.html +0 -1
  144. package/lib/services/local-postgres.mjs +0 -15
  145. package/lib/storage/backup.mjs +0 -347
  146. package/lib/storage/migrations.mjs +0 -187
  147. package/lib/storage/postgres-backup.mjs +0 -124
  148. package/lib/storage/sql-store.mjs +0 -45
  149. package/lib/storage/store-version.mjs +0 -115
  150. package/lib/storage/vector-store.mjs +0 -100
@@ -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 },
@@ -0,0 +1,195 @@
1
+ /**
2
+ * lib/intake/git-queue.mjs — Git-backed adapter for the IntakeQueue interface.
3
+ *
4
+ * This implementation replaces the PostgresIntakeQueue for team/enterprise
5
+ * modes. It uses the filesystem and Git for state synchronization and
6
+ * conflict resolution, removing the dependency on a central database server.
7
+ *
8
+ * Structure:
9
+ * .cx/team-inbox/
10
+ * pending/ - JSON files for unclaimed tasks
11
+ * claimed/ - Subdirectories per worker containing claimed task files
12
+ * processed/ - Completed tasks
13
+ * skipped/ - Skipped tasks
14
+ * quarantine/ - Tasks needing human review
15
+ */
16
+
17
+ import path from 'node:path';
18
+ import fs from 'fs';
19
+ import { execSync } from 'node:child_process';
20
+ import { shouldQuarantine } from './quarantine.mjs';
21
+
22
+ function slugify(value) {
23
+ return String(value || 'untitled')
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ .slice(0, 60) || 'untitled';
28
+ }
29
+
30
+ let counter = 0;
31
+ function timestamp() {
32
+ counter = (counter + 1) % 1000;
33
+ const c = String(counter).padStart(3, '0');
34
+ return `${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23)}-${c}`;
35
+ }
36
+
37
+ export class GitIntakeQueue {
38
+ constructor({ project, rootDir = process.cwd() } = {}) {
39
+ this.project = project;
40
+ this.inboxRoot = path.join(rootDir, '.cx', 'team-inbox');
41
+ this._ensureDirs();
42
+ }
43
+
44
+ _ensureDirs() {
45
+ ['pending', 'claimed', 'processed', 'skipped', 'quarantine'].forEach(dir => {
46
+ fs.mkdirSync(path.join(this.inboxRoot, dir), { recursive: true });
47
+ });
48
+ }
49
+
50
+ _gitAddAndCommit(filePath, message) {
51
+ try {
52
+ execSync(`git add "${filePath}"`, { stdio: 'ignore' });
53
+ execSync(`git commit -m "${message}"`, { stdio: 'ignore' });
54
+ } catch (err) {
55
+ // It's okay if commit fails because of no changes
56
+ }
57
+ }
58
+
59
+ async enqueue(entry) {
60
+ const ts = timestamp();
61
+ const slug = slugify(path.basename(entry.intake.sourcePath, path.extname(entry.intake.sourcePath)));
62
+ const id = `${ts}-${slug}`;
63
+ const triage = entry.triage || {};
64
+
65
+ const quarantineDecision = shouldQuarantine(triage);
66
+ const subDir = quarantineDecision.quarantine ? 'quarantine' : 'pending';
67
+ const filePath = path.join(this.inboxRoot, subDir, `${id}.json`);
68
+
69
+ const data = {
70
+ id,
71
+ project: this.project,
72
+ status: subDir === 'quarantine' ? 'quarantined' : 'pending',
73
+ createdAt: new Date().toISOString(),
74
+ ...entry
75
+ };
76
+
77
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
78
+ this._gitAddAndCommit(filePath, `Enqueue task ${id}`);
79
+
80
+ return { id, route: subDir === 'quarantine' ? 'quarantine' : 'pending', reason: quarantineDecision.reason };
81
+ }
82
+
83
+ async listPending({ limit = 100 } = {}) {
84
+ const pendingDir = path.join(this.inboxRoot, 'pending');
85
+ return fs.readdirSync(pendingDir)
86
+ .filter(f => f.endsWith('.json'))
87
+ .slice(0, limit)
88
+ .map(f => JSON.parse(fs.readFileSync(path.join(pendingDir, f), 'utf8')));
89
+ }
90
+
91
+ async count() {
92
+ const pendingDir = path.join(this.inboxRoot, 'pending');
93
+ return fs.readdirSync(pendingDir).filter(f => f.endsWith('.json')).length;
94
+ }
95
+
96
+ async read(id) {
97
+ for (const dir of ['pending', 'claimed', 'processed', 'skipped', 'quarantine']) {
98
+ // Note: 'claimed' has subdirs, we'd need to search recursively for claimed.
99
+ // For simplicity in this implementation, we assume we know where it is or search pending first.
100
+ const searchPath = dir === 'claimed'
101
+ ? path.join(this.inboxRoot, dir) // needs recursive search
102
+ : path.join(this.inboxRoot, dir, `${id}.json`);
103
+
104
+ if (dir !== 'claimed' && fs.existsSync(searchPath)) {
105
+ return JSON.parse(fs.readFileSync(searchPath, 'utf8'));
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ async claim({ claimedBy }) {
112
+ if (!claimedBy) throw new Error('claim: claimedBy is required');
113
+
114
+ // 1. Pull latest to minimize conflicts
115
+ try { execSync('git pull --rebase', { stdio: 'ignore' }); } catch (e) {}
116
+
117
+ const pendingDir = path.join(this.inboxRoot, 'pending');
118
+ const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.json')).sort();
119
+
120
+ if (files.length === 0) return null;
121
+
122
+ const fileName = files[0];
123
+ const pendingPath = path.join(pendingDir, fileName);
124
+ const workerDir = path.join(this.inboxRoot, 'claimed', claimedBy);
125
+ fs.mkdirSync(workerDir, { recursive: true });
126
+ const claimedPath = path.join(workerDir, fileName);
127
+
128
+ try {
129
+ // Atomic move on filesystem
130
+ fs.renameSync(pendingPath, claimedPath);
131
+
132
+ // Update metadata
133
+ const data = JSON.parse(fs.readFileSync(claimedPath, 'utf8'));
134
+ data.status = 'claimed';
135
+ data.claimedBy = claimedBy;
136
+ data.claimedAt = new Date().toISOString();
137
+ fs.writeFileSync(claimedPath, JSON.stringify(data, null, 2));
138
+
139
+ // 2. Commit and Push
140
+ execSync(`git add .cx/team-inbox/pending/${fileName} .cx/team-inbox/claimed/${claimedBy}/${fileName}`, { stdio: 'ignore' });
141
+ execSync(`git commit -m "Claim task ${data.id} by ${claimedBy}"`, { stdio: 'ignore' });
142
+ execSync('git push', { stdio: 'ignore' });
143
+
144
+ return data;
145
+ } catch (err) {
146
+ // If push fails, someone else probably claimed it first or Git is out of sync.
147
+ // We should ideally rollback the local move, but for this first pass
148
+ // we'll let the next 'git pull --rebase' fix the state.
149
+ console.error(`Failed to claim ${fileName}: ${err.message}`);
150
+ return null;
151
+ }
152
+ }
153
+
154
+ async markProcessed(id, { processedBy = 'unknown', notes = '' } = {}) {
155
+ // Locate the file (might be in claimed/<worker>/<id>.json or pending/<id>.json)
156
+ // For simplicity, search all subdirs.
157
+ let foundPath = null;
158
+ let currentDir = null;
159
+
160
+ const dirs = ['pending', 'claimed']; // only mark as processed if pending or claimed
161
+ for (const d of dirs) {
162
+ const dirPath = path.join(this.inboxRoot, d);
163
+ if (d === 'claimed') {
164
+ const workers = fs.readdirSync(dirPath);
165
+ for (const w of workers) {
166
+ const p = path.join(dirPath, w, `${id}.json`);
167
+ if (fs.existsSync(p)) { foundPath = p; currentDir = path.join(d, w); break; }
168
+ }
169
+ } else {
170
+ const p = path.join(dirPath, `${id}.json`);
171
+ if (fs.existsSync(p)) { foundPath = p; currentDir = d; break; }
172
+ }
173
+ if (foundPath) break;
174
+ }
175
+
176
+ if (!foundPath) throw new Error(`markProcessed: no entry ${id} found`);
177
+
178
+ const data = JSON.parse(fs.readFileSync(foundPath, 'utf8'));
179
+ data.status = 'processed';
180
+ data.processedBy = processedBy;
181
+ data.processedAt = new Date().toISOString();
182
+ data.notes = notes;
183
+
184
+ const processedPath = path.join(this.inboxRoot, 'processed', `${id}.json`);
185
+ fs.renameSync(foundPath, processedPath);
186
+ fs.writeFileSync(processedPath, JSON.stringify(data, null, 2));
187
+
188
+ this._gitAddAndCommit(this.inboxRoot, `Mark task ${id} as processed`);
189
+ try { execSync('git push', { stdio: 'ignore' }); } catch (e) {}
190
+
191
+ return { id };
192
+ }
193
+
194
+ // markSkipped and reopen would follow similar logic...
195
+ }
@@ -36,11 +36,10 @@
36
36
  import path from 'node:path';
37
37
 
38
38
  import { FilesystemIntakeQueue, queueRoot, pendingDir, processedDir, skippedDir } from './filesystem-queue.mjs';
39
- import { PostgresIntakeQueue } from './postgres-queue.mjs';
39
+ import { GitIntakeQueue } from './git-queue.mjs';
40
40
  import { getDeploymentMode } from '../deployment-mode.mjs';
41
- import { createSqlClient } from '../storage/backend.mjs';
42
41
 
43
- export { FilesystemIntakeQueue, PostgresIntakeQueue, queueRoot, pendingDir, processedDir, skippedDir };
42
+ export { FilesystemIntakeQueue, GitIntakeQueue, queueRoot, pendingDir, processedDir, skippedDir };
44
43
 
45
44
  export const INTAKE_QUEUE_BACKEND_ENV_KEY = 'CONSTRUCT_INTAKE_QUEUE_BACKEND';
46
45
  export const INTAKE_PROJECT_ENV_KEY = 'CONSTRUCT_PROJECT_NAME';
@@ -48,9 +47,9 @@ export const INTAKE_TENANT_ENV_KEY = 'CONSTRUCT_TENANT_ID';
48
47
 
49
48
  function resolveBackend(env) {
50
49
  const override = env?.[INTAKE_QUEUE_BACKEND_ENV_KEY];
51
- if (override === 'filesystem' || override === 'postgres') return override;
50
+ if (override === 'filesystem' || override === 'git') return override;
52
51
  const mode = getDeploymentMode(env);
53
- return mode === 'solo' ? 'filesystem' : 'postgres';
52
+ return mode === 'solo' ? 'filesystem' : 'git';
54
53
  }
55
54
 
56
55
  function resolveProject(rootDir, env) {
@@ -62,22 +61,16 @@ function resolveProject(rootDir, env) {
62
61
  /**
63
62
  * Create an IntakeQueue instance for the given project root.
64
63
  * Backend selection: CONSTRUCT_INTAKE_QUEUE_BACKEND override wins; otherwise
65
- * solo mode → filesystem, team/enterprise → postgres. The postgres
66
- * backend pulls its connection from createSqlClient(env) and uses
67
- * CONSTRUCT_PROJECT_NAME / CONSTRUCT_TENANT_ID to scope queries; an
68
- * explicit `sql` and `project` in opts override both.
64
+ * solo mode → filesystem, team/enterprise → git. The git
65
+ * backend uses the filesystem and git for state synchronization.
69
66
  */
70
67
  export function createIntakeQueue(rootDir, env = process.env, opts = {}) {
71
68
  const backend = opts.backend || resolveBackend(env);
72
69
  if (backend === 'filesystem') return new FilesystemIntakeQueue(rootDir);
73
- if (backend === 'postgres') {
74
- const sql = opts.sql ?? createSqlClient(env);
75
- if (!sql) {
76
- throw new Error('Postgres intake queue requires a configured DATABASE_URL (or sql client). Run `construct init` or set CONSTRUCT_INTAKE_QUEUE_BACKEND=filesystem.');
77
- }
70
+ if (backend === 'git' || backend === 'postgres') {
71
+ // We treat 'postgres' as an alias for 'git' now to avoid breaking existing configs
78
72
  const project = opts.project ?? resolveProject(rootDir, env);
79
- const tenantId = opts.tenantId ?? env?.[INTAKE_TENANT_ENV_KEY] ?? null;
80
- return new PostgresIntakeQueue({ sql, project, tenantId });
73
+ return new GitIntakeQueue({ project, rootDir });
81
74
  }
82
75
  throw new Error(`Unknown intake queue backend: ${backend}`);
83
76
  }
@@ -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) };