@geraldmaron/construct 1.2.0 → 1.2.1

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.
@@ -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;
@@ -148,6 +148,12 @@ export function resolveInboxDirs(rootDir, env = process.env) {
148
148
  const config = loadIntakeConfig(rootDir, env);
149
149
  const dirs = [];
150
150
 
151
+ if (config.includeRootInbox ?? config.includeArchetypeInbox) {
152
+ const rootInbox = join(rootDir, ARCHETYPE_INBOX_SUBDIR);
153
+ mkdirSync(rootInbox, { recursive: true });
154
+ dirs.push(rootInbox);
155
+ }
156
+
151
157
  if (config.includeProjectInbox) {
152
158
  const projectInbox = join(rootDir, DEFAULT_INBOX_SUBDIR);
153
159
  mkdirSync(projectInbox, { recursive: true });
@@ -159,16 +165,6 @@ export function resolveInboxDirs(rootDir, env = process.env) {
159
165
  if (existsSync(docsIntake)) dirs.push(docsIntake);
160
166
  }
161
167
 
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
- }
171
-
172
168
  for (const candidate of config.parentDirs) {
173
169
  if (!candidate) continue;
174
170
  if (!existsSync(candidate)) continue;
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * lib/export-branding.mjs — one honest branding contract for distribution exports.
3
+ */
4
+
5
+ import { BRAND_CAPABLE_FORMATS, PLAIN_OUTPUT_FORMATS } from './artifact-manifest.mjs';
6
+
7
+ export const EXPORT_BRANDING_CAPABILITIES = Object.freeze({
8
+ pdf: { capable: true, mechanism: 'typst-template', asset: 'construct-pdf.typ' },
9
+ html: { capable: true, mechanism: 'pandoc-template', asset: 'construct-web.html' },
10
+ deck: { capable: true, mechanism: 'pandoc-template', asset: 'construct-deck.html' },
11
+ pptx: { capable: true, mechanism: 'native-pptx', asset: 'pptxgenjs' },
12
+ docx: { capable: true, mechanism: 'reference-doc', asset: 'construct-reference.docx' },
13
+ doc: { capable: true, mechanism: 'docx-reference-via-libreoffice', asset: 'construct-reference.docx' },
14
+ rtf: { capable: true, mechanism: 'pandoc-writer', asset: null },
15
+ odt: { capable: true, mechanism: 'pandoc-writer', asset: null },
16
+ epub: { capable: true, mechanism: 'pandoc-writer', asset: null },
17
+ tex: { capable: true, mechanism: 'pandoc-writer', asset: null },
18
+ txt: { capable: false, mechanism: 'plain-source', asset: null },
19
+ md: { capable: false, mechanism: 'plain-source', asset: null },
20
+ mdx: { capable: false, mechanism: 'plain-source', asset: null },
21
+ });
22
+
23
+ export function resolveExportBranding(format, requested = 'construct') {
24
+ const capability = EXPORT_BRANDING_CAPABILITIES[format];
25
+ if (!capability) return { requested, applied: 'none', capable: false, reason: 'unsupported format' };
26
+ if (!BRAND_CAPABLE_FORMATS.includes(format) || PLAIN_OUTPUT_FORMATS.includes(format)) {
27
+ return { requested, applied: 'none', capable: false, reason: 'format has no styling surface' };
28
+ }
29
+ if (requested === 'plain') return { requested, applied: 'plain', capable: true, mechanism: capability.mechanism, reason: 'explicit opt-out' };
30
+ return { requested: 'construct', applied: 'construct', capable: true, mechanism: capability.mechanism, asset: capability.asset, reason: 'default policy' };
31
+ }
32
+
@@ -265,28 +265,31 @@ const selfKnowledgeNote = '\nSelf-knowledge: call `knowledge_search` for any Con
265
265
  let sourcesNote = '';
266
266
  let embedStatusNote = '';
267
267
  try {
268
- const repos = (process.env.GITHUB_REPOS ?? '').split(',').map(r => r.trim()).filter(Boolean);
269
- const jiraConfigured = !!(process.env.JIRA_BASE_URL);
270
- const linearConfigured = !!(process.env.LINEAR_API_KEY);
271
- const hasSources = repos.length > 0 || jiraConfigured || linearConfigured;
268
+ const { loadProjectConfig } = await import('../config/project-config.mjs');
269
+ const { resolveEffectiveSourceTargetsFromConfig } = await import('../config/source-targets.mjs');
270
+ const { config } = loadProjectConfig(process.cwd(), process.env);
271
+ const targets = resolveEffectiveSourceTargetsFromConfig(config, process.env);
272
+ const repos = targets.filter((t) => t.provider === 'github').map((t) => t.selector.repo);
273
+ const jiraTargets = targets.filter((t) => t.provider === 'jira');
274
+ const linearTargets = targets.filter((t) => t.provider === 'linear');
275
+ const slackTargets = targets.filter((t) => t.provider === 'slack');
276
+ const hasSources = targets.length > 0;
272
277
  if (hasSources) {
273
278
  const parts = [];
274
279
  if (repos.length > 0) parts.push(`GitHub repos: ${repos.join(', ')}`);
275
- if (jiraConfigured) parts.push('Jira: configured');
276
- if (linearConfigured) parts.push('Linear: configured');
277
- // Surface which sources are wired so `provider_fetch` is the obvious
278
- // tool for questions about them. The "fetch first, don't answer from
279
- // memory" policy lives in the persona.
280
+ if (jiraTargets.length > 0) parts.push(`Jira projects: ${jiraTargets.map((t) => t.selector.project).join(', ')}`);
281
+ if (linearTargets.length > 0) parts.push(`Linear teams: ${linearTargets.map((t) => t.selector.team).join(', ')}`);
282
+ if (slackTargets.length > 0) parts.push(`Slack channels: ${slackTargets.map((t) => t.selector.channel).join(', ')}`);
280
283
  sourcesNote = '\nProvider sources wired: ' + parts.join(' · ') +
281
284
  '. Use `provider_fetch` for any question about them.\n';
282
285
  }
286
+ } catch { /* best effort */ }
283
287
 
284
- // Embed daemon status — surface as a one-liner so operator always knows state
288
+ try {
285
289
  const { resolveEmbedStatus, autoStartEmbedIfNeeded } = await import('../embed/cli.mjs');
286
290
  const embedStatus = resolveEmbedStatus(process.env);
287
291
  if (embedStatus.level !== 'none') {
288
292
  embedStatusNote = `\n## Embed daemon\n${embedStatus.label} · ${embedStatus.detail}\n`;
289
- // Auto-start if CX_AUTO_EMBED=1 and daemon is stopped
290
293
  if (embedStatus.level === 'stopped' && process.env.CX_AUTO_EMBED === '1') {
291
294
  const result = await autoStartEmbedIfNeeded(process.env);
292
295
  if (result.started) {
@@ -886,21 +886,18 @@ async function main() {
886
886
  created.push(".cx/inbox/");
887
887
  }
888
888
 
889
- // Intake-archetype scaffolding. When the active profile declares
890
- // capabilities.intake.inbox, scaffold a project-root inbox/ drop zone and seed
891
- // the dedup manifest. The whole inbox/ is ignored via host-disposition
892
- // IGNORED_PATTERNS (ADR-0027 §1), so raw drops never enter source and no local
893
- // keep-file is needed. Existing-structure detection already opted out of
894
- // clobbering a user's pipeline above.
895
-
896
- if (intakeCap?.inbox && !inboxDecision.skip) {
889
+ // Universal project-root inbox/ drop zone. Raw drops stay out of source via
890
+ // host-disposition IGNORED_PATTERNS (ADR-0027 §1). Skipped when custom intake
891
+ // pipeline already owns the project root.
892
+
893
+ if (!inboxDecision.skip) {
897
894
  const projectInbox = path.join(target, 'inbox');
898
895
  if (!fs.existsSync(projectInbox)) {
899
896
  fs.mkdirSync(projectInbox, { recursive: true });
900
897
  created.push('inbox/');
901
898
  }
902
899
 
903
- if (intakeCap.dedup === 'sha256') {
900
+ if (intakeCap?.dedup === 'sha256') {
904
901
  const { saveManifest, loadManifest, MANIFEST_REL_PATH } = await import('./intake/manifest.mjs');
905
902
  const manifestExists = fs.existsSync(path.join(target, MANIFEST_REL_PATH));
906
903
  if (!manifestExists) {
@@ -908,8 +905,12 @@ async function main() {
908
905
  created.push(MANIFEST_REL_PATH);
909
906
  }
910
907
  }
908
+ } else {
909
+ skipped.push('inbox/ (deferred to existing intake)');
911
910
  }
912
911
 
912
+ // Legacy archetype block removed — root inbox/ is universal (construct-1arm).
913
+
913
914
  // Gitignore every Construct-generated artifact whose disposition is `ignored`
914
915
  // (ADR-0027 §1): the six adapter dirs, the `.construct/` launcher, `.cx/`
915
916
  // runtime state, and the generated config files all carry machine-specific
@@ -1080,26 +1081,26 @@ async function main() {
1080
1081
  // .cx/inbox/ and docs/intake/ (when present); extra dirs are opt-in.
1081
1082
 
1082
1083
  const intakeConfig = (await askIntakeCollection(target, skipInteractive)) ?? { parentDirs: [], maxDepth: 4 };
1083
- // When a custom intake surface was detected, default includeProjectInbox
1084
- // to false so the watcher does not double-claim with the user's existing
1085
- // pipeline. User can opt in later via `construct config intake.includeProjectInbox=true`.
1086
1084
 
1087
1085
  if (inboxDecision.skip) intakeConfig.includeProjectInbox = false;
1088
1086
 
1089
- // Archetype: flip includeArchetypeInbox on so resolveInboxDirs picks up
1090
- // the project-root inbox/ drop zone alongside .cx/inbox/. Mirrors the
1091
- // existing includeProjectInbox / includeDocsIntake toggles rather than
1092
- // polluting parentDirs (which is reserved for user-explicit dirs).
1087
+ intakeConfig.includeRootInbox = !inboxDecision.skip;
1088
+ intakeConfig.includeArchetypeInbox = intakeConfig.includeRootInbox;
1093
1089
 
1094
- if (intakeCap?.inbox) {
1095
- intakeConfig.includeArchetypeInbox = true;
1096
- }
1097
- const { saveIntakeConfig } = await import('./intake/intake-config.mjs');
1090
+ const { saveIntakePolicy } = await import('./config/intake-policy.mjs');
1098
1091
  try {
1099
- saveIntakeConfig(target, intakeConfig);
1100
- created.push('.cx/intake-config.json');
1092
+ saveIntakePolicy(target, {
1093
+ maxDepth: intakeConfig.maxDepth,
1094
+ additionalDirs: intakeConfig.parentDirs ?? [],
1095
+ zones: {
1096
+ rootInbox: intakeConfig.includeRootInbox !== false,
1097
+ projectInbox: intakeConfig.includeProjectInbox !== false,
1098
+ docsIntake: intakeConfig.includeDocsIntake !== false,
1099
+ },
1100
+ });
1101
+ created.push('construct.config.json (intakePolicy)');
1101
1102
  } catch (err) {
1102
- console.warn(`⚠️ Could not write intake config: ${err.message}`);
1103
+ console.warn(`⚠️ Could not write intake policy: ${err.message}`);
1103
1104
  }
1104
1105
 
1105
1106
  // Ask about documentation system
@@ -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,136 +1,83 @@
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.
5
+ * Legacy .cx/intake-config.json remains a warned compatibility fallback.
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
+ loadIntakePolicy,
11
+ resolvedIntakeConfig,
12
+ saveIntakePolicy,
13
+ migrateLegacyIntakeConfig,
14
+ DEFAULT_INTAKE_POLICY,
15
+ } from '../config/intake-policy.mjs';
16
+ import {
17
+ INTAKE_DEFAULT_MAX_DEPTH,
18
+ INTAKE_HARD_MAX_DEPTH,
19
+ INTAKE_DEPTH_GUIDANCE,
20
+ describeIntakeDepth,
21
+ } from './constants.mjs';
22
+ import { INTAKE_CONFIG_FILE } from './legacy-paths.mjs';
23
+ import { join } from 'node:path';
24
+
25
+ export {
26
+ INTAKE_DEFAULT_MAX_DEPTH,
27
+ INTAKE_HARD_MAX_DEPTH,
28
+ INTAKE_DEPTH_GUIDANCE,
29
+ describeIntakeDepth,
30
+ INTAKE_CONFIG_FILE,
31
+ migrateLegacyIntakeConfig,
32
+ DEFAULT_INTAKE_POLICY,
33
+ };
41
34
 
42
35
  export const DEFAULT_INTAKE_CONFIG = Object.freeze({
43
36
  parentDirs: [],
44
37
  maxDepth: INTAKE_DEFAULT_MAX_DEPTH,
45
38
  includeProjectInbox: true,
46
39
  includeDocsIntake: true,
47
- includeArchetypeInbox: false,
40
+ includeArchetypeInbox: true,
41
+ includeRootInbox: true,
48
42
  });
49
43
 
50
44
  export function intakeConfigPath(rootDir) {
51
45
  return join(rootDir, INTAKE_CONFIG_FILE);
52
46
  }
53
47
 
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
48
  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 = {}; }
49
+ const cfg = resolvedIntakeConfig(rootDir, env);
50
+ if (cfg.legacyWarning && process.stderr.isTTY) {
51
+ process.stderr.write(`[intake-config] ${cfg.legacyWarning}\n`);
78
52
  }
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
- };
53
+ return cfg;
104
54
  }
105
55
 
106
56
  export function saveIntakeConfig(rootDir, patch = {}) {
107
57
  if (!shouldCreateCx(rootDir)) {
108
58
  throw new Error('Refusing to write intake config: directory is not an initialized construct project. Run `construct init` first.');
109
59
  }
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
60
 
121
- const file = intakeConfigPath(rootDir);
122
- ensureCxDir(rootDir);
123
- writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
124
- return next;
61
+ const policyPatch = {};
62
+ if (patch.maxDepth !== undefined) policyPatch.maxDepth = patch.maxDepth;
63
+ if (patch.includeProjectInbox !== undefined) policyPatch.projectInbox = patch.includeProjectInbox;
64
+ if (patch.includeDocsIntake !== undefined) policyPatch.docsIntake = patch.includeDocsIntake;
65
+ if (patch.includeArchetypeInbox !== undefined || patch.includeRootInbox !== undefined) {
66
+ policyPatch.rootInbox = patch.includeRootInbox ?? patch.includeArchetypeInbox;
67
+ }
68
+ if (Array.isArray(patch.parentDirs)) policyPatch.additionalDirs = patch.parentDirs;
69
+
70
+ const { policy } = saveIntakePolicy(rootDir, policyPatch);
71
+ return policyToLegacyShape(policy);
125
72
  }
126
73
 
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;
74
+ function policyToLegacyShape(policy) {
131
75
  return {
132
- value,
133
- label: `Custom depth (${value})`,
134
- detail: `Walks up to ${value} levels of subdirectories below each parent.`,
76
+ parentDirs: policy.additionalDirs,
77
+ maxDepth: policy.maxDepth,
78
+ includeProjectInbox: policy.zones.projectInbox,
79
+ includeDocsIntake: policy.zones.docsIntake,
80
+ includeArchetypeInbox: policy.zones.rootInbox,
81
+ includeRootInbox: policy.zones.rootInbox,
135
82
  };
136
83
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * lib/intake/legacy-paths.mjs — legacy intake config file path constant.
3
+ */
4
+
5
+ export const INTAKE_CONFIG_FILE = '.cx/intake-config.json';