@geraldmaron/construct 1.0.12 → 1.0.15

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 (337) hide show
  1. package/README.md +14 -2
  2. package/bin/construct +262 -14
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/commands/build/feature.md +0 -6
  5. package/commands/build/fix.md +0 -6
  6. package/commands/design/access.md +0 -6
  7. package/commands/design/flow.md +0 -6
  8. package/commands/design/ui.md +0 -6
  9. package/commands/measure/experiment.md +0 -6
  10. package/commands/measure/metrics.md +0 -6
  11. package/commands/measure/results.md +0 -6
  12. package/commands/plan/api.md +0 -6
  13. package/commands/plan/challenge.md +0 -6
  14. package/commands/plan/decide.md +0 -6
  15. package/commands/plan/feature.md +0 -7
  16. package/commands/plan/requirements.md +0 -6
  17. package/commands/remember/context.md +0 -6
  18. package/commands/remember/handoff.md +0 -6
  19. package/commands/remember/runbook.md +0 -6
  20. package/commands/review/code.md +0 -6
  21. package/commands/review/quality.md +0 -6
  22. package/commands/review/security.md +0 -6
  23. package/commands/ship/ready.md +0 -6
  24. package/commands/ship/release.md +0 -6
  25. package/commands/ship/status.md +0 -6
  26. package/commands/understand/docs.md +0 -6
  27. package/commands/understand/research.md +0 -6
  28. package/commands/understand/this.md +0 -6
  29. package/commands/understand/why.md +0 -6
  30. package/commands/work/clean.md +0 -6
  31. package/commands/work/drive.md +0 -6
  32. package/commands/work/optimize-prompts.md +0 -6
  33. package/commands/work/parallel-review.md +0 -6
  34. package/lib/auto-docs.mjs +11 -7
  35. package/lib/beads-client.mjs +19 -1
  36. package/lib/comment-lint.mjs +5 -1
  37. package/lib/context-state.mjs +15 -3
  38. package/lib/contracts/validate.mjs +54 -10
  39. package/lib/contracts/violation-log.mjs +154 -0
  40. package/lib/dashboard-static.mjs +7 -4
  41. package/lib/doc-stamp.mjs +16 -0
  42. package/lib/docs-verify.mjs +1 -8
  43. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  44. package/lib/document-extract/docling-client.mjs +114 -0
  45. package/lib/document-extract/docling-sidecar.py +122 -0
  46. package/lib/document-extract/whisper-client.mjs +79 -0
  47. package/lib/document-extract.mjs +73 -8
  48. package/lib/document-ingest.mjs +26 -4
  49. package/lib/embed/daemon.mjs +26 -1
  50. package/lib/embed/docs-lifecycle.mjs +19 -0
  51. package/lib/embed/inbox.mjs +85 -2
  52. package/lib/embed/recommendation-store.mjs +29 -0
  53. package/lib/flavors/loader.mjs +1 -1
  54. package/lib/gates-audit.mjs +18 -12
  55. package/lib/handoffs/contract.mjs +2 -2
  56. package/lib/hooks/_lib/input.mjs +52 -0
  57. package/lib/hooks/adaptive-lint.mjs +4 -0
  58. package/lib/hooks/agent-tracker.mjs +59 -15
  59. package/lib/hooks/audit-reads.mjs +83 -42
  60. package/lib/hooks/audit-trail.mjs +28 -18
  61. package/lib/hooks/bash-output-logger.mjs +8 -2
  62. package/lib/hooks/block-no-verify.mjs +4 -0
  63. package/lib/hooks/ci-status-check.mjs +4 -0
  64. package/lib/hooks/comment-lint.mjs +6 -4
  65. package/lib/hooks/config-protection.mjs +4 -0
  66. package/lib/hooks/context-watch.mjs +4 -0
  67. package/lib/hooks/context-window-recovery.mjs +4 -0
  68. package/lib/hooks/dep-audit.mjs +12 -5
  69. package/lib/hooks/doc-coupling-check.mjs +5 -1
  70. package/lib/hooks/edit-accumulator.mjs +25 -10
  71. package/lib/hooks/edit-error-recovery.mjs +4 -0
  72. package/lib/hooks/edit-guard.mjs +4 -0
  73. package/lib/hooks/guard-bash.mjs +4 -0
  74. package/lib/hooks/mcp-audit.mjs +4 -0
  75. package/lib/hooks/mcp-health-check.mjs +4 -0
  76. package/lib/hooks/model-fallback.mjs +7 -11
  77. package/lib/hooks/policy-engine.mjs +4 -0
  78. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  79. package/lib/hooks/post-merge-tracking.mjs +82 -0
  80. package/lib/hooks/pre-compact.mjs +4 -0
  81. package/lib/hooks/pre-push-gate.mjs +84 -231
  82. package/lib/hooks/proactive-activation.mjs +5 -2
  83. package/lib/hooks/readme-age-check.mjs +4 -0
  84. package/lib/hooks/registry-sync.mjs +35 -20
  85. package/lib/hooks/rule-verifier.mjs +3 -0
  86. package/lib/hooks/scan-secrets.mjs +4 -0
  87. package/lib/hooks/session-optimize.mjs +4 -0
  88. package/lib/hooks/session-reflect.mjs +4 -0
  89. package/lib/hooks/session-start.mjs +49 -2
  90. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  91. package/lib/hooks/stop-notify.mjs +13 -2
  92. package/lib/hooks/stop-typecheck.mjs +4 -0
  93. package/lib/hooks/test-watch.mjs +8 -4
  94. package/lib/ingest/chunker.mjs +94 -0
  95. package/lib/ingest/pipeline.mjs +53 -0
  96. package/lib/ingest/store.mjs +82 -0
  97. package/lib/init-unified.mjs +90 -23
  98. package/lib/intake/attribution.mjs +77 -0
  99. package/lib/intake/intake-config.mjs +3 -0
  100. package/lib/intake/manifest.mjs +107 -0
  101. package/lib/intake/poll-lock.mjs +136 -0
  102. package/lib/intake/prepare.mjs +42 -4
  103. package/lib/knowledge/search.mjs +12 -0
  104. package/lib/logging/rotate.mjs +394 -0
  105. package/lib/mcp/server.mjs +142 -1
  106. package/lib/mcp/tools/project.mjs +2 -2
  107. package/lib/mcp/tools/skills.mjs +6 -3
  108. package/lib/mcp/tools/telemetry.mjs +1 -1
  109. package/lib/mcp/tools/workflow.mjs +6 -2
  110. package/lib/observation-store.mjs +14 -5
  111. package/lib/ollama-manager.mjs +32 -28
  112. package/lib/opencode-config.mjs +10 -3
  113. package/lib/orchestration/routing-tables.mjs +176 -0
  114. package/lib/orchestration-policy.mjs +18 -106
  115. package/lib/parity.mjs +48 -7
  116. package/lib/profiles/lifecycle.mjs +19 -1
  117. package/lib/project-init-shared.mjs +11 -5
  118. package/lib/project-root.mjs +104 -0
  119. package/lib/prompt-composer.js +11 -1
  120. package/lib/roles/catalog.mjs +1 -1
  121. package/lib/roles/event-bus.mjs +29 -9
  122. package/lib/roles/router.mjs +8 -7
  123. package/lib/runtime/uv-bootstrap.mjs +129 -0
  124. package/lib/runtime/whisper-bootstrap.mjs +102 -0
  125. package/lib/server/index.mjs +53 -10
  126. package/lib/server/static/index.html +1 -15
  127. package/lib/specialists/postconditions.mjs +1 -1
  128. package/lib/status.mjs +1 -1
  129. package/lib/storage/backup.mjs +2 -2
  130. package/lib/sync/skill-frontmatter.mjs +113 -21
  131. package/lib/telemetry/intent-verifications.mjs +16 -3
  132. package/lib/telemetry/skill-calls.mjs +12 -3
  133. package/lib/tracking-surfaces.mjs +377 -0
  134. package/lib/validators/skills.mjs +86 -54
  135. package/lib/worker/trace.mjs +19 -2
  136. package/package.json +10 -6
  137. package/personas/construct.md +4 -7
  138. package/platforms/claude/settings.template.json +29 -28
  139. package/rules/common/beads-hygiene.md +3 -9
  140. package/rules/common/code-review.md +3 -6
  141. package/rules/common/coding-style.md +3 -6
  142. package/rules/common/comments.md +3 -7
  143. package/rules/common/commit-approval.md +3 -6
  144. package/rules/common/cx-agent-routing.md +3 -7
  145. package/rules/common/cx-skill-routing.md +3 -3
  146. package/rules/common/doc-ownership.md +3 -8
  147. package/rules/common/efficiency.md +3 -8
  148. package/rules/common/framing.md +3 -8
  149. package/rules/common/git-workflow.md +3 -5
  150. package/rules/common/no-fabrication.md +4 -12
  151. package/rules/common/patterns.md +3 -5
  152. package/rules/common/release-gates.md +3 -8
  153. package/rules/common/research.md +3 -8
  154. package/rules/common/review-before-change.md +3 -9
  155. package/rules/common/security.md +3 -6
  156. package/rules/common/skill-composition.md +3 -7
  157. package/rules/common/testing.md +3 -6
  158. package/rules/golang/coding-style.md +1 -5
  159. package/rules/golang/hooks.md +1 -5
  160. package/rules/golang/patterns.md +1 -5
  161. package/rules/golang/security.md +1 -5
  162. package/rules/golang/testing.md +1 -5
  163. package/rules/python/coding-style.md +1 -5
  164. package/rules/python/hooks.md +1 -5
  165. package/rules/python/patterns.md +1 -5
  166. package/rules/python/security.md +1 -5
  167. package/rules/python/testing.md +1 -5
  168. package/rules/swift/coding-style.md +1 -5
  169. package/rules/swift/hooks.md +1 -5
  170. package/rules/swift/patterns.md +1 -5
  171. package/rules/swift/security.md +1 -5
  172. package/rules/swift/testing.md +1 -5
  173. package/rules/typescript/coding-style.md +1 -5
  174. package/rules/typescript/hooks.md +1 -5
  175. package/rules/typescript/patterns.md +1 -5
  176. package/rules/typescript/security.md +1 -5
  177. package/rules/typescript/testing.md +1 -5
  178. package/rules/web/coding-style.md +3 -5
  179. package/rules/web/design-quality.md +3 -5
  180. package/rules/web/hooks.md +3 -5
  181. package/rules/web/patterns.md +3 -5
  182. package/rules/web/performance.md +3 -5
  183. package/rules/web/security.md +3 -5
  184. package/rules/web/testing.md +3 -5
  185. package/scripts/sync-specialists.mjs +269 -75
  186. package/skills/ai/agent-dev.md +4 -5
  187. package/skills/ai/llm-security.md +4 -5
  188. package/skills/ai/ml-ops.md +4 -5
  189. package/skills/ai/orchestration-workflow.md +4 -5
  190. package/skills/ai/prompt-and-eval.md +4 -5
  191. package/skills/ai/prompt-optimizer.md +4 -6
  192. package/skills/ai/rag-system.md +4 -5
  193. package/skills/architecture/api-design.md +4 -5
  194. package/skills/architecture/caching.md +4 -5
  195. package/skills/architecture/cloud-native.md +4 -5
  196. package/skills/architecture/message-queue.md +4 -5
  197. package/skills/architecture/security-arch.md +4 -5
  198. package/skills/compliance/ai-disclosure.md +4 -5
  199. package/skills/compliance/data-privacy.md +4 -5
  200. package/skills/compliance/license-audit.md +4 -5
  201. package/skills/compliance/regulatory-review.md +4 -5
  202. package/skills/development/cpp.md +4 -5
  203. package/skills/development/go.md +4 -5
  204. package/skills/development/java.md +4 -5
  205. package/skills/development/kotlin.md +4 -5
  206. package/skills/development/mobile-crossplatform.md +4 -5
  207. package/skills/development/python.md +4 -5
  208. package/skills/development/rust.md +4 -5
  209. package/skills/development/shell.md +4 -5
  210. package/skills/development/swift.md +4 -5
  211. package/skills/development/typescript.md +4 -5
  212. package/skills/devops/ci-cd.md +4 -5
  213. package/skills/devops/containerization.md +4 -5
  214. package/skills/devops/cost-optimization.md +4 -5
  215. package/skills/devops/data-engineering.md +4 -5
  216. package/skills/devops/database.md +4 -5
  217. package/skills/devops/dependency-management.md +4 -5
  218. package/skills/devops/devsecops.md +4 -5
  219. package/skills/devops/git-workflow.md +4 -5
  220. package/skills/devops/incident-response.md +4 -5
  221. package/skills/devops/monorepo.md +4 -5
  222. package/skills/devops/observability.md +4 -5
  223. package/skills/devops/performance.md +4 -5
  224. package/skills/devops/testing.md +4 -5
  225. package/skills/docs/adr-workflow.md +4 -7
  226. package/skills/docs/backlog-proposal-workflow.md +4 -3
  227. package/skills/docs/customer-profile-workflow.md +4 -3
  228. package/skills/docs/document-ingest-workflow.md +4 -3
  229. package/skills/docs/evidence-ingest-workflow.md +4 -3
  230. package/skills/docs/init-docs.md +4 -5
  231. package/skills/docs/init-project.md +4 -5
  232. package/skills/docs/prd-workflow.md +4 -7
  233. package/skills/docs/prfaq-workflow.md +4 -3
  234. package/skills/docs/product-intelligence-review.md +4 -3
  235. package/skills/docs/product-intelligence-workflow.md +4 -6
  236. package/skills/docs/product-signal-workflow.md +4 -5
  237. package/skills/docs/research-workflow.md +4 -5
  238. package/skills/docs/runbook-workflow.md +4 -5
  239. package/skills/docs/strategy-workflow.md +4 -5
  240. package/skills/exploration/dependency-graph-reading.md +4 -7
  241. package/skills/exploration/repo-map.md +4 -5
  242. package/skills/exploration/tracer-bullet-method.md +4 -7
  243. package/skills/exploration/unknown-codebase-onboarding.md +4 -7
  244. package/skills/frameworks/django.md +4 -5
  245. package/skills/frameworks/nextjs.md +4 -5
  246. package/skills/frameworks/react.md +4 -5
  247. package/skills/frameworks/spring-boot.md +4 -5
  248. package/skills/frontend-design/accessibility.md +4 -5
  249. package/skills/frontend-design/component-patterns.md +4 -5
  250. package/skills/frontend-design/engineering.md +4 -5
  251. package/skills/frontend-design/state-management.md +4 -5
  252. package/skills/frontend-design/ui-aesthetics.md +4 -5
  253. package/skills/frontend-design/ux-principles.md +4 -5
  254. package/skills/operating/change-management.md +4 -8
  255. package/skills/operating/incident-response.md +4 -8
  256. package/skills/operating/oncall-rotation.md +4 -7
  257. package/skills/operating/orchestration-reference.md +4 -10
  258. package/skills/quality-gates/review-work.md +4 -5
  259. package/skills/quality-gates/verify-change.md +4 -5
  260. package/skills/quality-gates/verify-module.md +4 -5
  261. package/skills/quality-gates/verify-quality.md +4 -5
  262. package/skills/quality-gates/verify-security.md +4 -5
  263. package/skills/roles/architect.ai-systems.md +6 -9
  264. package/skills/roles/architect.data.md +6 -9
  265. package/skills/roles/architect.enterprise.md +6 -9
  266. package/skills/roles/architect.integration.md +6 -9
  267. package/skills/roles/architect.md +7 -14
  268. package/skills/roles/architect.platform.md +6 -9
  269. package/skills/roles/data-analyst.experiment.md +6 -9
  270. package/skills/roles/data-analyst.md +6 -9
  271. package/skills/roles/data-analyst.product-intelligence.md +7 -9
  272. package/skills/roles/data-analyst.product.md +6 -9
  273. package/skills/roles/data-analyst.telemetry.md +7 -9
  274. package/skills/roles/data-engineer.pipeline.md +6 -9
  275. package/skills/roles/data-engineer.vector-retrieval.md +7 -9
  276. package/skills/roles/data-engineer.warehouse.md +6 -9
  277. package/skills/roles/debugger.md +6 -9
  278. package/skills/roles/designer.accessibility.md +6 -9
  279. package/skills/roles/designer.md +7 -9
  280. package/skills/roles/engineer.ai.md +6 -9
  281. package/skills/roles/engineer.data.md +6 -9
  282. package/skills/roles/engineer.md +9 -9
  283. package/skills/roles/engineer.platform.md +6 -9
  284. package/skills/roles/operator.docs.md +6 -9
  285. package/skills/roles/operator.md +9 -9
  286. package/skills/roles/operator.release.md +6 -9
  287. package/skills/roles/operator.sre.md +6 -9
  288. package/skills/roles/orchestrator.md +6 -9
  289. package/skills/roles/product-manager.ai-product.md +6 -9
  290. package/skills/roles/product-manager.business-strategy.md +6 -9
  291. package/skills/roles/product-manager.enterprise.md +6 -9
  292. package/skills/roles/product-manager.growth.md +7 -9
  293. package/skills/roles/product-manager.md +7 -9
  294. package/skills/roles/product-manager.platform.md +6 -9
  295. package/skills/roles/product-manager.product.md +6 -9
  296. package/skills/roles/qa.ai-eval.md +8 -9
  297. package/skills/roles/qa.api-contract.md +7 -9
  298. package/skills/roles/qa.data-pipeline.md +7 -9
  299. package/skills/roles/qa.md +7 -9
  300. package/skills/roles/qa.test-automation.md +6 -9
  301. package/skills/roles/qa.web-ui.md +7 -9
  302. package/skills/roles/researcher.explorer.md +6 -9
  303. package/skills/roles/researcher.md +8 -9
  304. package/skills/roles/researcher.ux.md +6 -9
  305. package/skills/roles/reviewer.devil-advocate.md +6 -9
  306. package/skills/roles/reviewer.evaluator.md +6 -9
  307. package/skills/roles/reviewer.md +9 -9
  308. package/skills/roles/reviewer.trace.md +6 -9
  309. package/skills/roles/security.ai.md +7 -9
  310. package/skills/roles/security.appsec.md +6 -9
  311. package/skills/roles/security.cloud.md +6 -9
  312. package/skills/roles/security.legal-compliance.md +6 -9
  313. package/skills/roles/security.md +7 -9
  314. package/skills/roles/security.privacy.md +7 -9
  315. package/skills/roles/security.supply-chain.md +7 -9
  316. package/skills/security/blue-team.md +4 -5
  317. package/skills/security/code-audit.md +4 -5
  318. package/skills/security/pentest.md +4 -5
  319. package/skills/security/red-team.md +4 -5
  320. package/skills/security/threat-intel.md +4 -5
  321. package/skills/security/vuln-research.md +4 -5
  322. package/skills/strategy/competitive-landscape.md +4 -8
  323. package/skills/strategy/market-research-methods.md +4 -8
  324. package/skills/strategy/narrative-arc.md +4 -8
  325. package/skills/strategy/pricing-positioning.md +4 -8
  326. package/skills/utility/clean-code.md +4 -5
  327. package/specialists/policy-inventory.json +14 -0
  328. package/specialists/registry.json +158 -13
  329. package/lib/hooks/env-check.mjs +0 -83
  330. package/lib/hooks/read-tracker.mjs +0 -61
  331. package/lib/policy/unified-gates.mjs +0 -96
  332. package/lib/server/static/assets/index-ab25c707.js +0 -70
  333. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
  334. package/lib/specialist-contracts-enforce.mjs +0 -158
  335. package/rules/common/agents.md +0 -28
  336. package/rules/common/development-workflow.md +0 -32
  337. package/rules/common/performance.md +0 -55
package/lib/doc-stamp.mjs CHANGED
@@ -102,12 +102,17 @@ function splitStamp(content) {
102
102
  * sessionId {string} - Construct session ID if available
103
103
  * model {string} - Model name/version if available
104
104
  * preserve_id {boolean} - Keep existing cx_doc_id if present (default: true)
105
+ * attribution {object} - Optional { createdBy, createdByAgent } pair. When
106
+ * provided, written into the stamp and (on stamp
107
+ * refresh) carried forward via existing_fields so a
108
+ * later write does not erase the original author.
105
109
  */
106
110
  export function stampFrontmatter(content, {
107
111
  generator = 'construct',
108
112
  sessionId = null,
109
113
  model = null,
110
114
  preserve_id = true,
115
+ attribution = null,
111
116
  } = {}) {
112
117
  const existing = hasStamp(content) ? splitStamp(content) : null;
113
118
  const existingFields = existing ? parseStamp(content) : {};
@@ -116,6 +121,11 @@ export function stampFrontmatter(content, {
116
121
  const createdAt = existingFields.created_at || new Date().toISOString();
117
122
  const updatedAt = new Date().toISOString();
118
123
 
124
+ const createdBy = existingFields.created_by ?? attribution?.createdBy ?? null;
125
+ const createdByAgent = existingFields.created_by_agent ?? attribution?.createdByAgent ?? null;
126
+ const lastModifiedBy = attribution?.createdBy ?? existingFields.last_modified_by ?? null;
127
+ const lastModifiedByAgent = attribution?.createdByAgent ?? existingFields.last_modified_by_agent ?? null;
128
+
119
129
  // Body is everything after stamp (or all content if no prior stamp)
120
130
  const body = existing ? existing.body : content;
121
131
  const hash = bodyHash(body);
@@ -127,6 +137,12 @@ export function stampFrontmatter(content, {
127
137
  `updated_at: ${updatedAt}`,
128
138
  `generator: ${generator}`,
129
139
  ];
140
+ if (createdBy) lines.push(`created_by: ${createdBy}`);
141
+ if (createdByAgent) lines.push(`created_by_agent: ${createdByAgent}`);
142
+ if (lastModifiedBy && lastModifiedBy !== createdBy) lines.push(`last_modified_by: ${lastModifiedBy}`);
143
+ if (lastModifiedByAgent && lastModifiedByAgent !== createdByAgent) {
144
+ lines.push(`last_modified_by_agent: ${lastModifiedByAgent}`);
145
+ }
130
146
  if (model) lines.push(`model: ${model}`);
131
147
  if (sessionId) lines.push(`session_id: ${sessionId}`);
132
148
  lines.push(`body_hash: ${hash}`);
@@ -52,18 +52,11 @@ if (stagedMode) {
52
52
  if (changedCode.length === 0) process.exit(0);
53
53
  if (changedDocs.length > 0) process.exit(0);
54
54
 
55
- if (process.env.CONSTRUCT_SKIP_DOCS === "1") {
56
- process.stderr.write(
57
- `[docs:verify --staged] CONSTRUCT_SKIP_DOCS=1 — skipping doc-coupling check for ${changedCode.length} code file(s).\n`,
58
- );
59
- process.exit(0);
60
- }
61
-
62
55
  const sample = changedCode.slice(0, 6).map((f) => ` - ${f}`).join("\n");
63
56
  process.stderr.write(
64
57
  `[docs:verify --staged] ${changedCode.length} code file(s) staged but no CHANGELOG.md / docs/* / .cx/context.* updates.\n` +
65
58
  `${sample}${changedCode.length > 6 ? `\n ... (${changedCode.length - 6} more)` : ""}\n` +
66
- `Update docs in this commit, or override with CONSTRUCT_SKIP_DOCS=1.\n`,
59
+ `Update docs in this commit before pushing.\n`,
67
60
  );
68
61
  process.exit(1);
69
62
  }
@@ -12,8 +12,10 @@
12
12
  * as a label, regardless of how it got there (manual, ai, automated).
13
13
  */
14
14
 
15
- import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
16
  import { spawnSync } from 'node:child_process';
17
+ import { appendBounded } from '../../logging/rotate.mjs';
18
+ import { resolveProjectScope } from '../../project-root.mjs';
17
19
  import { homedir } from 'node:os';
18
20
  import { join } from 'node:path';
19
21
 
@@ -86,6 +88,7 @@ export async function tick() {
86
88
  if (seen[key]) continue;
87
89
  seen[key] = { ts: Date.now(), id: issue.id };
88
90
  const manifest = loadManifest(personaId);
91
+ const scope = resolveProjectScope();
89
92
  const entry = {
90
93
  ts: Date.now(),
91
94
  personaId,
@@ -96,8 +99,9 @@ export async function tick() {
96
99
  summary: `bd ${issue.id} labeled next:cx-${personaId} — ${(issue.title || '').slice(0, 120)}`,
97
100
  killSwitchEnv: manifest?.killSwitchEnv || '',
98
101
  source: 'bd-watch',
102
+ ...(scope?.projectId ? { projectId: scope.projectId } : {}),
99
103
  };
100
- appendFileSync(PENDING_PATH, JSON.stringify(entry) + '\n');
104
+ appendBounded('role-pending', PENDING_PATH, JSON.stringify(entry) + '\n');
101
105
  record({
102
106
  kind: 'action',
103
107
  watcher: name,
@@ -0,0 +1,114 @@
1
+ /**
2
+ * lib/document-extract/docling-client.mjs — Node side of the docling sidecar.
3
+ *
4
+ * Spawns one long-lived Python process per Node session, framed by newline-
5
+ * delimited JSON over stdin/stdout. Avoids per-call uv/venv warm-up
6
+ * (~2s otherwise) and keeps the docling model resident.
7
+ *
8
+ * Public API:
9
+ * extractViaDocling(filePath) → { markdown, metadata, droppedInfo }
10
+ * shutdownDoclingSidecar() → idempotent process exit
11
+ *
12
+ * The first call provisions the venv on demand via ensureDoclingVenv().
13
+ * Subsequent calls reuse the running sidecar.
14
+ */
15
+ import { spawn } from 'node:child_process';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { ensureDoclingVenv } from '../runtime/uv-bootstrap.mjs';
19
+
20
+ const SIDECAR_SCRIPT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'docling-sidecar.py');
21
+ const REQUEST_TIMEOUT_MS = 300_000;
22
+
23
+ let activeSidecar = null;
24
+ let requestSeq = 0;
25
+
26
+ function spawnSidecar({ runtimeDir } = {}) {
27
+ const { pythonBin } = ensureDoclingVenv({ runtimeDir });
28
+ const child = spawn(pythonBin, [SIDECAR_SCRIPT], {
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
31
+ });
32
+
33
+ const pending = new Map();
34
+ let stdoutBuffer = '';
35
+ let stderrBuffer = '';
36
+ let exitReason = null;
37
+
38
+ child.stdout.setEncoding('utf8');
39
+ child.stderr.setEncoding('utf8');
40
+
41
+ child.stdout.on('data', (chunk) => {
42
+ stdoutBuffer += chunk;
43
+ let newlineIdx;
44
+ while ((newlineIdx = stdoutBuffer.indexOf('\n')) !== -1) {
45
+ const line = stdoutBuffer.slice(0, newlineIdx);
46
+ stdoutBuffer = stdoutBuffer.slice(newlineIdx + 1);
47
+ if (!line.trim()) continue;
48
+ let message;
49
+ try { message = JSON.parse(line); }
50
+ catch { continue; }
51
+ const id = message.id;
52
+ const handler = pending.get(id);
53
+ if (!handler) continue;
54
+ pending.delete(id);
55
+ clearTimeout(handler.timer);
56
+ if (message.error) handler.reject(Object.assign(new Error(message.error.message || 'docling sidecar error'), { code: message.error.code, trace: message.error.trace }));
57
+ else handler.resolve(message.result);
58
+ }
59
+ });
60
+
61
+ child.stderr.on('data', (chunk) => { stderrBuffer += chunk; });
62
+
63
+ child.on('exit', (code) => {
64
+ exitReason = `exit code ${code}`;
65
+ for (const [, handler] of pending) {
66
+ clearTimeout(handler.timer);
67
+ handler.reject(new Error(`docling sidecar exited (${exitReason}); stderr: ${stderrBuffer.slice(-500)}`));
68
+ }
69
+ pending.clear();
70
+ if (activeSidecar === sidecar) activeSidecar = null;
71
+ });
72
+
73
+ function send(method, params = {}) {
74
+ if (child.killed || child.exitCode !== null) {
75
+ return Promise.reject(new Error(`docling sidecar not running: ${exitReason || 'killed'}`));
76
+ }
77
+ const id = ++requestSeq;
78
+ return new Promise((resolve, reject) => {
79
+ const timer = setTimeout(() => {
80
+ pending.delete(id);
81
+ reject(new Error(`docling sidecar timeout after ${REQUEST_TIMEOUT_MS}ms (method=${method})`));
82
+ }, REQUEST_TIMEOUT_MS);
83
+ pending.set(id, { resolve, reject, timer });
84
+ child.stdin.write(JSON.stringify({ id, method, params }) + '\n');
85
+ });
86
+ }
87
+
88
+ const sidecar = { send, child, get stderr() { return stderrBuffer; } };
89
+ return sidecar;
90
+ }
91
+
92
+ function getSidecar() {
93
+ if (activeSidecar && activeSidecar.child.exitCode === null && !activeSidecar.child.killed) return activeSidecar;
94
+ activeSidecar = spawnSidecar();
95
+ return activeSidecar;
96
+ }
97
+
98
+ export async function extractViaDocling(filePath) {
99
+ const sidecar = getSidecar();
100
+ const result = await sidecar.send('extract', { path: path.resolve(filePath) });
101
+ return result;
102
+ }
103
+
104
+ export async function shutdownDoclingSidecar() {
105
+ if (!activeSidecar) return;
106
+ const sidecar = activeSidecar;
107
+ activeSidecar = null;
108
+ try { await sidecar.send('shutdown'); } catch { /* sidecar already gone */ }
109
+ if (sidecar.child.exitCode === null && !sidecar.child.killed) {
110
+ sidecar.child.kill('SIGTERM');
111
+ }
112
+ }
113
+
114
+ process.on('exit', () => { if (activeSidecar) activeSidecar.child.kill('SIGTERM'); });
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ lib/document-extract/docling-sidecar.py — long-lived JSON-RPC wrapper around docling.
4
+
5
+ Protocol: newline-delimited JSON over stdin/stdout. Each request is
6
+ {"id": <int>, "method": <str>, "params": <obj>}. Each response is
7
+ {"id": <int>, "result": <obj>} or {"id": <int>, "error": {"code": <str>, "message": <str>}}.
8
+
9
+ Methods:
10
+ - ping → {"ok": true, "doclingVersion": "<x.y.z>"}
11
+ - extract {path} → {"markdown": "...", "metadata": {...}, "droppedInfo": [...]}
12
+ - shutdown → {"ok": true}; process exits after acknowledgement
13
+
14
+ Best-practice notes (2026-06):
15
+ - One sidecar per Node session, kept warm to avoid uv/venv startup (~2s).
16
+ - Stdio JSON-RPC is the leanest Python↔Node IPC for non-LLM use cases;
17
+ MCP rides the same transport but adds protocol overhead unsuitable
18
+ for the parser sidecar.
19
+ - Drops are surfaced explicitly (kind/count/reason/recoverable) so info
20
+ loss is observable to the CLI, not silent.
21
+ """
22
+ import json
23
+ import sys
24
+ import traceback
25
+ from pathlib import Path
26
+
27
+ try:
28
+ from docling.document_converter import DocumentConverter
29
+ from importlib.metadata import version as _pkg_version
30
+ DOCLING_VERSION = _pkg_version("docling")
31
+ except ImportError as exc:
32
+ sys.stderr.write(json.dumps({"fatal": "docling-import-failed", "detail": str(exc)}) + "\n")
33
+ sys.exit(2)
34
+
35
+
36
+ _converter = None
37
+
38
+
39
+ def get_converter():
40
+ global _converter
41
+ if _converter is None:
42
+ _converter = DocumentConverter()
43
+ return _converter
44
+
45
+
46
+ def extract(params):
47
+ path_raw = params.get("path")
48
+ if not path_raw:
49
+ raise ValueError("missing 'path' parameter")
50
+ path = Path(path_raw)
51
+ if not path.exists():
52
+ raise FileNotFoundError(f"file not found: {path}")
53
+
54
+ result = get_converter().convert(str(path))
55
+ doc = result.document
56
+ markdown = doc.export_to_markdown()
57
+
58
+ metadata = {
59
+ "format": result.input.format.value if hasattr(result.input, "format") else None,
60
+ "pageCount": len(doc.pages) if hasattr(doc, "pages") and doc.pages is not None else None,
61
+ "doclingVersion": DOCLING_VERSION,
62
+ "sourcePath": str(path),
63
+ }
64
+
65
+ dropped_info = []
66
+ try:
67
+ page_count = metadata.get("pageCount") or 0
68
+ if page_count and len(markdown) / max(page_count, 1) < 50:
69
+ dropped_info.append({
70
+ "kind": "low-text-yield",
71
+ "count": page_count,
72
+ "reason": "Extracted text density below 50 chars/page suggests image-heavy or scanned content; OCR may have partial coverage.",
73
+ "recoverable": True,
74
+ })
75
+ except Exception:
76
+ pass
77
+
78
+ return {
79
+ "markdown": markdown,
80
+ "metadata": metadata,
81
+ "droppedInfo": dropped_info,
82
+ }
83
+
84
+
85
+ def handle(request):
86
+ method = request.get("method")
87
+ params = request.get("params") or {}
88
+ if method == "ping":
89
+ return {"ok": True, "doclingVersion": DOCLING_VERSION}
90
+ if method == "extract":
91
+ return extract(params)
92
+ if method == "shutdown":
93
+ return {"ok": True}
94
+ raise ValueError(f"unknown method: {method}")
95
+
96
+
97
+ def main():
98
+ for line in sys.stdin:
99
+ line = line.strip()
100
+ if not line:
101
+ continue
102
+ request_id = None
103
+ try:
104
+ request = json.loads(line)
105
+ request_id = request.get("id")
106
+ result = handle(request)
107
+ sys.stdout.write(json.dumps({"id": request_id, "result": result}) + "\n")
108
+ sys.stdout.flush()
109
+ if request.get("method") == "shutdown":
110
+ return
111
+ except Exception as exc:
112
+ error = {
113
+ "code": type(exc).__name__,
114
+ "message": str(exc),
115
+ "trace": traceback.format_exc() if not isinstance(exc, (ValueError, FileNotFoundError)) else None,
116
+ }
117
+ sys.stdout.write(json.dumps({"id": request_id, "error": error}) + "\n")
118
+ sys.stdout.flush()
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
@@ -0,0 +1,79 @@
1
+ /**
2
+ * lib/document-extract/whisper-client.mjs — invoke whisper.cpp on audio/video.
3
+ *
4
+ * Returns `{ markdown, metadata, droppedInfo }` matching the shape returned
5
+ * by docling-client for symmetry with the document-extract dispatcher.
6
+ *
7
+ * Strategy: run whisper-cli with the source file → text transcript. The
8
+ * transcript is wrapped as a fenced "## Transcript" section in markdown so
9
+ * the format-converter pipeline downstream gets a uniform shape.
10
+ *
11
+ * 2026-06 notes:
12
+ * - whisper.cpp + Metal hits ~10× real-time on M-series for large-v3.
13
+ * Default model is `base.en` for speed; override via
14
+ * CONSTRUCT_WHISPER_MODEL for non-English or higher-quality runs.
15
+ * - Non-audio formats are rejected up front so the docling sidecar stays
16
+ * the single owner of PDF/Office/HTML parsing.
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import path from 'node:path';
20
+ import os from 'node:os';
21
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
22
+ import { ensureWhisperRuntime } from '../runtime/whisper-bootstrap.mjs';
23
+
24
+ const CLI_TIMEOUT_MS = 600_000;
25
+
26
+ function runWhisperCli({ binary, modelPath, sourcePath, outputBase }) {
27
+ return new Promise((resolve, reject) => {
28
+ const args = [
29
+ '-m', modelPath,
30
+ '-f', sourcePath,
31
+ '-otxt',
32
+ '-of', outputBase,
33
+ '-l', 'auto',
34
+ '-nt',
35
+ ];
36
+ const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
37
+ let stdout = '';
38
+ let stderr = '';
39
+ child.stdout.on('data', (c) => { stdout += c; });
40
+ child.stderr.on('data', (c) => { stderr += c; });
41
+ const timer = setTimeout(() => {
42
+ child.kill('SIGKILL');
43
+ reject(new Error(`whisper-cli timeout after ${CLI_TIMEOUT_MS}ms`));
44
+ }, CLI_TIMEOUT_MS);
45
+ child.on('exit', (code) => {
46
+ clearTimeout(timer);
47
+ if (code !== 0) reject(new Error(`whisper-cli exited ${code}: ${stderr.slice(-500)}`));
48
+ else resolve({ stdout, stderr });
49
+ });
50
+ child.on('error', (err) => { clearTimeout(timer); reject(err); });
51
+ });
52
+ }
53
+
54
+ export async function extractViaWhisper(filePath, { runtimeDir, model } = {}) {
55
+ const absolutePath = path.resolve(filePath);
56
+ const { binary, modelPath, model: resolvedModel } = await ensureWhisperRuntime({ runtimeDir, model });
57
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'construct-whisper-'));
58
+ const outputBase = path.join(tmpDir, 'transcript');
59
+ try {
60
+ const { stderr } = await runWhisperCli({ binary, modelPath, sourcePath: absolutePath, outputBase });
61
+ const transcript = readFileSync(`${outputBase}.txt`, 'utf8').trim();
62
+ const detectedLanguage = stderr.match(/auto-detected language: (\w+)/)?.[1] || null;
63
+ const markdown = `## Transcript\n\n${transcript}\n`;
64
+ return {
65
+ markdown,
66
+ metadata: {
67
+ sourcePath: absolutePath,
68
+ whisperModel: resolvedModel,
69
+ binarySource: binary,
70
+ detectedLanguage,
71
+ },
72
+ droppedInfo: transcript.length === 0
73
+ ? [{ kind: 'empty-transcript', count: 1, reason: 'whisper-cli returned no text — silent file, unsupported codec, or model mismatch.', recoverable: true }]
74
+ : [],
75
+ };
76
+ } finally {
77
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* tmp cleanup best-effort */ }
78
+ }
79
+ }
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * lib/document-extract.mjs — shared local document extraction for retrieval and MCP reads.
3
3
  *
4
- * Handles plain-text formats directly, XML/HTML via lightweight tag stripping,
5
- * Office zip containers via unzip, and PDFs via pdftotext or macOS-native fallbacks.
6
- * Also provides extractDocumentMetadata() for structured metadata (title, authors,
7
- * dates, links) from source document frontmatter and content.
4
+ * Two extraction paths:
5
+ * - Async (preferred): `extractDocumentTextAsync` routes PDF/Office/HTML
6
+ * through the docling Python sidecar and audio/video through whisper.cpp.
7
+ * Returns { text, markdown, metadata, droppedInfo, ... }.
8
+ * - Sync (legacy): `extractDocumentText` keeps regex/CLI extractors.
9
+ * PDF/Office output is lower fidelity (tables flattened, formulas
10
+ * dropped, scanned pages return empty). Audio/video throws
11
+ * { code: 'ASR_REQUIRED' } on this path.
8
12
  *
9
- * All paths now return { text, structured, droppedInfo, method, ... } where
10
- * droppedInfo is an array of { kind, count, reason, recoverable } objects
11
- * describing information that was silently dropped during extraction.
12
- * Audio/video files throw a typed { code: 'ASR_REQUIRED' } error.
13
+ * `extractDocumentMetadata` returns frontmatter + title/authors/dates.
14
+ *
15
+ * All paths return droppedInfo as { kind, count, reason, recoverable }[] so
16
+ * info loss is observable rather than silent.
13
17
  */
14
18
  import { existsSync, readFileSync, statSync } from 'node:fs';
15
19
  import { basename, extname, resolve } from 'node:path';
@@ -689,3 +693,64 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
689
693
  if (extracted.skipped) result.skipped = extracted.skipped;
690
694
  return result;
691
695
  }
696
+
697
+ // Async high-fidelity extraction path. Routes PDF/Office/HTML through the
698
+ // docling Python sidecar (layout-aware markdown) and audio/video through
699
+ // whisper.cpp (Metal-accelerated on macOS). Text/transcript/calendar/email
700
+ // paths reuse the sync extraction since regex-stripping is the right tool
701
+ // for those formats. Returns the same { text, droppedInfo, ... } shape as
702
+ // the sync extractor plus a `markdown` field for paths that produce it.
703
+ //
704
+ // 2026-06 best practice: this is the path new callers should use. The sync
705
+ // extractDocumentText is preserved for backwards compatibility with
706
+ // pre-async callers; PDF/Office output via that path is lower fidelity
707
+ // (tables flattened, formulas dropped) and emits a droppedInfo notice.
708
+
709
+ export async function extractDocumentTextAsync(filePath, { maxChars = null } = {}) {
710
+ const resolvedPath = resolve(filePath);
711
+ if (!existsSync(resolvedPath)) throw new Error(`File not found: ${resolvedPath}`);
712
+ const extension = extname(resolvedPath).toLowerCase();
713
+ if (!EXTRACTABLE_DOCUMENT_EXTS.has(extension)) {
714
+ throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
715
+ }
716
+
717
+ if (AUDIO_VIDEO_EXTS.has(extension)) {
718
+ const { extractViaWhisper } = await import('./document-extract/whisper-client.mjs');
719
+ const out = await extractViaWhisper(resolvedPath);
720
+ return finalizeAsyncResult({ resolvedPath, extension, extractionMethod: 'whisper', extracted: out, maxChars });
721
+ }
722
+
723
+ const doclingFormats = new Set([
724
+ '.pdf',
725
+ ...ZIP_DOCUMENT_EXTS,
726
+ ...RICH_TEXT_EXTS,
727
+ ...MDLS_DOCUMENT_EXTS,
728
+ ]);
729
+ if (doclingFormats.has(extension)) {
730
+ const { extractViaDocling } = await import('./document-extract/docling-client.mjs');
731
+ const out = await extractViaDocling(resolvedPath);
732
+ return finalizeAsyncResult({ resolvedPath, extension, extractionMethod: 'docling', extracted: out, maxChars });
733
+ }
734
+
735
+ return extractDocumentText(resolvedPath, { maxChars });
736
+ }
737
+
738
+ function finalizeAsyncResult({ resolvedPath, extension, extractionMethod, extracted, maxChars }) {
739
+ const text = extracted.markdown ?? extracted.text ?? '';
740
+ const limit = Number.isFinite(Number(maxChars)) && Number(maxChars) > 0
741
+ ? Math.min(Number(maxChars), 200_000)
742
+ : null;
743
+ const truncated = limit !== null && text.length > limit;
744
+ return {
745
+ filePath: resolvedPath,
746
+ extension,
747
+ extractionMethod,
748
+ text: truncated ? `${text.slice(0, limit)}\n` : text,
749
+ markdown: extracted.markdown ?? null,
750
+ metadata: extracted.metadata ?? null,
751
+ truncated,
752
+ characters: text.length,
753
+ droppedInfo: extracted.droppedInfo ?? [],
754
+ structured: extracted.structured ?? null,
755
+ };
756
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
9
9
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from 'node:path';
10
- import { extractDocumentText, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
10
+ import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
11
11
  import { syncFileStateToSql } from './storage/sync.mjs';
12
12
  import { stampFrontmatter } from './doc-stamp.mjs';
13
13
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
@@ -127,6 +127,8 @@ export async function ingestDocuments(inputPaths, {
127
127
  outputDir = null,
128
128
  target = 'knowledge/internal',
129
129
  sync = false,
130
+ strict = false,
131
+ highFidelity = true,
130
132
  env = process.env,
131
133
  } = {}) {
132
134
  if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
@@ -142,8 +144,12 @@ export async function ingestDocuments(inputPaths, {
142
144
  }
143
145
 
144
146
  const results = [];
147
+ let totalDrops = 0;
145
148
  for (const sourcePath of files) {
146
- const extracted = extractDocumentText(sourcePath, { maxChars: 200_000 });
149
+ const extracted = highFidelity
150
+ ? await extractDocumentTextAsync(sourcePath, { maxChars: 200_000 })
151
+ : extractDocumentText(sourcePath, { maxChars: 200_000 });
152
+ totalDrops += (extracted.droppedInfo ?? []).reduce((a, d) => a + (Number(d.count) || 1), 0);
147
153
  const metadata = extractDocumentMetadata(sourcePath);
148
154
  const targetPath = resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target });
149
155
  ensureDir(dirname(targetPath));
@@ -184,6 +190,13 @@ export async function ingestDocuments(inputPaths, {
184
190
  });
185
191
  }
186
192
 
193
+ if (strict && totalDrops > 0) {
194
+ const err = new Error(`--strict: ${totalDrops} dropped items across ${results.filter(r => r.droppedInfo.length).length} files`);
195
+ err.code = 'INGEST_DROPS';
196
+ err.droppedInfo = results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath })));
197
+ throw err;
198
+ }
199
+
187
200
  return {
188
201
  status: 'ok',
189
202
  target: outputPath ? 'custom' : target,
@@ -197,6 +210,9 @@ export async function ingestDocuments(inputPaths, {
197
210
  indexedLocally: true,
198
211
  storageSync: syncResult,
199
212
  files: results,
213
+ droppedInfo: results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath }))),
214
+ droppedCount: totalDrops,
215
+ highFidelity,
200
216
  };
201
217
  }
202
218
 
@@ -206,19 +222,25 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
206
222
  let outputDir = null;
207
223
  let target = 'knowledge/internal';
208
224
  let sync = false;
225
+ let strict = false;
226
+ let highFidelity = true;
209
227
 
210
228
  for (const arg of argv) {
211
229
  if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
212
230
  else if (arg.startsWith('--out-dir=')) outputDir = arg.split('=').slice(1).join('=');
213
231
  else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
214
232
  else if (arg === '--sync') sync = true;
233
+ else if (arg === '--strict') strict = true;
234
+ else if (arg === '--legacy-extractor') highFidelity = false;
215
235
  else inputs.push(arg);
216
236
  }
217
237
 
218
238
  if (inputs.length === 0) {
219
239
  throw new Error(
220
240
  `Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
221
- `[--target=sibling|knowledge/<subdir>] [--sync]\n` +
241
+ `[--target=sibling|knowledge/<subdir>] [--sync] [--strict] [--legacy-extractor]\n` +
242
+ ` --strict: exit non-zero if any extraction drops occur\n` +
243
+ ` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
222
244
  ` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
223
245
  );
224
246
  }
@@ -229,5 +251,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
229
251
  );
230
252
  }
231
253
 
232
- return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, env });
254
+ return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, env });
233
255
  }
@@ -30,6 +30,7 @@ import { dirname, join } from 'node:path';
30
30
 
31
31
  import { loadEmbedConfig } from './config.mjs';
32
32
  import { Scheduler } from './scheduler.mjs';
33
+ import { rotateIfOversized } from '../logging/rotate.mjs';
33
34
  import { SnapshotEngine, renderMarkdown } from './snapshot.mjs';
34
35
  import { ApprovalQueue } from './approval-queue.mjs';
35
36
  import { dispatchOutputs } from './output.mjs';
@@ -594,6 +595,25 @@ export class EmbedDaemon {
594
595
  },
595
596
  );
596
597
 
598
+ // ── Job 6: daemon-log-rotation ────────────────────────────────────────────
599
+ // OS supervisors (launchd/systemd/schtasks) redirect daemon stdout to
600
+ // ~/.cx/runtime/embed-daemon.log with no rotation. A stuck telemetry
601
+ // log line filled it to 34 GB on one local install; rotate proactively.
602
+ // CONSTRUCT_EMBED_LOG_MAX_MB overrides the default 50 MB cap.
603
+
604
+ this.#scheduler.register(
605
+ 'daemon-log-rotation',
606
+ 60_000,
607
+ async () => {
608
+ const logPath = join(homedir(), '.cx', 'runtime', 'embed-daemon.log');
609
+ const capMb = Number(process.env.CONSTRUCT_EMBED_LOG_MAX_MB) || 50;
610
+ const maxBytes = Math.floor(capMb * 1024 * 1024);
611
+ const maxSegments = Number(process.env.CONSTRUCT_EMBED_LOG_MAX_SEGMENTS) || 5;
612
+ await rotateIfOversized(logPath, { maxBytes, maxSegments, gzip: true });
613
+ },
614
+ { runImmediately: true },
615
+ );
616
+
597
617
  // ── Job 0: telemetry-setup ───────────────────────────────────────────────────
598
618
  // On startup — ensure annotation queues and eval configs exist (idempotent)
599
619
  this.#scheduler.register(
@@ -1075,7 +1095,12 @@ this.#scheduler.register(
1075
1095
  outputTokens = data.usage?.completion_tokens || 0;
1076
1096
  }
1077
1097
  }
1078
- } catch { /* probe call failed — still record timing */ }
1098
+ } catch (probeErr) {
1099
+ // Probe call failed; record the timing entry but surface the
1100
+ // failure to the daemon log so operators can distinguish a 0ms
1101
+ // success from a hard error. Don't crash the daemon.
1102
+ process.stderr.write(`[embed] Probe call failed: ${probeErr.message}\n`);
1103
+ }
1079
1104
 
1080
1105
  const endTime = Date.now();
1081
1106