@geraldmaron/construct 1.0.2 → 1.0.3

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.
package/.env.example CHANGED
@@ -46,7 +46,7 @@ OPENROUTER_TITLE=Your App Name
46
46
  # The installer can auto-configure local memory or let you skip/manualize it.
47
47
 
48
48
  # ─── Hybrid Retrieval Backend ─────────────────────────────────────────────────
49
- # `construct setup --yes` writes managed defaults automatically. When Docker is
49
+ # `construct init --yes` writes managed defaults automatically. When Docker is
50
50
  # available, it starts a localhost-only Postgres container on 127.0.0.1:54329.
51
51
  #
52
52
  # Shared Postgres store for team-ready state/querying
package/bin/construct CHANGED
@@ -130,7 +130,7 @@ function usage() {
130
130
 
131
131
  /** Show interactive context-aware menu when construct is run without arguments. */
132
132
  async function showInteractiveMenu() {
133
- const { loadProjectConfig } = await import('./lib/config/project-config.mjs');
133
+ const { loadProjectConfig } = await import('../lib/config/project-config.mjs');
134
134
  const { existsSync } = await import('node:fs');
135
135
  const { join } = await import('node:path');
136
136
 
@@ -142,7 +142,7 @@ async function showInteractiveMenu() {
142
142
  println('');
143
143
 
144
144
  if (isConstructProject) {
145
- const projectName = process.env.CX_PROJECT_NAME || require('path').basename(projectRoot);
145
+ const projectName = process.env.CX_PROJECT_NAME || path.basename(projectRoot);
146
146
  println(`${COLORS.dim}Project:${COLORS.reset} ${projectName}`);
147
147
  println('');
148
148
  }
@@ -160,7 +160,7 @@ async function showInteractiveMenu() {
160
160
 
161
161
  // Show context-aware suggestions
162
162
  if (isConstructProject) {
163
- const { readDashboardState } = await import('./lib/service-manager.mjs');
163
+ const { readDashboardState } = await import('../lib/service-manager.mjs');
164
164
  const dashboard = readDashboardState(HOME);
165
165
 
166
166
  if (!dashboard) {
@@ -288,12 +288,12 @@ async function cmdDoctor() {
288
288
  for (const varName of vars) {
289
289
  const sources = [];
290
290
 
291
- // 1. process.env
291
+ // process.env
292
292
  if (process.env[varName]) {
293
293
  sources.push(`process.env (${process.env[varName].slice(0, 8)}…)`);
294
294
  }
295
295
 
296
- // 2. .env files
296
+ // .env files
297
297
  for (const envPath of [join(process.cwd(), '.env'), join(homedir(), '.env'), join(homedir(), '.construct', 'config.env')]) {
298
298
  if (fs.existsSync(envPath)) {
299
299
  try {
@@ -304,7 +304,7 @@ async function cmdDoctor() {
304
304
  }
305
305
  }
306
306
 
307
- // 3. Shell rc files
307
+ // Shell rc files
308
308
  const shellFiles = [join(homedir(), '.zshrc'), join(homedir(), '.bashrc'), join(homedir(), '.bash_profile'), join(homedir(), '.profile')];
309
309
  for (const rcPath of shellFiles) {
310
310
  if (!fs.existsSync(rcPath)) continue;
@@ -904,9 +904,9 @@ async function cmdUp() {
904
904
  if (embedConfigExists && autoEmbed) {
905
905
  try {
906
906
  const { runEmbedCli } = await import('../lib/embed/cli.mjs');
907
- await runEmbedCli(['start']);
908
- } catch {
909
- // best effort
907
+ await runEmbedCli(['start'], { rootDir: ROOT_DIR });
908
+ } catch (err) {
909
+ warn(`embed start failed: ${err.message} — check ~/.cx/runtime/embed-daemon.log`);
910
910
  }
911
911
  }
912
912
 
@@ -1789,8 +1789,8 @@ async function cmdIntakeConfig(args) {
1789
1789
  async function cmdIntakeMetrics() {
1790
1790
  try {
1791
1791
  const { computeIntakeMetrics, pendingAge } = await import('../lib/embed/intake-metrics.mjs');
1792
- const metrics = computeIntakeMetrics({ rootDir: cwd });
1793
- const age = pendingAge({ rootDir: cwd });
1792
+ const metrics = computeIntakeMetrics({ rootDir: process.cwd() });
1793
+ const age = pendingAge({ rootDir: process.cwd() });
1794
1794
 
1795
1795
  println(`${COLORS.bold}Intake pipeline metrics:${COLORS.reset}`);
1796
1796
  println('');
@@ -2449,6 +2449,44 @@ async function cmdModels(args) {
2449
2449
  await cmdSync([]);
2450
2450
  return;
2451
2451
  }
2452
+ if (args.includes('--cheapest')) {
2453
+ const { selectCheapestProvider, rankConfiguredProvidersByCost, formatCheapestProviderMessage, isCheapestProviderEnabled } =
2454
+ await import('../lib/model-cheapest-provider.mjs');
2455
+ const cheapestTier = args.find((arg) => arg.startsWith('--tier='))?.split('=')[1] ?? 'standard';
2456
+ const verbose = args.includes('--verbose');
2457
+ const enabled = isCheapestProviderEnabled(envPath, { env: process.env });
2458
+ const result = await selectCheapestProvider(cheapestTier, { env: process.env });
2459
+ const msg = formatCheapestProviderMessage(result, { showRanking: verbose });
2460
+ println(msg);
2461
+ if (enabled) println('\nCheapest provider mode: enabled');
2462
+ return;
2463
+ }
2464
+ if (args.includes('--apply-cheapest')) {
2465
+ const { selectCheapestForAllTiers, setCheapestProviderPreference } =
2466
+ await import('../lib/model-cheapest-provider.mjs');
2467
+ const allTiers = args.includes('--all-tiers') || !args.find((arg) => arg.startsWith('--tier='));
2468
+ const tierArg = args.find((arg) => arg.startsWith('--tier='))?.split('=')[1];
2469
+ const tiers = allTiers ? ['reasoning', 'standard', 'fast'] : (tierArg ? [tierArg] : ['standard']);
2470
+ const selections = {};
2471
+ for (const currentTier of tiers) {
2472
+ const { selectCheapestProvider } = await import('../lib/model-cheapest-provider.mjs');
2473
+ const result = await selectCheapestProvider(currentTier, { env: process.env });
2474
+ if (result.modelId) selections[currentTier] = result.modelId;
2475
+ }
2476
+ if (Object.keys(selections).length === 0) {
2477
+ errorln('No configured providers found. Set API keys or install Ollama first.');
2478
+ process.exit(1);
2479
+ }
2480
+ applyToEnv(envPath, selections);
2481
+ println('Applying cheapest models:');
2482
+ for (const [tier, model] of Object.entries(selections)) {
2483
+ println(` ${tier.padEnd(11)} ${model}`);
2484
+ }
2485
+ println('Written to ~/.construct/config.env. Running construct sync...');
2486
+ setCheapestProviderPreference(envPath, true);
2487
+ await cmdSync([]);
2488
+ return;
2489
+ }
2452
2490
  const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'agents', 'registry.json'), 'utf8'));
2453
2491
  const result = readCurrentModels(envPath, registry.models ?? {});
2454
2492
  const nothingSelected = ['reasoning', 'standard', 'fast'].every((t) => !result[t]);
@@ -6,7 +6,7 @@
6
6
  * and runs `npm install`, npm fetches Construct into the project's
7
7
  * `node_modules/` and then runs this script. Its job: regenerate the project's
8
8
  * `.claude/agents/` and `.claude/settings.json` from the bundled registry so
9
- * the project clone is fully runnable without a manual `construct setup`.
9
+ * the project clone is fully runnable without a manual `construct init`.
10
10
  *
11
11
  * The script is a no-op in three cases:
12
12
  *
@@ -15,7 +15,7 @@
15
15
  * downloadSize approximate install size in bytes (informational)
16
16
  *
17
17
  * The registry is consulted by:
18
- * - `construct setup` walks every resource, asks consent, installs
18
+ * - `construct init` walks every resource, asks consent, installs
19
19
  * - `construct doctor --bootstrap` re-probes every resource verbosely
20
20
  * - lazy-install paths in hooks (consult the cached consent silently)
21
21
  *
@@ -114,7 +114,7 @@ export function formatProbe(probe) {
114
114
  ? `\n fallback: ${probe.fallback}`
115
115
  : '';
116
116
  const install = !probe.present && probe.installable
117
- ? `\n installable via construct setup`
117
+ ? `\n installable via construct init`
118
118
  : '';
119
119
  return ` ${status} ${probe.displayName}${v}${loc}${detail}${fallback}${install}`;
120
120
  }
@@ -77,7 +77,7 @@ function renderMarkdown({ sourcePath, extractedAt, title, extractionMethod, char
77
77
  return lines.join('\n');
78
78
  }
79
79
 
80
- function collectInputFiles(inputPath) {
80
+ function collectInputFiles(inputPath, { maxDepth = 10 } = {}) {
81
81
  const resolvedPath = resolve(inputPath);
82
82
  if (!existsSync(resolvedPath)) throw new Error(`Input path not found: ${resolvedPath}`);
83
83
 
@@ -90,13 +90,14 @@ function collectInputFiles(inputPath) {
90
90
  if (!stat.isDirectory()) return [];
91
91
 
92
92
  const files = [];
93
- const stack = [resolvedPath];
93
+ const stack = [{ path: resolvedPath, depth: 0 }];
94
94
  while (stack.length > 0) {
95
- const current = stack.pop();
95
+ const { path: current, depth } = stack.pop();
96
+ if (depth >= maxDepth) continue;
96
97
  for (const entry of readdirSync(current, { withFileTypes: true })) {
97
98
  const full = join(current, entry.name);
98
99
  if (entry.isDirectory()) {
99
- stack.push(full);
100
+ stack.push({ path: full, depth: depth + 1 });
100
101
  continue;
101
102
  }
102
103
  if (entry.isFile() && isExtractableDocumentPath(full)) files.push(full);
@@ -135,7 +136,7 @@ export async function ingestDocuments(inputPaths, {
135
136
  throw new Error('--out can only be used with a single input path');
136
137
  }
137
138
 
138
- const files = inputPaths.flatMap((inputPath) => collectInputFiles(isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath)));
139
+ const files = inputPaths.flatMap((inputPath) => collectInputFiles(isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath), { maxDepth: 10 }));
139
140
  if (files.length === 0) {
140
141
  throw new Error('No supported document files found');
141
142
  }
package/lib/embed/cli.mjs CHANGED
@@ -174,7 +174,7 @@ function parseArgs(args) {
174
174
  // Subcommand: start
175
175
  // ---------------------------------------------------------------------------
176
176
 
177
- async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir } = {}) {
177
+ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPath, _livenessCheckMs = 400 } = {}) {
178
178
  const existing = readRunningState(homeDir);
179
179
  if (existing) {
180
180
  process.stdout.write(`embed daemon already running (pid ${existing.pid})\n`);
@@ -188,7 +188,11 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir } = {}) {
188
188
  ? path.join(os.homedir(), '.construct', 'embed.yaml')
189
189
  : null;
190
190
 
191
- const workerPath = path.join(rootDir, 'lib', 'embed', 'worker.mjs');
191
+ const workerPath = _workerPath ?? path.join(rootDir, 'lib', 'embed', 'worker.mjs');
192
+ if (!fs.existsSync(workerPath)) {
193
+ throw new Error(`embed worker not found at ${workerPath}`);
194
+ }
195
+
192
196
  const workerArgs = configPath ? [workerPath, '--config', configPath] : [workerPath];
193
197
  const log = logPath(homeDir);
194
198
  fs.mkdirSync(path.dirname(log), { recursive: true });
@@ -199,9 +203,18 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir } = {}) {
199
203
  stdio: ['ignore', fd, fd],
200
204
  env: { ...process.env },
201
205
  });
206
+ fs.closeSync(fd);
202
207
  child.unref();
203
208
 
204
209
  writeState({ pid: child.pid, configPath: configPath ?? 'auto', startedAt: new Date().toISOString() }, homeDir);
210
+
211
+ // Brief liveness check — detects immediate crashes (e.g. missing module, bad config path)
212
+ await new Promise(r => setTimeout(r, _livenessCheckMs));
213
+ if (!processExists(child.pid)) {
214
+ clearState(homeDir);
215
+ throw new Error(`embed worker (pid ${child.pid}) exited immediately — check ${log}`);
216
+ }
217
+
205
218
  process.stdout.write(`embed daemon started (pid ${child.pid})\n`);
206
219
  process.stdout.write(`config: ${configPath ?? 'auto-discover from config.env'}\n`);
207
220
  process.stdout.write(`log: ${log}\n`);
@@ -299,7 +312,7 @@ export async function runEmbedCli(args, opts = {}) {
299
312
  const rootDir = opts.rootDir ?? new URL('../..', import.meta.url).pathname;
300
313
 
301
314
  switch (sub) {
302
- case 'start': return cmdEmbedStart(subArgs, { homeDir, rootDir });
315
+ case 'start': return cmdEmbedStart(subArgs, { homeDir, rootDir, _workerPath: opts._workerPath, _livenessCheckMs: opts._livenessCheckMs });
303
316
  case 'stop': return cmdEmbedStop(subArgs, { homeDir });
304
317
  case 'status': return cmdEmbedStatus(subArgs, { homeDir });
305
318
  case 'snapshot': return cmdEmbedSnapshot(subArgs, { homeDir });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Maintains durable customer/account memory in ~/.cx/product-intel/customer-profiles/.
5
5
  * Profiles are additive — history is preserved unless explicitly deleted.
6
- * Used by intake triage to link signals to customers and detect account-level patterns.
6
+ * Links signals to customers and detects account-level patterns during intake triage.
7
7
  *
8
8
  * Storage:
9
9
  * ~/.cx/product-intel/customer-profiles/<customer-id>.md
@@ -58,8 +58,8 @@ function logCatch(source, err) {
58
58
 
59
59
  /**
60
60
  * Resolve an LLM API key from multiple sources: env → config.env → ~/.env → shell rc.
61
- * Used by the telemetry-generation probe so it can find keys stored via 1Password
62
- * op:// refs in .zshrc or other shell config files.
61
+ * Supports 1Password op:// refs in .zshrc or other shell config files for the
62
+ * telemetry-generation probe.
63
63
  */
64
64
  async function resolveLLMKey(varName, env) {
65
65
  if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
@@ -575,7 +575,8 @@ this.#scheduler.register(
575
575
  const ok = result.results.filter(r => r.success).length;
576
576
  const total = result.results.length;
577
577
  process.stderr.write(`[embed] Telemetry setup: ${ok}/${total} resources configured\n`);
578
- } else if (!result.ok) {
578
+ } else if (!result.ok && !result.error?.includes('required')) {
579
+ // Suppress the "keys required" message — normal for solo mode without telemetry configured.
579
580
  process.stderr.write(`[embed] Telemetry setup skipped: ${result.error}\n`);
580
581
  } else if (result.results?.some(r => !r.success)) {
581
582
  const failures = result.results.filter(r => !r.success);
@@ -840,21 +841,23 @@ this.#scheduler.register(
840
841
  meta: { gaps: result.gaps.length, conflicts: result.conflicts?.length || 0, recommendations: result.recommendations?.length || 0, autoFixed, queued },
841
842
  });
842
843
  }
843
- emitTraceEvent({
844
- rootDir: this.#rootDir,
845
- eventType: 'lifecycle.completed',
846
- traceId: this.#daemonTraceId,
847
- spanId: newSpanId(),
848
- env: this.#env,
849
- metadata: {
850
- gaps: result.gaps.length,
851
- conflicts: result.conflicts?.length || 0,
852
- recommendations: result.recommendations?.length || 0,
853
- autoFixed,
854
- queued,
855
- targets: result.targets,
856
- },
857
- }).catch(() => {});
844
+ try {
845
+ emitTraceEvent({
846
+ rootDir: this.#rootDir,
847
+ eventType: 'lifecycle.completed',
848
+ traceId: this.#daemonTraceId,
849
+ spanId: newSpanId(),
850
+ env: this.#env,
851
+ metadata: {
852
+ gaps: result.gaps.length,
853
+ conflicts: result.conflicts?.length || 0,
854
+ recommendations: result.recommendations?.length || 0,
855
+ autoFixed,
856
+ queued,
857
+ targets: result.targets,
858
+ },
859
+ });
860
+ } catch { /* observability must not break the caller */ }
858
861
  } catch (err) {
859
862
  process.stderr.write(`[embed] Docs lifecycle error: ${err.message}\n`);
860
863
  }
@@ -1114,18 +1117,20 @@ this.#scheduler.register(
1114
1117
 
1115
1118
  this.#status = 'running';
1116
1119
  this.#daemonTraceId = newTraceId();
1117
- emitTraceEvent({
1118
- rootDir: this.#rootDir,
1119
- eventType: 'daemon.started',
1120
- traceId: this.#daemonTraceId,
1121
- spanId: newSpanId(),
1122
- env: this.#env,
1123
- metadata: {
1124
- pid: process.pid,
1125
- configSnapshotIntervalMs: this.#config.snapshot.intervalMs,
1126
- version: process.env.CONSTRUCT_VERSION || '0.0.0',
1127
- },
1128
- }).catch(() => {});
1120
+ try {
1121
+ emitTraceEvent({
1122
+ rootDir: this.#rootDir,
1123
+ eventType: 'daemon.started',
1124
+ traceId: this.#daemonTraceId,
1125
+ spanId: newSpanId(),
1126
+ env: this.#env,
1127
+ metadata: {
1128
+ pid: process.pid,
1129
+ configSnapshotIntervalMs: this.#config.snapshot.intervalMs,
1130
+ version: process.env.CONSTRUCT_VERSION || '0.0.0',
1131
+ },
1132
+ });
1133
+ } catch { /* observability must not break the caller */ }
1129
1134
  process.stderr.write(`[embed] Daemon started. Snapshot interval: ${this.#config.snapshot.intervalMs}ms\n`);
1130
1135
  }
1131
1136
 
@@ -1134,14 +1139,16 @@ this.#scheduler.register(
1134
1139
  this.#inboxLiveWatcher = null;
1135
1140
  this.#scheduler?.stop();
1136
1141
  this.#status = 'stopped';
1137
- emitTraceEvent({
1138
- rootDir: this.#rootDir,
1139
- eventType: 'daemon.stopped',
1140
- traceId: this.#daemonTraceId,
1141
- spanId: newSpanId(),
1142
- env: this.#env,
1143
- metadata: { pid: process.pid, uptimeMs: process.uptime() * 1000 },
1144
- }).catch(() => {});
1142
+ try {
1143
+ emitTraceEvent({
1144
+ rootDir: this.#rootDir,
1145
+ eventType: 'daemon.stopped',
1146
+ traceId: this.#daemonTraceId,
1147
+ spanId: newSpanId(),
1148
+ env: this.#env,
1149
+ metadata: { pid: process.pid, uptimeMs: process.uptime() * 1000 },
1150
+ });
1151
+ } catch { /* observability must not break the caller */ }
1145
1152
  process.stderr.write('[embed] Daemon stopped.\n');
1146
1153
  }
1147
1154
 
@@ -1182,10 +1189,10 @@ this.#scheduler.register(
1182
1189
  try {
1183
1190
  // Query strategy/PRDs/RFCs from knowledge base
1184
1191
 
1185
- const strategyDocs = searchObservations(this.#rootDir, 'PRD RFC strategy', {
1186
- category: 'knowledge',
1187
- limit: 50,
1188
- });
1192
+ const strategyDocs = searchObservations(this.#rootDir, 'PRD RFC strategy', {
1193
+ category: 'insight',
1194
+ limit: 50,
1195
+ });
1189
1196
 
1190
1197
  // Query existing Jira tickets via Atlassian provider
1191
1198
 
@@ -36,6 +36,7 @@ const STATE_FILE = '.cx/runtime/inbox-state.json';
36
36
  const DEFAULT_INBOX_SUBDIR = '.cx/inbox';
37
37
  const DOCS_INTAKE_SUBDIR = 'docs/intake';
38
38
  const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB hard cap
39
+ const STALE_STATE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
39
40
 
40
41
  function nextAvailablePath(targetPath) {
41
42
  if (!existsSync(targetPath)) return targetPath;
@@ -50,6 +51,28 @@ function nextAvailablePath(targetPath) {
50
51
  }
51
52
  }
52
53
 
54
+ // ─── Stale state pruning ─────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Remove state entries for source files absent from disk for more than 7 days.
58
+ * Returns the count of removed entries.
59
+ */
60
+ function pruneStaleState(rootDir, state) {
61
+ const cutoffMs = Date.now() - STALE_STATE_AGE_MS;
62
+ let removed = 0;
63
+ for (const key of Object.keys(state)) {
64
+ const entry = state[key];
65
+ if (!existsSync(key) && entry.processedAt) {
66
+ const processedMs = new Date(entry.processedAt).getTime();
67
+ if (processedMs < cutoffMs) {
68
+ delete state[key];
69
+ removed++;
70
+ }
71
+ }
72
+ }
73
+ return removed;
74
+ }
75
+
53
76
  // ─── State helpers ────────────────────────────────────────────────────────────
54
77
 
55
78
  function statePath(rootDir) {
@@ -327,6 +350,13 @@ export class InboxWatcher {
327
350
  }
328
351
  }
329
352
 
353
+ // Prune stale state entries: remove entries for source files absent from disk
354
+ // for more than 7 days (covers moved/renamed files that linger in state).
355
+ const staleRemoved = pruneStaleState(this.#rootDir, state);
356
+ if (staleRemoved > 0) {
357
+ process.stderr.write(`[inbox] pruned ${staleRemoved} stale state entries\n`);
358
+ }
359
+
330
360
  if (processed.length || errors.length) {
331
361
  writeState(this.#rootDir, state);
332
362
  }
@@ -191,7 +191,7 @@ export function dismissRecommendation(dedupKey, { reason = 'manually dismissed',
191
191
  }
192
192
 
193
193
  /**
194
- * Supersede a recommendation (mark as replaced by a newer one).
194
+ * Supersede a recommendation (mark as superseded by a newer one).
195
195
  *
196
196
  * @param {string} dedupKey - Existing recommendation to supersede
197
197
  * @param {string} supersedingId - ID of the recommendation that replaces it
@@ -26,8 +26,6 @@
26
26
  * }
27
27
  */
28
28
 
29
- import { getTemplate } from './templates.mjs';
30
-
31
29
  const DEFAULT_THRESHOLD = 0.7;
32
30
  const DEFAULT_MAX_ITERATIONS = 3;
33
31
 
package/lib/features.mjs CHANGED
@@ -287,6 +287,17 @@ function buildFeatures(overrides = {}) {
287
287
  if (platforms.length > 0) {
288
288
  return { status: 'configured', message: `Configured in ${platforms.join(', ')}` };
289
289
  }
290
+ // If every host explicitly marks this MCP as unsupported, it is delivered
291
+ // through the built-in construct-mcp stdio server — not a standalone tool.
292
+ const hostSupportEntries = Object.values(mcp.hostSupport ?? {});
293
+ const allUnsupported = hostSupportEntries.length > 0 &&
294
+ hostSupportEntries.every((s) => {
295
+ const mode = typeof s === 'string' ? s : (s?.mode ?? 'managed');
296
+ return mode === 'unsupported';
297
+ });
298
+ if (allUnsupported) {
299
+ return { status: 'configured', message: 'Available via construct-mcp (built-in)' };
300
+ }
290
301
  return { status: 'unavailable', message: 'Not configured in any host' };
291
302
  },
292
303
  }));
@@ -2,10 +2,8 @@
2
2
  /**
3
3
  * lib/health-check.mjs — reusable prerequisite and health checks.
4
4
  *
5
- * Used by:
6
- * - `construct dev` (auto-checks before starting services)
7
- * - `construct doctor` (comprehensive system audit)
8
- * - `construct init` (pre-init validation)
5
+ * Prerequisite and health check runners for `construct dev`, `construct doctor`,
6
+ * and `construct init`.
9
7
  */
10
8
 
11
9
  import { spawnSync } from 'node:child_process';
@@ -591,6 +591,108 @@ async function askDocumentationQuestions() {
591
591
  return { lanes: selectedLanes, withArchitecture, withReadme };
592
592
  }
593
593
 
594
+ // Intake collection — suggest directories to watch for context
595
+ function discoverProjectDirs(targetPath) {
596
+ const dirs = [];
597
+ let entries;
598
+ try { entries = fs.readdirSync(targetPath, { withFileTypes: true }); } catch { return dirs; }
599
+
600
+ for (const entry of entries) {
601
+ if (!entry.isDirectory()) continue;
602
+ const name = entry.name;
603
+ // Skip hidden dirs, node_modules, build artifacts
604
+ if (name.startsWith('.')) continue;
605
+ if (name === 'node_modules') continue;
606
+ if (name === 'dist' || name === 'build' || name === '.next' || name === '.out') continue;
607
+ dirs.push(name);
608
+ }
609
+ return dirs.sort();
610
+ }
611
+
612
+ const INTAKE_DIR_PRESETS = [
613
+ { value: 'src', label: 'src/', reason: 'Source code — most projects put code here' },
614
+ { value: 'lib', label: 'lib/', reason: 'Library code' },
615
+ { value: 'packages', label: 'packages/', reason: 'Monorepo packages' },
616
+ { value: 'services', label: 'services/', reason: 'Microservices or backend services' },
617
+ { value: 'apps', label: 'apps/', reason: 'Monorepo apps' },
618
+ { value: 'docs', label: 'docs/', reason: 'Documentation source' },
619
+ { value: 'tests', label: 'tests/', reason: 'Test files' },
620
+ { value: 'spec', label: 'spec/', reason: 'Specifications' },
621
+ { value: 'infra', label: 'infra/', reason: 'Infrastructure code (Terraform, etc.)' },
622
+ { value: 'config', label: 'config/', reason: 'Configuration files' },
623
+ { value: 'scripts', label: 'scripts/', reason: 'Build/ops scripts' },
624
+ { value: 'tools', label: 'tools/', reason: 'Internal tooling' },
625
+ ];
626
+
627
+ async function askIntakeCollection(targetPath, skipInteractive) {
628
+ if (skipInteractive) {
629
+ // Non-interactive: auto-detect and suggest based on what exists
630
+ const existingDirs = discoverProjectDirs(targetPath);
631
+ const selected = INTAKE_DIR_PRESETS
632
+ .filter(preset => existingDirs.includes(preset.value))
633
+ .map(p => p.value);
634
+
635
+ if (selected.length === 0) return null;
636
+
637
+ console.log('');
638
+ console.log('═══════════════════════════════════════════════════════════');
639
+ console.log(' INTAKE COLLECTION');
640
+ console.log('═══════════════════════════════════════════════════════════');
641
+ console.log('');
642
+ console.log(`Auto-detected ${selected.length} directory(ies) to watch for context:`);
643
+ for (const d of selected) console.log(` • ${d}/`);
644
+ console.log('');
645
+ console.log('The inbox watcher will scan these directories for new files');
646
+ console.log('and route them through R&D triage automatically.');
647
+ console.log('');
648
+
649
+ return { parentDirs: selected, maxDepth: 4 };
650
+ }
651
+
652
+ // Interactive mode
653
+ const existingDirs = discoverProjectDirs(targetPath);
654
+ const presetOptions = INTAKE_DIR_PRESETS.map(p => ({
655
+ label: p.label,
656
+ value: p.value,
657
+ checked: existingDirs.includes(p.value),
658
+ description: p.reason,
659
+ }));
660
+
661
+ console.log('');
662
+ console.log('═══════════════════════════════════════════════════════════');
663
+ console.log(' INTAKE COLLECTION');
664
+ console.log('═══════════════════════════════════════════════════════════');
665
+ console.log('');
666
+ console.log('Intake watches directories for new files and routes them');
667
+ console.log('through R&D triage. Select which directories to watch.');
668
+ console.log('(directories already found in your project are pre-checked)');
669
+ console.log('');
670
+
671
+ const selected = await multiSelect({
672
+ title: 'Directories to Watch',
673
+ instructions: 'Press Enter to confirm your selection',
674
+ options: presetOptions,
675
+ });
676
+
677
+ if (selected.length === 0) {
678
+ console.log('');
679
+ console.log('No directories selected. The inbox watcher will only scan');
680
+ console.log('.cx/inbox/ and docs/intake/ (built-in zones).');
681
+ console.log('');
682
+ console.log('You can always add more later with:');
683
+ console.log(' construct intake config set --add-dir=src');
684
+ console.log('');
685
+ return null;
686
+ }
687
+
688
+ console.log('');
689
+ console.log(`Watching ${selected.length} directory(ies):`);
690
+ for (const d of selected) console.log(` • ${d}/`);
691
+ console.log('');
692
+
693
+ return { parentDirs: selected, maxDepth: 4 };
694
+ }
695
+
594
696
  function buildProjectReadme(projectName) {
595
697
  return `# ${projectName}
596
698
 
@@ -824,6 +926,20 @@ async function main() {
824
926
  console.log('');
825
927
  }
826
928
 
929
+ // Intake collection — ask which directories to watch for context
930
+ console.log('[TRACE init:intake-ask]');
931
+
932
+ const intakeConfig = await askIntakeCollection(target, skipInteractive);
933
+ if (intakeConfig) {
934
+ const { saveIntakeConfig } = await import('./intake/intake-config.mjs');
935
+ try {
936
+ saveIntakeConfig(target, intakeConfig);
937
+ created.push('.cx/intake-config.json');
938
+ } catch (err) {
939
+ console.warn(`⚠️ Could not write intake config: ${err.message}`);
940
+ }
941
+ }
942
+
827
943
  // Ask about documentation system
828
944
  console.log('[TRACE init:docs-ask]');
829
945
 
@@ -895,7 +1011,7 @@ async function main() {
895
1011
  // can compare new intake against established PRDs/RFCs/ADRs from day
896
1012
  // one. Best-effort: skipped silently when Postgres + embedding model
897
1013
  // aren't ready yet (DATABASE_URL unset, no ONNX cache). User can re-run
898
- // `construct ingest` or `construct setup` later to seed manually.
1014
+ // `construct ingest` or `construct init` later to seed manually.
899
1015
 
900
1016
  try {
901
1017
  const { syncFileStateToSql } = await import('./storage/sync.mjs');
@@ -950,10 +1066,10 @@ async function main() {
950
1066
  if (verbose) console.log('\nStarting services...');
951
1067
  try {
952
1068
  const { startServices } = await import('./service-manager.mjs');
953
- const { os } = await import('node:os');
1069
+ const { homedir } = await import('node:os');
954
1070
  const { detectDockerCompose } = await import('./setup.mjs');
955
1071
 
956
- const homeDir = os.homedir();
1072
+ const homeDir = homedir();
957
1073
  const composeRunner = detectDockerCompose();
958
1074
 
959
1075
  const { results } = await startServices({