@geraldmaron/construct 1.0.5 → 1.0.7

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 (268) hide show
  1. package/README.md +24 -18
  2. package/agents/contracts.json +617 -2
  3. package/agents/contracts.schema.json +83 -0
  4. package/agents/prompts/cx-accessibility.md +5 -3
  5. package/agents/prompts/cx-ai-engineer.md +9 -7
  6. package/agents/prompts/cx-architect.md +8 -6
  7. package/agents/prompts/cx-business-strategist.md +8 -6
  8. package/agents/prompts/cx-data-analyst.md +9 -7
  9. package/agents/prompts/cx-data-engineer.md +5 -3
  10. package/agents/prompts/cx-debugger.md +8 -6
  11. package/agents/prompts/cx-designer.md +7 -5
  12. package/agents/prompts/cx-devil-advocate.md +6 -4
  13. package/agents/prompts/cx-docs-keeper.md +7 -5
  14. package/agents/prompts/cx-engineer.md +7 -5
  15. package/agents/prompts/cx-evaluator.md +4 -2
  16. package/agents/prompts/cx-explorer.md +10 -8
  17. package/agents/prompts/cx-legal-compliance.md +4 -2
  18. package/agents/prompts/cx-operations.md +7 -5
  19. package/agents/prompts/cx-orchestrator.md +12 -10
  20. package/agents/prompts/cx-platform-engineer.md +7 -5
  21. package/agents/prompts/cx-product-manager.md +6 -4
  22. package/agents/prompts/cx-qa.md +10 -8
  23. package/agents/prompts/cx-rd-lead.md +11 -9
  24. package/agents/prompts/cx-release-manager.md +7 -5
  25. package/agents/prompts/cx-researcher.md +24 -22
  26. package/agents/prompts/cx-reviewer.md +9 -7
  27. package/agents/prompts/cx-security.md +12 -10
  28. package/agents/prompts/cx-sre.md +9 -7
  29. package/agents/prompts/cx-test-automation.md +5 -3
  30. package/agents/prompts/cx-trace-reviewer.md +10 -8
  31. package/agents/prompts/cx-ux-researcher.md +5 -3
  32. package/agents/registry.json +8 -3
  33. package/bin/construct +444 -53
  34. package/commands/build/feature.md +4 -4
  35. package/commands/build/fix.md +8 -8
  36. package/commands/design/access.md +3 -3
  37. package/commands/design/flow.md +3 -3
  38. package/commands/design/ui.md +4 -4
  39. package/commands/measure/experiment.md +5 -5
  40. package/commands/measure/metrics.md +3 -3
  41. package/commands/measure/results.md +4 -4
  42. package/commands/plan/api.md +3 -3
  43. package/commands/plan/challenge.md +3 -3
  44. package/commands/plan/decide.md +3 -3
  45. package/commands/plan/feature.md +7 -7
  46. package/commands/plan/requirements.md +3 -3
  47. package/commands/remember/context.md +5 -5
  48. package/commands/remember/handoff.md +3 -3
  49. package/commands/remember/runbook.md +3 -3
  50. package/commands/review/code.md +8 -8
  51. package/commands/review/quality.md +4 -4
  52. package/commands/review/security.md +3 -3
  53. package/commands/ship/ready.md +3 -3
  54. package/commands/ship/release.md +3 -3
  55. package/commands/ship/status.md +4 -4
  56. package/commands/understand/docs.md +3 -3
  57. package/commands/understand/this.md +4 -4
  58. package/commands/understand/why.md +8 -8
  59. package/commands/work/clean.md +14 -14
  60. package/commands/work/drive.md +10 -10
  61. package/commands/work/optimize-prompts.md +9 -9
  62. package/commands/work/parallel-review.md +8 -8
  63. package/db/schema/006_graph.sql +24 -0
  64. package/examples/provider-plugin/README.md +7 -7
  65. package/examples/seed-observations/README.md +6 -6
  66. package/examples/seed-observations/anti-patterns.md +14 -14
  67. package/examples/seed-observations/decisions.md +4 -4
  68. package/examples/seed-observations/patterns.md +14 -14
  69. package/lib/auto-docs.mjs +13 -8
  70. package/lib/boundary.mjs +126 -0
  71. package/lib/cache-strategy-google.js +26 -31
  72. package/lib/cli-commands.mjs +2 -2
  73. package/lib/comment-lint.mjs +134 -0
  74. package/lib/contracts/validate.mjs +323 -0
  75. package/lib/daemons/contract.mjs +210 -0
  76. package/lib/docs-verify.mjs +59 -6
  77. package/lib/doctor/cli.mjs +16 -1
  78. package/lib/doctor/index.mjs +3 -1
  79. package/lib/doctor/watchers/consistency.mjs +310 -0
  80. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  81. package/lib/document-extract.mjs +211 -1
  82. package/lib/embed/cli.mjs +124 -3
  83. package/lib/embed/daemon.mjs +43 -4
  84. package/lib/embed/docs-lifecycle.mjs +1 -1
  85. package/lib/embed/inbox.mjs +2 -0
  86. package/lib/embed/scheduler.mjs +33 -5
  87. package/lib/evaluator-optimizer.mjs +2 -3
  88. package/lib/flavors/loader.mjs +1 -1
  89. package/lib/hooks/comment-lint.mjs +16 -0
  90. package/lib/hooks/mcp-audit.mjs +2 -1
  91. package/lib/hooks/proactive-activation.mjs +0 -14
  92. package/lib/hooks/rule-verifier.mjs +217 -0
  93. package/lib/hooks/session-optimize.mjs +2 -1
  94. package/lib/init-unified.mjs +55 -65
  95. package/lib/intake/classify.mjs +108 -24
  96. package/lib/intake/daemon.mjs +121 -0
  97. package/lib/intake/filesystem-queue.mjs +6 -1
  98. package/lib/intake/intake-config.mjs +2 -1
  99. package/lib/intake/prepare.mjs +0 -1
  100. package/lib/intake/session-prelude.mjs +7 -1
  101. package/lib/intake/traceability.mjs +90 -0
  102. package/lib/knowledge/graph.mjs +213 -0
  103. package/lib/knowledge/research-store.mjs +2 -0
  104. package/lib/maintenance/cleanup.mjs +315 -0
  105. package/lib/mcp/memory-bridge.mjs +276 -0
  106. package/lib/mcp/server.mjs +189 -1
  107. package/lib/mcp/tools/profile.mjs +270 -0
  108. package/lib/mcp/tools/workflow.mjs +25 -0
  109. package/lib/mcp-catalog.json +12 -8
  110. package/lib/mcp-platform-config.mjs +16 -8
  111. package/lib/migrations/index.mjs +106 -0
  112. package/lib/migrations/v1-baseline.mjs +33 -0
  113. package/lib/observation-store.mjs +9 -4
  114. package/lib/outcomes/record.mjs +2 -0
  115. package/lib/profiles/rebrand.mjs +46 -0
  116. package/lib/project-init-shared.mjs +12 -0
  117. package/lib/provider-capabilities.js +20 -7
  118. package/lib/providers/auth-manager.mjs +58 -17
  119. package/lib/reflect.mjs +49 -12
  120. package/lib/server/index.mjs +22 -28
  121. package/lib/session-store.mjs +6 -4
  122. package/lib/setup.mjs +14 -3
  123. package/lib/telemetry/client.mjs +5 -1
  124. package/lib/version.mjs +51 -0
  125. package/lib/worker/trace.mjs +5 -1
  126. package/package.json +4 -1
  127. package/personas/construct.md +3 -1
  128. package/platforms/claude/CLAUDE.md +6 -6
  129. package/rules/common/agents.md +2 -2
  130. package/rules/common/beads-hygiene.md +11 -11
  131. package/rules/common/code-review.md +1 -1
  132. package/rules/common/coding-style.md +1 -1
  133. package/rules/common/comments.md +8 -8
  134. package/rules/common/commit-approval.md +4 -4
  135. package/rules/common/cx-agent-routing.md +2 -2
  136. package/rules/common/cx-skill-routing.md +2 -2
  137. package/rules/common/development-workflow.md +3 -2
  138. package/rules/common/doc-ownership.md +2 -2
  139. package/rules/common/efficiency.md +3 -3
  140. package/rules/common/framing.md +1 -1
  141. package/rules/common/git-workflow.md +1 -1
  142. package/rules/common/no-fabrication.md +69 -0
  143. package/rules/common/patterns.md +1 -1
  144. package/rules/common/performance.md +1 -1
  145. package/rules/common/release-gates.md +7 -7
  146. package/rules/common/research.md +4 -4
  147. package/rules/common/review-before-change.md +58 -0
  148. package/rules/common/security.md +1 -1
  149. package/rules/common/skill-composition.md +8 -8
  150. package/rules/common/testing.md +1 -1
  151. package/rules/golang/coding-style.md +2 -2
  152. package/rules/golang/hooks.md +1 -1
  153. package/rules/golang/patterns.md +1 -1
  154. package/rules/golang/security.md +1 -1
  155. package/rules/golang/testing.md +1 -1
  156. package/rules/python/coding-style.md +1 -1
  157. package/rules/python/hooks.md +1 -1
  158. package/rules/python/patterns.md +1 -1
  159. package/rules/python/security.md +1 -1
  160. package/rules/python/testing.md +1 -1
  161. package/rules/swift/coding-style.md +3 -3
  162. package/rules/swift/hooks.md +2 -2
  163. package/rules/swift/patterns.md +2 -2
  164. package/rules/swift/security.md +4 -4
  165. package/rules/swift/testing.md +2 -2
  166. package/rules/typescript/coding-style.md +1 -1
  167. package/rules/typescript/hooks.md +1 -1
  168. package/rules/typescript/patterns.md +1 -1
  169. package/rules/typescript/security.md +1 -1
  170. package/rules/typescript/testing.md +1 -1
  171. package/rules/web/coding-style.md +1 -1
  172. package/rules/web/design-quality.md +1 -1
  173. package/rules/web/hooks.md +1 -1
  174. package/rules/web/patterns.md +1 -1
  175. package/rules/web/performance.md +1 -1
  176. package/rules/web/security.md +1 -1
  177. package/rules/web/testing.md +1 -1
  178. package/scripts/sync-agents.mjs +45 -14
  179. package/skills/ai/agent-dev.md +1 -1
  180. package/skills/ai/llm-security.md +1 -1
  181. package/skills/ai/ml-ops.md +6 -6
  182. package/skills/ai/orchestration-workflow.md +1 -1
  183. package/skills/ai/prompt-and-eval.md +1 -1
  184. package/skills/ai/prompt-optimizer.md +13 -13
  185. package/skills/ai/rag-system.md +1 -1
  186. package/skills/architecture/api-design.md +1 -1
  187. package/skills/architecture/caching.md +1 -1
  188. package/skills/architecture/cloud-native.md +1 -1
  189. package/skills/architecture/message-queue.md +1 -1
  190. package/skills/architecture/security-arch.md +1 -1
  191. package/skills/compliance/ai-disclosure.md +1 -1
  192. package/skills/compliance/data-privacy.md +1 -1
  193. package/skills/compliance/license-audit.md +2 -2
  194. package/skills/compliance/regulatory-review.md +1 -1
  195. package/skills/development/cpp.md +1 -1
  196. package/skills/development/go.md +1 -1
  197. package/skills/development/java.md +1 -1
  198. package/skills/development/kotlin.md +9 -9
  199. package/skills/development/mobile-crossplatform.md +13 -13
  200. package/skills/development/python.md +1 -1
  201. package/skills/development/rust.md +1 -1
  202. package/skills/development/shell.md +1 -1
  203. package/skills/development/swift.md +6 -6
  204. package/skills/development/typescript.md +1 -1
  205. package/skills/devops/ci-cd.md +5 -5
  206. package/skills/devops/containerization.md +9 -9
  207. package/skills/devops/cost-optimization.md +1 -1
  208. package/skills/devops/data-engineering.md +2 -2
  209. package/skills/devops/database.md +1 -1
  210. package/skills/devops/dependency-management.md +3 -3
  211. package/skills/devops/devsecops.md +1 -1
  212. package/skills/devops/git-workflow.md +1 -1
  213. package/skills/devops/incident-response.md +18 -18
  214. package/skills/devops/monorepo.md +5 -5
  215. package/skills/devops/observability.md +1 -1
  216. package/skills/devops/performance.md +1 -1
  217. package/skills/devops/testing.md +1 -1
  218. package/skills/docs/adr-workflow.md +2 -2
  219. package/skills/docs/backlog-proposal-workflow.md +1 -1
  220. package/skills/docs/customer-profile-workflow.md +1 -1
  221. package/skills/docs/document-ingest-workflow.md +1 -1
  222. package/skills/docs/evidence-ingest-workflow.md +1 -1
  223. package/skills/docs/init-docs.md +15 -15
  224. package/skills/docs/init-project.md +1 -1
  225. package/skills/docs/prd-workflow.md +3 -3
  226. package/skills/docs/prfaq-workflow.md +1 -1
  227. package/skills/docs/product-intelligence-review.md +1 -1
  228. package/skills/docs/product-intelligence-workflow.md +1 -1
  229. package/skills/docs/product-signal-workflow.md +9 -9
  230. package/skills/docs/research-workflow.md +10 -10
  231. package/skills/docs/runbook-workflow.md +2 -2
  232. package/skills/docs/strategy-workflow.md +3 -3
  233. package/skills/exploration/repo-map.md +11 -11
  234. package/skills/frameworks/django.md +15 -15
  235. package/skills/frameworks/nextjs.md +16 -16
  236. package/skills/frameworks/react.md +12 -12
  237. package/skills/frameworks/spring-boot.md +12 -12
  238. package/skills/frontend-design/accessibility.md +6 -6
  239. package/skills/frontend-design/component-patterns.md +1 -1
  240. package/skills/frontend-design/engineering.md +1 -1
  241. package/skills/frontend-design/state-management.md +1 -1
  242. package/skills/frontend-design/ui-aesthetics.md +1 -1
  243. package/skills/frontend-design/ux-principles.md +1 -1
  244. package/skills/operating/orchestration-reference.md +27 -27
  245. package/skills/quality-gates/review-work.md +3 -3
  246. package/skills/quality-gates/verify-change.md +1 -1
  247. package/skills/quality-gates/verify-module.md +1 -1
  248. package/skills/quality-gates/verify-quality.md +1 -1
  249. package/skills/quality-gates/verify-security.md +1 -1
  250. package/skills/routing.md +14 -14
  251. package/skills/security/blue-team.md +1 -1
  252. package/skills/security/code-audit.md +1 -1
  253. package/skills/security/pentest.md +1 -1
  254. package/skills/security/red-team.md +1 -1
  255. package/skills/security/threat-intel.md +1 -1
  256. package/skills/security/vuln-research.md +1 -1
  257. package/skills/utility/clean-code.md +2 -2
  258. package/templates/docs/changelog-entry.md +1 -1
  259. package/templates/docs/construct_guide.md +18 -18
  260. package/templates/docs/meta-prd.md +16 -16
  261. package/templates/docs/one-pager.md +1 -1
  262. package/templates/docs/prd-business.md +1 -1
  263. package/templates/docs/prd-platform.md +1 -1
  264. package/templates/docs/prd.md +17 -17
  265. package/templates/docs/research-brief.md +8 -8
  266. package/templates/docs/rfc.md +1 -1
  267. package/templates/docs/skill-artifact.md +1 -1
  268. package/templates/docs/strategy.md +1 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * lib/doctor/watchers/mcp-protocol.mjs — MCP handshake health probe.
3
+ *
4
+ * Sweeps every MCP server configured in the active editor configs (Claude
5
+ * Code, OpenCode) and performs a real MCP handshake against each: send
6
+ * `initialize`, parse the response, send `tools/list`, report tool count.
7
+ * cass-memory v0.2.x (the original cause of construct-1qt) rejects the
8
+ * handshake outright; this watcher flags servers that fail the handshake so
9
+ * the dashboard surfaces a protocol problem rather than a port-up false
10
+ * positive.
11
+ *
12
+ * Severity defaults to warning. Set `CONSTRUCT_MCP_PROTOCOL_BLOCKING=1` to
13
+ * escalate via `service.down` when a configured server fails the handshake.
14
+ *
15
+ * Tick: 10 min. Cheap — most checks are skipped when no editor configs exist.
16
+ */
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ import { record } from '../audit.mjs';
24
+ import { escalate } from '../escalate.mjs';
25
+
26
+ export const name = 'mcp-protocol';
27
+ export const intervalMs = 10 * 60 * 1000;
28
+
29
+ const PROBE_TIMEOUT_MS = 5000;
30
+ const HANDSHAKE_REQUEST_ID = 1;
31
+
32
+ function readJson(file) {
33
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
34
+ }
35
+
36
+ export function collectMcpServers({ home = homedir() } = {}) {
37
+ const servers = [];
38
+ const claudePath = join(home, '.claude', 'settings.json');
39
+ if (existsSync(claudePath)) {
40
+ const cfg = readJson(claudePath);
41
+ for (const [id, entry] of Object.entries(cfg?.mcpServers ?? {})) {
42
+ servers.push({ host: 'claude', id, entry });
43
+ }
44
+ }
45
+ const opencodePath = join(home, '.config', 'opencode', 'opencode.json');
46
+ if (existsSync(opencodePath)) {
47
+ const cfg = readJson(opencodePath);
48
+ for (const [id, entry] of Object.entries(cfg?.mcp ?? {})) {
49
+ servers.push({ host: 'opencode', id, entry });
50
+ }
51
+ }
52
+ return servers;
53
+ }
54
+
55
+ function classifyEntry(entry) {
56
+ if (!entry || typeof entry !== 'object') return { transport: 'unknown' };
57
+ if (entry.type === 'http' || entry.type === 'remote') {
58
+ return { transport: 'http', url: entry.url, headers: entry.headers ?? {} };
59
+ }
60
+ if (entry.type === 'local') {
61
+ const cmd = Array.isArray(entry.command) ? entry.command : [];
62
+ return { transport: 'stdio', command: cmd[0], args: cmd.slice(1), env: entry.environment ?? {} };
63
+ }
64
+ if (entry.command) {
65
+ return { transport: 'stdio', command: entry.command, args: entry.args ?? [], env: entry.env ?? {} };
66
+ }
67
+ return { transport: 'unknown' };
68
+ }
69
+
70
+ async function probeHttp({ url, headers }) {
71
+ const controller = new AbortController();
72
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
73
+ try {
74
+ const body = {
75
+ jsonrpc: '2.0',
76
+ id: HANDSHAKE_REQUEST_ID,
77
+ method: 'initialize',
78
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'construct-doctor', version: '1' } },
79
+ };
80
+ const res = await fetch(url, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers },
83
+ body: JSON.stringify(body),
84
+ signal: controller.signal,
85
+ });
86
+ if (!res.ok) {
87
+ return { ok: false, reason: `HTTP ${res.status} on initialize` };
88
+ }
89
+ const text = await res.text();
90
+ let parsed;
91
+ try { parsed = JSON.parse(text); } catch {
92
+ return { ok: false, reason: 'initialize response was not JSON' };
93
+ }
94
+ if (parsed?.error) {
95
+ return { ok: false, reason: `initialize returned error: ${parsed.error.message ?? parsed.error.code}` };
96
+ }
97
+ if (/Unsupported method/i.test(text)) {
98
+ return { ok: false, reason: 'backend rejects initialize (Unsupported method)' };
99
+ }
100
+ return { ok: true, protocolVersion: parsed?.result?.protocolVersion };
101
+ } catch (err) {
102
+ const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || String(err));
103
+ return { ok: false, reason };
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+
109
+ function spawnStdioServer({ command, args, env }) {
110
+ const child = spawn(command, args ?? [], {
111
+ env: { ...process.env, ...(env ?? {}) },
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ });
114
+ const frames = [];
115
+ let buffer = '';
116
+ child.stdout.setEncoding('utf8');
117
+ child.stdout.on('data', (chunk) => {
118
+ buffer += chunk;
119
+ let idx;
120
+ while ((idx = buffer.indexOf('\n')) >= 0) {
121
+ const raw = buffer.slice(0, idx).trim();
122
+ buffer = buffer.slice(idx + 1);
123
+ if (!raw) continue;
124
+ try { frames.push(JSON.parse(raw)); } catch { /* skip junk */ }
125
+ }
126
+ });
127
+ child.stderr.on('data', () => { /* swallow */ });
128
+ return { child, frames };
129
+ }
130
+
131
+ async function waitFor(frames, predicate, deadline) {
132
+ while (Date.now() < deadline) {
133
+ const hit = frames.find(predicate);
134
+ if (hit) return hit;
135
+ await new Promise((r) => setTimeout(r, 25));
136
+ }
137
+ return null;
138
+ }
139
+
140
+ async function probeStdio(spec) {
141
+ if (!spec.command) return { ok: false, reason: 'stdio entry missing command' };
142
+ let session;
143
+ try {
144
+ session = spawnStdioServer(spec);
145
+ } catch (err) {
146
+ return { ok: false, reason: `spawn failed: ${err?.message || err}` };
147
+ }
148
+ const { child, frames } = session;
149
+ const deadline = Date.now() + PROBE_TIMEOUT_MS;
150
+
151
+ try {
152
+ child.stdin.write(JSON.stringify({
153
+ jsonrpc: '2.0',
154
+ id: HANDSHAKE_REQUEST_ID,
155
+ method: 'initialize',
156
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'construct-doctor', version: '1' } },
157
+ }) + '\n');
158
+
159
+ const initFrame = await waitFor(frames, (f) => f.id === HANDSHAKE_REQUEST_ID, deadline);
160
+ if (!initFrame) return { ok: false, reason: 'no initialize response within timeout' };
161
+ if (initFrame.error) return { ok: false, reason: `initialize error: ${initFrame.error.message ?? initFrame.error.code}` };
162
+
163
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
164
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n');
165
+
166
+ const toolsFrame = await waitFor(frames, (f) => f.id === 2, deadline);
167
+ if (!toolsFrame) return { ok: false, reason: 'no tools/list response within timeout' };
168
+ if (toolsFrame.error) return { ok: false, reason: `tools/list error: ${toolsFrame.error.message ?? toolsFrame.error.code}` };
169
+
170
+ const tools = toolsFrame.result?.tools ?? [];
171
+ return {
172
+ ok: true,
173
+ protocolVersion: initFrame.result?.protocolVersion,
174
+ toolCount: Array.isArray(tools) ? tools.length : 0,
175
+ };
176
+ } finally {
177
+ try { child.kill('SIGTERM'); } catch { /* already exited */ }
178
+ }
179
+ }
180
+
181
+ export async function probeServer({ host, id, entry }) {
182
+ const spec = classifyEntry(entry);
183
+ if (spec.transport === 'unknown') return { host, id, transport: 'unknown', ok: false, reason: 'unrecognized entry shape' };
184
+ const result = spec.transport === 'http' ? await probeHttp(spec) : await probeStdio(spec);
185
+ return { host, id, transport: spec.transport, ...result };
186
+ }
187
+
188
+ export async function tick() {
189
+ const actions = [];
190
+ const escalations = [];
191
+ const samples = [];
192
+
193
+ const servers = collectMcpServers();
194
+ for (const server of servers) {
195
+ const result = await probeServer(server);
196
+ samples.push(result);
197
+
198
+ if (result.ok) {
199
+ record({
200
+ kind: 'sample',
201
+ watcher: name,
202
+ target: `${result.host}/${result.id}`,
203
+ result: 'ok',
204
+ summary: `${result.host}/${result.id} MCP handshake ok (transport=${result.transport})`,
205
+ context: { protocolVersion: result.protocolVersion, toolCount: result.toolCount },
206
+ });
207
+ continue;
208
+ }
209
+
210
+ record({
211
+ kind: 'sample',
212
+ watcher: name,
213
+ target: `${result.host}/${result.id}`,
214
+ result: 'warn',
215
+ summary: `${result.host}/${result.id} MCP handshake failed: ${result.reason}`,
216
+ context: { transport: result.transport, reason: result.reason },
217
+ });
218
+ actions.push({ type: 'protocol-warning', target: `${result.host}/${result.id}`, reason: result.reason });
219
+
220
+ if (process.env.CONSTRUCT_MCP_PROTOCOL_BLOCKING === '1') {
221
+ const r = await escalate({
222
+ watcher: name,
223
+ eventType: 'service.down',
224
+ summary: `MCP handshake failed for ${result.host}/${result.id}: ${result.reason}`,
225
+ context: { host: result.host, id: result.id, transport: result.transport, reason: result.reason },
226
+ });
227
+ escalations.push({ eventType: 'service.down', target: `${result.host}/${result.id}`, result: r });
228
+ }
229
+ }
230
+
231
+ return { actions, escalations, notes: samples };
232
+ }
@@ -10,6 +10,11 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
10
10
  import { basename, extname, resolve } from 'node:path';
11
11
  import { spawnSync } from 'node:child_process';
12
12
 
13
+ // 50 MB hard cap mirrors lib/embed/inbox.mjs and lib/observation-store.mjs.
14
+ // Anything larger is intentionally not extracted to keep the daemon path
15
+ // bounded; callers receive an empty text plus a `skipped` marker.
16
+ const MAX_EML_BYTES = 50 * 1024 * 1024;
17
+
13
18
  export const UTF8_TEXT_EXTS = new Set([
14
19
  '.md', '.txt', '.rst', '.adoc', '.json', '.yaml', '.yml', '.toml',
15
20
  '.js', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.sh', '.bash',
@@ -21,11 +26,13 @@ export const UTF8_TEXT_EXTS = new Set([
21
26
  export const ZIP_DOCUMENT_EXTS = new Set(['.docx', '.xlsx', '.pptx', '.odt', '.ods']);
22
27
  export const RICH_TEXT_EXTS = new Set(['.doc', '.rtf']);
23
28
  export const MDLS_DOCUMENT_EXTS = new Set(['.xls', '.ppt', '.pages', '.numbers', '.key']);
29
+ export const EMAIL_DOCUMENT_EXTS = new Set(['.eml']);
24
30
  export const EXTRACTABLE_DOCUMENT_EXTS = new Set([
25
31
  ...UTF8_TEXT_EXTS,
26
32
  ...ZIP_DOCUMENT_EXTS,
27
33
  ...RICH_TEXT_EXTS,
28
34
  ...MDLS_DOCUMENT_EXTS,
35
+ ...EMAIL_DOCUMENT_EXTS,
29
36
  '.pdf',
30
37
  ]);
31
38
 
@@ -176,6 +183,203 @@ export function isExtractableDocumentPath(filePath) {
176
183
  return EXTRACTABLE_DOCUMENT_EXTS.has(extname(filePath).toLowerCase());
177
184
  }
178
185
 
186
+ // Minimal RFC 5322 header decoder. Unfolds continuation lines (a line that
187
+ // begins with whitespace continues the previous header) and splits each
188
+ // header into (name, value). Stops at the first blank line, which RFC 5322
189
+ // defines as the header/body separator.
190
+ function parseRfc5322Headers(headerBlock) {
191
+ const lines = headerBlock.split('\n');
192
+ const unfolded = [];
193
+ for (const line of lines) {
194
+ if (/^[ \t]/.test(line) && unfolded.length > 0) {
195
+ unfolded[unfolded.length - 1] += ' ' + line.trim();
196
+ } else {
197
+ unfolded.push(line);
198
+ }
199
+ }
200
+ const headers = {};
201
+ for (const line of unfolded) {
202
+ const idx = line.indexOf(':');
203
+ if (idx === -1) continue;
204
+ const name = line.slice(0, idx).trim().toLowerCase();
205
+ const value = line.slice(idx + 1).trim();
206
+ if (!name) continue;
207
+ // Multiple Received headers are common; keep the first occurrence of
208
+ // any header so From/Subject/Date pick the outermost value.
209
+ if (!(name in headers)) headers[name] = value;
210
+ }
211
+ return headers;
212
+ }
213
+
214
+ // Split a Content-Type header value into the bare type and a parameter map.
215
+ // Returns { type, params } where type is lowercased and stripped, params is
216
+ // keyed by lowercased name with values stripped of surrounding quotes.
217
+ function parseContentType(value) {
218
+ if (!value) return { type: '', params: {} };
219
+ const parts = value.split(';').map((s) => s.trim()).filter(Boolean);
220
+ const type = (parts.shift() || '').toLowerCase();
221
+ const params = {};
222
+ for (const part of parts) {
223
+ const idx = part.indexOf('=');
224
+ if (idx === -1) continue;
225
+ const k = part.slice(0, idx).trim().toLowerCase();
226
+ let v = part.slice(idx + 1).trim();
227
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
228
+ params[k] = v;
229
+ }
230
+ return { type, params };
231
+ }
232
+
233
+ // Split a multipart body on the boundary marker. Each returned segment is
234
+ // itself a mini MIME entity (its own header block, blank line, body). The
235
+ // closing `--boundary--` terminator is honored by stripping anything past
236
+ // it. Boundaries that do not appear in the body produce a single segment.
237
+ function splitMultipart(body, boundary) {
238
+ if (!boundary) return [body];
239
+ const marker = '--' + boundary;
240
+ const closer = marker + '--';
241
+ const closeIdx = body.indexOf(closer);
242
+ const scoped = closeIdx === -1 ? body : body.slice(0, closeIdx);
243
+ const segments = scoped.split(marker);
244
+ // The piece before the first boundary is the preamble; discard it.
245
+ return segments.slice(1).map((seg) => seg.replace(/^\r?\n/, '').replace(/\r?\n$/, ''));
246
+ }
247
+
248
+ // Decode a quoted-printable body. Tolerant: malformed `=XX` sequences are
249
+ // passed through verbatim rather than throwing, which matches real-world
250
+ // mailers more closely than a strict decoder.
251
+ function decodeQuotedPrintable(text) {
252
+ return text
253
+ .replace(/=\r?\n/g, '')
254
+ .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
255
+ }
256
+
257
+ function decodeTransferEncoded(text, encoding) {
258
+ const enc = String(encoding || '').toLowerCase();
259
+ if (enc === 'quoted-printable') return decodeQuotedPrintable(text);
260
+ if (enc === 'base64') {
261
+ try { return Buffer.from(text.replace(/\s+/g, ''), 'base64').toString('utf8'); } catch { return text; }
262
+ }
263
+ return text;
264
+ }
265
+
266
+ // Walk a parsed MIME entity, returning the first text/plain body it finds
267
+ // and a flat list of attachment filenames encountered along the way. The
268
+ // walker stops at depth 8 so a hand-crafted pathological message cannot
269
+ // exhaust the stack.
270
+ function walkMimeForPlainText(rawEntity, depth = 0) {
271
+ if (depth > 8) return { text: '', attachments: [] };
272
+ const sepIdx = rawEntity.indexOf('\n\n') !== -1
273
+ ? rawEntity.indexOf('\n\n')
274
+ : rawEntity.indexOf('\r\n\r\n');
275
+ if (sepIdx === -1) return { text: '', attachments: [] };
276
+ const headerBlock = rawEntity.slice(0, sepIdx);
277
+ const body = rawEntity.slice(sepIdx).replace(/^\r?\n\r?\n/, '');
278
+ const headers = parseRfc5322Headers(headerBlock);
279
+ const { type, params } = parseContentType(headers['content-type'] || 'text/plain');
280
+ const disposition = parseContentType(headers['content-disposition'] || '');
281
+ const filename = disposition.params.filename || params.name || null;
282
+
283
+ if (type.startsWith('multipart/')) {
284
+ const attachments = [];
285
+ let firstPlain = '';
286
+ for (const seg of splitMultipart(body, params.boundary)) {
287
+ const child = walkMimeForPlainText(seg, depth + 1);
288
+ attachments.push(...child.attachments);
289
+ if (!firstPlain && child.text) firstPlain = child.text;
290
+ }
291
+ return { text: firstPlain, attachments };
292
+ }
293
+
294
+ if (disposition.type === 'attachment' && filename) {
295
+ return { text: '', attachments: [filename] };
296
+ }
297
+
298
+ if (type === '' || type === 'text/plain') {
299
+ return {
300
+ text: decodeTransferEncoded(body, headers['content-transfer-encoding']),
301
+ attachments: [],
302
+ };
303
+ }
304
+
305
+ // Non-plain leaves with an explicit filename are treated as attachments.
306
+ if (filename) return { text: '', attachments: [filename] };
307
+ return { text: '', attachments: [] };
308
+ }
309
+
310
+ /**
311
+ * Extract a normalized representation of an .eml message.
312
+ *
313
+ * Returns { text, headers, attachments, skipped } where:
314
+ * text concatenates Subject, From, Date and the first text/plain
315
+ * body, suitable for direct hand-off to the classifier.
316
+ * headers decoded From, Subject, Date, To (raw strings).
317
+ * attachments filenames of every non-plain part with a Content-Disposition
318
+ * of `attachment` or a filename parameter. Bodies are skipped.
319
+ * skipped set to `'too large'` if the message exceeded MAX_EML_BYTES;
320
+ * in that case text is empty and no parsing was attempted.
321
+ *
322
+ * The parser is intentionally minimal: it understands header folding, the
323
+ * `--boundary` framing of multipart messages, and quoted-printable / base64
324
+ * transfer encodings. It does not decode RFC 2047 encoded-word headers, does
325
+ * not chase nested message/rfc822 envelopes, and does not validate signatures.
326
+ */
327
+ export function extractEmlMessage(filePath) {
328
+ const stat = statSync(filePath);
329
+ if (stat.size > MAX_EML_BYTES) {
330
+ return { text: '', headers: {}, attachments: [], skipped: 'too large' };
331
+ }
332
+ const raw = readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
333
+ const sepIdx = raw.indexOf('\n\n');
334
+ if (sepIdx === -1) {
335
+ // No header/body separator. Treat the whole file as the body.
336
+ return {
337
+ text: normalizeText(raw),
338
+ headers: {},
339
+ attachments: [],
340
+ };
341
+ }
342
+ const headerBlock = raw.slice(0, sepIdx);
343
+ const body = raw.slice(sepIdx + 2);
344
+ const headers = parseRfc5322Headers(headerBlock);
345
+ const { type, params } = parseContentType(headers['content-type'] || 'text/plain');
346
+
347
+ let bodyText = '';
348
+ const attachments = [];
349
+ if (type.startsWith('multipart/')) {
350
+ for (const seg of splitMultipart(body, params.boundary)) {
351
+ const child = walkMimeForPlainText(seg);
352
+ attachments.push(...child.attachments);
353
+ if (!bodyText && child.text) bodyText = child.text;
354
+ }
355
+ } else {
356
+ bodyText = decodeTransferEncoded(body, headers['content-transfer-encoding']);
357
+ }
358
+
359
+ const subject = headers.subject || '';
360
+ const from = headers.from || '';
361
+ const date = headers.date || '';
362
+ const to = headers.to || '';
363
+ const text = normalizeText(
364
+ `Subject: ${subject}\nFrom: ${from}\nDate: ${date}\n\n${bodyText}`,
365
+ );
366
+ return {
367
+ text,
368
+ headers: { subject, from, date, to },
369
+ attachments,
370
+ };
371
+ }
372
+
373
+ function extractEml(filePath) {
374
+ const message = extractEmlMessage(filePath);
375
+ return {
376
+ text: message.text,
377
+ method: message.skipped ? 'eml-skipped' : 'eml',
378
+ skipped: message.skipped || null,
379
+ attachments: message.attachments,
380
+ };
381
+ }
382
+
179
383
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
180
384
  const URL_RE = /https?:\/\/[^\s)>\]"'`]+/g;
181
385
  const DATE_KEYS = ['date', 'created', 'created_at', 'updated', 'updated_at', 'published', 'last_modified'];
@@ -268,6 +472,7 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
268
472
  else if (ZIP_DOCUMENT_EXTS.has(extension)) extracted = extractZipDocument(resolvedPath, extension);
269
473
  else if (RICH_TEXT_EXTS.has(extension)) extracted = extractRichText(resolvedPath);
270
474
  else if (MDLS_DOCUMENT_EXTS.has(extension) && process.platform === 'darwin' && commandExists('mdls')) extracted = { text: extractWithMdls(resolvedPath), method: 'mdls' };
475
+ else if (EMAIL_DOCUMENT_EXTS.has(extension)) extracted = extractEml(resolvedPath);
271
476
  else if (extension === '.pdf') extracted = extractPdf(resolvedPath);
272
477
  else throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
273
478
 
@@ -277,7 +482,7 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
277
482
  : null;
278
483
  const truncated = limit !== null && text.length > limit;
279
484
 
280
- return {
485
+ const result = {
281
486
  filePath: resolvedPath,
282
487
  extension,
283
488
  extractionMethod: extracted.method,
@@ -285,4 +490,9 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
285
490
  truncated,
286
491
  characters: text.length,
287
492
  };
493
+ // Surface eml-specific metadata so callers (intake, ingest) can route
494
+ // attachment filenames and oversize-skip markers without re-parsing.
495
+ if (extracted.attachments) result.attachments = extracted.attachments;
496
+ if (extracted.skipped) result.skipped = extracted.skipped;
497
+ return result;
288
498
  }
package/lib/embed/cli.mjs CHANGED
@@ -41,6 +41,83 @@ function logPath(homeDir = os.homedir()) {
41
41
  return path.join(runtimeDir(homeDir), LOG_FILE);
42
42
  }
43
43
 
44
+ // ---------------------------------------------------------------------------
45
+ // Log rotation
46
+ //
47
+ // The embed daemon writes its stdout/stderr to a single appended file. The
48
+ // file descriptor is opened by the parent process at spawn time; the daemon
49
+ // has no opportunity to reopen mid-flight. Rotation therefore happens at
50
+ // each daemon spawn: if the existing log exceeds the size cap we shift
51
+ // segments (.log → .log.1 → .log.2 → …) and drop the oldest, then the
52
+ // spawn opens a fresh empty file.
53
+ //
54
+ // CONSTRUCT_EMBED_LOG_MAX_MB sets the rotation threshold (default 50 MB).
55
+ // CONSTRUCT_EMBED_LOG_KEEP sets retained segment count (default 3).
56
+ // Worst-case disk footprint is (MAX_MB * (KEEP + 1)).
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const DEFAULT_MAX_MB = 50;
60
+ const DEFAULT_KEEP = 3;
61
+ const HARD_CAP_MAX_MB = 500; // refuse absurd configured values
62
+ const HARD_CAP_KEEP = 20;
63
+
64
+ function rotationConfig(env = process.env) {
65
+ const maxMbRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_MAX_MB ?? '', 10);
66
+ const keepRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_KEEP ?? '', 10);
67
+ const maxMb = Number.isFinite(maxMbRaw) && maxMbRaw > 0 ? Math.min(maxMbRaw, HARD_CAP_MAX_MB) : DEFAULT_MAX_MB;
68
+ const keep = Number.isFinite(keepRaw) && keepRaw >= 0 ? Math.min(keepRaw, HARD_CAP_KEEP) : DEFAULT_KEEP;
69
+ return { maxBytes: maxMb * 1024 * 1024, keep };
70
+ }
71
+
72
+ /**
73
+ * Rotate the embed daemon log if it exceeds the size cap. Exported for tests.
74
+ *
75
+ * @param {string} log - Absolute path to the live log file
76
+ * @param {object} [env] - Env object (default: process.env)
77
+ * @returns {{ rotated: boolean, sizeBytes: number, droppedSegment?: string }}
78
+ */
79
+ export function rotateEmbedLogIfNeeded(log, env = process.env) {
80
+ let sizeBytes = 0;
81
+ try {
82
+ sizeBytes = fs.statSync(log).size;
83
+ } catch (err) {
84
+ if (err.code === 'ENOENT') return { rotated: false, sizeBytes: 0 };
85
+ process.stderr.write(`[cli.mjs] rotateEmbedLog stat: ${err.message}\n`);
86
+ return { rotated: false, sizeBytes: 0 };
87
+ }
88
+
89
+ const { maxBytes, keep } = rotationConfig(env);
90
+ if (sizeBytes <= maxBytes) return { rotated: false, sizeBytes };
91
+
92
+ // Shift segments oldest-first: .{keep} gets dropped, .{n} → .{n+1}, .log → .log.1
93
+ let droppedSegment;
94
+ try {
95
+ const oldest = `${log}.${keep}`;
96
+ if (keep === 0) {
97
+ // Retention of 0 means rotate-and-discard: the existing log is removed,
98
+ // no segment is kept.
99
+ fs.rmSync(log, { force: true });
100
+ droppedSegment = log;
101
+ return { rotated: true, sizeBytes, droppedSegment };
102
+ }
103
+ if (fs.existsSync(oldest)) {
104
+ fs.rmSync(oldest, { force: true });
105
+ droppedSegment = oldest;
106
+ }
107
+ for (let n = keep - 1; n >= 1; n--) {
108
+ const from = `${log}.${n}`;
109
+ const to = `${log}.${n + 1}`;
110
+ if (fs.existsSync(from)) fs.renameSync(from, to);
111
+ }
112
+ fs.renameSync(log, `${log}.1`);
113
+ } catch (err) {
114
+ process.stderr.write(`[cli.mjs] rotateEmbedLog: ${err.message}\n`);
115
+ return { rotated: false, sizeBytes };
116
+ }
117
+
118
+ return { rotated: true, sizeBytes, droppedSegment };
119
+ }
120
+
44
121
  // ---------------------------------------------------------------------------
45
122
  // State helpers
46
123
  // ---------------------------------------------------------------------------
@@ -140,6 +217,7 @@ export async function autoStartEmbedIfNeeded(env = process.env, rootDir, homeDir
140
217
  const workerPath = path.join(resolvedRoot, 'lib', 'embed', 'worker.mjs');
141
218
  const log = logPath(homeDir);
142
219
  fs.mkdirSync(path.dirname(log), { recursive: true });
220
+ rotateEmbedLogIfNeeded(log, env);
143
221
  const fd = fs.openSync(log, 'a');
144
222
 
145
223
  const child = spawn(process.execPath, [workerPath], {
@@ -149,7 +227,18 @@ export async function autoStartEmbedIfNeeded(env = process.env, rootDir, homeDir
149
227
  });
150
228
  child.unref();
151
229
 
152
- writeState({ pid: child.pid, configPath: 'auto', startedAt: new Date().toISOString() }, homeDir);
230
+ let resolvedRootDir = homeDir;
231
+ try {
232
+ const { resolveRootDir } = await import('./daemon.mjs');
233
+ resolvedRootDir = resolveRootDir(env, process.cwd());
234
+ } catch { /* fall back to homeDir */ }
235
+
236
+ writeState({
237
+ pid: child.pid,
238
+ configPath: 'auto',
239
+ startedAt: new Date().toISOString(),
240
+ rootDir: resolvedRootDir,
241
+ }, homeDir);
153
242
  return { started: true, pid: child.pid };
154
243
  } catch (err) {
155
244
  return { started: false, reason: `spawn_failed: ${err.message}` };
@@ -196,6 +285,7 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPat
196
285
  const workerArgs = configPath ? [workerPath, '--config', configPath] : [workerPath];
197
286
  const log = logPath(homeDir);
198
287
  fs.mkdirSync(path.dirname(log), { recursive: true });
288
+ rotateEmbedLogIfNeeded(log, process.env);
199
289
  const fd = fs.openSync(log, 'a');
200
290
 
201
291
  const child = spawn(process.execPath, workerArgs, {
@@ -206,7 +296,21 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPat
206
296
  fs.closeSync(fd);
207
297
  child.unref();
208
298
 
209
- writeState({ pid: child.pid, configPath: configPath ?? 'auto', startedAt: new Date().toISOString() }, homeDir);
299
+ // Resolve rootDir using the same precedence the spawned worker will so the
300
+ // operator can see which inbox is being watched via `construct embed status`.
301
+
302
+ let resolvedRootDir = homeDir;
303
+ try {
304
+ const { resolveRootDir } = await import('./daemon.mjs');
305
+ resolvedRootDir = resolveRootDir(process.env, process.cwd());
306
+ } catch { /* fall back to homeDir */ }
307
+
308
+ writeState({
309
+ pid: child.pid,
310
+ configPath: configPath ?? 'auto',
311
+ startedAt: new Date().toISOString(),
312
+ rootDir: resolvedRootDir,
313
+ }, homeDir);
210
314
 
211
315
  // Brief liveness check — detects immediate crashes (e.g. missing module, bad config path)
212
316
  await new Promise(r => setTimeout(r, _livenessCheckMs));
@@ -245,15 +349,32 @@ function cmdEmbedStop(_args, { homeDir = os.homedir() } = {}) {
245
349
  // Subcommand: status
246
350
  // ---------------------------------------------------------------------------
247
351
 
248
- function cmdEmbedStatus(_args, { homeDir = os.homedir() } = {}) {
352
+ async function cmdEmbedStatus(_args, { homeDir = os.homedir() } = {}) {
249
353
  const state = readRunningState(homeDir);
250
354
  if (!state) {
251
355
  process.stdout.write('embed daemon: stopped\n');
252
356
  return;
253
357
  }
358
+
359
+ // Re-resolve rootDir using the same precedence the daemon would so the
360
+ // operator sees which inbox is actually being watched (CX_DATA_DIR override,
361
+ // walked-up project root, or homedir fallback).
362
+
363
+ let rootDir = state.rootDir;
364
+ if (!rootDir) {
365
+ try {
366
+ const { resolveRootDir } = await import('./daemon.mjs');
367
+ rootDir = resolveRootDir(process.env, process.cwd());
368
+ } catch {
369
+ rootDir = homeDir;
370
+ }
371
+ }
372
+
254
373
  process.stdout.write(`embed daemon: running\n`);
255
374
  process.stdout.write(` pid: ${state.pid}\n`);
256
375
  process.stdout.write(` config: ${state.configPath}\n`);
376
+ process.stdout.write(` rootDir: ${rootDir}\n`);
377
+ process.stdout.write(` inbox: ${path.join(rootDir, '.cx', 'inbox')}\n`);
257
378
  process.stdout.write(` started at: ${state.startedAt}\n`);
258
379
  process.stdout.write(` log: ${logPath(homeDir)}\n`);
259
380
  }