@geraldmaron/construct 1.2.1 → 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 (198) hide show
  1. package/README.md +6 -6
  2. package/bin/construct +41 -67
  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-type-from-path.mjs +9 -5
  16. package/lib/audit-specialists.mjs +1 -1
  17. package/lib/audit-trail.mjs +3 -3
  18. package/lib/auto-docs.mjs +10 -10
  19. package/lib/bootstrap/built-ins.mjs +3 -3
  20. package/lib/boundary.mjs +3 -2
  21. package/lib/brand-prose.mjs +1 -1
  22. package/lib/cache-governor.js +4 -4
  23. package/lib/certification/document-io-fixtures.mjs +1 -1
  24. package/lib/chat/config.mjs +2 -2
  25. package/lib/chat/desktop-binary.mjs +5 -3
  26. package/lib/cli-commands.mjs +3 -3
  27. package/lib/cli-service-inventory.mjs +1 -1
  28. package/lib/comment-lint.mjs +9 -5
  29. package/lib/config/alias.mjs +3 -2
  30. package/lib/config/intake-policy.mjs +8 -78
  31. package/lib/config/project-config.mjs +95 -5
  32. package/lib/config/schema.mjs +9 -19
  33. package/lib/config/xdg.mjs +63 -0
  34. package/lib/contracts/violation-log.mjs +2 -2
  35. package/lib/cost-ledger.mjs +4 -3
  36. package/lib/cost.mjs +3 -2
  37. package/lib/decisions/registry.mjs +3 -3
  38. package/lib/distill.mjs +2 -2
  39. package/lib/docs-verify.mjs +2 -0
  40. package/lib/doctor/audit.mjs +4 -3
  41. package/lib/doctor/cli.mjs +0 -1
  42. package/lib/doctor/index.mjs +4 -4
  43. package/lib/doctor/report.mjs +3 -3
  44. package/lib/doctor/watchers/bd-watch.mjs +5 -5
  45. package/lib/doctor/watchers/cost.mjs +4 -4
  46. package/lib/doctor/watchers/disk.mjs +4 -3
  47. package/lib/doctor/watchers/service-health.mjs +5 -5
  48. package/lib/document-export.mjs +1 -1
  49. package/lib/document-extract.mjs +3 -3
  50. package/lib/document-ingest.mjs +13 -3
  51. package/lib/efficiency.mjs +2 -1
  52. package/lib/embed/approval-queue.mjs +3 -2
  53. package/lib/embed/cli.mjs +7 -5
  54. package/lib/embed/config.mjs +6 -5
  55. package/lib/embed/daemon.mjs +6 -5
  56. package/lib/embed/inbox-live-watcher.mjs +1 -1
  57. package/lib/embed/inbox.mjs +52 -37
  58. package/lib/embed/intake-metrics.mjs +3 -2
  59. package/lib/embed/reconcile.mjs +1 -1
  60. package/lib/embed/semantic.mjs +3 -2
  61. package/lib/embed/supervision.mjs +4 -2
  62. package/lib/engine/registry.mjs +3 -3
  63. package/lib/env-config.mjs +34 -12
  64. package/lib/features.mjs +4 -3
  65. package/lib/health-check.mjs +7 -6
  66. package/lib/hook-health.mjs +5 -4
  67. package/lib/hooks/_lib/log.mjs +4 -3
  68. package/lib/hooks/_lib/output-mode.mjs +2 -1
  69. package/lib/hooks/agent-tracker.mjs +3 -4
  70. package/lib/hooks/audit-reads.mjs +2 -2
  71. package/lib/hooks/audit-trail.mjs +2 -2
  72. package/lib/hooks/bash-output-logger.mjs +3 -3
  73. package/lib/hooks/brand-prose-lint.mjs +1 -1
  74. package/lib/hooks/ci-status-check.mjs +3 -2
  75. package/lib/hooks/context-watch.mjs +2 -2
  76. package/lib/hooks/context-window-recovery.mjs +5 -5
  77. package/lib/hooks/dep-audit.mjs +2 -2
  78. package/lib/hooks/doc-coupling-check.mjs +3 -2
  79. package/lib/hooks/edit-accumulator.mjs +3 -2
  80. package/lib/hooks/edit-guard.mjs +3 -3
  81. package/lib/hooks/guard-bash.mjs +2 -2
  82. package/lib/hooks/model-fallback.mjs +2 -1
  83. package/lib/hooks/policy-engine.mjs +4 -3
  84. package/lib/hooks/pre-compact.mjs +7 -7
  85. package/lib/hooks/readme-age-check.mjs +2 -2
  86. package/lib/hooks/session-optimize.mjs +4 -3
  87. package/lib/hooks/session-start.mjs +6 -5
  88. package/lib/hooks/stop-notify.mjs +9 -8
  89. package/lib/hooks/stop-typecheck.mjs +4 -3
  90. package/lib/hooks/test-watch.mjs +2 -2
  91. package/lib/host-disposition.mjs +1 -1
  92. package/lib/ingest/provider-extract.mjs +2 -1
  93. package/lib/init/detect-existing-structure.mjs +2 -2
  94. package/lib/init/doc-lanes.mjs +3 -10
  95. package/lib/init-docs.mjs +12 -54
  96. package/lib/init-unified.mjs +35 -81
  97. package/lib/init.mjs +7 -5
  98. package/lib/install/desktop-binary-download.mjs +5 -2
  99. package/lib/intake/daemon.mjs +31 -8
  100. package/lib/intake/intake-config.mjs +5 -32
  101. package/lib/integrations/intake-integrations.mjs +8 -7
  102. package/lib/knowledge/rag.mjs +2 -2
  103. package/lib/knowledge/search.mjs +15 -15
  104. package/lib/maintenance/cleanup.mjs +14 -10
  105. package/lib/mcp/server.mjs +1 -1
  106. package/lib/mcp/tools/project.mjs +2 -1
  107. package/lib/model-cheapest-provider.mjs +4 -3
  108. package/lib/model-pricing.mjs +3 -2
  109. package/lib/model-router.mjs +4 -3
  110. package/lib/models/catalog.mjs +2 -1
  111. package/lib/models/execution-capability-profile.mjs +2 -1
  112. package/lib/models/provider-poll.mjs +2 -1
  113. package/lib/ollama-manager.mjs +0 -1
  114. package/lib/op-log.mjs +2 -1
  115. package/lib/opencode-runtime-plugin.mjs +3 -2
  116. package/lib/oracle/artifact-gate.mjs +3 -0
  117. package/lib/oracle/cli.mjs +2 -1
  118. package/lib/oracle/execute.mjs +3 -2
  119. package/lib/oracle/index.mjs +2 -1
  120. package/lib/oracle/read-model.mjs +3 -2
  121. package/lib/performance/generate.mjs +4 -3
  122. package/lib/platforms/capabilities.mjs +1 -1
  123. package/lib/plugin-registry.mjs +2 -1
  124. package/lib/profiles/lifecycle.mjs +3 -3
  125. package/lib/project-profile.mjs +2 -1
  126. package/lib/project-root.mjs +9 -7
  127. package/lib/provider-capabilities.js +4 -3
  128. package/lib/providers/auth-manager.mjs +2 -1
  129. package/lib/providers/copilot-auth.mjs +3 -2
  130. package/lib/providers/creds.mjs +3 -2
  131. package/lib/providers/registry.mjs +3 -3
  132. package/lib/providers/secret-resolver.mjs +3 -2
  133. package/lib/read-tracker-store.mjs +2 -1
  134. package/lib/reconcile/mcp-entry-reconcile.mjs +2 -2
  135. package/lib/reflect.mjs +2 -1
  136. package/lib/registry/generate-docs.mjs +2 -2
  137. package/lib/roles/approval-surface.mjs +5 -4
  138. package/lib/roles/event-bus.mjs +0 -1
  139. package/lib/roles/gateway.mjs +6 -2
  140. package/lib/rules-delivery.mjs +1 -1
  141. package/lib/runtime-pressure.mjs +5 -3
  142. package/lib/sandbox.mjs +3 -2
  143. package/lib/scheduler/solo.mjs +6 -4
  144. package/lib/server/auth.mjs +4 -3
  145. package/lib/server/index.mjs +24 -26
  146. package/lib/server/insights.mjs +5 -4
  147. package/lib/server/webhook.mjs +2 -1
  148. package/lib/service-manager.mjs +5 -3
  149. package/lib/setup.mjs +9 -8
  150. package/lib/status.mjs +6 -5
  151. package/lib/storage/embeddings-local.mjs +3 -2
  152. package/lib/storage/sync.mjs +2 -2
  153. package/lib/telemetry/client.mjs +0 -1
  154. package/lib/telemetry/intent-verifications.mjs +6 -6
  155. package/lib/telemetry/model-pricing-catalog.mjs +4 -3
  156. package/lib/telemetry/rule-calls.mjs +3 -3
  157. package/lib/telemetry/skill-calls.mjs +3 -3
  158. package/lib/template-registry.mjs +1 -0
  159. package/lib/templates/visual-requirements.mjs +1 -1
  160. package/lib/test-corpus-inventory.mjs +1 -1
  161. package/lib/uninstall/uninstall.mjs +12 -10
  162. package/package.json +3 -3
  163. package/platforms/claude/settings.template.json +43 -43
  164. package/rules/common/no-fabrication.md +1 -1
  165. package/rules/common/release-gates.md +1 -1
  166. package/rules/common/research.md +1 -1
  167. package/rules/common/review-before-change.md +1 -1
  168. package/scripts/sync-specialists.mjs +11 -8
  169. package/skills/docs/adr-workflow.md +3 -3
  170. package/skills/docs/init-docs.md +9 -9
  171. package/skills/docs/prd-workflow.md +5 -5
  172. package/skills/docs/product-intelligence-review.md +1 -1
  173. package/skills/docs/product-intelligence-workflow.md +2 -2
  174. package/skills/docs/product-signal-workflow.md +1 -1
  175. package/skills/docs/runbook-workflow.md +4 -4
  176. package/skills/exploration/unknown-codebase-onboarding.md +1 -1
  177. package/skills/operating/orchestration-reference.md +1 -1
  178. package/skills/routing.md +3 -3
  179. package/specialists/prompts/cx-architect.md +1 -1
  180. package/specialists/prompts/cx-docs-keeper.md +1 -1
  181. package/specialists/prompts/cx-researcher.md +1 -1
  182. package/specialists/prompts/cx-sre.md +1 -1
  183. package/specialists/role-manifests.json +6 -6
  184. package/templates/docs/README.md +125 -0
  185. package/templates/docs/construct_guide.md +3 -4
  186. package/templates/docs/persona-artifact.md +1 -1
  187. package/templates/docs/prds/README.md +25 -0
  188. package/templates/docs/prds/templates/_template.md +206 -0
  189. package/templates/docs/prds/templates/meta-prd.template.md +177 -0
  190. package/templates/docs/prds/templates/prd-business.template.md +61 -0
  191. package/templates/docs/prds/templates/prd-platform.template.md +81 -0
  192. package/templates/docs/prds/templates/prfaq.template.md +38 -0
  193. package/templates/docs/rfcs/README.md +22 -0
  194. package/templates/docs/rfcs/templates/_template.md +58 -0
  195. package/templates/docs/rfcs/templates/rfc-platform.template.md +72 -0
  196. package/templates/homebrew/construct.rb +1 -1
  197. package/templates/workflows/new-feature.yml +8 -8
  198. package/lib/intake/legacy-paths.mjs +0 -5
@@ -25,12 +25,12 @@
25
25
 
26
26
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
27
  import { join, dirname } from 'node:path';
28
- import { homedir } from 'node:os';
29
28
  import { createHash } from 'node:crypto';
30
29
  import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
31
30
  import { resolveProjectScopedPath } from '../project-root.mjs';
31
+ import { doctorRoot } from '../config/xdg.mjs';
32
32
 
33
- const CX_DIR = join(homedir(), '.cx');
33
+ const CX_DIR = doctorRoot();
34
34
  const LAST_AGENT = join(CX_DIR, 'last-agent.json');
35
35
 
36
36
  // contract-violations are PROJECT-SCOPED: resolves to
@@ -5,7 +5,7 @@
5
5
  * before they blow the daily budget. Separate from lib/roles/ because both
6
6
  * the gateway and the cost watcher consume it.
7
7
  *
8
- * Storage: ~/.cx/cost-ledger.json — small JSON object keyed by `<dayKey>:<personaId>`.
8
+ * Storage: <doctorRoot>/cost-ledger.json — small JSON object keyed by `<dayKey>:<personaId>`.
9
9
  * Rotates: entries older than 30 days are pruned on each write.
10
10
  *
11
11
  * Budgets are env-driven defaults; explicit `CONSTRUCT_BUDGET_<PERSONA>` wins.
@@ -15,16 +15,17 @@
15
15
  */
16
16
 
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
- import { homedir } from 'node:os';
19
18
  import { join } from 'node:path';
20
19
  import { cwd as procCwd } from 'node:process';
21
20
 
21
+ import { doctorRoot } from './config/xdg.mjs';
22
+
22
23
  const PRUNE_DAYS = 30;
23
24
  const DEFAULT_PER_PERSONA = 10.0;
24
25
  const DEFAULT_TOTAL = 50.0;
25
26
 
26
27
  function rootDir() {
27
- return process.env.CONSTRUCT_DOCTOR_ROOT || join(homedir(), '.cx');
28
+ return doctorRoot();
28
29
  }
29
30
  function ledgerPath() {
30
31
  return join(rootDir(), 'cost-ledger.json');
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);
@@ -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';
@@ -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
  }
package/lib/embed/cli.mjs CHANGED
@@ -21,6 +21,8 @@ import fs from 'node:fs';
21
21
  import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { spawn } from 'node:child_process';
24
+ import { configDir } from '../config/xdg.mjs';
25
+ import { doctorRoot } from '../config/xdg.mjs';
24
26
 
25
27
  const STATE_FILE = 'embed-daemon.json';
26
28
  const LOG_FILE = 'embed-daemon.log';
@@ -30,7 +32,7 @@ const LOG_FILE = 'embed-daemon.log';
30
32
  // ---------------------------------------------------------------------------
31
33
 
32
34
  function runtimeDir(homeDir = os.homedir()) {
33
- return path.join(homeDir, '.cx', 'runtime');
35
+ return path.join(doctorRoot(homeDir), 'runtime');
34
36
  }
35
37
 
36
38
  function statePath(homeDir = os.homedir()) {
@@ -273,8 +275,8 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPat
273
275
  const { config } = parseArgs(args);
274
276
  const configPath = config
275
277
  ? path.resolve(config)
276
- : fs.existsSync(path.join(os.homedir(), '.construct', 'embed.yaml'))
277
- ? path.join(os.homedir(), '.construct', 'embed.yaml')
278
+ : fs.existsSync(path.join(configDir(), 'embed.yaml'))
279
+ ? path.join(configDir(), 'embed.yaml')
278
280
  : null;
279
281
 
280
282
  const workerPath = _workerPath ?? path.join(rootDir, 'lib', 'embed', 'worker.mjs');
@@ -374,7 +376,7 @@ async function cmdEmbedStatus(_args, { homeDir = os.homedir() } = {}) {
374
376
  process.stdout.write(` pid: ${state.pid}\n`);
375
377
  process.stdout.write(` config: ${state.configPath}\n`);
376
378
  process.stdout.write(` rootDir: ${rootDir}\n`);
377
- process.stdout.write(` inbox: ${path.join(rootDir, '.cx', 'inbox')}\n`);
379
+ process.stdout.write(` inbox: ${path.join(rootDir, 'inbox')}\n`);
378
380
  process.stdout.write(` started at: ${state.startedAt}\n`);
379
381
  process.stdout.write(` log: ${logPath(homeDir)}\n`);
380
382
  }
@@ -387,7 +389,7 @@ async function cmdEmbedSnapshot(args, { homeDir = os.homedir() } = {}) {
387
389
  const { config } = parseArgs(args);
388
390
  const configPath = config
389
391
  ? path.resolve(config)
390
- : path.join(os.homedir(), '.construct', 'embed.yaml');
392
+ : path.join(configDir(), 'embed.yaml');
391
393
 
392
394
  const { loadEmbedConfig, EMPTY_CONFIG } = await import('./config.mjs');
393
395
  const { SnapshotEngine, renderMarkdown } = await import('./snapshot.mjs');
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/embed/config.mjs — embed mode configuration schema and loader.
3
3
  *
4
- * Reads embed.yaml from ~/.construct/embed.yaml (or a supplied path) and validates
4
+ * Reads embed.yaml from the XDG config dir (or a supplied path) and validates
5
5
  * the file against the expected schema. Returns a parsed, normalized config object.
6
6
  *
7
7
  * Schema:
@@ -55,6 +55,7 @@
55
55
  import fs from 'node:fs';
56
56
  import os from 'node:os';
57
57
  import path from 'node:path';
58
+ import { configDir, stateDir } from '../config/xdg.mjs';
58
59
 
59
60
  const DEFAULTS = {
60
61
  snapshot: { intervalMs: 300_000, maxItems: 100 },
@@ -74,13 +75,13 @@ const DEFAULTS = {
74
75
  * ref: github.com/org/project-y
75
76
  * path: /Users/user/Github/project-y # both — prefer local, fall back to remote
76
77
  * - type: workspace # always present as fallback
77
- * path: ~/.construct/workspace
78
+ * path: <XDG_STATE_HOME>/construct/workspace
78
79
  *
79
80
  * roles:
80
81
  * primary: product-manager # shapes embed analysis lens
81
82
  * secondary: architect # persistent background concern
82
83
  */
83
- export const DEFAULT_WORKSPACE_PATH = path.join(os.homedir(), '.construct', 'workspace');
84
+ export const DEFAULT_WORKSPACE_PATH = path.join(stateDir(), 'workspace');
84
85
 
85
86
  export const WORKSPACE_DOCS_LANES = ['adrs', 'prds', 'memos', 'notes', 'intake'];
86
87
 
@@ -99,7 +100,7 @@ export const DEFAULT_OPERATING_PROFILE = {
99
100
  },
100
101
  focalResources: [
101
102
  { type: 'plan', path: 'plan.md', purpose: 'active implementation plan' },
102
- { type: 'architecture', path: 'docs/concepts/architecture.md', purpose: 'system architecture and operating model' },
103
+ { type: 'architecture', path: 'docs/guides/concepts/architecture.md', purpose: 'system architecture and operating model' },
103
104
  { type: 'knowledge', path: '.cx/knowledge', purpose: 'typed project knowledge and ingested evidence' },
104
105
  { type: 'runtime', path: '.cx/roadmap.md', purpose: 'generated roadmap output' },
105
106
  ],
@@ -351,5 +352,5 @@ export function parseEmbedYaml(text) {
351
352
  }
352
353
 
353
354
  export function defaultEmbedConfigPath() {
354
- return path.join(os.homedir(), '.construct', 'embed.yaml');
355
+ return path.join(configDir(), 'embed.yaml');
355
356
  }
@@ -13,7 +13,7 @@
13
13
  * - approval-expiry — expires stale approval queue items
14
14
  * - eval-dataset-sync — syncs scored traces to telemetry dataset items
15
15
  * - prompt-regression-check — detects low-quality score clusters per promptHash
16
- * - inbox-watcher — ingests new files from .cx/inbox/ + CX_INBOX_DIRS into
16
+ * - inbox-watcher — ingests new files from inbox/ + CX_INBOX_DIRS into
17
17
  * observations (agnostic: specs, ADRs, meeting notes, etc.)
18
18
  * - roadmap — cross-references open items + observations → .cx/roadmap.md
19
19
  *
@@ -48,6 +48,7 @@ import { emitEmbedNotification, notifySlack } from './notifications.mjs';
48
48
  import { intentToCategory } from './providers/slack.mjs';
49
49
  import { runTelemetrySetup } from '../telemetry/setup.mjs';
50
50
  import { emitTraceEvent, newTraceId, newSpanId } from '../worker/trace.mjs';
51
+ import { doctorRoot } from '../config/xdg.mjs';
51
52
 
52
53
  /**
53
54
  * Log a non-fatal error to stderr. Use instead of empty catch {} blocks
@@ -121,8 +122,8 @@ function resolveDaemonStatePath(env = process.env) {
121
122
 
122
123
  // ─── Self-repair helpers ────────────────────────────────────────────────────
123
124
 
124
- const LOCK_PATH = join(homedir(), '.cx', 'sync.lock');
125
- const DAEMON_STATE_PATH = join(homedir(), '.cx', 'runtime', 'embed-daemon.json');
125
+ const LOCK_PATH = join(doctorRoot(), 'sync.lock');
126
+ const DAEMON_STATE_PATH = join(doctorRoot(), 'runtime', 'embed-daemon.json');
126
127
 
127
128
  /**
128
129
  * Run in-process health checks and fix what can be fixed.
@@ -562,7 +563,7 @@ export class EmbedDaemon {
562
563
  'daemon-log-rotation',
563
564
  60_000,
564
565
  async () => {
565
- const logPath = join(homedir(), '.cx', 'runtime', 'embed-daemon.log');
566
+ const logPath = join(doctorRoot(), 'runtime', 'embed-daemon.log');
566
567
  const capMb = Number(process.env.CONSTRUCT_EMBED_LOG_MAX_MB) || 50;
567
568
  const maxBytes = Math.floor(capMb * 1024 * 1024);
568
569
  const maxSegments = Number(process.env.CONSTRUCT_EMBED_LOG_MAX_SEGMENTS) || 5;
@@ -729,7 +730,7 @@ this.#scheduler.register(
729
730
  // Every 2 min — scan configured inbox dirs for new files, ingest them, and
730
731
  // record observations. Agnostic to content: specs, ADRs, meeting notes,
731
732
  // internal docs, PDFs, Office files — anything on the local filesystem.
732
- // Dirs: <rootDir>/.cx/inbox/ always; CX_INBOX_DIRS env for extra paths.
733
+ // Dirs: <rootDir>/inbox/ always; CX_INBOX_DIRS env for extra paths.
733
734
  this.#scheduler.register(
734
735
  'inbox-watcher',
735
736
  2 * 60_000,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Registers a node:fs.watch listener on each inbox directory and triggers
5
5
  * `InboxWatcher.poll()` shortly after filesystem activity settles, so files
6
- * dropped into `.cx/inbox/` flow through ingest + the review queue in
6
+ * dropped into `inbox/` flow through ingest + the review queue in
7
7
  * seconds rather than waiting for the scheduler's next interval.
8
8
  *
9
9
  * Two limits keep this stable across platforms: