@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/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');
@@ -401,7 +403,8 @@ async function cmdEmbedSnapshot(args, { homeDir = os.homedir() } = {}) {
401
403
  if (fs.existsSync(configPath)) {
402
404
  cfg = loadEmbedConfig(configPath);
403
405
  } else {
404
- cfg = { ...EMPTY_CONFIG, sources: registry.autoSources(env) };
406
+ const { resolveAutoEmbedSources } = await import('./auto-sources.mjs');
407
+ cfg = { ...EMPTY_CONFIG, sources: resolveAutoEmbedSources({ cwd: process.cwd(), env, registry }) };
405
408
  }
406
409
 
407
410
  if (!cfg.sources.length) {
@@ -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,
@@ -19,6 +19,11 @@ import { homedir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
  import { loadConstructEnv } from '../env-config.mjs';
21
21
  import { addObservation } from '../observation-store.mjs';
22
+ import { loadProjectConfig } from '../config/project-config.mjs';
23
+ import {
24
+ resolveEffectiveSourceTargetsFromConfig,
25
+ resolveKnownSourcesFromTargets,
26
+ } from '../config/source-targets.mjs';
22
27
 
23
28
  // ─── Self-query detection ────────────────────────────────────────────────────
24
29
 
@@ -62,7 +67,13 @@ function isSelfQuery(query) {
62
67
  * @param {object} env
63
68
  * @returns {{ id: string, provider: string, ref: string, display: string }[]}
64
69
  */
65
- export function resolveKnownSources(env = process.env) {
70
+ export function resolveKnownSources(env = process.env, cwd = process.cwd()) {
71
+ const { config } = loadProjectConfig(cwd, env);
72
+ const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
73
+ if (targets.length > 0) {
74
+ return resolveKnownSourcesFromTargets(targets);
75
+ }
76
+
66
77
  const sources = [];
67
78
 
68
79
  // GitHub repos
@@ -112,8 +123,8 @@ export function resolveKnownSources(env = process.env) {
112
123
  * @param {object} env
113
124
  * @returns {{ provider: string, ref: string, display: string } | null}
114
125
  */
115
- export function matchSourceFromQuery(query, env = process.env) {
116
- const sources = resolveKnownSources(env);
126
+ export function matchSourceFromQuery(query, env = process.env, cwd = process.cwd()) {
127
+ const sources = resolveKnownSources(env, cwd);
117
128
  const q = query.toLowerCase();
118
129
 
119
130
  // Exact or substring match — longest id wins
@@ -138,13 +149,12 @@ export function matchSourceFromQuery(query, env = process.env) {
138
149
  * @param {object} [opts.env] - Env override
139
150
  * @returns {Promise<DemandFetchResult>}
140
151
  */
141
- export async function demandFetch({ query, rootDir, env } = {}) {
152
+ export async function demandFetch({ query, rootDir, env, cwd } = {}) {
142
153
  const resolvedEnv = env ?? loadConstructEnv();
143
154
  const root = rootDir ?? (resolvedEnv.CX_DATA_DIR?.trim() || homedir());
155
+ const projectRoot = cwd ?? root;
144
156
 
145
- // Provider match takes priority: if the query names a configured repo/project,
146
- // always fetch from that provider — even if isSelfQuery() also fires.
147
- const match = matchSourceFromQuery(query, resolvedEnv);
157
+ const match = matchSourceFromQuery(query, resolvedEnv, projectRoot);
148
158
 
149
159
  // Self-referential queries about Construct itself → route to knowledge_search,
150
160
  // but only when no external provider source was matched.
@@ -168,8 +178,7 @@ export async function demandFetch({ query, rootDir, env } = {}) {
168
178
  }
169
179
 
170
180
  if (!match) {
171
- // No specific source match and not a self-query — fetch from all configured providers
172
- return demandFetchAll({ query, rootDir: root, env: resolvedEnv });
181
+ return demandFetchAll({ query, rootDir: root, env: resolvedEnv, cwd: projectRoot });
173
182
  }
174
183
 
175
184
  // Dynamically import provider registry to avoid loading all providers at module init
@@ -188,7 +197,7 @@ export async function demandFetch({ query, rootDir, env } = {}) {
188
197
  }
189
198
 
190
199
  // Build read calls for the matched source and execute them
191
- const readCalls = buildReadCalls(match, resolvedEnv);
200
+ const readCalls = buildReadCalls(match, resolvedEnv, projectRoot);
192
201
  if (!readCalls.length) {
193
202
  return {
194
203
  ok: false,
@@ -281,23 +290,32 @@ export async function demandFetch({ query, rootDir, env } = {}) {
281
290
  * @param {object} [opts.env] - Resolved env
282
291
  * @returns {Promise<DemandFetchResult>}
283
292
  */
284
- async function demandFetchAll({ query, rootDir, env } = {}) {
293
+ async function demandFetchAll({ query, rootDir, env, cwd } = {}) {
285
294
  const { ProviderRegistry } = await import('./providers/registry.mjs');
286
295
  const registry = await ProviderRegistry.fromEnv(env);
287
- const providerNames = registry.names();
296
+ const projectRoot = cwd ?? rootDir ?? process.cwd();
297
+ const { config } = loadProjectConfig(projectRoot, env);
298
+ const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
299
+ const scoped = targets.length > 0;
300
+
301
+ const providerNames = scoped
302
+ ? [...new Set(targets.map((t) => t.provider))].filter((name) => registry.get(name))
303
+ : registry.names();
288
304
 
289
305
  if (!providerNames.length) {
290
306
  return {
291
307
  ok: false,
292
308
  reason: 'no_providers',
293
- message: 'No provider credentials configured in config.env',
309
+ message: scoped
310
+ ? 'No provider credentials configured for the declared source targets'
311
+ : 'No provider credentials configured in config.env',
294
312
  items: [],
295
313
  };
296
314
  }
297
315
 
298
316
  const allItems = [];
299
317
  const errors = [];
300
- const sources = resolveKnownSources(env);
318
+ const sources = resolveKnownSources(env, projectRoot);
301
319
 
302
320
  // Group known sources by provider so we can batch-fetch per-provider
303
321
  const byProvider = new Map();
@@ -306,11 +324,16 @@ async function demandFetchAll({ query, rootDir, env } = {}) {
306
324
  byProvider.get(src.provider).push(src);
307
325
  }
308
326
 
309
- // Also include providers that have no explicit sources (Jira, Linear default fetch)
310
327
  for (const name of providerNames) {
311
328
  if (!byProvider.has(name)) byProvider.set(name, []);
312
329
  }
313
330
 
331
+ if (scoped) {
332
+ for (const key of [...byProvider.keys()]) {
333
+ if (!providerNames.includes(key)) byProvider.delete(key);
334
+ }
335
+ }
336
+
314
337
  for (const [providerName, provSources] of byProvider) {
315
338
  const provider = registry.get(providerName);
316
339
  if (!provider) continue;
@@ -318,10 +341,12 @@ async function demandFetchAll({ query, rootDir, env } = {}) {
318
341
  // Build read calls — one set per known source, or a default if none listed
319
342
  const matchList = provSources.length > 0
320
343
  ? provSources
321
- : [{ provider: providerName, ref: null, display: providerName }];
344
+ : (scoped ? [] : [{ provider: providerName, ref: null, display: providerName }]);
345
+
346
+ if (!matchList.length) continue;
322
347
 
323
348
  for (const src of matchList) {
324
- const readCalls = buildReadCalls(src, env);
349
+ const readCalls = buildReadCalls(src, env, projectRoot);
325
350
  if (!readCalls.length) continue;
326
351
  for (const { ref, opts, display } of readCalls) {
327
352
  try {
@@ -384,7 +409,7 @@ async function demandFetchAll({ query, rootDir, env } = {}) {
384
409
  * @param {object} env
385
410
  * @returns {{ ref: string, opts: object, display: string }[]}
386
411
  */
387
- function buildReadCalls(match, env) {
412
+ function buildReadCalls(match, env, cwd = process.cwd()) {
388
413
  if (match.provider === 'github') {
389
414
  const opts = { repos: [match.ref], limit: 25 };
390
415
  return [
@@ -397,7 +422,13 @@ function buildReadCalls(match, env) {
397
422
  ];
398
423
  }
399
424
  if (match.provider === 'jira') {
400
- const projects = (env.JIRA_PROJECTS ?? '').split(',').map(p => p.trim()).filter(Boolean);
425
+ const { config } = loadProjectConfig(cwd, env);
426
+ const targets = resolveEffectiveSourceTargetsFromConfig(config, env)
427
+ .filter((t) => t.provider === 'jira')
428
+ .map((t) => t.selector.project);
429
+ const projects = targets.length
430
+ ? targets
431
+ : (env.JIRA_PROJECTS ?? '').split(',').map((p) => p.trim()).filter(Boolean);
401
432
  const recencyDays = parseInt(env.JIRA_FETCH_RECENCY_DAYS, 10) || 30;
402
433
 
403
434
  let jql;
@@ -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:
@@ -6,10 +6,14 @@
6
6
  * handles specs, ADRs, meeting notes, internal docs, research, PDFs,
7
7
  * Office files, plain text, code, or any format supported by document-extract.
8
8
  *
9
- * Watch dirs (in priority order):
10
- * 1. CX_INBOX_DIRS env var — colon-separated absolute paths
11
- * 2. <rootDir>/.cx/inbox/ per-project drop folder (always included)
12
- * 3. <rootDir>/docs/intake/ — project intake drop zone when docs init is used
9
+ * Watch dirs:
10
+ * 1. <rootDir>/inbox/ — the canonical project-root drop zone (always watched)
11
+ * 2. CX_INBOX_DIRS env var + intakePolicy.additionalDirs opt-in extra paths
12
+ *
13
+ * Atomic handoff (ADR-0045 §C): writers stage a file under `inbox/.staging/`
14
+ * and atomically rename it into `inbox/`. The watcher ignores `.staging/` and
15
+ * dotfiles, and skips any top-level file whose size is still changing between
16
+ * two stats, so a partially-written drop is never consumed mid-write.
13
17
  *
14
18
  * State tracking:
15
19
  * <rootDir>/.cx/runtime/inbox-state.json — maps filePath → processedAt
@@ -23,7 +27,6 @@
23
27
 
24
28
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
25
29
  import { join, resolve, isAbsolute } from 'node:path';
26
- import { homedir } from 'node:os';
27
30
  import { isExtractableDocumentPath, AUDIO_VIDEO_EXTS } from '../document-extract.mjs';
28
31
  import { ingestDocuments } from '../document-ingest.mjs';
29
32
  import { addObservation } from '../observation-store.mjs';
@@ -36,29 +39,29 @@ import { loadIntakeConfig, INTAKE_DEFAULT_MAX_DEPTH } from '../intake/intake-con
36
39
  import { loadManifest, recordFile, hasFile, sha256Of, MANIFEST_REL_PATH } from '../intake/manifest.mjs';
37
40
  import { gatherAttribution } from '../intake/attribution.mjs';
38
41
  import { acquirePollLock, releasePollLock } from '../intake/poll-lock.mjs';
42
+ import { doctorRoot } from '../config/xdg.mjs';
39
43
 
40
44
  const STATE_FILE = '.cx/runtime/inbox-state.json';
41
- const DEFAULT_INBOX_SUBDIR = '.cx/inbox';
42
- const DOCS_INTAKE_SUBDIR = 'docs/intake';
43
- const ARCHETYPE_INBOX_SUBDIR = 'inbox';
45
+ const ROOT_INBOX_SUBDIR = 'inbox';
46
+ const STAGING_SUBDIR = '.staging';
44
47
  const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB hard cap
45
48
  const STALE_STATE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
46
49
 
47
50
  // ─── Extraction-warning helpers ───────────────────────────────────────────────
48
51
 
49
52
  function extractionWarningsLog() {
50
- return join(homedir(), '.cx', 'intake', 'extraction-warnings.jsonl');
53
+ return join(doctorRoot(), 'intake', 'extraction-warnings.jsonl');
51
54
  }
52
55
 
53
56
  function needsAsrDir() {
54
- return join(homedir(), '.cx', 'intake', 'needs-asr');
57
+ return join(doctorRoot(), 'intake', 'needs-asr');
55
58
  }
56
59
 
57
60
  function writeExtractionWarning({ sourcePath, droppedInfo }) {
58
61
  if (!droppedInfo?.length) return;
59
62
  const logPath = extractionWarningsLog();
60
63
  try {
61
- mkdirSync(join(homedir(), '.cx', 'intake'), { recursive: true });
64
+ mkdirSync(join(doctorRoot(), 'intake'), { recursive: true });
62
65
  const line = JSON.stringify({ ts: new Date().toISOString(), sourcePath, droppedInfo }) + '\n';
63
66
  appendFileSync(logPath, line);
64
67
  } catch { /* non-fatal */ }
@@ -139,35 +142,20 @@ function writeState(rootDir, state) {
139
142
 
140
143
  /**
141
144
  * Resolve inbox watch directories from the persisted intake-config.
142
- * Always includes <rootDir>/.cx/inbox/ (created on first use) when the
143
- * config has includeProjectInbox: true (default).
144
- * Includes <rootDir>/docs/intake/ when it exists and includeDocsIntake is true.
145
- * `parentDirs` from intake-config.json plus CX_INBOX_DIRS env var add extras.
145
+ * The canonical <rootDir>/inbox/ is always watched (ADR-0045 §C). `parentDirs`
146
+ * from intake-config plus CX_INBOX_DIRS env var add opt-in extras.
147
+ *
148
+ * The canonical inbox gets a `.staging/` subdir on resolution so writers have
149
+ * a stable place to assemble a file before the atomic rename into `inbox/`.
146
150
  */
147
151
  export function resolveInboxDirs(rootDir, env = process.env) {
148
152
  const config = loadIntakeConfig(rootDir, env);
149
153
  const dirs = [];
150
154
 
151
- if (config.includeProjectInbox) {
152
- const projectInbox = join(rootDir, DEFAULT_INBOX_SUBDIR);
153
- mkdirSync(projectInbox, { recursive: true });
154
- dirs.push(projectInbox);
155
- }
156
-
157
- if (config.includeDocsIntake) {
158
- const docsIntake = join(rootDir, DOCS_INTAKE_SUBDIR);
159
- if (existsSync(docsIntake)) dirs.push(docsIntake);
160
- }
161
-
162
- // Archetype inbox is a profile-driven drop zone for raw source files
163
- // (capabilities.intake.inbox). Off by default; init flips it on when the
164
- // active profile declares the capability so non-archetype projects keep
165
- // their pre-Piece-C behavior.
166
-
167
- if (config.includeArchetypeInbox) {
168
- const archetypeInbox = join(rootDir, ARCHETYPE_INBOX_SUBDIR);
169
- if (existsSync(archetypeInbox)) dirs.push(archetypeInbox);
170
- }
155
+ const rootInbox = join(rootDir, ROOT_INBOX_SUBDIR);
156
+ mkdirSync(rootInbox, { recursive: true });
157
+ mkdirSync(join(rootInbox, STAGING_SUBDIR), { recursive: true });
158
+ dirs.push(rootInbox);
171
159
 
172
160
  for (const candidate of config.parentDirs) {
173
161
  if (!candidate) continue;
@@ -181,6 +169,20 @@ export function resolveInboxDirs(rootDir, env = process.env) {
181
169
 
182
170
  // ─── File scanning ────────────────────────────────────────────────────────────
183
171
 
172
+ // Dotfiles and dot-directories — including the `inbox/.staging/` assembly
173
+ // area writers rename out of — are skipped wholesale, so a staged file is
174
+ // never visible to the scanner until it lands at a top-level name.
175
+
176
+ function isStableFile(full) {
177
+ try {
178
+ const a = statSync(full);
179
+ const b = statSync(full);
180
+ return a.size === b.size && a.mtimeMs === b.mtimeMs ? a : null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
184
186
  /**
185
187
  * Walk `dir` up to `maxDepth` subdirectory levels.
186
188
  * maxDepth=0 → only files in `dir` itself (no subdirs).
@@ -202,11 +204,15 @@ function scanDir(dir, maxDepth = INTAKE_DEFAULT_MAX_DEPTH) {
202
204
  }
203
205
  if (!entry.isFile()) continue;
204
206
  if (!isExtractableDocumentPath(full)) continue;
205
- try {
206
- const st = statSync(full);
207
- if (st.size === 0 || st.size > MAX_FILE_SIZE_BYTES) continue;
208
- results.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
209
- } catch (err) { process.stderr.write('[inbox.mjs] scan-file-stat: ' + (err?.message ?? String(err)) + '\n'); }
207
+
208
+ // Stability backstop for writers that drop in place instead of staging:
209
+ // a file whose size/mtime is still moving between two stats is mid-write,
210
+ // so leave it for the next poll once it settles.
211
+
212
+ const st = isStableFile(full);
213
+ if (!st) continue;
214
+ if (st.size === 0 || st.size > MAX_FILE_SIZE_BYTES) continue;
215
+ results.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
210
216
  }
211
217
  }
212
218
  } catch (err) { process.stderr.write('[inbox.mjs] scan-dir: ' + (err?.message ?? String(err)) + '\n'); }
@@ -273,7 +279,12 @@ function maybePromoteToDocs(rootDir, { sourcePath, extractedText }) {
273
279
  if (!existsSync(laneDir)) return null;
274
280
 
275
281
  const sourceName = sourcePath.split('/').pop() ?? 'intake-doc';
276
- const targetPath = nextAvailablePath(join(laneDir, `${sourceName}.md`));
282
+
283
+ // A source already in markdown keeps its name; others gain a .md rendering
284
+ // suffix. Without the guard an intake item named foo.md promotes to foo.md.md.
285
+
286
+ const docFileName = /\.md$/i.test(sourceName) ? sourceName : `${sourceName}.md`;
287
+ const targetPath = nextAvailablePath(join(laneDir, docFileName));
277
288
  const title = sourceName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'Promoted intake document';
278
289
  const markdown = [
279
290
  '# ' + title,
@@ -16,7 +16,8 @@
16
16
 
17
17
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
- import { homedir } from 'node:os';
19
+
20
+ import { doctorRoot } from '../config/xdg.mjs';
20
21
 
21
22
  /**
22
23
  * Scan an intake directory and return file metadata.
@@ -125,7 +126,7 @@ export function computeIntakeMetrics({ rootDir } = {}) {
125
126
  // — Metrics from recommendation store
126
127
  let recStats = { active: 0, dismissed: 0, total: 0 };
127
128
  try {
128
- const recIndexPath = join(homedir(), '.cx', 'intake', 'recommendations-index.json');
129
+ const recIndexPath = join(doctorRoot(), 'intake', 'recommendations-index.json');
129
130
  if (existsSync(recIndexPath)) {
130
131
  const index = JSON.parse(readFileSync(recIndexPath, 'utf8'));
131
132
  const entries = Object.values(index);
@@ -12,7 +12,7 @@
12
12
  * with no changes re-embeds nothing.
13
13
  *
14
14
  * Reconciliation-on-sync is preferred over a fixed-interval polling cron (see
15
- * docs/research/vector-search-best-practices.md): the pass runs where Construct
15
+ * docs/notes/research/vector-search-best-practices.md): the pass runs where Construct
16
16
  * already touches storage (the model-change re-embed, the storage CLI), keys
17
17
  * idempotency on a content hash, and re-embeds on model change.
18
18
  */
@@ -21,9 +21,10 @@
21
21
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
22
22
  import { join } from 'node:path';
23
23
  import { createHash, randomUUID } from 'node:crypto';
24
- import { homedir } from 'node:os';
25
24
 
26
- const CACHE_DIR = join(homedir(), '.cx', 'cache', 'embeddings');
25
+ import { doctorRoot } from '../config/xdg.mjs';
26
+
27
+ const CACHE_DIR = join(doctorRoot(), 'cache', 'embeddings');
27
28
  const INDEX_FILE = join(CACHE_DIR, 'index.json');
28
29
  const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2';
29
30
  const DIMENSIONS = 384;
@@ -21,13 +21,15 @@ import os from 'node:os';
21
21
  import path from 'node:path';
22
22
  import { spawnSync } from 'node:child_process';
23
23
 
24
+ import { stateDir, doctorRoot } from '../config/xdg.mjs';
25
+
24
26
  const HOME = os.homedir();
25
27
  const PLATFORM = process.platform;
26
28
  const NODE_BIN = process.execPath;
27
29
 
28
30
  // Path to the construct bin — we use absolute path so launchd/systemd can find it.
29
31
  function constructBin() {
30
- const local = path.join(HOME, '.construct', 'bin', 'construct');
32
+ const local = path.join(stateDir(HOME), 'bin', 'construct');
31
33
  if (fs.existsSync(local)) return local;
32
34
  const result = spawnSync('which', ['construct'], { encoding: 'utf8' });
33
35
  return result.status === 0 ? result.stdout.trim() : 'construct';
@@ -195,7 +197,7 @@ function windowsStatus() {
195
197
  // ── Public API ──────────────────────────────────────────────────────────────
196
198
 
197
199
  function logPath() {
198
- return path.join(HOME, '.cx', 'runtime', 'embed-daemon.log');
200
+ return path.join(doctorRoot(), 'runtime', 'embed-daemon.log');
199
201
  }
200
202
 
201
203
  /**
@@ -28,8 +28,9 @@ if (configPath) {
28
28
  } else {
29
29
  const { ProviderRegistry } = await import('./providers/registry.mjs');
30
30
  const { EMPTY_CONFIG } = await import('./config.mjs');
31
+ const { resolveAutoEmbedSources } = await import('./auto-sources.mjs');
31
32
  const registry = await ProviderRegistry.fromEnv(env);
32
- const sources = registry.autoSources(env);
33
+ const sources = resolveAutoEmbedSources({ cwd: process.cwd(), env, registry });
33
34
  daemonOpts.registry = registry;
34
35
  daemonOpts.config = {
35
36
  ...EMPTY_CONFIG,
@@ -13,6 +13,7 @@ import { recommendPlan as recommendPlanCore } from './triage.mjs';
13
13
  import { invokeWorkflow as invokeWorkflowCore } from './workflow-invoke.mjs';
14
14
  import { buildCapabilityContract } from './capability.mjs';
15
15
  import { resolveExecution as resolveExecutionCore } from './execution.mjs';
16
+ import { planArtifactWorkflow as planArtifactWorkflowCore, runArtifactWorkflow as runArtifactWorkflowCore } from '../artifact-workflow.mjs';
16
17
 
17
18
  export { CONTRACT_VERSION, MIN_CLIENT_CONTRACT_VERSION, isClientCompatible } from './contract-version.mjs';
18
19
  export { APPROVAL_MODES } from './audit.mjs';
@@ -85,3 +86,13 @@ export function describeCapabilities({ env, cwd, rootDir } = {}) {
85
86
  export function resolveExecution(request = {}, { env, cwd, registryPath } = {}) {
86
87
  return wrapContractResult(resolveExecutionCore(request, { env, cwd, registryPath }), { surface: 'sdk', env, cwd });
87
88
  }
89
+
90
+ /** Plan a manifest-backed artifact workflow without executing specialists. */
91
+ export function planArtifactWorkflow(request = {}, { env, cwd, rootDir } = {}) {
92
+ return wrapContractResult(planArtifactWorkflowCore(request, { cwd, rootDir }), { surface: 'sdk', env, cwd });
93
+ }
94
+
95
+ /** Run only locally observable artifact steps and return truthful provenance. */
96
+ export function runArtifactWorkflow(request = {}, { env, cwd, rootDir } = {}) {
97
+ return wrapContractResult(runArtifactWorkflowCore(request, { cwd, rootDir }), { surface: 'sdk', env, cwd });
98
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Resolution order (last-write wins):
5
5
  * 1. Built-in defaults (lib/engine/defaults.mjs)
6
- * 2. ~/.construct/plugins.json (operator-level overrides)
6
+ * 2. plugins.json in the XDG config dir (operator-level overrides)
7
7
  * 3. <project>/.cx/plugins.json (repo-level overrides)
8
8
  * 4. CONSTRUCT_PLUGIN_<LAYER> environment variables (one-off overrides)
9
9
  *
@@ -20,11 +20,11 @@
20
20
  */
21
21
 
22
22
  import { readFileSync, existsSync } from 'node:fs';
23
- import { homedir } from 'node:os';
24
23
  import { join, isAbsolute, resolve } from 'node:path';
25
24
  import { pathToFileURL } from 'node:url';
26
25
  import { LAYERS, assertContract } from './contracts.mjs';
27
26
  import { loadDefaults } from './defaults.mjs';
27
+ import { configDir } from '../config/xdg.mjs';
28
28
 
29
29
  const ENV_PREFIX = 'CONSTRUCT_PLUGIN_';
30
30
 
@@ -43,7 +43,7 @@ function readJsonIfExists(path) {
43
43
  function gatherOverrides(rootDir) {
44
44
  const overrides = [];
45
45
 
46
- const userConfig = readJsonIfExists(join(homedir(), '.construct', 'plugins.json'));
46
+ const userConfig = readJsonIfExists(join(configDir(), 'plugins.json'));
47
47
  if (userConfig?.plugins) overrides.push(...userConfig.plugins);
48
48
 
49
49
  const projectConfig = readJsonIfExists(join(rootDir, '.cx', 'plugins.json'));