@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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * lib/artifact-workflow.mjs — intent-to-artifact workflow planning.
3
+ *
4
+ * Planning describes specialist work and manifest ordering without reporting a
5
+ * planned role as executed.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { loadProjectConfig } from './config/project-config.mjs';
10
+ import {
11
+ BRAND_CAPABLE_FORMATS,
12
+ artifactTypes,
13
+ resolveArtifactType,
14
+ resolveArtifactWorkflowContract,
15
+ } from './artifact-manifest.mjs';
16
+ import { inferArtifactTypeFromPath } from './artifact-type-from-path.mjs';
17
+ import { validateArtifactRelease } from './artifact-release-gate.mjs';
18
+ import { exportMarkdown } from './document-export.mjs';
19
+
20
+ function escapeRegex(value) {
21
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
+ }
23
+
24
+ function findRequestedTypes(input, { rootDir, cwd }) {
25
+ const text = String(input ?? '').toLowerCase();
26
+ const matches = [];
27
+ for (const type of artifactTypes({ rootDir, cwd }).sort((a, b) => b.length - a.length)) {
28
+ const entry = resolveArtifactType(type, { rootDir, cwd }).entry;
29
+ for (const candidate of [type, ...(entry?.aliases ?? [])]) {
30
+ if (new RegExp(`(?:^|[^a-z0-9])${escapeRegex(candidate.toLowerCase())}(?:$|[^a-z0-9])`, 'i').test(text)) {
31
+ matches.push(type);
32
+ break;
33
+ }
34
+ }
35
+ }
36
+ return [...new Set(matches)];
37
+ }
38
+
39
+ function inferFormat(input, requestedFormat) {
40
+ if (requestedFormat) return String(requestedFormat).toLowerCase();
41
+ const text = String(input ?? '').toLowerCase();
42
+ for (const format of ['pptx', 'docx', 'pdf', 'deck', 'html', 'doc', 'rtf', 'odt', 'epub', 'tex', 'txt', 'mdx', 'md']) {
43
+ if (new RegExp(`\\b${format}\\b`, 'i').test(text)) return format;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function inferBranding(input, requestedBranding) {
49
+ if (requestedBranding) return requestedBranding;
50
+ return /\b(?:unbranded|plain|without (?:the )?construct brand)\b/i.test(String(input ?? '')) ? 'plain' : 'construct';
51
+ }
52
+
53
+ function buildSteps({ contract, input, targetFormat, branding }) {
54
+ const text = String(input ?? '').toLowerCase();
55
+ const needsResearch = Boolean(contract.researchProfile) && /\b(?:research|source|evidence|validate claims)\b/.test(text);
56
+ const needsReview = /\b(?:review|critique|audit|feedback)\b/.test(text) || contract.requiredReviewers.length > 0;
57
+ const rewrite = /\b(?:rewrite|revise|edit|polish|improve)\b/.test(text);
58
+ const steps = [];
59
+ if (needsResearch) steps.push({ id: 'research', action: 'research', roles: [], required: false, reason: `research profile: ${contract.researchProfile}` });
60
+ if (needsReview) steps.push({ id: 'review', action: 'review', roles: contract.reviewerChain, required: contract.requiredReviewers.length > 0, reason: 'review occurs before any rewrite' });
61
+ steps.push({
62
+ id: rewrite ? 'rewrite' : 'author',
63
+ action: rewrite ? 'rewrite' : 'author',
64
+ roles: contract.authorChain,
65
+ required: true,
66
+ reason: rewrite ? 'requested revision after review' : 'manifest author chain',
67
+ });
68
+ if (contract.validation.releaseGate !== false) {
69
+ steps.push({ id: 'validate', action: 'validate', roles: [], required: true, reason: 'manifest validation policy' });
70
+ }
71
+ if (targetFormat && BRAND_CAPABLE_FORMATS.includes(targetFormat) && branding === 'construct') {
72
+ steps.push({ id: 'brand', action: 'brand', roles: [], required: true, reason: 'default Construct branding for brand-capable format' });
73
+ }
74
+ if (targetFormat) steps.push({ id: 'export', action: 'export', roles: [], required: true, format: targetFormat, reason: 'requested distribution format' });
75
+ return steps;
76
+ }
77
+
78
+ /**
79
+ * Build an artifact plan from an ordinary request. A missing or ambiguous
80
+ * class is a clarification result, never an implicit PRD route.
81
+ */
82
+ export function planArtifactWorkflow(request = {}, { cwd = process.cwd(), rootDir } = {}) {
83
+ const input = request.input ?? request.request ?? '';
84
+ const sourcePath = request.sourcePath ?? request.filePath ?? '';
85
+ const project = request.projectConfig ?? loadProjectConfig(cwd).config;
86
+ const requested = request.artifactType ?? request.documentClass ?? null;
87
+ const pathType = sourcePath && existsSync(sourcePath) ? inferArtifactTypeFromPath(sourcePath, { rootDir: cwd }) : null;
88
+ const inferredTypes = requested ? [] : findRequestedTypes(input, { rootDir, cwd });
89
+ const candidates = requested ? [requested] : [...new Set([pathType, ...inferredTypes].filter(Boolean))];
90
+
91
+ if (candidates.length !== 1) {
92
+ return {
93
+ kind: 'artifact-workflow',
94
+ status: 'needs-classification',
95
+ plannedSteps: [],
96
+ executedSteps: [],
97
+ skippedSteps: [],
98
+ producedFiles: [],
99
+ validation: null,
100
+ appliedOverrides: [],
101
+ clarification: candidates.length > 1
102
+ ? `Request names multiple document classes (${candidates.join(', ')}). Specify one registered class.`
103
+ : 'Specify a registered document class, use a typed source path, or register the requested class in specialists/artifact-manifest.json.',
104
+ };
105
+ }
106
+
107
+ const resolution = resolveArtifactType(candidates[0], { rootDir, cwd });
108
+ if (resolution.status !== 'registered') {
109
+ return {
110
+ kind: 'artifact-workflow',
111
+ status: 'needs-classification',
112
+ plannedSteps: [],
113
+ executedSteps: [],
114
+ skippedSteps: [],
115
+ producedFiles: [],
116
+ validation: null,
117
+ appliedOverrides: [],
118
+ clarification: resolution.guidance,
119
+ };
120
+ }
121
+
122
+ const targetFormat = inferFormat(input, request.format ?? request.targetFormat);
123
+ const requestedBranding = inferBranding(input, request.branding);
124
+ const contract = resolveArtifactWorkflowContract(resolution.type, {
125
+ rootDir,
126
+ cwd,
127
+ projectConfig: project,
128
+ overrides: request.overrides,
129
+ });
130
+ const branding = request.branding ?? (requestedBranding === 'plain' ? 'plain' : (contract.outputs.branding ?? 'construct'));
131
+ const steps = buildSteps({ contract, input, targetFormat, branding });
132
+ const unavailableFormat = targetFormat && !contract.outputs.formats.includes(targetFormat);
133
+
134
+ return {
135
+ kind: 'artifact-workflow',
136
+ status: unavailableFormat ? 'needs-classification' : 'planned',
137
+ documentClass: contract.documentClass,
138
+ artifactType: contract.type,
139
+ sourcePath: sourcePath || null,
140
+ target: {
141
+ format: targetFormat,
142
+ audience: /\bcustomer(?:-facing)?\b/i.test(input) ? 'customer' : 'internal',
143
+ branding,
144
+ brandCapable: Boolean(targetFormat && BRAND_CAPABLE_FORMATS.includes(targetFormat)),
145
+ },
146
+ contract,
147
+ plannedSteps: steps,
148
+ executedSteps: [],
149
+ skippedSteps: [],
150
+ producedFiles: [],
151
+ validation: null,
152
+ appliedOverrides: contract.appliedOverrides,
153
+ clarification: unavailableFormat ? `${targetFormat} is not an output capability for ${contract.type}.` : null,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Produce a provenance-preserving workflow run report. Specialist authoring
159
+ * and review are never fabricated: Construct can plan them, but only reports
160
+ * them as executed when a host returns execution evidence (not available in
161
+ * the deterministic entry point). Validation/export are local operations and
162
+ * run only with the explicit durable-write approval mode.
163
+ */
164
+ export function runArtifactWorkflow(request = {}, { cwd = process.cwd(), rootDir } = {}) {
165
+ const plan = planArtifactWorkflow(request, { cwd, rootDir });
166
+ const approvalMode = request.approvalMode ?? 'proposal-only';
167
+ const report = {
168
+ reportVersion: 1,
169
+ kind: 'artifact-workflow-run',
170
+ status: plan.status === 'planned' ? 'planned' : plan.status,
171
+ approval: {
172
+ mode: approvalMode,
173
+ durableWriteAllowed: approvalMode === 'allow-durable-write',
174
+ },
175
+ plan,
176
+ plannedSteps: plan.plannedSteps,
177
+ executedSteps: [],
178
+ skippedSteps: [],
179
+ producedFiles: [],
180
+ validation: null,
181
+ appliedOverrides: plan.appliedOverrides,
182
+ };
183
+ if (plan.status !== 'planned') return report;
184
+
185
+ const pendingReason = approvalMode === 'allow-durable-write'
186
+ ? 'specialist execution is host-owned; this local command has no specialist execution evidence'
187
+ : `approval mode '${approvalMode}' permits planning only`;
188
+ for (const step of plan.plannedSteps) {
189
+ if (['review', 'rewrite', 'author', 'research'].includes(step.action)) {
190
+ report.skippedSteps.push({ id: step.id, reason: pendingReason });
191
+ }
192
+ }
193
+ if (approvalMode !== 'allow-durable-write') {
194
+ for (const step of plan.plannedSteps) {
195
+ if (!['review', 'rewrite', 'author', 'research'].includes(step.action)) {
196
+ report.skippedSteps.push({ id: step.id, reason: pendingReason });
197
+ }
198
+ }
199
+ return report;
200
+ }
201
+
202
+ const sourcePath = plan.sourcePath;
203
+ for (const step of plan.plannedSteps) {
204
+ if (step.action === 'validate') {
205
+ if (!sourcePath) {
206
+ report.skippedSteps.push({ id: step.id, reason: 'validation needs sourcePath or filePath' });
207
+ } else {
208
+ const result = validateArtifactRelease({ filePath: sourcePath, type: plan.artifactType, cwd, rootDir });
209
+ report.validation = result;
210
+ report.executedSteps.push({ id: step.id, result: result.ok ? 'passed' : 'failed' });
211
+ if (!result.ok) {
212
+ report.status = 'validation-failed';
213
+ return report;
214
+ }
215
+ }
216
+ }
217
+ if (step.action === 'brand') {
218
+ // Branding is applied by the export operation. Keeping this pending until
219
+ // output exists avoids claiming a template was applied when no export ran.
220
+ report.skippedSteps.push({ id: step.id, reason: 'branding is evidenced by the export result' });
221
+ }
222
+ if (step.action === 'export') {
223
+ if (!sourcePath) {
224
+ report.skippedSteps.push({ id: step.id, reason: 'export needs sourcePath or filePath' });
225
+ } else {
226
+ const result = exportMarkdown({
227
+ inputPath: sourcePath,
228
+ outputPath: request.outputPath,
229
+ format: step.format,
230
+ artifactType: plan.artifactType,
231
+ branding: plan.target.branding,
232
+ cwd,
233
+ rootDir,
234
+ });
235
+ if (!result.ok) {
236
+ report.status = 'export-failed';
237
+ report.skippedSteps.push({ id: step.id, reason: result.message });
238
+ return report;
239
+ }
240
+ report.executedSteps.push({ id: step.id, result: 'produced', format: step.format });
241
+ report.producedFiles.push({ path: result.outputPath, format: step.format, branding: result.branding });
242
+ const brandStep = report.skippedSteps.findIndex((entry) => entry.id === 'brand');
243
+ if (brandStep >= 0 && result.branding?.applied === 'construct') {
244
+ report.skippedSteps.splice(brandStep, 1);
245
+ report.executedSteps.push({ id: 'brand', result: 'applied', mechanism: result.branding.mechanism });
246
+ }
247
+ }
248
+ }
249
+ }
250
+ report.status = report.executedSteps.length ? 'completed-local-steps' : 'planned';
251
+ return report;
252
+ }
package/lib/auto-docs.mjs CHANGED
@@ -368,7 +368,9 @@ function renderCommandPage(category, commands) {
368
368
  lines.push('**Subcommands**');
369
369
  lines.push('');
370
370
  for (const sub of cmd.subcommands) {
371
- lines.push(`- \`${sub}\``);
371
+ const name = typeof sub === 'string' ? sub : sub.name;
372
+ const description = typeof sub === 'string' ? '' : (sub.desc || sub.description || '');
373
+ lines.push(`- \`${name}\`${description ? ` — ${description}` : ''}`);
372
374
  }
373
375
  lines.push('');
374
376
  }
@@ -225,6 +225,7 @@ export function sessionMetaToSsePayload({ session, layers, workingBranch, oracle
225
225
  return {
226
226
  type: 'session_meta',
227
227
  model: session?.model || null,
228
+ demoLabel: session?.demoGuide ? (session.demoTitle || 'scripted') : null,
228
229
  modelMode: session?.modelMode || 'pinned',
229
230
  sandbox: session?.sandbox || null,
230
231
  permissionMode: session?.permissionMode || null,
@@ -330,12 +330,14 @@ export const CLI_COMMANDS = [
330
330
  category: 'Work',
331
331
  core: false,
332
332
  description: 'Export markdown to PDF, DOCX, HTML, and other Pandoc formats via Pandoc + Typst (optional system binaries; ADR-0024)',
333
- usage: 'construct export <markdown-file> --to=<pdf|docx|deck|pptx|html|rtf|odt|epub|tex|txt|md|mdx> [--output=<path>] [--figures] [--detect]',
333
+ usage: 'construct export <markdown-file> --to=<pdf|docx|deck|pptx|html|rtf|odt|epub|tex|txt|md|mdx> [--output=<path>] [--figures|--no-figures] [--plain] [--detect]',
334
334
  strictFlags: true,
335
335
  options: [
336
336
  { flag: '--to=<format>', desc: 'pdf, docx, deck, pptx, html, rtf, odt, epub, tex, txt, md, mdx' },
337
337
  { flag: '--output=<path>', desc: 'Output path' },
338
338
  { flag: '--figures', desc: 'Render d2/mermaid via pandoc-ext/diagram filter' },
339
+ { flag: '--no-figures', desc: 'Skip diagram rendering' },
340
+ { flag: '--plain, --no-brand', desc: 'Explicitly opt out of Construct branding for a brand-capable output' },
339
341
  { flag: '--detect', desc: 'Report binary availability (JSON)' },
340
342
  ],
341
343
  },
@@ -658,10 +660,11 @@ export const CLI_COMMANDS = [
658
660
  emoji: '📋',
659
661
  category: 'Work',
660
662
  core: false,
661
- description: 'Validate typed document artifacts against the release gate',
662
- usage: 'construct artifact validate <path> --type=<doc-type> [--json]',
663
+ description: 'Plan or locally execute manifest-backed artifact workflows with execution provenance',
664
+ usage: 'construct artifact <validate|workflow> ...',
663
665
  subcommands: [
664
666
  { name: 'validate', desc: 'Run manifest structure, citation, and reviewer checks' },
667
+ { name: 'workflow', desc: 'Return a truthful plan/run report; --apply only runs local validation/export after approval' },
665
668
  ],
666
669
  },
667
670
  {
@@ -772,6 +775,20 @@ export const CLI_COMMANDS = [
772
775
  description: 'Deployment mode configuration',
773
776
  usage: 'construct config <get|set>',
774
777
  },
778
+ {
779
+ name: 'sources',
780
+ emoji: '🔗',
781
+ category: 'Advanced',
782
+ core: false,
783
+ description: 'Manage typed integration source targets in construct.config.json',
784
+ usage: 'construct sources list|add|remove|validate',
785
+ subcommands: [
786
+ { name: 'list', desc: 'Show config targets, legacy env merge, and effective set' },
787
+ { name: 'add <provider> <id> <selector-json>', desc: 'Add a typed target (github, jira, linear, slack)' },
788
+ { name: 'remove <id>', desc: 'Remove a config target by id' },
789
+ { name: 'validate', desc: 'Validate sources.targets in construct.config.json' },
790
+ ],
791
+ },
775
792
  {
776
793
  name: 'uninstall',
777
794
  emoji: '🧹',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * lib/cli-service-inventory.mjs — auditable public CLI/catalog/docs inventory.
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { CLI_COMMANDS } from './cli-commands.mjs';
8
+
9
+ function slugify(category) {
10
+ return category.toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
11
+ }
12
+
13
+ function handlerNames(rootDir) {
14
+ const source = fs.readFileSync(path.join(rootDir, 'bin', 'construct'), 'utf8');
15
+ const names = new Set();
16
+ for (const match of source.matchAll(/\n {2,3}\[\s*'([^']+)'\s*,/g)) names.add(match[1]);
17
+ return names;
18
+ }
19
+
20
+ /**
21
+ * The catalog is the public-service source of truth. This projection joins it
22
+ * to runtime dispatch and the generated reference pages so drift is observable
23
+ * without treating internal handlers as public services.
24
+ */
25
+ export function buildCliServiceInventory({ rootDir = process.cwd() } = {}) {
26
+ const handlers = handlerNames(rootDir);
27
+ return CLI_COMMANDS.filter((spec) => !spec.internal).map((spec) => {
28
+ const page = path.join(rootDir, 'docs', 'reference', 'cli', `${slugify(spec.category)}.md`);
29
+ const pageText = fs.existsSync(page) ? fs.readFileSync(page, 'utf8') : '';
30
+ return {
31
+ name: spec.name,
32
+ category: spec.category,
33
+ runnable: handlers.has(spec.name),
34
+ documented: pageText.includes(`## construct ${spec.name}`),
35
+ usage: spec.usage,
36
+ subcommands: (spec.subcommands ?? []).map((sub) => ({
37
+ name: typeof sub === 'string' ? sub : sub.name,
38
+ documented: typeof sub === 'string' ? true : Boolean(sub.desc || sub.description),
39
+ })),
40
+ };
41
+ });
42
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * lib/config/intake-policy.mjs — project-scoped intake watcher policy.
3
+ *
4
+ * Resolves watch zones, scan depth, and additional directories from
5
+ * construct.config.json intakePolicy, with warned fallback to legacy
6
+ * .cx/intake-config.json and env overrides.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { isAbsolute, join, resolve } from 'node:path';
11
+ import { loadProjectConfig, writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } from './project-config.mjs';
12
+ import {
13
+ INTAKE_DEFAULT_MAX_DEPTH,
14
+ INTAKE_HARD_MAX_DEPTH,
15
+ INTAKE_DEPTH_GUIDANCE,
16
+ describeIntakeDepth,
17
+ } from '../intake/constants.mjs';
18
+ import { INTAKE_CONFIG_FILE } from '../intake/legacy-paths.mjs';
19
+
20
+ export { INTAKE_DEFAULT_MAX_DEPTH, INTAKE_HARD_MAX_DEPTH, INTAKE_DEPTH_GUIDANCE, describeIntakeDepth };
21
+
22
+ export const DEFAULT_INTAKE_POLICY = Object.freeze({
23
+ maxDepth: INTAKE_DEFAULT_MAX_DEPTH,
24
+ zones: Object.freeze({
25
+ rootInbox: true,
26
+ projectInbox: true,
27
+ docsIntake: true,
28
+ }),
29
+ additionalDirs: [],
30
+ });
31
+
32
+ function clampDepth(value) {
33
+ const n = Number(value);
34
+ if (!Number.isFinite(n) || n < 0) return INTAKE_DEFAULT_MAX_DEPTH;
35
+ return Math.min(Math.floor(n), INTAKE_HARD_MAX_DEPTH);
36
+ }
37
+
38
+ function normalizeDir(dir, rootDir) {
39
+ if (typeof dir !== 'string') return null;
40
+ const trimmed = dir.trim();
41
+ if (!trimmed) return null;
42
+ return isAbsolute(trimmed) ? trimmed : resolve(rootDir, trimmed);
43
+ }
44
+
45
+ function parseEnvDirs(env) {
46
+ const raw = String(env?.CX_INBOX_DIRS ?? '').trim();
47
+ if (!raw) return [];
48
+ return raw.split(':').map((p) => p.trim()).filter(Boolean);
49
+ }
50
+
51
+ function readLegacyIntakeConfig(rootDir) {
52
+ const file = join(rootDir, INTAKE_CONFIG_FILE);
53
+ if (!existsSync(file)) return null;
54
+ try {
55
+ return JSON.parse(readFileSync(file, 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function intakePolicyFromProjectConfig(config) {
62
+ const raw = config?.intakePolicy ?? {};
63
+ const zones = raw.zones ?? {};
64
+ return {
65
+ maxDepth: clampDepth(raw.maxDepth ?? DEFAULT_INTAKE_POLICY.maxDepth),
66
+ zones: {
67
+ rootInbox: zones.rootInbox !== false,
68
+ projectInbox: zones.projectInbox !== false,
69
+ docsIntake: zones.docsIntake !== false,
70
+ },
71
+ additionalDirs: Array.isArray(raw.additionalDirs)
72
+ ? raw.additionalDirs.map((d) => String(d).trim()).filter(Boolean)
73
+ : [],
74
+ };
75
+ }
76
+
77
+ export function legacyIntakeToPolicy(stored = {}) {
78
+ return {
79
+ maxDepth: clampDepth(stored.maxDepth ?? INTAKE_DEFAULT_MAX_DEPTH),
80
+ zones: {
81
+ rootInbox: stored.includeArchetypeInbox === true || stored.includeRootInbox === true,
82
+ projectInbox: stored.includeProjectInbox !== false,
83
+ docsIntake: stored.includeDocsIntake !== false,
84
+ },
85
+ additionalDirs: Array.isArray(stored.parentDirs) ? stored.parentDirs : [],
86
+ };
87
+ }
88
+
89
+ export function policyToLegacyIntakeShape(policy) {
90
+ return {
91
+ parentDirs: policy.additionalDirs,
92
+ maxDepth: policy.maxDepth,
93
+ includeProjectInbox: policy.zones.projectInbox,
94
+ includeDocsIntake: policy.zones.docsIntake,
95
+ includeArchetypeInbox: policy.zones.rootInbox,
96
+ includeRootInbox: policy.zones.rootInbox,
97
+ };
98
+ }
99
+
100
+ export function loadIntakePolicy(rootDir, env = process.env) {
101
+ const { config, path: configPath, raw } = loadProjectConfig(rootDir, env);
102
+ const legacy = readLegacyIntakeConfig(rootDir);
103
+ let policy;
104
+ let source = 'default';
105
+ let legacyWarning = null;
106
+
107
+ if (configPath && raw?.intakePolicy) {
108
+ policy = intakePolicyFromProjectConfig(config);
109
+ source = 'project-config';
110
+ } else if (legacy) {
111
+ policy = legacyIntakeToPolicy(legacy);
112
+ source = 'legacy-intake-config';
113
+ legacyWarning = `Using deprecated ${INTAKE_CONFIG_FILE}; migrate with \`construct intake config migrate\``;
114
+ } else {
115
+ policy = structuredClone(DEFAULT_INTAKE_POLICY);
116
+ source = 'default';
117
+ }
118
+
119
+ const envDirs = parseEnvDirs(env).map((dir) => normalizeDir(dir, rootDir)).filter(Boolean);
120
+ const additionalDirs = []
121
+ .concat(policy.additionalDirs.map((dir) => normalizeDir(dir, rootDir)).filter(Boolean))
122
+ .concat(envDirs);
123
+
124
+ const seen = new Set();
125
+ const uniqueDirs = [];
126
+ for (const dir of additionalDirs) {
127
+ if (seen.has(dir)) continue;
128
+ seen.add(dir);
129
+ uniqueDirs.push(dir);
130
+ }
131
+
132
+ const envDepth = env?.CX_INTAKE_MAX_DEPTH;
133
+ const maxDepth = clampDepth(envDepth ?? policy.maxDepth);
134
+
135
+ return {
136
+ maxDepth,
137
+ zones: { ...policy.zones },
138
+ additionalDirs: uniqueDirs,
139
+ source,
140
+ legacyWarning,
141
+ };
142
+ }
143
+
144
+ export function resolvedIntakeConfig(rootDir, env = process.env) {
145
+ const policy = loadIntakePolicy(rootDir, env);
146
+ return {
147
+ parentDirs: policy.additionalDirs,
148
+ maxDepth: policy.maxDepth,
149
+ includeProjectInbox: policy.zones.projectInbox,
150
+ includeDocsIntake: policy.zones.docsIntake,
151
+ includeArchetypeInbox: policy.zones.rootInbox,
152
+ includeRootInbox: policy.zones.rootInbox,
153
+ source: policy.source,
154
+ legacyWarning: policy.legacyWarning,
155
+ };
156
+ }
157
+
158
+ export function saveIntakePolicy(rootDir, patch = {}, options = {}) {
159
+ const configPath = findProjectConfigPath(rootDir) ?? join(rootDir, PROJECT_CONFIG_FILENAME);
160
+ const { config } = loadProjectConfig(rootDir, {});
161
+ const current = intakePolicyFromProjectConfig(config.intakePolicy ? config : { intakePolicy: DEFAULT_INTAKE_POLICY });
162
+
163
+ const nextZones = { ...current.zones, ...(patch.zones ?? {}) };
164
+ const next = {
165
+ maxDepth: clampDepth(patch.maxDepth ?? current.maxDepth),
166
+ zones: {
167
+ rootInbox: patch.rootInbox !== undefined ? Boolean(patch.rootInbox) : (patch.zones?.rootInbox ?? nextZones.rootInbox),
168
+ projectInbox: patch.projectInbox !== undefined ? Boolean(patch.projectInbox) : (patch.zones?.projectInbox ?? nextZones.projectInbox),
169
+ docsIntake: patch.docsIntake !== undefined ? Boolean(patch.docsIntake) : (patch.zones?.docsIntake ?? nextZones.docsIntake),
170
+ },
171
+ additionalDirs: Array.isArray(patch.additionalDirs)
172
+ ? patch.additionalDirs.map((dir) => normalizeDir(dir, rootDir)).filter(Boolean)
173
+ : current.additionalDirs,
174
+ };
175
+
176
+ if (patch.addDir) {
177
+ const dir = normalizeDir(patch.addDir, rootDir);
178
+ if (dir && !next.additionalDirs.includes(dir)) {
179
+ next.additionalDirs = [...next.additionalDirs, dir];
180
+ }
181
+ }
182
+ if (patch.removeDir) {
183
+ const dir = normalizeDir(patch.removeDir, rootDir);
184
+ next.additionalDirs = next.additionalDirs.filter((d) => d !== dir);
185
+ }
186
+
187
+ const updated = {
188
+ ...config,
189
+ version: config.version ?? 1,
190
+ intakePolicy: next,
191
+ };
192
+
193
+ if (options.dryRun) {
194
+ return { policy: next, configPath, dryRun: true };
195
+ }
196
+
197
+ writeProjectConfig(configPath, updated);
198
+ return { policy: next, configPath };
199
+ }
200
+
201
+ export function migrateLegacyIntakeConfig(rootDir) {
202
+ const legacy = readLegacyIntakeConfig(rootDir);
203
+ if (!legacy) {
204
+ return { migrated: false, reason: 'no legacy file' };
205
+ }
206
+ const policy = legacyIntakeToPolicy(legacy);
207
+ saveIntakePolicy(rootDir, policy);
208
+ return { migrated: true, policy };
209
+ }
@@ -15,6 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { CONFIG_SCHEMA_VERSION, DEFAULT_PROJECT_CONFIG, validateProjectConfig, FIELD_RULES } from './schema.mjs';
18
+ import { validateSourceTargets } from './source-targets.mjs';
18
19
 
19
20
  export const PROJECT_CONFIG_FILENAME = 'construct.config.json';
20
21
 
@@ -85,13 +86,17 @@ export function loadProjectConfig(cwd = process.cwd(), env = process.env) {
85
86
  };
86
87
  }
87
88
  const validation = validateProjectConfig(raw);
88
- if (!validation.valid) {
89
+ const targetErrors = raw?.sources?.targets !== undefined
90
+ ? validateSourceTargets(raw.sources.targets)
91
+ : [];
92
+ const allErrors = [...(validation.errors ?? []), ...targetErrors];
93
+ if (!validation.valid || targetErrors.length) {
89
94
  return {
90
95
  path: configPath,
91
96
  raw,
92
97
  config: structuredClone(DEFAULT_PROJECT_CONFIG),
93
98
  source: 'invalid',
94
- errors: validation.errors,
99
+ errors: allErrors,
95
100
  };
96
101
  }
97
102
  const merged = deepMerge(structuredClone(DEFAULT_PROJECT_CONFIG), raw);
@@ -161,6 +166,10 @@ export function writeProjectConfig(filePath, config, options = {}) {
161
166
  let validation = { valid: true, errors: [] };
162
167
  if (validate) {
163
168
  validation = validateProjectConfig(config);
169
+ if (config?.sources?.targets !== undefined) {
170
+ validation.errors = [...(validation.errors ?? []), ...validateSourceTargets(config.sources.targets)];
171
+ validation.valid = validation.errors.length === 0;
172
+ }
164
173
  }
165
174
 
166
175
  // Combine all errors (handle case where validation.errors is undefined)
@@ -5,7 +5,8 @@
5
5
  * AJV/zod to keep Construct dependency-free at startup — the loader runs
6
6
  * before npm install completes in some bootstrap paths.
7
7
  *
8
- * Schema v1 covers: alias, deployment, providers, autoEmbed, ingest, telemetry.
8
+ * Schema v1 covers: alias, deployment, providers, sources, intakePolicy,
9
+ * autoEmbed, ingest, telemetry.
9
10
  * Tier model selection lives in `specialists/registry.json` only — this config
10
11
  * file is intentionally not a second edit surface for model assignments.
11
12
  * Later phases (6, 7b) extend it with `resources` and `costs` blocks
@@ -47,6 +48,22 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
47
48
  tenantId: null,
48
49
  }),
49
50
  providers: Object.freeze({}),
51
+ sources: Object.freeze({
52
+ targets: [],
53
+ }),
54
+ intakePolicy: Object.freeze({
55
+ maxDepth: 4,
56
+ zones: Object.freeze({
57
+ rootInbox: true,
58
+ projectInbox: true,
59
+ docsIntake: true,
60
+ }),
61
+ additionalDirs: [],
62
+ }),
63
+ artifactWorkflow: Object.freeze({
64
+ defaults: Object.freeze({}),
65
+ types: Object.freeze({}),
66
+ }),
50
67
  profile: DEFAULT_PROFILE_ID,
51
68
  autoEmbed: false,
52
69
  ingest: Object.freeze({
@@ -121,6 +138,38 @@ export const FIELD_RULES = {
121
138
  },
122
139
  },
123
140
  providers: { type: 'object', required: false },
141
+ sources: {
142
+ type: 'object',
143
+ required: false,
144
+ fields: {
145
+ targets: { type: 'array' },
146
+ },
147
+ },
148
+ intakePolicy: {
149
+ type: 'object',
150
+ required: false,
151
+ fields: {
152
+ maxDepth: { type: 'number' },
153
+ additionalDirs: { type: 'array' },
154
+ zones: {
155
+ type: 'object',
156
+ required: false,
157
+ fields: {
158
+ rootInbox: { type: 'boolean' },
159
+ projectInbox: { type: 'boolean' },
160
+ docsIntake: { type: 'boolean' },
161
+ },
162
+ },
163
+ },
164
+ },
165
+ artifactWorkflow: {
166
+ type: 'object',
167
+ required: false,
168
+ fields: {
169
+ defaults: { type: 'object', required: false },
170
+ types: { type: 'object', required: false },
171
+ },
172
+ },
124
173
  profile: { type: 'string', required: false, maxLength: 40 },
125
174
  autoEmbed: { type: 'boolean', required: false },
126
175
  ingest: {