@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/init.mjs CHANGED
@@ -77,12 +77,14 @@ function main() {
77
77
  content: `${JSON.stringify({ version: 1, formality: 3, toneOverride: {}, audienceLexicon: { prefer: [], avoid: [] } }, null, 2)}\n`,
78
78
  generator: "construct/init",
79
79
  });
80
- // `.cx/` already exists from the context writes above and is gitignored in full
81
- // (ADR-0027), so a keep file there could never be tracked; `.cx/inbox/` is the local
82
- // intake drop zone the watcher polls. Create the directories; write no dead keep file.
80
+ // Single canonical drop zone (ADR-0045 §C): the visible project-root `inbox/`,
81
+ // covered by the host .gitignore (ADR-0027 §1). Writers stage under the
82
+ // gitignored `inbox/.staging/` and atomically rename in; the watcher consumes
83
+ // only complete top-level files. Machine/runtime intake state lives under the
84
+ // gitignored `.cx/intake/`, created lazily by the queue.
83
85
 
84
- fs.mkdirSync(path.join(target, ".cx", "inbox"), { recursive: true });
85
- created.push(".cx/inbox/");
86
+ fs.mkdirSync(path.join(target, "inbox", ".staging"), { recursive: true });
87
+ created.push("inbox/");
86
88
 
87
89
  process.stdout.write(`\nConstruct init → ${target}\n\n`);
88
90
  if (created.length) {
@@ -2,7 +2,8 @@
2
2
  * lib/install/desktop-binary-download.mjs — downloads the Construct chat desktop binary.
3
3
  *
4
4
  * Fetches the platform-matching construct-chat binary from the latest GitHub
5
- * release and writes it to ~/.construct/bin/. Called by `construct install --scope=user`.
5
+ * release and writes it to the bin/ subdir of the XDG state dir. Called by
6
+ * `construct install --scope=user`.
6
7
  * Network failures are non-fatal: returns { status: 'skipped', reason } so the
7
8
  * rest of setup can proceed without the desktop binary.
8
9
  */
@@ -10,6 +11,8 @@
10
11
  import fs from 'node:fs';
11
12
  import path from 'node:path';
12
13
 
14
+ import { stateDir } from '../config/xdg.mjs';
15
+
13
16
  const GITHUB_REPO = 'geraldmaron/construct';
14
17
 
15
18
  function assetName() {
@@ -46,7 +49,7 @@ async function downloadAsset(url, dest, env) {
46
49
 
47
50
  // Returns { status: 'downloaded'|'skipped'|'failed' }; 'skipped' and 'failed' are both non-fatal.
48
51
  export async function downloadDesktopBinary({ homeDir, env = process.env } = {}) {
49
- const binDir = path.join(homeDir, '.construct', 'bin');
52
+ const binDir = path.join(stateDir(homeDir), 'bin');
50
53
  const dest = path.join(binDir, destBinaryName());
51
54
  const name = assetName();
52
55
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * lib/intake/constants.mjs — shared intake watcher depth constants and guidance.
3
+ */
4
+
5
+ export const INTAKE_DEFAULT_MAX_DEPTH = 4;
6
+ export const INTAKE_HARD_MAX_DEPTH = 16;
7
+
8
+ export const INTAKE_DEPTH_GUIDANCE = [
9
+ { value: 0, label: 'Only this directory', detail: 'Scans files directly in the parent dir, ignores all subdirs.' },
10
+ { value: 1, label: 'One level deep', detail: 'Parent dir plus its immediate subdirs (e.g. parent/intake/file.md).' },
11
+ { value: 2, label: 'Two levels deep', detail: 'Parent and two subdirs. A reasonable default for organized intake roots.' },
12
+ { value: 4, label: 'Four levels (default)', detail: 'Catches most nested layouts without scanning huge trees.' },
13
+ { value: 8, label: 'Deep scan', detail: 'Useful for archives. Slower; skip if the parent contains build output.' },
14
+ { value: INTAKE_HARD_MAX_DEPTH, label: 'Unlimited (capped)', detail: `Walks up to ${INTAKE_HARD_MAX_DEPTH} levels — effectively unlimited. May be slow.` },
15
+ ];
16
+
17
+ export function describeIntakeDepth(depth = INTAKE_DEFAULT_MAX_DEPTH) {
18
+ const n = Number(depth);
19
+ const value = !Number.isFinite(n) || n < 0
20
+ ? INTAKE_DEFAULT_MAX_DEPTH
21
+ : Math.min(Math.floor(n), INTAKE_HARD_MAX_DEPTH);
22
+ const exact = INTAKE_DEPTH_GUIDANCE.find((g) => g.value === value);
23
+ if (exact) return exact;
24
+ return {
25
+ value,
26
+ label: `Custom depth (${value})`,
27
+ detail: `Walks up to ${value} levels of subdirectories below each parent.`,
28
+ };
29
+ }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * lib/intake/daemon.mjs — Continuous intake daemon with safeguards.
3
3
  *
4
- * Polls `.cx/inbox/` for new files, classifies them via the existing intake
5
- * classifier, and writes packets to `.cx/intake/pending/`. Packets past their
6
- * TTL move to `.cx/intake/dead-letter/`; failed classification retries up to
7
- * the budget then dead-letters with the failure reason.
4
+ * Polls the canonical project-root `inbox/` for new files, classifies them via
5
+ * the existing intake classifier, and writes packets to `.cx/intake/pending/`.
6
+ * Packets past their TTL move to `.cx/intake/dead-letter/`; failed
7
+ * classification retries up to the budget then dead-letters with the reason.
8
+ *
9
+ * Atomic handoff (ADR-0045 §C): only complete top-level files are enqueued.
10
+ * Dotfiles and the `inbox/.staging/` assembly directory are skipped, so a file
11
+ * a writer is still staging is invisible until it is renamed into `inbox/`.
8
12
  *
9
13
  * Built on lib/daemons/contract.mjs — every safeguard (bounded lifetime,
10
14
  * idle shutdown, heartbeat, killswitch, single-writer lock) applies.
@@ -12,30 +16,49 @@
12
16
 
13
17
  import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
14
18
  import { join, basename } from 'node:path';
15
- import { homedir } from 'node:os';
16
19
 
17
20
  import { createDaemon, classifyPacket } from '../daemons/contract.mjs';
18
21
  import { memoryCapMbFor } from '../resources/process-budget.mjs';
22
+ import { stateDir } from '../config/xdg.mjs';
19
23
 
20
24
  const KILLSWITCH_ENV = 'CONSTRUCT_INTAKE_DAEMON';
21
25
 
22
- function inboxDir(cwd) { return join(cwd, '.cx', 'inbox'); }
26
+ function inboxDir(cwd) { return join(cwd, 'inbox'); }
23
27
  function pendingDir(cwd) { return join(cwd, '.cx', 'intake', 'pending'); }
24
28
  function deadLetterDir(cwd) { return join(cwd, '.cx', 'intake', 'dead-letter'); }
25
- function heartbeatPath() { return join(homedir(), '.construct', 'intake-daemon.heartbeat'); }
29
+ function heartbeatPath() { return join(stateDir(), 'intake-daemon.heartbeat'); }
26
30
 
27
31
  function ensureDir(dir) {
28
32
  try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
29
33
  }
30
34
 
35
+ // A writer staging into `inbox/.staging/` and renaming into `inbox/` is the
36
+ // atomic handoff. As a backstop for writers that drop in place, a file whose
37
+ // size is still moving between two stats is mid-write — skip it this tick.
38
+
39
+ function isSizeStable(full) {
40
+ try {
41
+ const a = statSync(full);
42
+ const b = statSync(full);
43
+ return a.size === b.size;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
31
49
  function listInboxFiles(cwd) {
32
50
  const dir = inboxDir(cwd);
33
51
  if (!existsSync(dir)) return [];
34
52
  let entries;
35
53
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
54
+
55
+ // Only complete top-level files: dotfiles (and the `.staging/` assembly dir)
56
+ // are skipped, so a file a writer is still staging is invisible until rename.
57
+
36
58
  return entries
37
59
  .filter((e) => e.isFile() && !e.name.startsWith('.'))
38
- .map((e) => join(dir, e.name));
60
+ .map((e) => join(dir, e.name))
61
+ .filter((full) => isSizeStable(full));
39
62
  }
40
63
 
41
64
  /**
@@ -1,136 +1,56 @@
1
1
  /**
2
- * lib/intake/intake-config.mjs — user-facing intake watcher config.
2
+ * lib/intake/intake-config.mjs — intake watcher config facade.
3
3
  *
4
- * Lets the user define which directories the inbox watcher scans and how
5
- * deep it descends through subdirectories. Persists to
6
- * `<rootDir>/.cx/intake-config.json` and merges with env-driven defaults so
7
- * CLI, dashboard, and process env all converge on the same answer.
8
- *
9
- * Schema:
10
- * {
11
- * parentDirs: string[] // absolute or rootDir-relative
12
- * maxDepth: number // 0 = only the parent dir, no subdirs
13
- * includeProjectInbox: boolean // always include <rootDir>/.cx/inbox
14
- * includeDocsIntake: boolean // include <rootDir>/docs/intake when present
15
- * }
16
- *
17
- * maxDepth guidance (surfaced verbatim in the dashboard):
18
- * 0 — only files directly inside the parent dir
19
- * 1 — parent dir + its immediate subdirs
20
- * 2 — two levels of subdirs (a common default for project intake roots)
21
- * 3+ — deep scans; use with care on large trees
4
+ * Reads and writes intake policy via construct.config.json intakePolicy, the
5
+ * only config source (single-zone model, ADR-0045 §C).
22
6
  */
23
7
 
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
- import { isAbsolute, join, resolve } from 'node:path';
26
8
  import { shouldCreateCx } from '../project-detection.mjs';
27
- import { ensureCxDir } from '../project-init-shared.mjs';
28
-
29
- export const INTAKE_CONFIG_FILE = '.cx/intake-config.json';
30
- export const INTAKE_DEFAULT_MAX_DEPTH = 4;
31
- export const INTAKE_HARD_MAX_DEPTH = 16;
32
-
33
- export const INTAKE_DEPTH_GUIDANCE = [
34
- { value: 0, label: 'Only this directory', detail: 'Scans files directly in the parent dir, ignores all subdirs.' },
35
- { value: 1, label: 'One level deep', detail: 'Parent dir plus its immediate subdirs (e.g. parent/intake/file.md).' },
36
- { value: 2, label: 'Two levels deep', detail: 'Parent and two subdirs. A reasonable default for organized intake roots.' },
37
- { value: 4, label: 'Four levels (default)', detail: 'Catches most nested layouts without scanning huge trees.' },
38
- { value: 8, label: 'Deep scan', detail: 'Useful for archives. Slower; skip if the parent contains build output.' },
39
- { value: INTAKE_HARD_MAX_DEPTH, label: 'Unlimited (capped)', detail: `Walks up to ${INTAKE_HARD_MAX_DEPTH} levels — effectively unlimited. May be slow.` },
40
- ];
9
+ import {
10
+ resolvedIntakeConfig,
11
+ saveIntakePolicy,
12
+ DEFAULT_INTAKE_POLICY,
13
+ } from '../config/intake-policy.mjs';
14
+ import {
15
+ INTAKE_DEFAULT_MAX_DEPTH,
16
+ INTAKE_HARD_MAX_DEPTH,
17
+ INTAKE_DEPTH_GUIDANCE,
18
+ describeIntakeDepth,
19
+ } from './constants.mjs';
20
+
21
+ export {
22
+ INTAKE_DEFAULT_MAX_DEPTH,
23
+ INTAKE_HARD_MAX_DEPTH,
24
+ INTAKE_DEPTH_GUIDANCE,
25
+ describeIntakeDepth,
26
+ DEFAULT_INTAKE_POLICY,
27
+ };
41
28
 
42
29
  export const DEFAULT_INTAKE_CONFIG = Object.freeze({
43
30
  parentDirs: [],
44
31
  maxDepth: INTAKE_DEFAULT_MAX_DEPTH,
45
- includeProjectInbox: true,
46
- includeDocsIntake: true,
47
- includeArchetypeInbox: false,
48
32
  });
49
33
 
50
- export function intakeConfigPath(rootDir) {
51
- return join(rootDir, INTAKE_CONFIG_FILE);
52
- }
53
-
54
- function clampDepth(value) {
55
- const n = Number(value);
56
- if (!Number.isFinite(n) || n < 0) return INTAKE_DEFAULT_MAX_DEPTH;
57
- return Math.min(Math.floor(n), INTAKE_HARD_MAX_DEPTH);
58
- }
59
-
60
- function normalizeDir(dir, rootDir) {
61
- if (typeof dir !== 'string') return null;
62
- const trimmed = dir.trim();
63
- if (!trimmed) return null;
64
- return isAbsolute(trimmed) ? trimmed : resolve(rootDir, trimmed);
65
- }
66
-
67
- function parseEnvDirs(env) {
68
- const raw = String(env?.CX_INBOX_DIRS ?? '').trim();
69
- if (!raw) return [];
70
- return raw.split(':').map((p) => p.trim()).filter(Boolean);
71
- }
72
-
73
34
  export function loadIntakeConfig(rootDir, env = process.env) {
74
- const file = intakeConfigPath(rootDir);
75
- let stored = {};
76
- if (existsSync(file)) {
77
- try { stored = JSON.parse(readFileSync(file, 'utf8')); } catch { stored = {}; }
78
- }
79
-
80
- const parentDirs = []
81
- .concat(Array.isArray(stored.parentDirs) ? stored.parentDirs : [])
82
- .concat(parseEnvDirs(env))
83
- .map((dir) => normalizeDir(dir, rootDir))
84
- .filter(Boolean);
85
-
86
- const seen = new Set();
87
- const uniqueDirs = [];
88
- for (const dir of parentDirs) {
89
- if (seen.has(dir)) continue;
90
- seen.add(dir);
91
- uniqueDirs.push(dir);
92
- }
93
-
94
- const envDepth = env?.CX_INTAKE_MAX_DEPTH;
95
- const maxDepth = clampDepth(envDepth ?? stored.maxDepth ?? DEFAULT_INTAKE_CONFIG.maxDepth);
96
-
97
- return {
98
- parentDirs: uniqueDirs,
99
- maxDepth,
100
- includeProjectInbox: stored.includeProjectInbox !== false,
101
- includeDocsIntake: stored.includeDocsIntake !== false,
102
- includeArchetypeInbox: stored.includeArchetypeInbox === true,
103
- };
35
+ return resolvedIntakeConfig(rootDir, env);
104
36
  }
105
37
 
106
38
  export function saveIntakeConfig(rootDir, patch = {}) {
107
39
  if (!shouldCreateCx(rootDir)) {
108
40
  throw new Error('Refusing to write intake config: directory is not an initialized construct project. Run `construct init` first.');
109
41
  }
110
- const current = loadIntakeConfig(rootDir, {});
111
- const next = {
112
- parentDirs: Array.isArray(patch.parentDirs)
113
- ? patch.parentDirs.map((dir) => normalizeDir(dir, rootDir)).filter(Boolean)
114
- : current.parentDirs,
115
- maxDepth: clampDepth(patch.maxDepth ?? current.maxDepth),
116
- includeProjectInbox: patch.includeProjectInbox !== undefined ? Boolean(patch.includeProjectInbox) : current.includeProjectInbox,
117
- includeDocsIntake: patch.includeDocsIntake !== undefined ? Boolean(patch.includeDocsIntake) : current.includeDocsIntake,
118
- includeArchetypeInbox: patch.includeArchetypeInbox !== undefined ? Boolean(patch.includeArchetypeInbox) : current.includeArchetypeInbox,
119
- };
120
42
 
121
- const file = intakeConfigPath(rootDir);
122
- ensureCxDir(rootDir);
123
- writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
124
- return next;
43
+ const policyPatch = {};
44
+ if (patch.maxDepth !== undefined) policyPatch.maxDepth = patch.maxDepth;
45
+ if (Array.isArray(patch.parentDirs)) policyPatch.additionalDirs = patch.parentDirs;
46
+
47
+ const { policy } = saveIntakePolicy(rootDir, policyPatch);
48
+ return policyToConfigShape(policy);
125
49
  }
126
50
 
127
- export function describeIntakeDepth(depth = INTAKE_DEFAULT_MAX_DEPTH) {
128
- const value = clampDepth(depth);
129
- const exact = INTAKE_DEPTH_GUIDANCE.find((g) => g.value === value);
130
- if (exact) return exact;
51
+ function policyToConfigShape(policy) {
131
52
  return {
132
- value,
133
- label: `Custom depth (${value})`,
134
- detail: `Walks up to ${value} levels of subdirectories below each parent.`,
53
+ parentDirs: policy.additionalDirs,
54
+ maxDepth: policy.maxDepth,
135
55
  };
136
56
  }
@@ -18,6 +18,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
20
  import { spawnSync } from 'node:child_process';
21
+ import { configDir } from '../config/xdg.mjs';
21
22
 
22
23
  // ── Multi-method auth helpers ────────────────────────────────────────────
23
24
 
@@ -268,8 +269,8 @@ export async function createJiraTicket(packet, { host, email, token, project, is
268
269
  const resolvedToken = token || auth.token || process.env.JIRA_API_TOKEN;
269
270
  const resolvedProject = project || auth.project || process.env.JIRA_PROJECT;
270
271
 
271
- if (!resolvedHost) return { ok: false, externalUrl: null, externalId: null, error: 'JIRA_HOST not set (e.g. https://your-domain.atlassian.net). Check ~/.construct/config.env or shell rc.' };
272
- if (!resolvedEmail || !resolvedToken) return { ok: false, externalUrl: null, externalId: null, error: 'JIRA_USER and JIRA_API_TOKEN required. Check ~/.construct/config.env, ~/.env, or 1Password.' };
272
+ if (!resolvedHost) return { ok: false, externalUrl: null, externalId: null, error: 'JIRA_HOST not set (e.g. https://your-domain.atlassian.net). Check ~/.config/construct/config.env or shell rc.' };
273
+ if (!resolvedEmail || !resolvedToken) return { ok: false, externalUrl: null, externalId: null, error: 'JIRA_USER and JIRA_API_TOKEN required. Check ~/.config/construct/config.env, ~/.env, or 1Password.' };
273
274
  if (!resolvedProject) return { ok: false, externalUrl: null, externalId: null, error: 'JIRA_PROJECT not set (e.g. PROJ)' };
274
275
 
275
276
  const t = packet?.triage || {};
@@ -385,8 +386,8 @@ export async function publishArtifactToConfluence(artifact, { host, email, token
385
386
  const resolvedToken = token || auth.token || process.env.CONFLUENCE_API_TOKEN;
386
387
  const resolvedSpace = space || auth.space || process.env.CONFLUENCE_SPACE;
387
388
 
388
- if (!resolvedHost) return { ok: false, externalUrl: null, externalId: null, error: 'CONFLUENCE_HOST not set (e.g. https://your-domain.atlassian.net/wiki). Check ~/.construct/config.env or shell rc.' };
389
- if (!resolvedEmail || !resolvedToken) return { ok: false, externalUrl: null, externalId: null, error: 'CONFLUENCE_USER and CONFLUENCE_API_TOKEN required. Check ~/.construct/config.env, ~/.env, or 1Password.' };
389
+ if (!resolvedHost) return { ok: false, externalUrl: null, externalId: null, error: 'CONFLUENCE_HOST not set (e.g. https://your-domain.atlassian.net/wiki). Check ~/.config/construct/config.env or shell rc.' };
390
+ if (!resolvedEmail || !resolvedToken) return { ok: false, externalUrl: null, externalId: null, error: 'CONFLUENCE_USER and CONFLUENCE_API_TOKEN required. Check ~/.config/construct/config.env, ~/.env, or 1Password.' };
390
391
  if (!resolvedSpace) return { ok: false, externalUrl: null, externalId: null, error: 'CONFLUENCE_SPACE not set (e.g. PROD)' };
391
392
 
392
393
  const prefix = { prd: 'PRD', adr: 'ADR', rfc: 'RFC' }[artifact.type] || artifact.type.toUpperCase();
@@ -608,7 +609,7 @@ function resolveCredentialSync(varName, homeDir) {
608
609
  }
609
610
  } catch { /* skip */ }
610
611
  try {
611
- const cfgPath = join(homeDir, '.construct', 'config.env');
612
+ const cfgPath = join(configDir(homeDir), 'config.env');
612
613
  if (existsSync(cfgPath)) {
613
614
  const content = readFileSync(cfgPath, 'utf8');
614
615
  const m = content.match(new RegExp(`^${varName}=(.+)$`, 'm'));
@@ -703,9 +704,9 @@ function resolveCredential(varName, homeDir) {
703
704
  }
704
705
  } catch { /* skip */ }
705
706
 
706
- // ~/.construct/config.env
707
+ // config.env in the XDG config dir
707
708
  try {
708
- const cfgPath = join(homeDir, '.construct', 'config.env');
709
+ const cfgPath = join(configDir(homeDir), 'config.env');
709
710
  if (existsSync(cfgPath)) {
710
711
  const content = readFileSync(cfgPath, 'utf8');
711
712
  const m = content.match(new RegExp(`^${varName}=(.+)$`, 'm'));
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Sources indexed:
9
9
  * - Observations (.cx/observations/)
10
- * - Artifacts (docs/adr/, docs/prd/, docs/rfc/)
10
+ * - Artifacts (docs/decisions/adr/, docs/specs/prd/, docs/decisions/rfc/)
11
11
  * - Snapshots (.cx/snapshot.md + any configured output paths)
12
12
  * - Approval queue (.cx/approval-queue.jsonl)
13
13
  *
@@ -102,7 +102,7 @@ function loadMarkdownChunks(dir, source) {
102
102
  */
103
103
  function loadArtifactChunks(rootDir) {
104
104
  const chunks = [];
105
- for (const subdir of ['docs/adr', 'docs/prd', 'docs/rfc', 'docs/concepts/architecture.md']) {
105
+ for (const subdir of ['docs/adr', 'docs/prd', 'docs/rfc', 'docs/guides/concepts/architecture.md']) {
106
106
  const full = path.resolve(rootDir, subdir);
107
107
  if (subdir.endsWith('.md')) {
108
108
  if (!fs.existsSync(full)) continue;
@@ -6,11 +6,11 @@
6
6
  * `knowledge_search` MCP tool — no daemon, no network, no external deps.
7
7
  *
8
8
  * Sources searched (in priority order):
9
- * 1. docs/concepts/*.md — architecture, agents, gates, durable state
10
- * 2. docs/start/*.mdx — install + first task
9
+ * 1. docs/guides/concepts/*.md — architecture, agents, gates, durable state
10
+ * 2. docs/guides/start/*.mdx — install + first task
11
11
  * 3. docs/README.md — docs index + contract
12
12
  * 4. .cx/knowledge/ — operator-written internal docs
13
- * 5. Any *.md in docs/cookbook/ — task-oriented recipes
13
+ * 5. Any *.md in docs/guides/cookbook/ — task-oriented recipes
14
14
  * 6. <projectRoot>/.cx/knowledge/** — the cwd project's knowledge tree,
15
15
  * including `external/research/` written by `construct knowledge add`,
16
16
  * so foreign-project queries surface project content alongside bundled docs.
@@ -47,17 +47,17 @@ function buildSourceList(repoRoot = REPO_ROOT, projectRoot = null) {
47
47
  const sources = [];
48
48
 
49
49
  const priority = [
50
- 'docs/concepts/architecture.md',
51
- 'docs/concepts/agents-and-personas.mdx',
52
- 'docs/concepts/gates-and-enforcement.md',
53
- 'docs/concepts/beads-and-state.md',
54
- 'docs/concepts/prompt-surfaces.md',
55
- 'docs/concepts/knowledge-layout.md',
56
- 'docs/concepts/deployment-model.md',
57
- 'docs/concepts/embedding-boundary.md',
50
+ 'docs/guides/concepts/architecture.md',
51
+ 'docs/guides/concepts/agents-and-personas.mdx',
52
+ 'docs/guides/concepts/gates-and-enforcement.md',
53
+ 'docs/guides/concepts/beads-and-state.md',
54
+ 'docs/guides/concepts/prompt-surfaces.md',
55
+ 'docs/guides/concepts/knowledge-layout.md',
56
+ 'docs/guides/concepts/deployment-model.md',
57
+ 'docs/guides/concepts/embedding-boundary.md',
58
58
  'docs/README.md',
59
- 'docs/start/install.mdx',
60
- 'docs/start/first-task.mdx',
59
+ 'docs/guides/start/install.mdx',
60
+ 'docs/guides/start/first-task.mdx',
61
61
  ];
62
62
 
63
63
  for (const rel of priority) {
@@ -66,12 +66,12 @@ function buildSourceList(repoRoot = REPO_ROOT, projectRoot = null) {
66
66
  }
67
67
 
68
68
  // Cookbook recipes (task-oriented how-tos)
69
- const cookbookDir = join(repoRoot, 'docs', 'cookbook');
69
+ const cookbookDir = join(repoRoot, 'docs', 'guides', 'cookbook');
70
70
  if (existsSync(cookbookDir)) {
71
71
  for (const file of readdirSync(cookbookDir)) {
72
72
  if (file.endsWith('.md') || file.endsWith('.mdx')) {
73
73
  const full = join(cookbookDir, file);
74
- sources.push({ path: full, rel: `docs/cookbook/${file}`, priority: 2 });
74
+ sources.push({ path: full, rel: `docs/guides/cookbook/${file}`, priority: 2 });
75
75
  }
76
76
  }
77
77
  }
@@ -10,8 +10,9 @@
10
10
  * Triggered from two entry points:
11
11
  * - `construct cleanup` — manual sweep, optionally --dry-run
12
12
  * - bin/construct startup check — automatic when the installed version
13
- * differs from ~/.construct/.cleanup-stamp,
14
- * so an upgrade reclaims disk without
13
+ * differs from the XDG state-dir
14
+ * .cleanup-stamp, so an upgrade reclaims
15
+ * disk without
15
16
  * requiring a manual command.
16
17
  *
17
18
  * Scope is deliberately narrow: only artifacts whose retention is owned by
@@ -24,6 +25,9 @@ import fs from 'node:fs';
24
25
  import os from 'node:os';
25
26
  import path from 'node:path';
26
27
 
28
+ import { stateDir, cacheDir } from '../config/xdg.mjs';
29
+ import { doctorRoot } from '../config/xdg.mjs';
30
+
27
31
  const HARD_BUDGET_MS = 5000;
28
32
 
29
33
  // Size caps in bytes. Each entry: [filename relative to ~/.cx/, maxBytes].
@@ -92,7 +96,7 @@ function rotationConfig(env = process.env) {
92
96
  * were oversized before the cap existed.
93
97
  */
94
98
  export function cleanupEmbedLog({ homeDir = os.homedir(), env = process.env, dryRun = false } = {}) {
95
- const dir = path.join(homeDir, '.cx', 'runtime');
99
+ const dir = path.join(doctorRoot(homeDir), 'runtime');
96
100
  const baseLog = path.join(dir, 'embed-daemon.log');
97
101
  const { maxBytes, keep } = rotationConfig(env);
98
102
 
@@ -149,7 +153,7 @@ export function cleanupEmbedLog({ homeDir = os.homedir(), env = process.env, dry
149
153
  * recent records survive.
150
154
  */
151
155
  export function cleanupJsonlLogs({ homeDir = os.homedir(), dryRun = false } = {}) {
152
- const dir = path.join(homeDir, '.cx');
156
+ const dir = doctorRoot(homeDir);
153
157
  const summary = { truncated: [], freedBytes: 0 };
154
158
  if (!fs.existsSync(dir)) return summary;
155
159
 
@@ -168,11 +172,11 @@ export function cleanupJsonlLogs({ homeDir = os.homedir(), dryRun = false } = {}
168
172
  }
169
173
 
170
174
  /**
171
- * Age out ~/.construct/cache/ entries older than `maxAgeDays`. Cache hits are
175
+ * Age out XDG cache-dir entries older than `maxAgeDays`. Cache hits are
172
176
  * idempotent and re-fetched on demand, so pruning is safe.
173
177
  */
174
178
  export function cleanupCacheDir({ homeDir = os.homedir(), maxAgeDays = 30, dryRun = false } = {}) {
175
- const dir = path.join(homeDir, '.construct', 'cache');
179
+ const dir = cacheDir(homeDir);
176
180
  const summary = { removed: [], freedBytes: 0 };
177
181
  if (!fs.existsSync(dir)) return summary;
178
182
 
@@ -249,18 +253,18 @@ export function formatBytes(bytes) {
249
253
  // ---------------------------------------------------------------------------
250
254
  // Version stamp — drives automatic cleanup on upgrade.
251
255
  //
252
- // ~/.construct/.cleanup-stamp records the last version that ran cleanup.
253
- // On every CLI invocation, bin/construct compares the current package
256
+ // The .cleanup-stamp in the XDG state dir records the last version that ran
257
+ // cleanup. On every CLI invocation, bin/construct compares the current package
254
258
  // version to the stamp. A mismatch triggers a single cleanup pass and
255
259
  // writes the new version into the stamp. The first ever invocation also
256
260
  // triggers cleanup (no stamp present) — this is what unblocks existing
257
261
  // installs that already have 34 GB logs sitting around.
258
262
  // ---------------------------------------------------------------------------
259
263
 
260
- const STAMP_RELATIVE = path.join('.construct', '.cleanup-stamp');
264
+ const STAMP_FILENAME = '.cleanup-stamp';
261
265
 
262
266
  export function stampPath(homeDir = os.homedir()) {
263
- return path.join(homeDir, STAMP_RELATIVE);
267
+ return path.join(stateDir(homeDir), STAMP_FILENAME);
264
268
  }
265
269
 
266
270
  export function readVersionStamp({ homeDir = os.homedir() } = {}) {
@@ -58,7 +58,7 @@ import {
58
58
  profileCreate, profileArchive, sandboxList, learningStatus,
59
59
  knowledgeGraphAsk,
60
60
  } from './tools/profile.mjs';
61
- import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe, executionResolve } from './tools/embedded-contract.mjs';
61
+ import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe, executionResolve, artifactWorkflow } from './tools/embedded-contract.mjs';
62
62
 
63
63
  const DEFAULT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
64
64
  const ROOT_DIR = resolve(process.env.CX_TOOLKIT_DIR || DEFAULT_ROOT_DIR);
@@ -859,7 +859,7 @@ const ALL_TOOL_DEFS = [
859
859
  },
860
860
  {
861
861
  name: 'profile_create',
862
- description: 'Scaffold a draft org profile under `.cx/profiles/draft-<id>/` (requirements.md + profile.json + persona stubs + department charters). Writes durable state — requires `confirm=true`. For curated catalog work, follow `docs/concepts/profile-lifecycle.md` after creation.',
862
+ description: 'Scaffold a draft org profile under `.cx/profiles/draft-<id>/` (requirements.md + profile.json + persona stubs + department charters). Writes durable state — requires `confirm=true`. For curated catalog work, follow `docs/guides/concepts/profile-lifecycle.md` after creation.',
863
863
  inputSchema: {
864
864
  type: 'object',
865
865
  required: ['confirm', 'id'],
@@ -1134,6 +1134,23 @@ const ALL_TOOL_DEFS = [
1134
1134
  },
1135
1135
  },
1136
1136
  },
1137
+ {
1138
+ name: 'artifact_workflow',
1139
+ description: 'Plan a manifest-backed document artifact workflow and return a truthful provenance report. It separates planned steps from locally executed validation/export and never claims a host-planned specialist review or rewrite was completed. Durable local export requires approval_mode=allow-durable-write.',
1140
+ inputSchema: {
1141
+ type: 'object',
1142
+ properties: {
1143
+ input: { type: 'string', description: 'Natural-language artifact request.' },
1144
+ artifact_type: { type: 'string', description: 'Registered manifest document class.' },
1145
+ file_path: { type: 'string', description: 'Optional existing source document for local validation/export.' },
1146
+ format: { type: 'string', description: 'Distribution format such as pdf, docx, html, deck, or pptx.' },
1147
+ output_path: { type: 'string', description: 'Optional output destination when locally exporting.' },
1148
+ branding: { type: 'string', enum: ['construct', 'plain'], description: 'Construct is default; plain is an explicit opt-out.' },
1149
+ overrides: { type: 'object', description: 'Per-invocation manifest workflow overrides.' },
1150
+ approval_mode: { type: 'string', enum: ['proposal-only', 'requires-human-approval', 'allow-durable-write'], description: 'Only allow-durable-write performs local validation/export.' },
1151
+ },
1152
+ },
1153
+ },
1137
1154
  {
1138
1155
  name: 'model_resolve',
1139
1156
  description: 'Resolve which model an embedded Construct workflow should use given the host/IDE provider context. Precedence: host model → same-provider-family fallback → Construct tier default → structured config error. Never reads or returns credential values (requiresCredential is a boolean) and never claims unverified provider health. Read-only; performs no writes.',
@@ -1421,6 +1438,7 @@ export async function dispatchToolByName(name, args = {}) {
1421
1438
  else if (name === 'workflow_invoke') result = await workflowInvoke(args);
1422
1439
  else if (name === 'capability_describe') result = capabilityDescribe(args);
1423
1440
  else if (name === 'construct_execution_resolve') result = executionResolve(args);
1441
+ else if (name === 'artifact_workflow') result = artifactWorkflow(args);
1424
1442
  else if (name === 'orchestration_run') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationRun(args); }
1425
1443
  else if (name === 'orchestration_status') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationStatus(args); }
1426
1444
  else if (name === 'construct_call') {
@@ -14,6 +14,7 @@ import { invokeWorkflow as invokeWorkflowCore } from '../../embedded-contract/wo
14
14
  import { buildCapabilityContract } from '../../embedded-contract/capability.mjs';
15
15
  import { resolveExecution as resolveExecutionCore } from '../../embedded-contract/execution.mjs';
16
16
  import { resolveInput } from '../../embedded-contract/ingest.mjs';
17
+ import { runArtifactWorkflow as runArtifactWorkflowCore } from '../../artifact-workflow.mjs';
17
18
 
18
19
  async function ingestArgs(args) {
19
20
  if (args.input || !args.file_path) return { input: args.input, ingestion: undefined };
@@ -91,3 +92,16 @@ export function executionResolve(args = {}) {
91
92
  };
92
93
  return wrapContractResult(resolveExecutionCore(request), { surface: 'mcp' });
93
94
  }
95
+
96
+ export function artifactWorkflow(args = {}) {
97
+ return wrapContractResult(runArtifactWorkflowCore({
98
+ input: args.input || args.request,
99
+ artifactType: args.artifact_type,
100
+ filePath: args.file_path,
101
+ format: args.format,
102
+ outputPath: args.output_path,
103
+ branding: args.branding,
104
+ overrides: args.overrides,
105
+ approvalMode: args.approval_mode,
106
+ }), { surface: 'mcp' });
107
+ }
@@ -13,6 +13,7 @@ import { inspectContextState } from '../../context-state.mjs';
13
13
  import { loadWorkflow, summarizeWorkflow, inspectWorkflowHealth } from '../../workflow-state.mjs';
14
14
  import { readCurrentModels, resolveExecutionContractModelMetadata, selectModelTierForWorkCategory } from '../../model-router.mjs';
15
15
  import { buildPublicHealthSurface } from '../../status.mjs';
16
+ import { doctorRoot } from '../../config/xdg.mjs';
16
17
 
17
18
  export function exec(cmd, cwd) {
18
19
  return execSync(cmd, { stdio: 'pipe', timeout: 10000, cwd }).toString().trim();
@@ -60,7 +61,7 @@ function isBinary(buffer) {
60
61
  }
61
62
 
62
63
  export function agentHealth(args) {
63
- const reviewDir = join(homedir(), '.cx', 'performance-reviews');
64
+ const reviewDir = join(doctorRoot(), 'performance-reviews');
64
65
 
65
66
  if (!existsSync(reviewDir)) {
66
67
  return { error: "No performance reviews found. Run 'construct review' to generate one." };