@geraldmaron/construct 1.2.0 → 1.2.2

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 (214) hide show
  1. package/README.md +8 -7
  2. package/bin/construct +206 -62
  3. package/commands/plan/decide.md +1 -1
  4. package/commands/remember/runbook.md +1 -1
  5. package/examples/README.md +1 -1
  6. package/examples/distribution/README.md +1 -1
  7. package/examples/distribution/sources/adr.md +2 -2
  8. package/examples/distribution/sources/deck-one-pager.md +1 -1
  9. package/examples/distribution/sources/prd-platform.md +1 -1
  10. package/examples/distribution/sources/research-brief.md +2 -2
  11. package/examples/distribution/sources/rfc-platform.md +1 -1
  12. package/examples/distribution/sources/runbook.md +2 -2
  13. package/examples/distribution/sources/strategy.md +1 -1
  14. package/lib/agent-instructions/inject.mjs +1 -1
  15. package/lib/artifact-manifest.mjs +150 -4
  16. package/lib/artifact-type-from-path.mjs +9 -5
  17. package/lib/artifact-workflow.mjs +252 -0
  18. package/lib/audit-specialists.mjs +1 -1
  19. package/lib/audit-trail.mjs +3 -3
  20. package/lib/auto-docs.mjs +13 -11
  21. package/lib/bootstrap/built-ins.mjs +3 -3
  22. package/lib/boundary.mjs +3 -2
  23. package/lib/brand-prose.mjs +1 -1
  24. package/lib/cache-governor.js +4 -4
  25. package/lib/certification/document-io-fixtures.mjs +1 -1
  26. package/lib/chat/config.mjs +2 -2
  27. package/lib/chat/desktop-binary.mjs +5 -3
  28. package/lib/chat/present.mjs +1 -0
  29. package/lib/cli-commands.mjs +23 -6
  30. package/lib/cli-service-inventory.mjs +42 -0
  31. package/lib/comment-lint.mjs +9 -5
  32. package/lib/config/alias.mjs +3 -2
  33. package/lib/config/intake-policy.mjs +139 -0
  34. package/lib/config/project-config.mjs +106 -7
  35. package/lib/config/schema.mjs +45 -6
  36. package/lib/config/source-targets.mjs +311 -0
  37. package/lib/config/xdg.mjs +63 -0
  38. package/lib/contracts/violation-log.mjs +2 -2
  39. package/lib/cost-ledger.mjs +4 -3
  40. package/lib/cost.mjs +3 -2
  41. package/lib/decisions/registry.mjs +3 -3
  42. package/lib/distill.mjs +2 -2
  43. package/lib/docs-verify.mjs +2 -0
  44. package/lib/doctor/audit.mjs +4 -3
  45. package/lib/doctor/cli.mjs +0 -1
  46. package/lib/doctor/index.mjs +4 -4
  47. package/lib/doctor/report.mjs +3 -3
  48. package/lib/doctor/source-checkout.mjs +16 -0
  49. package/lib/doctor/watchers/bd-watch.mjs +5 -5
  50. package/lib/doctor/watchers/cost.mjs +4 -4
  51. package/lib/doctor/watchers/disk.mjs +4 -3
  52. package/lib/doctor/watchers/service-health.mjs +5 -5
  53. package/lib/document-export.mjs +39 -17
  54. package/lib/document-extract.mjs +3 -3
  55. package/lib/document-ingest.mjs +13 -3
  56. package/lib/efficiency.mjs +2 -1
  57. package/lib/embed/approval-queue.mjs +3 -2
  58. package/lib/embed/auto-sources.mjs +44 -0
  59. package/lib/embed/cli.mjs +9 -6
  60. package/lib/embed/config.mjs +6 -5
  61. package/lib/embed/daemon.mjs +6 -5
  62. package/lib/embed/demand-fetch.mjs +50 -19
  63. package/lib/embed/inbox-live-watcher.mjs +1 -1
  64. package/lib/embed/inbox.mjs +52 -41
  65. package/lib/embed/intake-metrics.mjs +3 -2
  66. package/lib/embed/reconcile.mjs +1 -1
  67. package/lib/embed/semantic.mjs +3 -2
  68. package/lib/embed/supervision.mjs +4 -2
  69. package/lib/embed/worker.mjs +2 -1
  70. package/lib/embedded-contract/index.mjs +11 -0
  71. package/lib/engine/registry.mjs +3 -3
  72. package/lib/env-config.mjs +34 -12
  73. package/lib/export-branding.mjs +32 -0
  74. package/lib/features.mjs +4 -3
  75. package/lib/health-check.mjs +7 -6
  76. package/lib/hook-health.mjs +5 -4
  77. package/lib/hooks/_lib/log.mjs +4 -3
  78. package/lib/hooks/_lib/output-mode.mjs +2 -1
  79. package/lib/hooks/agent-tracker.mjs +3 -4
  80. package/lib/hooks/audit-reads.mjs +2 -2
  81. package/lib/hooks/audit-trail.mjs +2 -2
  82. package/lib/hooks/bash-output-logger.mjs +3 -3
  83. package/lib/hooks/brand-prose-lint.mjs +1 -1
  84. package/lib/hooks/ci-status-check.mjs +3 -2
  85. package/lib/hooks/context-watch.mjs +2 -2
  86. package/lib/hooks/context-window-recovery.mjs +5 -5
  87. package/lib/hooks/dep-audit.mjs +2 -2
  88. package/lib/hooks/doc-coupling-check.mjs +3 -2
  89. package/lib/hooks/edit-accumulator.mjs +3 -2
  90. package/lib/hooks/edit-guard.mjs +3 -3
  91. package/lib/hooks/guard-bash.mjs +2 -2
  92. package/lib/hooks/model-fallback.mjs +2 -1
  93. package/lib/hooks/policy-engine.mjs +4 -3
  94. package/lib/hooks/pre-compact.mjs +7 -7
  95. package/lib/hooks/readme-age-check.mjs +2 -2
  96. package/lib/hooks/session-optimize.mjs +4 -3
  97. package/lib/hooks/session-start.mjs +20 -16
  98. package/lib/hooks/stop-notify.mjs +9 -8
  99. package/lib/hooks/stop-typecheck.mjs +4 -3
  100. package/lib/hooks/test-watch.mjs +2 -2
  101. package/lib/host-disposition.mjs +1 -1
  102. package/lib/ingest/provider-extract.mjs +2 -1
  103. package/lib/init/detect-existing-structure.mjs +2 -2
  104. package/lib/init/doc-lanes.mjs +3 -10
  105. package/lib/init-docs.mjs +12 -54
  106. package/lib/init-unified.mjs +47 -92
  107. package/lib/init.mjs +7 -5
  108. package/lib/install/desktop-binary-download.mjs +5 -2
  109. package/lib/intake/constants.mjs +29 -0
  110. package/lib/intake/daemon.mjs +31 -8
  111. package/lib/intake/intake-config.mjs +32 -112
  112. package/lib/integrations/intake-integrations.mjs +8 -7
  113. package/lib/knowledge/rag.mjs +2 -2
  114. package/lib/knowledge/search.mjs +15 -15
  115. package/lib/maintenance/cleanup.mjs +14 -10
  116. package/lib/mcp/server.mjs +20 -2
  117. package/lib/mcp/tools/embedded-contract.mjs +14 -0
  118. package/lib/mcp/tools/project.mjs +2 -1
  119. package/lib/model-cheapest-provider.mjs +4 -3
  120. package/lib/model-pricing.mjs +3 -2
  121. package/lib/model-router.mjs +4 -3
  122. package/lib/models/catalog.mjs +2 -1
  123. package/lib/models/execution-capability-profile.mjs +2 -1
  124. package/lib/models/provider-poll.mjs +2 -1
  125. package/lib/ollama-manager.mjs +0 -1
  126. package/lib/op-log.mjs +2 -1
  127. package/lib/opencode-runtime-plugin.mjs +3 -2
  128. package/lib/oracle/artifact-gate.mjs +3 -0
  129. package/lib/oracle/cli.mjs +2 -1
  130. package/lib/oracle/execute.mjs +3 -2
  131. package/lib/oracle/index.mjs +2 -1
  132. package/lib/oracle/read-model.mjs +3 -2
  133. package/lib/performance/generate.mjs +4 -3
  134. package/lib/platforms/capabilities.mjs +1 -1
  135. package/lib/playwright-demo.mjs +2 -0
  136. package/lib/plugin-registry.mjs +2 -1
  137. package/lib/profiles/lifecycle.mjs +3 -3
  138. package/lib/project-profile.mjs +2 -1
  139. package/lib/project-root.mjs +9 -7
  140. package/lib/provider-capabilities.js +4 -3
  141. package/lib/providers/auth-manager.mjs +2 -1
  142. package/lib/providers/copilot-auth.mjs +3 -2
  143. package/lib/providers/creds.mjs +3 -2
  144. package/lib/providers/registry.mjs +3 -3
  145. package/lib/providers/secret-resolver.mjs +3 -2
  146. package/lib/read-tracker-store.mjs +2 -1
  147. package/lib/reconcile/mcp-entry-reconcile.mjs +2 -2
  148. package/lib/reflect.mjs +2 -1
  149. package/lib/registry/generate-docs.mjs +2 -2
  150. package/lib/roles/approval-surface.mjs +5 -4
  151. package/lib/roles/event-bus.mjs +0 -1
  152. package/lib/roles/gateway.mjs +6 -2
  153. package/lib/rules-delivery.mjs +1 -1
  154. package/lib/runtime-pressure.mjs +5 -3
  155. package/lib/sandbox.mjs +3 -2
  156. package/lib/scheduler/solo.mjs +6 -4
  157. package/lib/server/auth.mjs +4 -3
  158. package/lib/server/index.mjs +53 -26
  159. package/lib/server/insights.mjs +5 -4
  160. package/lib/server/rate-limit.mjs +5 -3
  161. package/lib/server/webhook.mjs +5 -3
  162. package/lib/service-manager.mjs +5 -3
  163. package/lib/setup.mjs +9 -8
  164. package/lib/status.mjs +6 -5
  165. package/lib/storage/embeddings-local.mjs +3 -2
  166. package/lib/storage/sync.mjs +2 -2
  167. package/lib/telemetry/client.mjs +0 -1
  168. package/lib/telemetry/intent-verifications.mjs +6 -6
  169. package/lib/telemetry/model-pricing-catalog.mjs +4 -3
  170. package/lib/telemetry/rule-calls.mjs +3 -3
  171. package/lib/telemetry/skill-calls.mjs +3 -3
  172. package/lib/template-registry.mjs +1 -0
  173. package/lib/templates/visual-requirements.mjs +1 -1
  174. package/lib/test-corpus-inventory.mjs +1 -1
  175. package/lib/uninstall/uninstall.mjs +12 -10
  176. package/package.json +3 -3
  177. package/platforms/claude/settings.template.json +43 -43
  178. package/rules/common/no-fabrication.md +1 -1
  179. package/rules/common/release-gates.md +1 -1
  180. package/rules/common/research.md +1 -1
  181. package/rules/common/review-before-change.md +1 -1
  182. package/scripts/sync-specialists.mjs +11 -8
  183. package/skills/docs/adr-workflow.md +3 -3
  184. package/skills/docs/init-docs.md +9 -9
  185. package/skills/docs/prd-workflow.md +5 -5
  186. package/skills/docs/product-intelligence-review.md +1 -1
  187. package/skills/docs/product-intelligence-workflow.md +2 -2
  188. package/skills/docs/product-signal-workflow.md +1 -1
  189. package/skills/docs/runbook-workflow.md +4 -4
  190. package/skills/exploration/unknown-codebase-onboarding.md +1 -1
  191. package/skills/operating/orchestration-reference.md +1 -1
  192. package/skills/routing.md +3 -3
  193. package/specialists/artifact-manifest.json +11 -2
  194. package/specialists/artifact-manifest.schema.json +31 -1
  195. package/specialists/prompts/cx-architect.md +1 -1
  196. package/specialists/prompts/cx-docs-keeper.md +1 -1
  197. package/specialists/prompts/cx-researcher.md +1 -1
  198. package/specialists/prompts/cx-sre.md +1 -1
  199. package/specialists/role-manifests.json +6 -6
  200. package/templates/distribution/construct-reference.docx +0 -0
  201. package/templates/docs/README.md +125 -0
  202. package/templates/docs/construct_guide.md +3 -4
  203. package/templates/docs/persona-artifact.md +1 -1
  204. package/templates/docs/prds/README.md +25 -0
  205. package/templates/docs/prds/templates/_template.md +206 -0
  206. package/templates/docs/prds/templates/meta-prd.template.md +177 -0
  207. package/templates/docs/prds/templates/prd-business.template.md +61 -0
  208. package/templates/docs/prds/templates/prd-platform.template.md +81 -0
  209. package/templates/docs/prds/templates/prfaq.template.md +38 -0
  210. package/templates/docs/rfcs/README.md +22 -0
  211. package/templates/docs/rfcs/templates/_template.md +58 -0
  212. package/templates/docs/rfcs/templates/rfc-platform.template.md +72 -0
  213. package/templates/homebrew/construct.rb +1 -1
  214. package/templates/workflows/new-feature.yml +8 -8
package/lib/cost.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { readFileSync, existsSync, writeFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
+ import { doctorRoot } from './config/xdg.mjs';
11
12
 
12
13
  function numberFrom(...values) {
13
14
  for (const value of values) {
@@ -82,7 +83,7 @@ export function normalizeCostEntry(entry = {}) {
82
83
  }
83
84
 
84
85
  export function readCostLog(homeDir) {
85
- const logPath = join(homeDir, '.cx', 'session-cost.jsonl');
86
+ const logPath = join(doctorRoot(homeDir), 'session-cost.jsonl');
86
87
  if (!existsSync(logPath)) return [];
87
88
  try {
88
89
  return readFileSync(logPath, 'utf8')
@@ -97,7 +98,7 @@ export function readCostLog(homeDir) {
97
98
  }
98
99
 
99
100
  export function clearCostLog(homeDir) {
100
- const logPath = join(homeDir, '.cx', 'session-cost.jsonl');
101
+ const logPath = join(doctorRoot(homeDir), 'session-cost.jsonl');
101
102
  if (existsSync(logPath)) writeFileSync(logPath, '');
102
103
  }
103
104
 
@@ -7,7 +7,7 @@
7
7
  * contracts as the single source of truth.
8
8
  *
9
9
  * Three decision sources are indexed:
10
- * - ADRs docs/adr/*.md (status + supersedes parsed from body)
10
+ * - ADRs docs/decisions/adr/*.md (status + supersedes parsed from body)
11
11
  * - Contracts specialists/contracts.json (executable postconditions = enforcement)
12
12
  * - Rules rules/**\/*.md (enforcement link via frontmatter; bead wvbf.4)
13
13
  *
@@ -37,7 +37,7 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
37
37
  const ENFORCES_LINE_RE = /^(?:\/\/|#|\*)?\s*@enforces\s+(\S+)\s*$/;
38
38
 
39
39
  // The body carries Status and Supersedes as markdown definition lines, not
40
- // frontmatter (see docs/adr/templates/_template.md); parse them positionally.
40
+ // frontmatter (see docs/decisions/adr/templates/_template.md); parse them positionally.
41
41
 
42
42
  // ADR field styles vary across the lane: inline bold (`- **Status**: x`,
43
43
  // `**Status:** X`) and heading-then-value (`## Status` followed by the value on
@@ -121,7 +121,7 @@ function collectEnforcementBindings({ repoRoot }) {
121
121
  }
122
122
 
123
123
  function adrDecisions({ repoRoot, bindings }) {
124
- const dir = join(repoRoot, 'docs', 'adr');
124
+ const dir = join(repoRoot, 'docs', 'decisions', 'adr');
125
125
  const out = [];
126
126
  if (!existsSync(dir)) return out;
127
127
  for (const name of readdirSync(dir)) {
package/lib/distill.mjs CHANGED
@@ -9,8 +9,8 @@
9
9
  import { readdirSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
10
10
  import { join, extname, relative, dirname, basename } from 'path';
11
11
  import { execSync, spawnSync } from 'child_process';
12
- import { homedir } from 'os';
13
12
  import { getActiveOverlays } from './headhunt.mjs';
13
+ import { doctorRoot } from './config/xdg.mjs';
14
14
  import { withGenAiSpan, GenAiAttrs } from './telemetry/otel-tracer.mjs';
15
15
  import {
16
16
  EXTRACTABLE_DOCUMENT_EXTS,
@@ -347,7 +347,7 @@ export async function distill(dir, { format = 'summary', query = '', mode = 'aut
347
347
 
348
348
  const prompt = structured.prompt;
349
349
  if (mode === 'prompt' || !hasClaude()) {
350
- const tmpFile = out || join(homedir(), '.cx', 'distill-prompt.txt');
350
+ const tmpFile = out || join(doctorRoot(), 'distill-prompt.txt');
351
351
  try { mkdirSync(dirname(tmpFile), { recursive: true }); } catch { /* exists */ }
352
352
  writeFileSync(tmpFile, prompt);
353
353
  process.stderr.write(`\n⚠ Prompt written to: ${tmpFile}\n`);
@@ -210,7 +210,9 @@ function gitTrackedMdFiles(cwd, dir) {
210
210
 
211
211
  function checkIntakeTraceability(cwd) {
212
212
  const intakeFedDirs = [
213
+ path.join(cwd, 'docs', 'specs', 'prd'),
213
214
  path.join(cwd, 'docs', 'prd'),
215
+ path.join(cwd, 'docs', 'notes', 'research'),
214
216
  path.join(cwd, 'docs', 'research'),
215
217
  path.join(cwd, '.cx', 'knowledge', 'internal'),
216
218
  ];
@@ -6,17 +6,18 @@
6
6
  * what the L0 layer did while they weren't looking — and the basis for rolling
7
7
  * back a misfire if rules are wrong.
8
8
  *
9
- * Path: ~/.cx/doctor-log.jsonl (overridable via CONSTRUCT_DOCTOR_ROOT for tests).
9
+ * Path: <doctorRoot>/doctor-log.jsonl (overridable via CONSTRUCT_DOCTOR_ROOT for tests).
10
10
  */
11
11
 
12
12
  import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
13
- import { homedir } from 'node:os';
14
13
  import { join } from 'node:path';
15
14
 
15
+ import { doctorRoot } from '../config/xdg.mjs';
16
+
16
17
  const MAX_LINES = 2000;
17
18
 
18
19
  function rootDir() {
19
- return process.env.CONSTRUCT_DOCTOR_ROOT || join(homedir(), '.cx');
20
+ return doctorRoot();
20
21
  }
21
22
  function logPath() {
22
23
  return join(rootDir(), 'doctor-log.jsonl');
@@ -10,7 +10,6 @@
10
10
  */
11
11
 
12
12
  import { existsSync, readFileSync } from 'node:fs';
13
- import { homedir } from 'node:os';
14
13
  import { join } from 'node:path';
15
14
 
16
15
  import { readState } from './index.mjs';
@@ -7,12 +7,11 @@
7
7
  * never starves the others. Termination via SIGTERM/SIGINT triggers a clean
8
8
  * shutdown that records a final audit entry.
9
9
  *
10
- * State file: ~/.construct/doctor.json (pid, started, lastTick).
10
+ * State file: doctor.json in the XDG state dir (pid, started, lastTick).
11
11
  * Audit log: ~/.cx/doctor-log.jsonl.
12
12
  */
13
13
 
14
14
  import { writeFileSync, existsSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
15
- import { homedir } from 'node:os';
16
15
  import { join } from 'node:path';
17
16
 
18
17
  import { record } from './audit.mjs';
@@ -26,8 +25,9 @@ import * as consistency from './watchers/consistency.mjs';
26
25
  import * as mcpProtocol from './watchers/mcp-protocol.mjs';
27
26
  import * as cxBudget from './watchers/cx-budget.mjs';
28
27
  import * as graphStaleness from './watchers/graph-staleness.mjs';
28
+ import { stateDir } from '../config/xdg.mjs';
29
29
 
30
- const STATE_PATH = join(homedir(), '.construct', 'doctor.json');
30
+ const STATE_PATH = join(stateDir(), 'doctor.json');
31
31
  const WATCHERS = [disk, cost, processPressure, serviceHealth, bdWatch, handoffs, consistency, mcpProtocol, cxBudget, graphStaleness];
32
32
 
33
33
  let running = false;
@@ -35,7 +35,7 @@ let timers = [];
35
35
  let lastTickAt = {};
36
36
 
37
37
  function writeState(state) {
38
- const dir = join(homedir(), '.construct');
38
+ const dir = stateDir();
39
39
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
40
  writeFileSync(STATE_PATH, JSON.stringify({ ...state, updatedAt: Date.now() }));
41
41
  }
@@ -8,10 +8,10 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
- import { homedir } from 'node:os';
12
11
  import { join } from 'node:path';
13
12
 
14
13
  import { recent } from './audit.mjs';
14
+ import { doctorRoot } from '../config/xdg.mjs';
15
15
  import { getTotalDailySpend, getDailySpend, totalBudget, personaBudget, dayKey } from '../cost-ledger.mjs';
16
16
 
17
17
  function parseWindow(arg) {
@@ -67,8 +67,8 @@ export async function runReport(args = []) {
67
67
  const since = Date.now() - windowMs;
68
68
 
69
69
  const auditEntries = recent({ since, limit: 10000 });
70
- const events = readJsonl(join(homedir(), '.cx', 'events.jsonl')).filter((e) => e.ts >= since);
71
- const pending = readJsonl(join(homedir(), '.cx', 'role-pending.jsonl')).filter((e) => e.ts >= since);
70
+ const events = readJsonl(join(doctorRoot(), 'events.jsonl')).filter((e) => e.ts >= since);
71
+ const pending = readJsonl(join(doctorRoot(), 'role-pending.jsonl')).filter((e) => e.ts >= since);
72
72
 
73
73
  const summary = summarizeActions(auditEntries);
74
74
  const eventsByType = groupBy(events, (e) => e.type);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * lib/doctor/source-checkout.mjs — distinguish Construct source tree vs npm install.
3
+ *
4
+ * `isConstructPackageRepo` is true for both checkouts and consumer installs.
5
+ * Source-only doctor checks (dashboard static export, certification role cards)
6
+ * should run only when this returns true.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export function isConstructSourceCheckout(rootDir) {
13
+ const root = path.resolve(rootDir);
14
+ return fs.existsSync(path.join(root, 'apps', 'dashboard'))
15
+ || fs.existsSync(path.join(root, 'tests', 'certification', 'specialists'));
16
+ }
@@ -4,7 +4,7 @@
4
4
  * Every 5 min, polls bd for open issues labeled `next:cx-<role>` where the
5
5
  * target persona is onboarded in the role framework. Newly-seen issues are
6
6
  * enqueued as pending invocations so session-start surfaces them to
7
- * Construct. Seen issues are remembered in ~/.cx/bd-watch-seen.json to
7
+ * Construct. Seen issues are remembered in <doctorRoot>/bd-watch-seen.json to
8
8
  * prevent re-emission while the label remains.
9
9
  *
10
10
  * Closes a gap in agent-tracker: agent-tracker only catches handoffs that
@@ -16,20 +16,20 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
16
  import { spawnSync } from 'node:child_process';
17
17
  import { appendBounded } from '../../logging/rotate.mjs';
18
18
  import { resolveProjectScope } from '../../project-root.mjs';
19
- import { homedir } from 'node:os';
20
19
  import { join } from 'node:path';
21
20
 
22
21
  import { record } from '../audit.mjs';
23
22
  import { listOnboardedPersonas, loadManifest } from '../../roles/manifest.mjs';
23
+ import { doctorRoot } from '../../config/xdg.mjs';
24
24
 
25
25
  export const name = 'bd-watch';
26
26
  export const intervalMs = 5 * 60 * 1000;
27
27
 
28
- const SEEN_PATH = join(homedir(), '.cx', 'bd-watch-seen.json');
29
- const PENDING_PATH = join(homedir(), '.cx', 'role-pending.jsonl');
28
+ const SEEN_PATH = join(doctorRoot(), 'bd-watch-seen.json');
29
+ const PENDING_PATH = join(doctorRoot(), 'role-pending.jsonl');
30
30
 
31
31
  function ensureDir() {
32
- const dir = join(homedir(), '.cx');
32
+ const dir = doctorRoot();
33
33
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
34
34
  }
35
35
 
@@ -10,10 +10,10 @@
10
10
  */
11
11
 
12
12
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
13
- import { homedir } from 'node:os';
14
13
  import { join } from 'node:path';
15
14
 
16
15
  import { record } from '../audit.mjs';
16
+ import { doctorRoot } from '../../config/xdg.mjs';
17
17
  import {
18
18
  getTotalDailySpend,
19
19
  getDailySpend,
@@ -26,15 +26,15 @@ import {
26
26
  export const name = 'cost';
27
27
  export const intervalMs = 10 * 60 * 1000;
28
28
 
29
- const SESSION_COST_PATH = join(homedir(), '.cx', 'session-cost.jsonl');
30
- const STATE_PATH = join(homedir(), '.cx', 'cost-watcher-state.json');
29
+ const SESSION_COST_PATH = join(doctorRoot(), 'session-cost.jsonl');
30
+ const STATE_PATH = join(doctorRoot(), 'cost-watcher-state.json');
31
31
 
32
32
  function loadState() {
33
33
  if (!existsSync(STATE_PATH)) return { lastIngestedTsMs: 0 };
34
34
  try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')); } catch { return { lastIngestedTsMs: 0 }; }
35
35
  }
36
36
  function saveState(state) {
37
- const dir = join(homedir(), '.cx');
37
+ const dir = doctorRoot();
38
38
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39
39
  writeFileSync(STATE_PATH, JSON.stringify(state));
40
40
  }
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
 
17
17
  import { record } from '../audit.mjs';
18
18
  import { escalate } from '../escalate.mjs';
19
+ import { cacheDir } from '../../config/xdg.mjs';
19
20
 
20
21
  export const name = 'disk';
21
22
  export const intervalMs = 5 * 60 * 1000;
@@ -54,7 +55,7 @@ function rotateTarget({ path: relPath, maxLines }) {
54
55
  }
55
56
 
56
57
  function pruneRuntimeLogs() {
57
- const runtimeDir = join(homedir(), '.construct', '.runtime');
58
+ const runtimeDir = join(cacheDir(), '.runtime');
58
59
  if (!existsSync(runtimeDir)) return { pruned: 0 };
59
60
  const maxAgeMs = 7 * 24 * 60 * 60 * 1000;
60
61
  const now = Date.now();
@@ -99,11 +100,11 @@ export async function tick() {
99
100
  kind: 'action',
100
101
  watcher: name,
101
102
  action: 'prune',
102
- target: '.construct/.runtime/',
103
+ target: '.cache/construct/.runtime/',
103
104
  summary: `pruned ${runtimePrune.pruned} stale runtime log(s) (>7 days)`,
104
105
  context: runtimePrune,
105
106
  });
106
- actions.push({ type: 'prune', target: '.construct/.runtime/' });
107
+ actions.push({ type: 'prune', target: '.cache/construct/.runtime/' });
107
108
  }
108
109
 
109
110
  const free = freeBytesAt(homedir());
@@ -13,12 +13,12 @@
13
13
 
14
14
  import { spawnSync, spawn } from 'node:child_process';
15
15
  import { existsSync, readFileSync, openSync, mkdirSync } from 'node:fs';
16
- import { homedir } from 'node:os';
17
16
  import { dirname, join } from 'node:path';
18
17
  import { fileURLToPath } from 'node:url';
19
18
 
20
19
  import { record } from '../audit.mjs';
21
20
  import { escalate } from '../escalate.mjs';
21
+ import { configDir, stateDir, cacheDir } from '../../config/xdg.mjs';
22
22
 
23
23
  export const name = 'service-health';
24
24
  export const intervalMs = 60 * 1000;
@@ -56,7 +56,7 @@ function probeTcp(host, port, timeoutMs = 1500) {
56
56
  }
57
57
 
58
58
  function probeDashboardPort() {
59
- const statePath = join(homedir(), '.construct', 'dashboard.json');
59
+ const statePath = join(stateDir(), 'dashboard.json');
60
60
  if (!existsSync(statePath)) return true;
61
61
  try {
62
62
  const state = JSON.parse(readFileSync(statePath, 'utf8'));
@@ -68,7 +68,7 @@ function probeDashboardPort() {
68
68
  }
69
69
 
70
70
  function probeMemoryHealth() {
71
- const envPath = join(homedir(), '.construct', 'config.env');
71
+ const envPath = join(configDir(), 'config.env');
72
72
  if (!existsSync(envPath)) return true;
73
73
  try {
74
74
  const env = readFileSync(envPath, 'utf8');
@@ -87,7 +87,7 @@ function dockerRestart(container) {
87
87
 
88
88
  function detachSpawn(command, args, logName) {
89
89
  try {
90
- const runtimeDir = join(homedir(), '.construct', '.runtime');
90
+ const runtimeDir = join(cacheDir(), '.runtime');
91
91
  if (!existsSync(runtimeDir)) mkdirSync(runtimeDir, { recursive: true });
92
92
  const logPath = join(runtimeDir, logName);
93
93
  const fd = openSync(logPath, 'a');
@@ -106,7 +106,7 @@ function respawnViaConstructUp() {
106
106
  }
107
107
 
108
108
  function respawnCm() {
109
- const envPath = join(homedir(), '.construct', 'config.env');
109
+ const envPath = join(configDir(), 'config.env');
110
110
  let port = '4231';
111
111
  try {
112
112
  if (existsSync(envPath)) {
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Export **source** is Construct-authored markdown (typed artifacts). **Output**
7
7
  * spans many distributable formats. Ingest (separate pipeline) accepts PDF, Office,
8
- * email, AV, and plain text — see docs/reference/document-io.md.
8
+ * email, AV, and plain text — see docs/guides/reference/document-io.md.
9
9
  */
10
10
 
11
11
  import fs from 'node:fs';
@@ -27,6 +27,7 @@ import {
27
27
  import { exportDeckPptx, pptxgenPresent } from './deck-export-pptx.mjs';
28
28
  import { pdfUsesBundledBrandSans } from './brand-fonts.mjs';
29
29
  import { convertDocxToDoc, libreOfficeInstallHint, libreOfficePresent, resolveLibreOfficeBin } from './libreoffice-export.mjs';
30
+ import { resolveExportBranding } from './export-branding.mjs';
30
31
 
31
32
  export const EXPORT_FORMATS = ['pdf', 'docx', 'doc', 'deck', 'pptx', 'html', 'rtf', 'odt', 'epub', 'tex', 'txt', 'md', 'mdx'];
32
33
 
@@ -153,6 +154,7 @@ function installHint(name) {
153
154
  if (name === 'mmdc') return 'Install mermaid-cli for mermaid blocks (`npm install -g @mermaid-js/mermaid-cli`).';
154
155
  if (name === 'pptxgenjs') return 'Install pptxgenjs to enable PPTX export (`npm install pptxgenjs` in the Construct package).';
155
156
  if (name === 'construct-deck.html') return 'Bundled deck template missing from templates/distribution/construct-deck.html (Construct install integrity issue).';
157
+ if (name === 'construct-reference.docx') return 'Bundled branded reference doc missing from templates/distribution/construct-reference.docx; construct-branded DOCX export degrades to pandoc default styling. Regenerate from a source checkout with `node scripts/build-reference-docx.mjs`.';
156
158
  if (name === 'libreoffice') return libreOfficeInstallHint();
157
159
  return `Install ${name} to enable this export format.`;
158
160
  }
@@ -160,13 +162,17 @@ function installHint(name) {
160
162
  export function detect(format, env = process.env, {
161
163
  figures = false,
162
164
  artifactType = null,
165
+ branding = 'construct',
163
166
  cwd = process.cwd(),
164
167
  repoRoot = REPO_ROOT,
165
168
  } = {}) {
166
- const config = FORMAT_ENGINES[format];
167
- if (!config) return { ok: false, format, present: false, missing: [], message: `Unsupported format: ${format}. Supported: ${EXPORT_FORMATS.join(', ')}.` };
169
+ const brandingPolicy = resolveExportBranding(format, branding);
170
+ const config = format === 'pptx' && brandingPolicy.applied === 'plain'
171
+ ? { engine: 'pandoc', writer: 'pptx', pdfEngine: null, extraBinaries: [] }
172
+ : FORMAT_ENGINES[format];
173
+ if (!config) return { ok: false, format, branding: brandingPolicy, present: false, missing: [], message: `Unsupported format: ${format}. Supported: ${EXPORT_FORMATS.join(', ')}.` };
168
174
  if (config.engine === 'copy') {
169
- return { ok: true, format, figures: false, present: true, binaries: [], missing: [], message: `Ready: source emit (${format})` };
175
+ return { ok: true, format, branding: brandingPolicy, figures: false, present: true, binaries: [], missing: [], message: `Ready: source emit (${format})` };
170
176
  }
171
177
  const required = [];
172
178
  if (config.engine === 'pptxgenjs') {
@@ -174,13 +180,19 @@ export function detect(format, env = process.env, {
174
180
  } else {
175
181
  required.push(config.engine, ...(config.extraBinaries || []));
176
182
  }
177
- if (format === 'pdf') {
183
+ if (format === 'pdf' && brandingPolicy.applied === 'construct') {
178
184
  const template = detectPdfTemplate({ artifactType, cwd, repoRoot });
179
185
  if (!template.present) required.push('construct-pdf.typ');
180
186
  }
181
- if (format === 'deck' && !deckTemplatePath(repoRoot)) {
187
+ if (format === 'deck' && brandingPolicy.applied === 'construct' && !deckTemplatePath(repoRoot)) {
182
188
  required.push('construct-deck.html');
183
189
  }
190
+ if (format === 'html' && brandingPolicy.applied === 'construct' && !htmlTemplatePath(repoRoot)) required.push('construct-web.html');
191
+
192
+ // The DOCX reference doc only restyles pandoc output; pandoc emits a valid file without it and
193
+ // the export passes --reference-doc only when present, so unlike the PDF/HTML/deck templates a
194
+ // missing reference degrades gracefully rather than blocking the export.
195
+
184
196
  if (format === 'doc' && !libreOfficePresent(env)) {
185
197
  required.push('libreoffice');
186
198
  }
@@ -193,9 +205,10 @@ export function detect(format, env = process.env, {
193
205
  if (name.endsWith('.lua')) {
194
206
  return { name, path: fs.existsSync(diagramFilterPath()) ? diagramFilterPath() : null, version: null };
195
207
  }
196
- if (name.endsWith('.html')) {
208
+ if (name === 'construct-deck.html') {
197
209
  return { name, path: deckTemplatePath(repoRoot), version: null };
198
210
  }
211
+ if (name === 'construct-web.html') return { name, path: htmlTemplatePath(repoRoot), version: null };
199
212
  if (name === 'pptxgenjs') {
200
213
  return { name, path: pptxgenPresent() ? 'bundled' : null, version: null };
201
214
  }
@@ -213,6 +226,7 @@ export function detect(format, env = process.env, {
213
226
  return {
214
227
  ok: true,
215
228
  format,
229
+ branding: brandingPolicy,
216
230
  figures,
217
231
  present: missing.length === 0,
218
232
  binaries: status,
@@ -290,6 +304,7 @@ export function exportMarkdown({
290
304
  outputPath,
291
305
  format,
292
306
  figures = false,
307
+ branding = 'construct',
293
308
  artifactType = null,
294
309
  env = process.env,
295
310
  spawnFn = spawnSync,
@@ -304,21 +319,24 @@ export function exportMarkdown({
304
319
  const resolvedType = artifactType || metadata.artifactType || null;
305
320
  const effectiveEnv = figures ? buildDistributionDiagramEnv(env) : env;
306
321
 
307
- const detection = detect(format, effectiveEnv, { figures, artifactType: resolvedType, cwd, repoRoot });
322
+ const brandingPolicy = resolveExportBranding(format, branding);
323
+ const detection = detect(format, effectiveEnv, { figures, artifactType: resolvedType, branding, cwd, repoRoot });
308
324
  if (!detection.present) {
309
325
  return { ok: false, format, inputPath, missing: detection.missing, message: detection.message };
310
326
  }
311
327
 
312
- const config = FORMAT_ENGINES[format];
328
+ const config = format === 'pptx' && brandingPolicy.applied === 'plain'
329
+ ? { engine: 'pandoc', writer: 'pptx', pdfEngine: null, extraBinaries: [] }
330
+ : FORMAT_ENGINES[format];
313
331
  const target = path.resolve(outputPath || defaultOutputPath(inputPath, format));
314
332
  fs.mkdirSync(path.dirname(target), { recursive: true });
315
333
 
316
334
  if (config.engine === 'copy') {
317
- return exportSourceCopy({ inputPath, target, format });
335
+ return { ...exportSourceCopy({ inputPath, target, format }), branding: brandingPolicy };
318
336
  }
319
337
 
320
- if (format === 'pptx') {
321
- return exportDeckPptx({ inputPath, outputPath: target, metadata, repoRoot });
338
+ if (format === 'pptx' && brandingPolicy.applied === 'construct') {
339
+ return { ...exportDeckPptx({ inputPath, outputPath: target, metadata, repoRoot }), branding: brandingPolicy };
322
340
  }
323
341
 
324
342
  if (format === 'doc') {
@@ -328,6 +346,7 @@ export function exportMarkdown({
328
346
  outputPath: intermediate,
329
347
  format: 'docx',
330
348
  figures,
349
+ branding,
331
350
  artifactType: resolvedType,
332
351
  env: effectiveEnv,
333
352
  spawnFn,
@@ -356,12 +375,13 @@ export function exportMarkdown({
356
375
  pdfEngine: null,
357
376
  figures,
358
377
  template: null,
378
+ branding: brandingPolicy,
359
379
  message: loResult.message,
360
380
  };
361
381
  }
362
382
 
363
383
  const prepared = prepareExportInput(inputPath, { format, figures, metadata });
364
- const templatePath = format === 'pdf'
384
+ const templatePath = format === 'pdf' && brandingPolicy.applied === 'construct'
365
385
  ? resolvePdfTemplatePath({ artifactType: resolvedType, cwd, repoRoot })
366
386
  : null;
367
387
 
@@ -378,25 +398,26 @@ export function exportMarkdown({
378
398
  });
379
399
 
380
400
  const args = ['-f', 'markdown', '-o', target, '--standalone'];
401
+ if (config.writer) args.push('-t', config.writer);
381
402
  if (config.pdfEngine) args.push(`--pdf-engine=${config.pdfEngine}`);
382
403
  if (format === 'pdf') {
383
404
  if (templatePath) args.push('--template', templatePath);
384
- args.push(...pdfEngineFontOpts(repoRoot));
405
+ if (brandingPolicy.applied === 'construct') args.push(...pdfEngineFontOpts(repoRoot));
385
406
  args.push(...metadataArgs);
386
407
  } else if (format === 'html') {
387
408
  const htmlTpl = htmlTemplatePath(repoRoot);
388
- if (htmlTpl) args.push('--template', htmlTpl);
409
+ if (htmlTpl && brandingPolicy.applied === 'construct') args.push('--template', htmlTpl);
389
410
  args.push('--embed-resources');
390
411
  args.push(...metadataArgs);
391
412
  } else if (format === 'deck') {
392
413
  const deckTpl = deckTemplatePath(repoRoot);
393
- if (deckTpl) args.push('--template', deckTpl);
414
+ if (deckTpl && brandingPolicy.applied === 'construct') args.push('--template', deckTpl);
394
415
  args.push('--section-divs');
395
416
  args.push('--embed-resources');
396
417
  args.push(...metadataArgs);
397
418
  } else if (format === 'docx') {
398
419
  const ref = docxReferencePath(repoRoot);
399
- if (ref) args.push('--reference-doc', ref);
420
+ if (ref && brandingPolicy.applied === 'construct') args.push('--reference-doc', ref);
400
421
  args.push(...metadataArgs);
401
422
  }
402
423
  if (figures && fs.existsSync(diagramFilterPath())) {
@@ -443,6 +464,7 @@ export function exportMarkdown({
443
464
  pdfEngine: config.pdfEngine,
444
465
  figures,
445
466
  template: templatePath,
467
+ branding: brandingPolicy,
446
468
  message: `Wrote ${path.relative(process.cwd(), target)}`,
447
469
  };
448
470
  }
@@ -345,8 +345,8 @@ function extractPdf(filePath) {
345
345
  if (extracted.text.length < threshold) {
346
346
  const ocrHint = process.env.CONSTRUCT_PDF_OCR_HINT || 'none';
347
347
  const hintMsg = ocrHint !== 'none'
348
- ? `OCR backend hint: ${ocrHint}. See docs/intake/scanned-pdfs.md.`
349
- : 'Set CONSTRUCT_PDF_OCR_HINT=tesseract|pdfocr to enable OCR. See docs/intake/scanned-pdfs.md.';
348
+ ? `OCR backend hint: ${ocrHint}. See docs/guides/intake/scanned-pdfs.md.`
349
+ : 'Set CONSTRUCT_PDF_OCR_HINT=tesseract|pdfocr to enable OCR. See docs/guides/intake/scanned-pdfs.md.';
350
350
  extracted.droppedInfo.push(makeDropInfo({
351
351
  kind: 'scanned-pdf',
352
352
  count: pages,
@@ -692,7 +692,7 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
692
692
  // Throw a typed error so callers can route to .cx/intake/needs-asr/.
693
693
  if (AUDIO_VIDEO_EXTS.has(extension)) {
694
694
  const err = new Error(
695
- `Audio/video extraction requires ASR. Set CONSTRUCT_ASR_BACKEND=whisper|assemblyai to enable. See docs/intake/audio-video.md.`,
695
+ `Audio/video extraction requires ASR. Set CONSTRUCT_ASR_BACKEND=whisper|assemblyai to enable. See docs/guides/intake/audio-video.md.`,
696
696
  );
697
697
  err.code = 'ASR_REQUIRED';
698
698
  err.extension = extension;
@@ -137,19 +137,29 @@ function collectInputFiles(inputPath, { maxDepth = 10 } = {}) {
137
137
  return files.sort((a, b) => a.localeCompare(b));
138
138
  }
139
139
 
140
+ // Ingest writes a markdown rendering of the source, preserving the source name
141
+ // for provenance (report.pdf → report.pdf.md). A source already in markdown keeps
142
+ // its name unchanged so it never gains a second extension (_template.md →
143
+ // _template.md, not _template.md.md).
144
+
145
+ function markdownOutputName(sourcePath) {
146
+ const base = basename(sourcePath);
147
+ return /\.md$/i.test(base) ? base : `${base}.md`;
148
+ }
149
+
140
150
  function resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target }) {
141
151
  if (outputPath) return normalizeOutputPath(outputPath, cwd);
142
152
  if (target === 'sibling') {
143
- return join(dirname(sourcePath), `${basename(sourcePath)}.md`);
153
+ return join(dirname(sourcePath), markdownOutputName(sourcePath));
144
154
  }
145
155
  // knowledge/<subdir> targets land in .cx/knowledge/<subdir>/
146
156
  if (target.startsWith('knowledge/')) {
147
157
  const sub = target.slice('knowledge/'.length);
148
158
  const resolvedDir = normalizeOutputPath(`${KNOWLEDGE_ROOT}/${sub}`, cwd);
149
- return join(resolvedDir, `${basename(sourcePath)}.md`);
159
+ return join(resolvedDir, markdownOutputName(sourcePath));
150
160
  }
151
161
  const resolvedDir = normalizeOutputPath(outputDir || DEFAULT_TARGET_DIR, cwd);
152
- return join(resolvedDir, `${basename(sourcePath)}.md`);
162
+ return join(resolvedDir, markdownOutputName(sourcePath));
153
163
  }
154
164
 
155
165
  // The high-fidelity (docling) path provisions a Python venv and downloads ML
@@ -6,13 +6,14 @@
6
6
  */
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
+ import { doctorRoot } from './config/xdg.mjs';
9
10
 
10
11
  const HIGH_BYTES_THRESHOLD = 750_000;
11
12
  const HIGH_REPEATED_RATIO = 0.4;
12
13
  const HIGH_LARGE_READ_COUNT = 3;
13
14
 
14
15
  export function readEfficiencyLog(homeDir) {
15
- const filePath = join(homeDir, '.cx', 'session-efficiency.json');
16
+ const filePath = join(doctorRoot(homeDir), 'session-efficiency.json');
16
17
  if (!existsSync(filePath)) return null;
17
18
  try {
18
19
  return JSON.parse(readFileSync(filePath, 'utf8'));
@@ -10,7 +10,8 @@
10
10
 
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
- import os from 'node:os';
13
+
14
+ import { doctorRoot } from '../config/xdg.mjs';
14
15
 
15
16
  export class ApprovalQueue {
16
17
  #items = new Map(); // id → ApprovalItem
@@ -171,6 +172,6 @@ export class ApprovalQueue {
171
172
  }
172
173
 
173
174
  static defaultPersistPath() {
174
- return path.join(os.homedir(), '.cx', 'approval-queue.jsonl');
175
+ return path.join(doctorRoot(), 'approval-queue.jsonl');
175
176
  }
176
177
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * lib/embed/auto-sources.mjs — resolve embed auto-discovery sources from project config.
3
+ *
4
+ * When embed.yaml is absent, builds provider source records from typed
5
+ * construct.config.json targets merged with legacy env lists. Falls back
6
+ * to broad provider defaults only when no targets are configured.
7
+ */
8
+
9
+ import { loadProjectConfig } from '../config/project-config.mjs';
10
+ import {
11
+ resolveEffectiveSourceTargetsFromConfig,
12
+ targetsToEmbedSources,
13
+ legacyEnvSourceTargets,
14
+ } from '../config/source-targets.mjs';
15
+
16
+ export function resolveAutoEmbedSources({ cwd = process.cwd(), env = process.env, registry }) {
17
+ const { config } = loadProjectConfig(cwd, env);
18
+ const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
19
+
20
+ if (targets.length === 0) {
21
+ return registry.autoSources(env);
22
+ }
23
+
24
+ const configuredProviders = new Set(targets.map((t) => t.provider));
25
+ const hasLegacyForUnconfigured = (provider) =>
26
+ legacyEnvSourceTargets(env).some((t) => t.provider === provider);
27
+
28
+ const sources = targetsToEmbedSources(targets);
29
+
30
+ for (const name of registry.names()) {
31
+ if (configuredProviders.has(name)) continue;
32
+ if (hasLegacyForUnconfigured(name)) continue;
33
+ const instance = registry.get(name);
34
+ if (typeof instance?.defaultSources !== 'function') continue;
35
+ }
36
+
37
+ const seen = new Set();
38
+ return sources.filter((s) => {
39
+ const key = `${s.provider}:${(s.refs ?? []).join(',')}:${JSON.stringify(s.repos ?? s.project ?? s.team ?? s.channels ?? '')}`;
40
+ if (seen.has(key)) return false;
41
+ seen.add(key);
42
+ return true;
43
+ });
44
+ }