@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
@@ -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
  }
@@ -1095,7 +1095,12 @@ this.#scheduler.register(
1095
1095
  outputTokens = data.usage?.completion_tokens || 0;
1096
1096
  }
1097
1097
  }
1098
- } 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
+ }
1099
1104
 
1100
1105
  const endTime = Date.now();
1101
1106
 
@@ -6,7 +6,7 @@
6
6
  * - validateFlavor(path) — frontmatter conformance check against the schema
7
7
  * - perRoleFlavorCount(profile) — flavor counts per role for cap enforcement
8
8
  *
9
- * The cap is enforced at sync time and in lint-prose's sibling lint-flavors.
9
+ * The cap is enforced at sync time and in lint-profiles.
10
10
  * Six flavors per role per profile is the current ceiling; bumping requires
11
11
  * an ADR. Existing roles like qa already have 6, so the cap is set tight.
12
12
  */
@@ -38,7 +38,7 @@
38
38
  *
39
39
  * `parseHandoff` is intentionally permissive — pre-contract handoffs with
40
40
  * no frontmatter still parse with status='legacy' so they aren't lost.
41
- * `validateHandoff` is the strict version, used when writing.
41
+ * `validateHandoffFile` is the strict version, used when writing.
42
42
  */
43
43
 
44
44
  import { readFileSync } from 'node:fs';
@@ -109,7 +109,7 @@ export function parseHandoffFile(filePath) {
109
109
  return parseHandoff(text);
110
110
  }
111
111
 
112
- export function validateHandoff(parsed) {
112
+ export function validateHandoffFile(parsed) {
113
113
  const errors = [];
114
114
  const fm = parsed?.frontmatter || {};
115
115
  if (fm.schema !== HANDOFF_SCHEMA_VERSION) errors.push(`schema must be '${HANDOFF_SCHEMA_VERSION}'`);
@@ -271,7 +271,7 @@ try {
271
271
  if (hasSources) {
272
272
  const parts = [];
273
273
  if (repos.length > 0) parts.push(`GitHub repos: ${repos.join(', ')}`);
274
- if (jiraConfigured) parts.push(`Jira: ${process.env.JIRA_BASE_URL}`);
274
+ if (jiraConfigured) parts.push('Jira: configured');
275
275
  if (linearConfigured) parts.push('Linear: configured');
276
276
  // Surface which sources are wired so `provider_fetch` is the obvious
277
277
  // tool for questions about them. The "fetch first, don't answer from
@@ -26,6 +26,7 @@ import { listSessions, loadSession, updateSession } from '../session-store.mjs';
26
26
  import { captureSessionArtifacts } from '../artifact-capture.mjs';
27
27
  import { appendSessionStats } from '../memory-stats.mjs';
28
28
  import { estimateUsageCost } from '../telemetry/model-pricing-catalog.mjs';
29
+ import { flushReadTrackerDeltas } from '../read-tracker-store.mjs';
29
30
 
30
31
  function loadTranscriptCheckpoints(checkpointPath) {
31
32
  try {
@@ -140,6 +141,12 @@ let raw = '';
140
141
  try { raw = readFileSync(0, 'utf8'); } catch { /* non-critical */ }
141
142
  if (raw) process.stdout.write(raw);
142
143
 
144
+ // PostToolUse Read hooks append delta lines instead of rewriting the full
145
+ // read-tracker JSON on every call. Stop fires once per session end; this
146
+ // is the canonical point to fold accumulated deltas back into the JSON.
147
+ // Already wired into pre-compact + audit-reads; Stop is the third anchor.
148
+ try { flushReadTrackerDeltas({ env: process.env }); } catch { /* non-critical */ }
149
+
143
150
  const home = homedir();
144
151
  const tsResultPath = join(home, '.cx', 'ts-result.txt');
145
152
  const warnFlagsPath = join(home, '.cx', 'warn-flags.txt');
@@ -0,0 +1,94 @@
1
+ /**
2
+ * lib/ingest/chunker.mjs — paragraph-boundary chunker with sentence overlap.
3
+ *
4
+ * 2026-06 best practice notes:
5
+ * - Recursive/sentence chunking remains the high-recall default (~85-90%
6
+ * on 2026 RAG benchmarks). Semantic chunking gains only ~2-3% recall at
7
+ * ~14× the embedding cost and only matters above ~5k token docs.
8
+ * - Parent-document retrieval is the meaningful 2026 upgrade beyond
9
+ * paragraph chunking — track separately if recall plateaus.
10
+ *
11
+ * Strategy:
12
+ * 1. Split markdown by blank-line paragraph boundaries.
13
+ * 2. Pack paragraphs into chunks under maxChars (default 1500 ≈ 375 tokens).
14
+ * 3. Add 1-2 sentence overlap between consecutive chunks to preserve
15
+ * cross-chunk anchors.
16
+ * 4. Preserve markdown structure: never split inside a fenced code block.
17
+ */
18
+
19
+ const DEFAULT_MAX_CHARS = 1500;
20
+ const DEFAULT_OVERLAP_SENTENCES = 2;
21
+ const SENTENCE_RE = /[^.!?]+[.!?]+["')\]]*\s*/g;
22
+
23
+ export function splitSentences(text) {
24
+ const matches = text.match(SENTENCE_RE);
25
+ if (matches && matches.length) return matches.map((s) => s.trim()).filter(Boolean);
26
+ return text.split('\n').map((s) => s.trim()).filter(Boolean);
27
+ }
28
+
29
+ function tailSentences(text, count) {
30
+ const sentences = splitSentences(text);
31
+ return sentences.slice(-count).join(' ');
32
+ }
33
+
34
+ function splitParagraphs(markdown) {
35
+ const blocks = [];
36
+ let buffer = '';
37
+ let inFence = false;
38
+ for (const line of markdown.split('\n')) {
39
+ if (/^```/.test(line.trim())) inFence = !inFence;
40
+ if (!inFence && line.trim() === '' && buffer.trim()) {
41
+ blocks.push(buffer.trim());
42
+ buffer = '';
43
+ continue;
44
+ }
45
+ buffer += line + '\n';
46
+ }
47
+ if (buffer.trim()) blocks.push(buffer.trim());
48
+ return blocks;
49
+ }
50
+
51
+ export function chunkMarkdown(markdown, { maxChars = DEFAULT_MAX_CHARS, overlapSentences = DEFAULT_OVERLAP_SENTENCES } = {}) {
52
+ const paragraphs = splitParagraphs(markdown || '');
53
+ if (paragraphs.length === 0) return [];
54
+
55
+ const chunks = [];
56
+ let current = '';
57
+ let chunkIndex = 0;
58
+ const flushChunk = () => {
59
+ if (!current.trim()) return;
60
+ const text = current.trim();
61
+ chunks.push({
62
+ index: chunkIndex++,
63
+ text,
64
+ chars: text.length,
65
+ });
66
+ const overlap = overlapSentences > 0 ? tailSentences(text, overlapSentences) : '';
67
+ current = overlap ? overlap + '\n\n' : '';
68
+ };
69
+
70
+ for (const para of paragraphs) {
71
+ if (current.length + para.length + 2 > maxChars && current.trim()) {
72
+ flushChunk();
73
+ }
74
+ if (para.length > maxChars) {
75
+ flushChunk();
76
+ const sentences = splitSentences(para);
77
+ let buf = '';
78
+ for (const s of sentences) {
79
+ if (buf.length + s.length + 1 > maxChars && buf.trim()) {
80
+ chunks.push({ index: chunkIndex++, text: buf.trim(), chars: buf.trim().length });
81
+ buf = (overlapSentences > 0 ? tailSentences(buf, overlapSentences) : '') + (overlapSentences > 0 ? ' ' : '');
82
+ }
83
+ buf += s + ' ';
84
+ }
85
+ if (buf.trim()) {
86
+ current = buf.trim() + '\n\n';
87
+ }
88
+ continue;
89
+ }
90
+ current += (current ? '\n\n' : '') + para;
91
+ }
92
+ flushChunk();
93
+ return chunks;
94
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * lib/ingest/pipeline.mjs — orchestrate the content-hashed ingest pipeline.
3
+ *
4
+ * Flow: hash → de-dup → extract via docling/whisper → chunk → write to
5
+ * .cx/ingest/<sha>/. Returns a record describing what was stored along
6
+ * with any droppedInfo surfaced by the extractor.
7
+ *
8
+ * Idempotent: re-ingesting the same content (by sha256) returns the
9
+ * existing record without re-extracting.
10
+ */
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { extractDocumentTextAsync } from '../document-extract.mjs';
14
+ import { hashFile, defaultIngestRoot, readRecord, writeRecord } from './store.mjs';
15
+ import { chunkMarkdown } from './chunker.mjs';
16
+
17
+ export async function ingestFile(filePath, { cwd = process.cwd(), force = false } = {}) {
18
+ const absPath = path.resolve(filePath);
19
+ const stat = await fs.stat(absPath);
20
+ if (!stat.isFile()) throw new Error(`not a regular file: ${absPath}`);
21
+
22
+ const root = defaultIngestRoot(cwd);
23
+ const sha256 = await hashFile(absPath);
24
+
25
+ if (!force) {
26
+ const existing = await readRecord(root, sha256);
27
+ if (existing) return { ...existing, status: 'cached' };
28
+ }
29
+
30
+ const extracted = await extractDocumentTextAsync(absPath);
31
+ const markdown = extracted.markdown ?? extracted.text ?? '';
32
+ const chunks = chunkMarkdown(markdown);
33
+
34
+ const source = {
35
+ sourcePath: absPath,
36
+ fileName: path.basename(absPath),
37
+ extension: extracted.extension,
38
+ bytes: stat.size,
39
+ sha256,
40
+ ingestedAt: new Date().toISOString(),
41
+ };
42
+
43
+ const meta = {
44
+ extractionMethod: extracted.extractionMethod,
45
+ extractorMetadata: extracted.metadata ?? null,
46
+ droppedInfo: extracted.droppedInfo ?? [],
47
+ chunkCount: chunks.length,
48
+ chunkChars: chunks.reduce((a, c) => a + c.chars, 0),
49
+ };
50
+
51
+ const record = await writeRecord(root, { sha256, source, meta, markdown });
52
+ return { ...record, status: 'ingested', droppedInfo: meta.droppedInfo, chunks };
53
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * lib/ingest/store.mjs — content-addressed ingest store at .cx/ingest/<hash>/.
3
+ *
4
+ * Each ingested document is stored under its SHA-256 content hash:
5
+ * .cx/ingest/<sha256>/source.json — original path, size, sha256, ingestedAt
6
+ * .cx/ingest/<sha256>/markdown.md — extracted markdown body
7
+ * .cx/ingest/<sha256>/meta.json — extractor metadata, droppedInfo, chunks
8
+ *
9
+ * Idempotent: re-ingesting a file with the same content is a no-op (returns
10
+ * the existing record). Re-running ingestion is the safe default.
11
+ *
12
+ * 2026-06 best practice notes:
13
+ * - SHA-256 is still the universal content-addressing primitive. BLAKE3 is
14
+ * ~2.3× faster on M-series and worth considering at multi-GB scale,
15
+ * but is not the industry default and adds an unfamiliar dependency.
16
+ * Revisit if ingest throughput becomes a measured bottleneck.
17
+ * - Storing markdown body separately from metadata keeps the embedding
18
+ * and search paths simple: they only read markdown.md and look up
19
+ * provenance from meta.json on demand.
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import { createReadStream } from 'node:fs';
23
+ import fs from 'node:fs/promises';
24
+ import path from 'node:path';
25
+
26
+ export function defaultIngestRoot(cwd = process.cwd()) {
27
+ return path.join(cwd, '.cx', 'ingest');
28
+ }
29
+
30
+ export async function hashFile(filePath) {
31
+ return new Promise((resolveHash, reject) => {
32
+ const hash = createHash('sha256');
33
+ const stream = createReadStream(filePath);
34
+ stream.on('data', (chunk) => hash.update(chunk));
35
+ stream.on('error', reject);
36
+ stream.on('end', () => resolveHash(hash.digest('hex')));
37
+ });
38
+ }
39
+
40
+ function recordDirFor(root, sha256) {
41
+ return path.join(root, sha256);
42
+ }
43
+
44
+ export async function readRecord(root, sha256) {
45
+ const dir = recordDirFor(root, sha256);
46
+ try {
47
+ const [source, meta, markdown] = await Promise.all([
48
+ fs.readFile(path.join(dir, 'source.json'), 'utf8').then(JSON.parse),
49
+ fs.readFile(path.join(dir, 'meta.json'), 'utf8').then(JSON.parse),
50
+ fs.readFile(path.join(dir, 'markdown.md'), 'utf8'),
51
+ ]);
52
+ return { sha256, source, meta, markdown, dir };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export async function writeRecord(root, { sha256, source, meta, markdown }) {
59
+ const dir = recordDirFor(root, sha256);
60
+ await fs.mkdir(dir, { recursive: true });
61
+ await Promise.all([
62
+ fs.writeFile(path.join(dir, 'source.json'), JSON.stringify(source, null, 2) + '\n', 'utf8'),
63
+ fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8'),
64
+ fs.writeFile(path.join(dir, 'markdown.md'), markdown, 'utf8'),
65
+ ]);
66
+ return { sha256, dir, source, meta, markdown };
67
+ }
68
+
69
+ export async function listRecords(root) {
70
+ let entries;
71
+ try { entries = await fs.readdir(root, { withFileTypes: true }); }
72
+ catch { return []; }
73
+ const records = [];
74
+ for (const entry of entries) {
75
+ if (!entry.isDirectory()) continue;
76
+ const sha = entry.name;
77
+ if (!/^[a-f0-9]{64}$/.test(sha)) continue;
78
+ const record = await readRecord(root, sha);
79
+ if (record) records.push(record);
80
+ }
81
+ return records;
82
+ }
@@ -24,8 +24,12 @@ import { parseEnvFile, writeEnvValues, getUserEnvPath } from '../env-config.mjs'
24
24
  import { probeAll, formatProbe } from '../bootstrap/resources.mjs';
25
25
 
26
26
  const BOOTSTRAP_CHECKED_KEY = 'BOOTSTRAP_CHECKED';
27
+ // 'install' is the actual handler name in bin/construct's dispatch map;
28
+ // 'setup' is kept for backwards-compat in case anything still routes via
29
+ // that key. Without 'install' the probe will re-prompt the user while
30
+ // they're already running the very command meant to satisfy it.
27
31
  const SKIP_COMMANDS = new Set([
28
- 'hook', 'setup', 'uninstall', 'version', 'help', 'completions', 'doctor',
32
+ 'hook', 'install', 'setup', 'uninstall', 'version', 'help', 'completions', 'doctor', 'upgrade', 'update',
29
33
  ]);
30
34
 
31
35
  export function shouldSkipProbe({ command, env = process.env, stdin = process.stdin } = {}) {
@@ -32,12 +32,13 @@ import path from 'node:path';
32
32
  import { buildHybridSearchResultsAsync } from '../storage/hybrid-query.mjs';
33
33
  import { suggestDocsLaneForFile } from '../docs-routing.mjs';
34
34
  import { createIntakeQueue } from './queue.mjs';
35
- import { classifyRdIntake } from './classify.mjs';
35
+ import { classifyRdIntake, suggestTags } from './classify.mjs';
36
36
  import { detectCustomerMentions, linkSignalToCustomer, updateCustomerProfile } from '../embed/customer-profiles.mjs';
37
37
  import { resolveActiveProfile } from '../profiles/loader.mjs';
38
38
  import { emitBestEffort as emitRoleEvent } from '../roles/event-bus.mjs';
39
39
  import { gatherAttribution, stampAttribution } from './attribution.mjs';
40
40
  import { MANIFEST_REL_PATH } from './manifest.mjs';
41
+ import { loadVocabulary } from '../tags/vocabulary.mjs';
41
42
 
42
43
  const DEFAULT_RELATED_LIMIT = 5;
43
44
  const EXCERPT_CHARS = 800;
@@ -66,12 +67,18 @@ export async function prepareIntakeForIngestedFile({
66
67
 
67
68
  let related = [];
68
69
  try {
69
- const search = await hybridSearchFn(rootDir, query, { limit: relatedLimit, env });
70
- related = (search?.results || []).map((hit) => ({
71
- path: hit.source_path || hit.id,
70
+ const raw = await hybridSearchFn(rootDir, query, { limit: relatedLimit, env });
71
+ // Test mocks return an array directly; production wrapper returns
72
+ // { results: [...] }. Accept both shapes so suggestTags sees the
73
+ // same `related` regardless of caller. Preserve `tags` for the
74
+ // related-inherit suggestion path.
75
+ const hits = Array.isArray(raw) ? raw : (raw?.results || []);
76
+ related = hits.map((hit) => ({
77
+ path: hit.source_path || hit.path || hit.id,
72
78
  title: hit.title || hit.id,
73
79
  score: hit.score,
74
80
  summary: hit.summary || '',
81
+ tags: Array.isArray(hit.tags) ? hit.tags : undefined,
75
82
  }));
76
83
  } catch (err) {
77
84
  related = [];
@@ -98,6 +105,16 @@ export async function prepareIntakeForIngestedFile({
98
105
 
99
106
  const droppedInfo = ingestedFile.droppedInfo ?? [];
100
107
 
108
+ // Tag auto-attribution: deterministic suggestions from triage + related
109
+ // doc inheritance, filtered against the project tag vocabulary. Vocab
110
+ // load failures fall through with empty suggestions — never block the
111
+ // packet write on a missing/malformed vocab file.
112
+
113
+ let vocab = null;
114
+ try { vocab = loadVocabulary(rootDir); }
115
+ catch { vocab = null; }
116
+ const tagSuggestions = suggestTags(triage, related, vocab);
117
+
101
118
  const baseEntry = {
102
119
  intake: {
103
120
  sourcePath: ingestedFile.sourcePath,
@@ -112,6 +129,7 @@ export async function prepareIntakeForIngestedFile({
112
129
  excerpt: extracted.slice(0, EXCERPT_CHARS),
113
130
  query,
114
131
  customers: customers.length ? customers : undefined,
132
+ tags: tagSuggestions.length > 0 ? tagSuggestions : undefined,
115
133
  };
116
134
 
117
135
  // Capability detection mirrors inbox.mjs: stamp provenance onto the packet
@@ -88,6 +88,22 @@ function resolveConfluenceAuth(homeDir) {
88
88
 
89
89
  // ── GitHub Issues ────────────────────────────────────────────────────────
90
90
 
91
+ // A packet that originated from a demo/fixture/inbox-test source must not
92
+ // be published to a real GitHub repo. The caller can override via
93
+ // `publishDemo: true` when the demo run is intentional (e.g. an explicit
94
+ // integration test that creates and then deletes the issue). Default-safe.
95
+
96
+ export function isDemoIntakePacket(packet) {
97
+ if (process.env.CONSTRUCT_DEMO === '1') return true;
98
+ if (process.env.CONSTRUCT_INTAKE_DEMO === '1') return true;
99
+ const sourcePath = String(packet?.intake?.sourcePath || packet?.sourcePath || '');
100
+ if (!sourcePath) return false;
101
+ if (sourcePath.includes('/tests/fixtures/')) return true;
102
+ if (sourcePath.includes('/.cx/intake/demo/')) return true;
103
+ if (sourcePath.includes('/cx-intake-demo')) return true;
104
+ return false;
105
+ }
106
+
91
107
  /**
92
108
  * Create a GitHub issue from an intake packet.
93
109
  *
@@ -98,9 +114,19 @@ function resolveConfluenceAuth(homeDir) {
98
114
  * @param {string} [opts.apiUrl] - GitHub API URL (default: api.github.com)
99
115
  * @param {string} [opts.host] - GitHub host (default: github.com)
100
116
  * @param {object} [opts.fetchImpl]
101
- * @returns {Promise<{ ok: boolean, externalUrl: string|null, externalId: string|null, error?: string }>}
117
+ * @param {boolean} [opts.publishDemo] - Override the demo-source gate
118
+ * @returns {Promise<{ ok: boolean, externalUrl: string|null, externalId: string|null, error?: string, skipped?: string }>}
102
119
  */
103
- export async function createGitHubIssue(packet, { repo, token, apiUrl, host, fetchImpl = globalThis.fetch } = {}) {
120
+ export async function createGitHubIssue(packet, { repo, token, apiUrl, host, fetchImpl = globalThis.fetch, publishDemo = false } = {}) {
121
+ if (!publishDemo && isDemoIntakePacket(packet)) {
122
+ return {
123
+ ok: false,
124
+ externalUrl: null,
125
+ externalId: null,
126
+ skipped: 'demo-source',
127
+ error: 'Refused to publish: packet originated from a demo/fixture path (set CONSTRUCT_DEMO=0 and re-run with --publish-issues to override).',
128
+ };
129
+ }
104
130
  const hd = homedir();
105
131
  const auth = resolveGitHubAuth(hd);
106
132
  const resolvedRepo = repo || auth.repo || process.env.GITHUB_REPO;
@@ -80,6 +80,18 @@ function buildSourceList(repoRoot = REPO_ROOT) {
80
80
  }
81
81
  }
82
82
 
83
+ // User-ingested documents (PDF/Office/audio routed through docling + whisper)
84
+ const ingestRoot = join(repoRoot, '.cx', 'ingest');
85
+ if (existsSync(ingestRoot)) {
86
+ for (const entry of readdirSync(ingestRoot)) {
87
+ const dir = join(ingestRoot, entry);
88
+ const markdown = join(dir, 'markdown.md');
89
+ if (!/^[a-f0-9]{64}$/.test(entry)) continue;
90
+ if (!existsSync(markdown)) continue;
91
+ sources.push({ path: markdown, rel: relative(repoRoot, markdown), priority: 4, source: 'ingest' });
92
+ }
93
+ }
94
+
83
95
  return sources;
84
96
  }
85
97