@geraldmaron/construct 1.0.14 → 1.0.16

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 (275) hide show
  1. package/README.md +21 -2
  2. package/bin/construct +146 -83
  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/db/schema/010_cx_scores.sql +51 -0
  34. package/lib/beads-client.mjs +19 -1
  35. package/lib/cli-commands.mjs +280 -146
  36. package/lib/comment-lint.mjs +5 -1
  37. package/lib/context-state.mjs +15 -3
  38. package/lib/contracts/validate.mjs +72 -18
  39. package/lib/contracts/violation-log.mjs +169 -0
  40. package/lib/document-extract/docling-client.mjs +114 -0
  41. package/lib/document-extract/docling-sidecar.py +122 -0
  42. package/lib/document-extract/whisper-client.mjs +79 -0
  43. package/lib/document-extract.mjs +73 -8
  44. package/lib/document-ingest.mjs +26 -4
  45. package/lib/embed/daemon.mjs +6 -1
  46. package/lib/flavors/loader.mjs +1 -1
  47. package/lib/handoffs/contract.mjs +2 -2
  48. package/lib/hooks/session-start.mjs +1 -1
  49. package/lib/hooks/stop-notify.mjs +7 -0
  50. package/lib/ingest/chunker.mjs +94 -0
  51. package/lib/ingest/pipeline.mjs +53 -0
  52. package/lib/ingest/store.mjs +82 -0
  53. package/lib/install/first-invocation.mjs +5 -1
  54. package/lib/intake/prepare.mjs +22 -4
  55. package/lib/integrations/intake-integrations.mjs +28 -2
  56. package/lib/knowledge/search.mjs +12 -0
  57. package/lib/mcp/server.mjs +142 -1
  58. package/lib/mcp/tools/skills.mjs +6 -3
  59. package/lib/mcp/tools/telemetry.mjs +30 -0
  60. package/lib/mcp/tools/workflow.mjs +6 -2
  61. package/lib/observation-store.mjs +14 -5
  62. package/lib/ollama-manager.mjs +32 -28
  63. package/lib/orchestration-policy.mjs +15 -0
  64. package/lib/prompt-composer.js +11 -1
  65. package/lib/roles/gateway.mjs +30 -1
  66. package/lib/runtime/uv-bootstrap.mjs +129 -0
  67. package/lib/runtime/whisper-bootstrap.mjs +102 -0
  68. package/lib/scheduler/index.mjs +24 -4
  69. package/lib/server/index.mjs +22 -1
  70. package/lib/server/insights.mjs +12 -0
  71. package/lib/server/static/index.html +1 -1
  72. package/lib/setup.mjs +54 -9
  73. package/lib/specialists/postconditions.mjs +1 -1
  74. package/lib/sync/skill-frontmatter.mjs +113 -21
  75. package/lib/tracking-surfaces.mjs +2 -0
  76. package/lib/update.mjs +31 -3
  77. package/lib/upgrade.mjs +31 -4
  78. package/lib/validators/skills.mjs +86 -54
  79. package/package.json +5 -5
  80. package/personas/construct.md +6 -7
  81. package/platforms/claude/CLAUDE.md +43 -15
  82. package/platforms/claude/settings.template.json +1 -1
  83. package/rules/common/beads-hygiene.md +3 -9
  84. package/rules/common/code-review.md +3 -6
  85. package/rules/common/coding-style.md +3 -6
  86. package/rules/common/comments.md +3 -7
  87. package/rules/common/commit-approval.md +3 -6
  88. package/rules/common/cx-agent-routing.md +3 -7
  89. package/rules/common/cx-skill-routing.md +3 -3
  90. package/rules/common/doc-ownership.md +3 -8
  91. package/rules/common/efficiency.md +3 -8
  92. package/rules/common/framing.md +3 -8
  93. package/rules/common/git-workflow.md +3 -5
  94. package/rules/common/no-fabrication.md +4 -12
  95. package/rules/common/patterns.md +3 -5
  96. package/rules/common/release-gates.md +3 -8
  97. package/rules/common/research.md +3 -8
  98. package/rules/common/review-before-change.md +3 -9
  99. package/rules/common/security.md +3 -6
  100. package/rules/common/skill-composition.md +3 -7
  101. package/rules/common/testing.md +3 -6
  102. package/rules/golang/coding-style.md +1 -5
  103. package/rules/golang/hooks.md +1 -5
  104. package/rules/golang/patterns.md +1 -5
  105. package/rules/golang/security.md +1 -5
  106. package/rules/golang/testing.md +1 -5
  107. package/rules/python/coding-style.md +1 -5
  108. package/rules/python/hooks.md +1 -5
  109. package/rules/python/patterns.md +1 -5
  110. package/rules/python/security.md +1 -5
  111. package/rules/python/testing.md +1 -5
  112. package/rules/swift/coding-style.md +1 -5
  113. package/rules/swift/hooks.md +1 -5
  114. package/rules/swift/patterns.md +1 -5
  115. package/rules/swift/security.md +1 -5
  116. package/rules/swift/testing.md +1 -5
  117. package/rules/typescript/coding-style.md +1 -5
  118. package/rules/typescript/hooks.md +1 -5
  119. package/rules/typescript/patterns.md +1 -5
  120. package/rules/typescript/security.md +1 -5
  121. package/rules/typescript/testing.md +1 -5
  122. package/rules/web/coding-style.md +3 -5
  123. package/rules/web/design-quality.md +3 -5
  124. package/rules/web/hooks.md +3 -5
  125. package/rules/web/patterns.md +3 -5
  126. package/rules/web/performance.md +3 -5
  127. package/rules/web/security.md +3 -5
  128. package/rules/web/testing.md +3 -5
  129. package/scripts/sync-specialists.mjs +20 -2
  130. package/skills/ai/agent-dev.md +4 -5
  131. package/skills/ai/llm-security.md +4 -5
  132. package/skills/ai/ml-ops.md +4 -5
  133. package/skills/ai/orchestration-workflow.md +4 -5
  134. package/skills/ai/prompt-and-eval.md +4 -5
  135. package/skills/ai/prompt-optimizer.md +4 -6
  136. package/skills/ai/rag-system.md +4 -5
  137. package/skills/architecture/api-design.md +4 -5
  138. package/skills/architecture/caching.md +4 -5
  139. package/skills/architecture/cloud-native.md +4 -5
  140. package/skills/architecture/message-queue.md +4 -5
  141. package/skills/architecture/security-arch.md +4 -5
  142. package/skills/compliance/ai-disclosure.md +4 -5
  143. package/skills/compliance/data-privacy.md +4 -5
  144. package/skills/compliance/license-audit.md +4 -5
  145. package/skills/compliance/regulatory-review.md +4 -5
  146. package/skills/development/cpp.md +4 -5
  147. package/skills/development/go.md +4 -5
  148. package/skills/development/java.md +4 -5
  149. package/skills/development/kotlin.md +4 -5
  150. package/skills/development/mobile-crossplatform.md +4 -5
  151. package/skills/development/python.md +4 -5
  152. package/skills/development/rust.md +4 -5
  153. package/skills/development/shell.md +4 -5
  154. package/skills/development/swift.md +4 -5
  155. package/skills/development/typescript.md +4 -5
  156. package/skills/devops/ci-cd.md +4 -5
  157. package/skills/devops/containerization.md +4 -5
  158. package/skills/devops/cost-optimization.md +4 -5
  159. package/skills/devops/data-engineering.md +4 -5
  160. package/skills/devops/database.md +4 -5
  161. package/skills/devops/dependency-management.md +4 -5
  162. package/skills/devops/devsecops.md +4 -5
  163. package/skills/devops/git-workflow.md +4 -5
  164. package/skills/devops/incident-response.md +4 -5
  165. package/skills/devops/monorepo.md +4 -5
  166. package/skills/devops/observability.md +4 -5
  167. package/skills/devops/performance.md +4 -5
  168. package/skills/devops/testing.md +4 -5
  169. package/skills/docs/adr-workflow.md +4 -7
  170. package/skills/docs/backlog-proposal-workflow.md +4 -3
  171. package/skills/docs/customer-profile-workflow.md +4 -3
  172. package/skills/docs/document-ingest-workflow.md +4 -3
  173. package/skills/docs/evidence-ingest-workflow.md +4 -3
  174. package/skills/docs/init-docs.md +4 -5
  175. package/skills/docs/init-project.md +4 -5
  176. package/skills/docs/prd-workflow.md +4 -7
  177. package/skills/docs/prfaq-workflow.md +4 -3
  178. package/skills/docs/product-intelligence-review.md +4 -3
  179. package/skills/docs/product-intelligence-workflow.md +4 -6
  180. package/skills/docs/product-signal-workflow.md +4 -5
  181. package/skills/docs/research-workflow.md +4 -5
  182. package/skills/docs/runbook-workflow.md +4 -5
  183. package/skills/docs/strategy-workflow.md +4 -5
  184. package/skills/exploration/dependency-graph-reading.md +4 -7
  185. package/skills/exploration/repo-map.md +4 -5
  186. package/skills/exploration/tracer-bullet-method.md +4 -7
  187. package/skills/exploration/unknown-codebase-onboarding.md +4 -7
  188. package/skills/frameworks/django.md +4 -5
  189. package/skills/frameworks/nextjs.md +4 -5
  190. package/skills/frameworks/react.md +4 -5
  191. package/skills/frameworks/spring-boot.md +4 -5
  192. package/skills/frontend-design/accessibility.md +4 -5
  193. package/skills/frontend-design/component-patterns.md +4 -5
  194. package/skills/frontend-design/engineering.md +4 -5
  195. package/skills/frontend-design/state-management.md +4 -5
  196. package/skills/frontend-design/ui-aesthetics.md +4 -5
  197. package/skills/frontend-design/ux-principles.md +4 -5
  198. package/skills/operating/change-management.md +4 -8
  199. package/skills/operating/incident-response.md +4 -8
  200. package/skills/operating/oncall-rotation.md +4 -7
  201. package/skills/operating/orchestration-reference.md +4 -10
  202. package/skills/quality-gates/review-work.md +4 -5
  203. package/skills/quality-gates/verify-change.md +4 -5
  204. package/skills/quality-gates/verify-module.md +4 -5
  205. package/skills/quality-gates/verify-quality.md +4 -5
  206. package/skills/quality-gates/verify-security.md +4 -5
  207. package/skills/roles/architect.ai-systems.md +6 -9
  208. package/skills/roles/architect.data.md +6 -9
  209. package/skills/roles/architect.enterprise.md +6 -9
  210. package/skills/roles/architect.integration.md +6 -9
  211. package/skills/roles/architect.md +7 -14
  212. package/skills/roles/architect.platform.md +6 -9
  213. package/skills/roles/data-analyst.experiment.md +6 -9
  214. package/skills/roles/data-analyst.md +6 -9
  215. package/skills/roles/data-analyst.product-intelligence.md +7 -9
  216. package/skills/roles/data-analyst.product.md +6 -9
  217. package/skills/roles/data-analyst.telemetry.md +7 -9
  218. package/skills/roles/data-engineer.pipeline.md +6 -9
  219. package/skills/roles/data-engineer.vector-retrieval.md +7 -9
  220. package/skills/roles/data-engineer.warehouse.md +6 -9
  221. package/skills/roles/debugger.md +6 -9
  222. package/skills/roles/designer.accessibility.md +6 -9
  223. package/skills/roles/designer.md +7 -9
  224. package/skills/roles/engineer.ai.md +6 -9
  225. package/skills/roles/engineer.data.md +6 -9
  226. package/skills/roles/engineer.md +9 -9
  227. package/skills/roles/engineer.platform.md +6 -9
  228. package/skills/roles/operator.docs.md +6 -9
  229. package/skills/roles/operator.md +9 -9
  230. package/skills/roles/operator.release.md +6 -9
  231. package/skills/roles/operator.sre.md +6 -9
  232. package/skills/roles/orchestrator.md +6 -9
  233. package/skills/roles/product-manager.ai-product.md +6 -9
  234. package/skills/roles/product-manager.business-strategy.md +6 -9
  235. package/skills/roles/product-manager.enterprise.md +6 -9
  236. package/skills/roles/product-manager.growth.md +7 -9
  237. package/skills/roles/product-manager.md +7 -9
  238. package/skills/roles/product-manager.platform.md +6 -9
  239. package/skills/roles/product-manager.product.md +6 -9
  240. package/skills/roles/qa.ai-eval.md +8 -9
  241. package/skills/roles/qa.api-contract.md +7 -9
  242. package/skills/roles/qa.data-pipeline.md +7 -9
  243. package/skills/roles/qa.md +7 -9
  244. package/skills/roles/qa.test-automation.md +6 -9
  245. package/skills/roles/qa.web-ui.md +7 -9
  246. package/skills/roles/researcher.explorer.md +6 -9
  247. package/skills/roles/researcher.md +8 -9
  248. package/skills/roles/researcher.ux.md +6 -9
  249. package/skills/roles/reviewer.devil-advocate.md +6 -9
  250. package/skills/roles/reviewer.evaluator.md +6 -9
  251. package/skills/roles/reviewer.md +9 -9
  252. package/skills/roles/reviewer.trace.md +6 -9
  253. package/skills/roles/security.ai.md +7 -9
  254. package/skills/roles/security.appsec.md +6 -9
  255. package/skills/roles/security.cloud.md +6 -9
  256. package/skills/roles/security.legal-compliance.md +6 -9
  257. package/skills/roles/security.md +7 -9
  258. package/skills/roles/security.privacy.md +7 -9
  259. package/skills/roles/security.supply-chain.md +7 -9
  260. package/skills/security/blue-team.md +4 -5
  261. package/skills/security/code-audit.md +4 -5
  262. package/skills/security/pentest.md +4 -5
  263. package/skills/security/red-team.md +4 -5
  264. package/skills/security/threat-intel.md +4 -5
  265. package/skills/security/vuln-research.md +4 -5
  266. package/skills/strategy/competitive-landscape.md +4 -8
  267. package/skills/strategy/market-research-methods.md +4 -8
  268. package/skills/strategy/narrative-arc.md +4 -8
  269. package/skills/strategy/pricing-positioning.md +4 -8
  270. package/skills/utility/clean-code.md +4 -5
  271. package/specialists/policy-inventory.json +14 -0
  272. package/lib/specialist-contracts-enforce.mjs +0 -172
  273. package/rules/common/agents.md +0 -28
  274. package/rules/common/development-workflow.md +0 -32
  275. package/rules/common/performance.md +0 -55
@@ -79,11 +79,15 @@ function requiresHeader(rel) {
79
79
 
80
80
  const JS_HEADER_RE = /^(?:#![^\n]*\n)?(?:\/\/[^\n]*\n)*\/\*\*[\s\S]*?\*\//;
81
81
  const MD_HEADER_RE = /^<!--[\s\S]*?-->/;
82
+ const MD_YAML_HEADER_RE = /^---\n(?:[^\n]*\n)*?description:[\s\S]*?\n---/;
82
83
  const SH_HEADER_RE = /^#!.*\n(?:#[^\n]*\n)*/;
83
84
 
84
85
  function hasHeader(content, type) {
85
86
  if (type === 'js') return JS_HEADER_RE.test(content.trimStart());
86
- if (type === 'md') return MD_HEADER_RE.test(content.trimStart());
87
+ if (type === 'md') {
88
+ const trimmed = content.trimStart();
89
+ return MD_HEADER_RE.test(trimmed) || MD_YAML_HEADER_RE.test(trimmed);
90
+ }
87
91
  return SH_HEADER_RE.test(content.trimStart());
88
92
  }
89
93
 
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * lib/context-state.mjs — compact project/session context persistence.
4
+ *
5
+ * Writes go through writeContextState, which uses write-to-tmp + atomic
6
+ * rename so concurrent writers from different hooks (pre-compact, Stop,
7
+ * context-window-recovery, tracking-surfaces) cannot leave a half-written
8
+ * JSON or markdown file behind. POSIX rename(2) is atomic for files on
9
+ * the same filesystem; the .cx directory always lives next to the target.
4
10
  */
5
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
11
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
6
12
  import { join } from 'node:path';
7
13
 
8
14
  export function contextJsonPath(rootDir) {
@@ -58,6 +64,12 @@ export function inspectContextState(rootDir) {
58
64
  };
59
65
  }
60
66
 
67
+ function atomicWrite(targetPath, content) {
68
+ const tmpPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
69
+ writeFileSync(tmpPath, content, 'utf8');
70
+ renameSync(tmpPath, targetPath);
71
+ }
72
+
61
73
  export function writeContextState(rootDir, state, { markdown = null } = {}) {
62
74
  const cxDir = join(rootDir, '.cx');
63
75
  mkdirSync(cxDir, { recursive: true });
@@ -67,10 +79,10 @@ export function writeContextState(rootDir, state, { markdown = null } = {}) {
67
79
  savedAt: new Date().toISOString(),
68
80
  ...state,
69
81
  };
70
- writeFileSync(contextJsonPath(rootDir), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
82
+ atomicWrite(contextJsonPath(rootDir), `${JSON.stringify(payload, null, 2)}\n`);
71
83
 
72
84
  if (markdown !== null) {
73
- writeFileSync(contextMarkdownPath(rootDir), markdown.endsWith('\n') ? markdown : `${markdown}\n`, 'utf8');
85
+ atomicWrite(contextMarkdownPath(rootDir), markdown.endsWith('\n') ? markdown : `${markdown}\n`);
74
86
  }
75
87
 
76
88
  return payload;
@@ -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,71 @@ 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
- export function validateHandoff({
153
- producer,
154
- consumer,
155
- id,
156
- artifact,
157
- contractsPath = CONTRACTS_PATH,
158
- repoRoot = REPO_ROOT,
159
- enforcement = process.env.CONSTRUCT_CONTRACT_ENFORCEMENT || 'warn',
160
- }) {
160
+ export function validateHandoff(opts = {}) {
161
+ const {
162
+ producer,
163
+ consumer,
164
+ id,
165
+ artifact,
166
+ packet,
167
+ contractsPath = CONTRACTS_PATH,
168
+ repoRoot = REPO_ROOT,
169
+ enforcement = 'block',
170
+ } = opts;
171
+ // Distinguish caller-supplied repoRoot from the default. Only an explicit
172
+ // repoRoot routes the violation log write — the default REPO_ROOT is for
173
+ // joining schema paths in this module, and forwarding it to the logger
174
+ // would override the cwd-based project-scope resolver that the test
175
+ // suite (and runtime project state) relies on.
176
+ const explicitRepoRoot = Object.prototype.hasOwnProperty.call(opts, 'repoRoot') ? repoRoot : undefined;
177
+
178
+ const errors = [];
179
+ const postconditionFailures = [];
180
+
181
+ // Binary postconditions are producer-bound invariants that fire regardless
182
+ // of whether the producer→consumer contract is registered in contracts.json.
183
+ // A producer with rules cannot have its packet validation skipped by
184
+ // claiming an unregistered consumer.
185
+ const hasBinaryRules = Array.isArray(POSTCONDITIONS[producer]) && POSTCONDITIONS[producer].length > 0;
186
+ if (hasBinaryRules) {
187
+ if (packet === undefined || packet === null) {
188
+ errors.push(`producer '${producer}' has binary postconditions; validateHandoff must be called with a packet argument`);
189
+ } else {
190
+ const binary = validateBinaryPostconditions(producer, packet);
191
+ for (const failure of binary.failures) {
192
+ errors.push(`[${failure.id}] ${failure.reason}`);
193
+ postconditionFailures.push(failure);
194
+ }
195
+ }
196
+ }
197
+
161
198
  const contract = findContract({ producer, consumer, id, contractsPath });
162
199
  if (!contract) {
200
+ if (errors.length > 0) {
201
+ logViolation(`${producer}->${consumer}`, 'output', errors, packet ?? artifact, {
202
+ verdict: postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION',
203
+ postconditionFailures,
204
+ repoRoot: explicitRepoRoot,
205
+ });
206
+ return enforcement === 'block'
207
+ ? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`, ...errors], contract: null }
208
+ : { ok: true, warnings: [`no contract found for ${producer}→${consumer}`, ...errors], contract: null };
209
+ }
163
210
  return enforcement === 'block'
164
211
  ? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`], contract: null }
165
212
  : { ok: true, warnings: [`no contract found for ${producer}→${consumer}`], contract: null };
166
213
  }
167
214
 
168
- const errors = [];
169
-
170
215
  // A handoff carries a producer's output, which is the consumer's input. The
171
216
  // input contract is what the consumer expects to receive; check mustContain
172
217
  // against that first. Output.schema validation only applies when the artifact
@@ -193,18 +238,27 @@ export function validateHandoff({
193
238
  }
194
239
  }
195
240
 
196
- // Postcondition checks run against the artifact file on disk when the
197
- // handoff envelope declares `artifactPath`. Resolved relative to repoRoot.
241
+ // Disk-artifact postconditions: frontmatter, sections, citations.
198
242
  const declaredArtifactPath = artifact?.artifactPath || artifact?.output?.artifactPath;
199
243
  if (declaredArtifactPath) {
200
244
  const absPath = isAbsolutePath(declaredArtifactPath)
201
245
  ? declaredArtifactPath
202
246
  : join(repoRoot, declaredArtifactPath);
203
- const pcErrors = validatePostconditions({ contract, artifactPath: absPath });
247
+ const pcErrors = validateArtifactPostconditions({ contract, artifactPath: absPath });
204
248
  for (const e of pcErrors) errors.push(e);
205
249
  }
206
250
 
207
251
  if (errors.length === 0) return { ok: true, contract };
252
+
253
+ // Persist to the chain-hashed violation log so `construct doctor` and
254
+ // forensic replay see the failure. Best-effort; logging never throws.
255
+ const verdict = postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION';
256
+ logViolation(contract.id, 'output', errors, packet ?? artifact, {
257
+ verdict,
258
+ postconditionFailures,
259
+ repoRoot: explicitRepoRoot,
260
+ });
261
+
208
262
  if (enforcement === 'block') return { ok: false, status: 'BLOCKED_CONTRACT', errors, contract };
209
263
  return { ok: true, warnings: errors, contract };
210
264
  }
@@ -272,7 +326,7 @@ function claimsHaveCitations(body) {
272
326
  * disk. Postconditions can be strings (descriptive, ignored here) or objects
273
327
  * with { id, check, field?, section? }. Returns an array of error strings.
274
328
  */
275
- export function validatePostconditions({ contract, artifactPath }) {
329
+ export function validateArtifactPostconditions({ contract, artifactPath }) {
276
330
  if (!contract?.postconditions?.length) return [];
277
331
  if (!artifactPath || !existsSync(artifactPath)) {
278
332
  const structured = contract.postconditions.filter((p) => typeof p === 'object');
@@ -0,0 +1,169 @@
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(repoRoot) {
43
+ // Explicit repoRoot bypasses the marker-walk in resolveProjectScope: the
44
+ // caller has already decided the scope (test fixture, sandbox, embedded
45
+ // worktree). Without this, a fixture without a `.cx/` marker falls back
46
+ // to ~/.cx/ and pollutes the developer's home log.
47
+ if (repoRoot) return join(repoRoot, '.cx', 'contract-violations.jsonl');
48
+ return resolveProjectScopedPath('contract-violations.jsonl', { ensureDir: false });
49
+ }
50
+
51
+ // Exposed for diagnostic surfaces (e.g. doctor) that need to print the
52
+ // real path. Resolves on every call so cwd/HOME changes inside the same
53
+ // process route correctly. `repoRoot` overrides cwd-based resolution so
54
+ // callers running against a fixture (tests, sandboxes) can isolate their
55
+ // log writes from the developer's project log.
56
+ export function violationLogPath(repoRoot) { return logFile(repoRoot); }
57
+
58
+ function sha256(input) { return createHash('sha256').update(input).digest('hex'); }
59
+
60
+ function readLastAgent() {
61
+ try { return JSON.parse(readFileSync(LAST_AGENT, 'utf8'))?.agent || 'construct'; }
62
+ catch { return 'construct'; }
63
+ }
64
+
65
+ function readTailRecord(repoRoot) {
66
+ const file = logFile(repoRoot);
67
+ const lastLine = readLastLineAcrossSegments(file);
68
+ if (!lastLine) return null;
69
+ try { return JSON.parse(lastLine); }
70
+ catch { return null; }
71
+ }
72
+
73
+ function readPrevLineHash(repoRoot) {
74
+ const file = logFile(repoRoot);
75
+ const lastLine = readLastLineAcrossSegments(file);
76
+ return lastLine ? sha256(lastLine) : null;
77
+ }
78
+
79
+ function nextSequence(repoRoot) {
80
+ const tail = readTailRecord(repoRoot);
81
+ const prior = Number.isInteger(tail?.sequence) ? tail.sequence : 0;
82
+ return prior + 1;
83
+ }
84
+
85
+ /**
86
+ * Append a violation record. Best-effort: file I/O failures are swallowed
87
+ * so logging never crashes the caller. `extra.repoRoot` (extracted, not
88
+ * persisted) routes the write to a fixture log when callers run against
89
+ * a tmpdir — required for test isolation.
90
+ */
91
+ export function logViolation(contractId, direction, missing, packet, extra = {}) {
92
+ try {
93
+ const { repoRoot, ...persistedExtra } = extra || {};
94
+ const file = logFile(repoRoot);
95
+ mkdirSync(dirname(file), { recursive: true });
96
+ const record = {
97
+ ts: new Date().toISOString(),
98
+ sequence: nextSequence(repoRoot),
99
+ agent: readLastAgent(),
100
+ contractId,
101
+ direction,
102
+ missing,
103
+ packet_keys: packet && typeof packet === 'object' ? Object.keys(packet) : null,
104
+ prev_line_hash: readPrevLineHash(repoRoot),
105
+ ...persistedExtra,
106
+ };
107
+ appendBounded('contract-violations', file, JSON.stringify(record) + '\n');
108
+ } catch { /* logging is best-effort */ }
109
+ }
110
+
111
+ /**
112
+ * Read recent violations from the active log segment. Returns oldest-first.
113
+ * Rotated segments are intentionally not included — this is the doctor
114
+ * surface, scoped to the active window.
115
+ */
116
+ export function recentViolations({ windowMs = 24 * 60 * 60 * 1000 } = {}) {
117
+ const file = logFile();
118
+ if (!existsSync(file)) return [];
119
+ try {
120
+ const cutoff = Date.now() - windowMs;
121
+ return readFileSync(file, 'utf8')
122
+ .trim()
123
+ .split('\n')
124
+ .filter(Boolean)
125
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } })
126
+ .filter((r) => r && new Date(r.ts).getTime() >= cutoff);
127
+ } catch { return []; }
128
+ }
129
+
130
+ /**
131
+ * Walk the active log segment, asserting:
132
+ * - each record's prev_line_hash matches sha256 of the prior line
133
+ * - sequence numbers are monotonic with no gaps
134
+ *
135
+ * Returns { ok, brokenAt? } where brokenAt is { index, reason } on first
136
+ * failure. An empty / missing log is `{ ok: true }` (nothing to break).
137
+ */
138
+ export function verifyChain() {
139
+ const file = logFile();
140
+ if (!existsSync(file)) return { ok: true };
141
+ try {
142
+ const lines = readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
143
+ let priorLine = null;
144
+ let priorSeq = null;
145
+ for (let i = 0; i < lines.length; i++) {
146
+ const line = lines[i];
147
+ let record;
148
+ try { record = JSON.parse(line); }
149
+ catch { return { ok: false, brokenAt: { index: i, reason: 'unparseable JSON' } }; }
150
+
151
+ if (priorLine !== null) {
152
+ const expectedHash = sha256(priorLine);
153
+ if (record.prev_line_hash !== expectedHash) {
154
+ return { ok: false, brokenAt: { index: i, reason: 'prev_line_hash mismatch' } };
155
+ }
156
+ }
157
+ if (Number.isInteger(record.sequence)) {
158
+ if (priorSeq !== null && record.sequence !== priorSeq + 1) {
159
+ return { ok: false, brokenAt: { index: i, reason: `sequence gap (expected ${priorSeq + 1}, got ${record.sequence})` } };
160
+ }
161
+ priorSeq = record.sequence;
162
+ }
163
+ priorLine = line;
164
+ }
165
+ return { ok: true };
166
+ } catch (err) {
167
+ return { ok: false, brokenAt: { index: -1, reason: err.message } };
168
+ }
169
+ }
@@ -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
+ }