@geraldmaron/construct 1.0.14 → 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 (259) hide show
  1. package/README.md +6 -0
  2. package/bin/construct +3 -3
  3. package/commands/build/feature.md +0 -6
  4. package/commands/build/fix.md +0 -6
  5. package/commands/design/access.md +0 -6
  6. package/commands/design/flow.md +0 -6
  7. package/commands/design/ui.md +0 -6
  8. package/commands/measure/experiment.md +0 -6
  9. package/commands/measure/metrics.md +0 -6
  10. package/commands/measure/results.md +0 -6
  11. package/commands/plan/api.md +0 -6
  12. package/commands/plan/challenge.md +0 -6
  13. package/commands/plan/decide.md +0 -6
  14. package/commands/plan/feature.md +0 -7
  15. package/commands/plan/requirements.md +0 -6
  16. package/commands/remember/context.md +0 -6
  17. package/commands/remember/handoff.md +0 -6
  18. package/commands/remember/runbook.md +0 -6
  19. package/commands/review/code.md +0 -6
  20. package/commands/review/quality.md +0 -6
  21. package/commands/review/security.md +0 -6
  22. package/commands/ship/ready.md +0 -6
  23. package/commands/ship/release.md +0 -6
  24. package/commands/ship/status.md +0 -6
  25. package/commands/understand/docs.md +0 -6
  26. package/commands/understand/research.md +0 -6
  27. package/commands/understand/this.md +0 -6
  28. package/commands/understand/why.md +0 -6
  29. package/commands/work/clean.md +0 -6
  30. package/commands/work/drive.md +0 -6
  31. package/commands/work/optimize-prompts.md +0 -6
  32. package/commands/work/parallel-review.md +0 -6
  33. package/lib/beads-client.mjs +19 -1
  34. package/lib/comment-lint.mjs +5 -1
  35. package/lib/context-state.mjs +15 -3
  36. package/lib/contracts/validate.mjs +54 -10
  37. package/lib/contracts/violation-log.mjs +154 -0
  38. package/lib/document-extract/docling-client.mjs +114 -0
  39. package/lib/document-extract/docling-sidecar.py +122 -0
  40. package/lib/document-extract/whisper-client.mjs +79 -0
  41. package/lib/document-extract.mjs +73 -8
  42. package/lib/document-ingest.mjs +26 -4
  43. package/lib/embed/daemon.mjs +6 -1
  44. package/lib/flavors/loader.mjs +1 -1
  45. package/lib/handoffs/contract.mjs +2 -2
  46. package/lib/hooks/session-start.mjs +1 -1
  47. package/lib/ingest/chunker.mjs +94 -0
  48. package/lib/ingest/pipeline.mjs +53 -0
  49. package/lib/ingest/store.mjs +82 -0
  50. package/lib/knowledge/search.mjs +12 -0
  51. package/lib/mcp/server.mjs +142 -1
  52. package/lib/mcp/tools/skills.mjs +6 -3
  53. package/lib/mcp/tools/workflow.mjs +6 -2
  54. package/lib/observation-store.mjs +14 -5
  55. package/lib/ollama-manager.mjs +32 -28
  56. package/lib/prompt-composer.js +11 -1
  57. package/lib/runtime/uv-bootstrap.mjs +129 -0
  58. package/lib/runtime/whisper-bootstrap.mjs +102 -0
  59. package/lib/server/index.mjs +22 -1
  60. package/lib/server/static/index.html +1 -1
  61. package/lib/specialists/postconditions.mjs +1 -1
  62. package/lib/sync/skill-frontmatter.mjs +113 -21
  63. package/lib/tracking-surfaces.mjs +2 -0
  64. package/lib/validators/skills.mjs +86 -54
  65. package/package.json +5 -5
  66. package/personas/construct.md +4 -7
  67. package/platforms/claude/settings.template.json +1 -1
  68. package/rules/common/beads-hygiene.md +3 -9
  69. package/rules/common/code-review.md +3 -6
  70. package/rules/common/coding-style.md +3 -6
  71. package/rules/common/comments.md +3 -7
  72. package/rules/common/commit-approval.md +3 -6
  73. package/rules/common/cx-agent-routing.md +3 -7
  74. package/rules/common/cx-skill-routing.md +3 -3
  75. package/rules/common/doc-ownership.md +3 -8
  76. package/rules/common/efficiency.md +3 -8
  77. package/rules/common/framing.md +3 -8
  78. package/rules/common/git-workflow.md +3 -5
  79. package/rules/common/no-fabrication.md +4 -12
  80. package/rules/common/patterns.md +3 -5
  81. package/rules/common/release-gates.md +3 -8
  82. package/rules/common/research.md +3 -8
  83. package/rules/common/review-before-change.md +3 -9
  84. package/rules/common/security.md +3 -6
  85. package/rules/common/skill-composition.md +3 -7
  86. package/rules/common/testing.md +3 -6
  87. package/rules/golang/coding-style.md +1 -5
  88. package/rules/golang/hooks.md +1 -5
  89. package/rules/golang/patterns.md +1 -5
  90. package/rules/golang/security.md +1 -5
  91. package/rules/golang/testing.md +1 -5
  92. package/rules/python/coding-style.md +1 -5
  93. package/rules/python/hooks.md +1 -5
  94. package/rules/python/patterns.md +1 -5
  95. package/rules/python/security.md +1 -5
  96. package/rules/python/testing.md +1 -5
  97. package/rules/swift/coding-style.md +1 -5
  98. package/rules/swift/hooks.md +1 -5
  99. package/rules/swift/patterns.md +1 -5
  100. package/rules/swift/security.md +1 -5
  101. package/rules/swift/testing.md +1 -5
  102. package/rules/typescript/coding-style.md +1 -5
  103. package/rules/typescript/hooks.md +1 -5
  104. package/rules/typescript/patterns.md +1 -5
  105. package/rules/typescript/security.md +1 -5
  106. package/rules/typescript/testing.md +1 -5
  107. package/rules/web/coding-style.md +3 -5
  108. package/rules/web/design-quality.md +3 -5
  109. package/rules/web/hooks.md +3 -5
  110. package/rules/web/patterns.md +3 -5
  111. package/rules/web/performance.md +3 -5
  112. package/rules/web/security.md +3 -5
  113. package/rules/web/testing.md +3 -5
  114. package/skills/ai/agent-dev.md +4 -5
  115. package/skills/ai/llm-security.md +4 -5
  116. package/skills/ai/ml-ops.md +4 -5
  117. package/skills/ai/orchestration-workflow.md +4 -5
  118. package/skills/ai/prompt-and-eval.md +4 -5
  119. package/skills/ai/prompt-optimizer.md +4 -6
  120. package/skills/ai/rag-system.md +4 -5
  121. package/skills/architecture/api-design.md +4 -5
  122. package/skills/architecture/caching.md +4 -5
  123. package/skills/architecture/cloud-native.md +4 -5
  124. package/skills/architecture/message-queue.md +4 -5
  125. package/skills/architecture/security-arch.md +4 -5
  126. package/skills/compliance/ai-disclosure.md +4 -5
  127. package/skills/compliance/data-privacy.md +4 -5
  128. package/skills/compliance/license-audit.md +4 -5
  129. package/skills/compliance/regulatory-review.md +4 -5
  130. package/skills/development/cpp.md +4 -5
  131. package/skills/development/go.md +4 -5
  132. package/skills/development/java.md +4 -5
  133. package/skills/development/kotlin.md +4 -5
  134. package/skills/development/mobile-crossplatform.md +4 -5
  135. package/skills/development/python.md +4 -5
  136. package/skills/development/rust.md +4 -5
  137. package/skills/development/shell.md +4 -5
  138. package/skills/development/swift.md +4 -5
  139. package/skills/development/typescript.md +4 -5
  140. package/skills/devops/ci-cd.md +4 -5
  141. package/skills/devops/containerization.md +4 -5
  142. package/skills/devops/cost-optimization.md +4 -5
  143. package/skills/devops/data-engineering.md +4 -5
  144. package/skills/devops/database.md +4 -5
  145. package/skills/devops/dependency-management.md +4 -5
  146. package/skills/devops/devsecops.md +4 -5
  147. package/skills/devops/git-workflow.md +4 -5
  148. package/skills/devops/incident-response.md +4 -5
  149. package/skills/devops/monorepo.md +4 -5
  150. package/skills/devops/observability.md +4 -5
  151. package/skills/devops/performance.md +4 -5
  152. package/skills/devops/testing.md +4 -5
  153. package/skills/docs/adr-workflow.md +4 -7
  154. package/skills/docs/backlog-proposal-workflow.md +4 -3
  155. package/skills/docs/customer-profile-workflow.md +4 -3
  156. package/skills/docs/document-ingest-workflow.md +4 -3
  157. package/skills/docs/evidence-ingest-workflow.md +4 -3
  158. package/skills/docs/init-docs.md +4 -5
  159. package/skills/docs/init-project.md +4 -5
  160. package/skills/docs/prd-workflow.md +4 -7
  161. package/skills/docs/prfaq-workflow.md +4 -3
  162. package/skills/docs/product-intelligence-review.md +4 -3
  163. package/skills/docs/product-intelligence-workflow.md +4 -6
  164. package/skills/docs/product-signal-workflow.md +4 -5
  165. package/skills/docs/research-workflow.md +4 -5
  166. package/skills/docs/runbook-workflow.md +4 -5
  167. package/skills/docs/strategy-workflow.md +4 -5
  168. package/skills/exploration/dependency-graph-reading.md +4 -7
  169. package/skills/exploration/repo-map.md +4 -5
  170. package/skills/exploration/tracer-bullet-method.md +4 -7
  171. package/skills/exploration/unknown-codebase-onboarding.md +4 -7
  172. package/skills/frameworks/django.md +4 -5
  173. package/skills/frameworks/nextjs.md +4 -5
  174. package/skills/frameworks/react.md +4 -5
  175. package/skills/frameworks/spring-boot.md +4 -5
  176. package/skills/frontend-design/accessibility.md +4 -5
  177. package/skills/frontend-design/component-patterns.md +4 -5
  178. package/skills/frontend-design/engineering.md +4 -5
  179. package/skills/frontend-design/state-management.md +4 -5
  180. package/skills/frontend-design/ui-aesthetics.md +4 -5
  181. package/skills/frontend-design/ux-principles.md +4 -5
  182. package/skills/operating/change-management.md +4 -8
  183. package/skills/operating/incident-response.md +4 -8
  184. package/skills/operating/oncall-rotation.md +4 -7
  185. package/skills/operating/orchestration-reference.md +4 -10
  186. package/skills/quality-gates/review-work.md +4 -5
  187. package/skills/quality-gates/verify-change.md +4 -5
  188. package/skills/quality-gates/verify-module.md +4 -5
  189. package/skills/quality-gates/verify-quality.md +4 -5
  190. package/skills/quality-gates/verify-security.md +4 -5
  191. package/skills/roles/architect.ai-systems.md +6 -9
  192. package/skills/roles/architect.data.md +6 -9
  193. package/skills/roles/architect.enterprise.md +6 -9
  194. package/skills/roles/architect.integration.md +6 -9
  195. package/skills/roles/architect.md +7 -14
  196. package/skills/roles/architect.platform.md +6 -9
  197. package/skills/roles/data-analyst.experiment.md +6 -9
  198. package/skills/roles/data-analyst.md +6 -9
  199. package/skills/roles/data-analyst.product-intelligence.md +7 -9
  200. package/skills/roles/data-analyst.product.md +6 -9
  201. package/skills/roles/data-analyst.telemetry.md +7 -9
  202. package/skills/roles/data-engineer.pipeline.md +6 -9
  203. package/skills/roles/data-engineer.vector-retrieval.md +7 -9
  204. package/skills/roles/data-engineer.warehouse.md +6 -9
  205. package/skills/roles/debugger.md +6 -9
  206. package/skills/roles/designer.accessibility.md +6 -9
  207. package/skills/roles/designer.md +7 -9
  208. package/skills/roles/engineer.ai.md +6 -9
  209. package/skills/roles/engineer.data.md +6 -9
  210. package/skills/roles/engineer.md +9 -9
  211. package/skills/roles/engineer.platform.md +6 -9
  212. package/skills/roles/operator.docs.md +6 -9
  213. package/skills/roles/operator.md +9 -9
  214. package/skills/roles/operator.release.md +6 -9
  215. package/skills/roles/operator.sre.md +6 -9
  216. package/skills/roles/orchestrator.md +6 -9
  217. package/skills/roles/product-manager.ai-product.md +6 -9
  218. package/skills/roles/product-manager.business-strategy.md +6 -9
  219. package/skills/roles/product-manager.enterprise.md +6 -9
  220. package/skills/roles/product-manager.growth.md +7 -9
  221. package/skills/roles/product-manager.md +7 -9
  222. package/skills/roles/product-manager.platform.md +6 -9
  223. package/skills/roles/product-manager.product.md +6 -9
  224. package/skills/roles/qa.ai-eval.md +8 -9
  225. package/skills/roles/qa.api-contract.md +7 -9
  226. package/skills/roles/qa.data-pipeline.md +7 -9
  227. package/skills/roles/qa.md +7 -9
  228. package/skills/roles/qa.test-automation.md +6 -9
  229. package/skills/roles/qa.web-ui.md +7 -9
  230. package/skills/roles/researcher.explorer.md +6 -9
  231. package/skills/roles/researcher.md +8 -9
  232. package/skills/roles/researcher.ux.md +6 -9
  233. package/skills/roles/reviewer.devil-advocate.md +6 -9
  234. package/skills/roles/reviewer.evaluator.md +6 -9
  235. package/skills/roles/reviewer.md +9 -9
  236. package/skills/roles/reviewer.trace.md +6 -9
  237. package/skills/roles/security.ai.md +7 -9
  238. package/skills/roles/security.appsec.md +6 -9
  239. package/skills/roles/security.cloud.md +6 -9
  240. package/skills/roles/security.legal-compliance.md +6 -9
  241. package/skills/roles/security.md +7 -9
  242. package/skills/roles/security.privacy.md +7 -9
  243. package/skills/roles/security.supply-chain.md +7 -9
  244. package/skills/security/blue-team.md +4 -5
  245. package/skills/security/code-audit.md +4 -5
  246. package/skills/security/pentest.md +4 -5
  247. package/skills/security/red-team.md +4 -5
  248. package/skills/security/threat-intel.md +4 -5
  249. package/skills/security/vuln-research.md +4 -5
  250. package/skills/strategy/competitive-landscape.md +4 -8
  251. package/skills/strategy/market-research-methods.md +4 -8
  252. package/skills/strategy/narrative-arc.md +4 -8
  253. package/skills/strategy/pricing-positioning.md +4 -8
  254. package/skills/utility/clean-code.md +4 -5
  255. package/specialists/policy-inventory.json +14 -0
  256. package/lib/specialist-contracts-enforce.mjs +0 -172
  257. package/rules/common/agents.md +0 -28
  258. package/rules/common/development-workflow.md +0 -32
  259. package/rules/common/performance.md +0 -55
@@ -19,6 +19,8 @@
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import { join, dirname, resolve } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import { POSTCONDITIONS, validateBinaryPostconditions } from '../specialists/postconditions.mjs';
23
+ import { logViolation } from './violation-log.mjs';
22
24
 
23
25
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
24
26
  const CONTRACTS_PATH = join(REPO_ROOT, 'specialists', 'contracts.json');
@@ -145,28 +147,62 @@ export function findContract({ producer, consumer, id, contractsPath = CONTRACTS
145
147
  * { ok: true, contract }
146
148
  * { ok: false, status: 'BLOCKED_CONTRACT', errors[], contract }
147
149
  *
148
- * Enforcement defaults to the value of process.env.CONSTRUCT_CONTRACT_ENFORCEMENT
149
- * ('warn' or 'block'), and may be overridden by the caller. In warn mode the
150
- * result includes errors but ok stays true so a session is not blocked.
150
+ * Enforcement defaults to `block` (CISA Secure-by-Design pledge —
151
+ * strong-default configurations). Callers can pass `enforcement: 'warn'`
152
+ * explicitly per-call when they genuinely need advisory mode.
153
+ *
154
+ * Binary postconditions are enforced when the producer has a rule table in
155
+ * `lib/specialists/postconditions.mjs`. The check is self-enforcing: if a
156
+ * producer has rules and the caller does not pass a `packet`, the call is
157
+ * itself a contract violation (MCP best-practice 2025 — no silent skip on
158
+ * tool-argument omission).
151
159
  */
152
160
  export function validateHandoff({
153
161
  producer,
154
162
  consumer,
155
163
  id,
156
164
  artifact,
165
+ packet,
157
166
  contractsPath = CONTRACTS_PATH,
158
167
  repoRoot = REPO_ROOT,
159
- enforcement = process.env.CONSTRUCT_CONTRACT_ENFORCEMENT || 'warn',
168
+ enforcement = 'block',
160
169
  }) {
170
+ const errors = [];
171
+ const postconditionFailures = [];
172
+
173
+ // Binary postconditions are producer-bound invariants that fire regardless
174
+ // of whether the producer→consumer contract is registered in contracts.json.
175
+ // A producer with rules cannot have its packet validation skipped by
176
+ // claiming an unregistered consumer.
177
+ const hasBinaryRules = Array.isArray(POSTCONDITIONS[producer]) && POSTCONDITIONS[producer].length > 0;
178
+ if (hasBinaryRules) {
179
+ if (packet === undefined || packet === null) {
180
+ errors.push(`producer '${producer}' has binary postconditions; validateHandoff must be called with a packet argument`);
181
+ } else {
182
+ const binary = validateBinaryPostconditions(producer, packet);
183
+ for (const failure of binary.failures) {
184
+ errors.push(`[${failure.id}] ${failure.reason}`);
185
+ postconditionFailures.push(failure);
186
+ }
187
+ }
188
+ }
189
+
161
190
  const contract = findContract({ producer, consumer, id, contractsPath });
162
191
  if (!contract) {
192
+ if (errors.length > 0) {
193
+ logViolation(`${producer}->${consumer}`, 'output', errors, packet ?? artifact, {
194
+ verdict: postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION',
195
+ postconditionFailures,
196
+ });
197
+ return enforcement === 'block'
198
+ ? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`, ...errors], contract: null }
199
+ : { ok: true, warnings: [`no contract found for ${producer}→${consumer}`, ...errors], contract: null };
200
+ }
163
201
  return enforcement === 'block'
164
202
  ? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`], contract: null }
165
203
  : { ok: true, warnings: [`no contract found for ${producer}→${consumer}`], contract: null };
166
204
  }
167
205
 
168
- const errors = [];
169
-
170
206
  // A handoff carries a producer's output, which is the consumer's input. The
171
207
  // input contract is what the consumer expects to receive; check mustContain
172
208
  // against that first. Output.schema validation only applies when the artifact
@@ -193,18 +229,26 @@ export function validateHandoff({
193
229
  }
194
230
  }
195
231
 
196
- // Postcondition checks run against the artifact file on disk when the
197
- // handoff envelope declares `artifactPath`. Resolved relative to repoRoot.
232
+ // Disk-artifact postconditions: frontmatter, sections, citations.
198
233
  const declaredArtifactPath = artifact?.artifactPath || artifact?.output?.artifactPath;
199
234
  if (declaredArtifactPath) {
200
235
  const absPath = isAbsolutePath(declaredArtifactPath)
201
236
  ? declaredArtifactPath
202
237
  : join(repoRoot, declaredArtifactPath);
203
- const pcErrors = validatePostconditions({ contract, artifactPath: absPath });
238
+ const pcErrors = validateArtifactPostconditions({ contract, artifactPath: absPath });
204
239
  for (const e of pcErrors) errors.push(e);
205
240
  }
206
241
 
207
242
  if (errors.length === 0) return { ok: true, contract };
243
+
244
+ // Persist to the chain-hashed violation log so `construct doctor` and
245
+ // forensic replay see the failure. Best-effort; logging never throws.
246
+ const verdict = postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION';
247
+ logViolation(contract.id, 'output', errors, packet ?? artifact, {
248
+ verdict,
249
+ postconditionFailures,
250
+ });
251
+
208
252
  if (enforcement === 'block') return { ok: false, status: 'BLOCKED_CONTRACT', errors, contract };
209
253
  return { ok: true, warnings: errors, contract };
210
254
  }
@@ -272,7 +316,7 @@ function claimsHaveCitations(body) {
272
316
  * disk. Postconditions can be strings (descriptive, ignored here) or objects
273
317
  * with { id, check, field?, section? }. Returns an array of error strings.
274
318
  */
275
- export function validatePostconditions({ contract, artifactPath }) {
319
+ export function validateArtifactPostconditions({ contract, artifactPath }) {
276
320
  if (!contract?.postconditions?.length) return [];
277
321
  if (!artifactPath || !existsSync(artifactPath)) {
278
322
  const structured = contract.postconditions.filter((p) => typeof p === 'object');
@@ -0,0 +1,154 @@
1
+ /**
2
+ * lib/contracts/violation-log.mjs — tamper-evident append-only log for
3
+ * specialist-contract violations.
4
+ *
5
+ * Each record carries:
6
+ * ts — ISO timestamp
7
+ * sequence — monotonic per-log counter, gap-detects truncation
8
+ * prev_line_hash — sha256 of the prior line, gap-detects modification
9
+ * agent — last-known active agent (from ~/.cx/last-agent.json)
10
+ * contractId — the contract that fired the violation
11
+ * direction — 'input' | 'output'
12
+ * missing — field names that failed shape validation
13
+ * packet_keys — Object.keys(packet) when packet is an object
14
+ * verdict — 'CONTRACT_VIOLATION' (default) or 'BLOCKED_CONTRACT'
15
+ * (binary postcondition failures)
16
+ * postconditionFailures — [{id, reason}] when verdict is BLOCKED_CONTRACT
17
+ *
18
+ * The hash chain spans rotation: when the active file is empty (post-rotate),
19
+ * the tail is read from the most recent segment (gzipped or plain) via
20
+ * readLastLineAcrossSegments. The sequence counter follows the same path.
21
+ *
22
+ * Single owner of the file. All readers go through recentViolations or
23
+ * verifyChain; all writers go through logViolation.
24
+ */
25
+
26
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
27
+ import { join, dirname } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+ import { createHash } from 'node:crypto';
30
+ import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
31
+ import { resolveProjectScopedPath } from '../project-root.mjs';
32
+
33
+ const CX_DIR = join(homedir(), '.cx');
34
+ const LAST_AGENT = join(CX_DIR, 'last-agent.json');
35
+
36
+ // contract-violations are PROJECT-SCOPED: resolves to
37
+ // <project>/.cx/contract-violations.jsonl when inside a project, falling
38
+ // back to ~/.cx for standalone invocations. Resolved on every call so
39
+ // cwd/HOME changes inside the same process (tests, harness reuse) route
40
+ // correctly.
41
+
42
+ function logFile() {
43
+ return resolveProjectScopedPath('contract-violations.jsonl', { ensureDir: false });
44
+ }
45
+
46
+ function sha256(input) { return createHash('sha256').update(input).digest('hex'); }
47
+
48
+ function readLastAgent() {
49
+ try { return JSON.parse(readFileSync(LAST_AGENT, 'utf8'))?.agent || 'construct'; }
50
+ catch { return 'construct'; }
51
+ }
52
+
53
+ function readTailRecord() {
54
+ const file = logFile();
55
+ const lastLine = readLastLineAcrossSegments(file);
56
+ if (!lastLine) return null;
57
+ try { return JSON.parse(lastLine); }
58
+ catch { return null; }
59
+ }
60
+
61
+ function readPrevLineHash() {
62
+ const file = logFile();
63
+ const lastLine = readLastLineAcrossSegments(file);
64
+ return lastLine ? sha256(lastLine) : null;
65
+ }
66
+
67
+ function nextSequence() {
68
+ const tail = readTailRecord();
69
+ const prior = Number.isInteger(tail?.sequence) ? tail.sequence : 0;
70
+ return prior + 1;
71
+ }
72
+
73
+ /**
74
+ * Append a violation record. Best-effort: file I/O failures are swallowed
75
+ * so logging never crashes the caller.
76
+ */
77
+ export function logViolation(contractId, direction, missing, packet, extra = {}) {
78
+ try {
79
+ const file = logFile();
80
+ mkdirSync(dirname(file), { recursive: true });
81
+ const record = {
82
+ ts: new Date().toISOString(),
83
+ sequence: nextSequence(),
84
+ agent: readLastAgent(),
85
+ contractId,
86
+ direction,
87
+ missing,
88
+ packet_keys: packet && typeof packet === 'object' ? Object.keys(packet) : null,
89
+ prev_line_hash: readPrevLineHash(),
90
+ ...extra,
91
+ };
92
+ appendBounded('contract-violations', file, JSON.stringify(record) + '\n');
93
+ } catch { /* logging is best-effort */ }
94
+ }
95
+
96
+ /**
97
+ * Read recent violations from the active log segment. Returns oldest-first.
98
+ * Rotated segments are intentionally not included — this is the doctor
99
+ * surface, scoped to the active window.
100
+ */
101
+ export function recentViolations({ windowMs = 24 * 60 * 60 * 1000 } = {}) {
102
+ const file = logFile();
103
+ if (!existsSync(file)) return [];
104
+ try {
105
+ const cutoff = Date.now() - windowMs;
106
+ return readFileSync(file, 'utf8')
107
+ .trim()
108
+ .split('\n')
109
+ .filter(Boolean)
110
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } })
111
+ .filter((r) => r && new Date(r.ts).getTime() >= cutoff);
112
+ } catch { return []; }
113
+ }
114
+
115
+ /**
116
+ * Walk the active log segment, asserting:
117
+ * - each record's prev_line_hash matches sha256 of the prior line
118
+ * - sequence numbers are monotonic with no gaps
119
+ *
120
+ * Returns { ok, brokenAt? } where brokenAt is { index, reason } on first
121
+ * failure. An empty / missing log is `{ ok: true }` (nothing to break).
122
+ */
123
+ export function verifyChain() {
124
+ const file = logFile();
125
+ if (!existsSync(file)) return { ok: true };
126
+ try {
127
+ const lines = readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
128
+ let priorLine = null;
129
+ let priorSeq = null;
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const line = lines[i];
132
+ let record;
133
+ try { record = JSON.parse(line); }
134
+ catch { return { ok: false, brokenAt: { index: i, reason: 'unparseable JSON' } }; }
135
+
136
+ if (priorLine !== null) {
137
+ const expectedHash = sha256(priorLine);
138
+ if (record.prev_line_hash !== expectedHash) {
139
+ return { ok: false, brokenAt: { index: i, reason: 'prev_line_hash mismatch' } };
140
+ }
141
+ }
142
+ if (Number.isInteger(record.sequence)) {
143
+ if (priorSeq !== null && record.sequence !== priorSeq + 1) {
144
+ return { ok: false, brokenAt: { index: i, reason: `sequence gap (expected ${priorSeq + 1}, got ${record.sequence})` } };
145
+ }
146
+ priorSeq = record.sequence;
147
+ }
148
+ priorLine = line;
149
+ }
150
+ return { ok: true };
151
+ } catch (err) {
152
+ return { ok: false, brokenAt: { index: -1, reason: err.message } };
153
+ }
154
+ }
@@ -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
+ }