@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 +1 -1
- package/bin/construct +49 -11
- package/bin/construct-postinstall.mjs +1 -1
- package/lib/bootstrap/resources.mjs +2 -2
- package/lib/document-ingest.mjs +6 -5
- package/lib/embed/cli.mjs +16 -3
- package/lib/embed/customer-profiles.mjs +1 -1
- package/lib/embed/daemon.mjs +49 -42
- package/lib/embed/inbox.mjs +30 -0
- package/lib/embed/recommendation-store.mjs +1 -1
- package/lib/evaluator-optimizer.mjs +0 -2
- package/lib/features.mjs +11 -0
- package/lib/health-check.mjs +2 -4
- package/lib/init-unified.mjs +119 -3
- package/lib/install/first-invocation.mjs +4 -4
- package/lib/intake/queue.mjs +1 -1
- package/lib/integrations/intake-integrations.mjs +4 -5
- package/lib/knowledge/rag.mjs +16 -0
- package/lib/model-cheapest-provider.mjs +231 -0
- package/lib/model-router.mjs +7 -8
- package/lib/project-profile.mjs +1 -1
- package/lib/roles/catalog.mjs +133 -0
- package/lib/roles/preference.mjs +74 -0
- package/lib/server/index.mjs +33 -47
- package/lib/server/insights.mjs +1 -1
- package/lib/service-manager.mjs +2 -2
- package/lib/services/telemetry-backend.mjs +1 -1
- package/lib/setup-prompts.mjs +2 -2
- package/lib/setup.mjs +47 -3
- package/lib/status.mjs +5 -3
- package/lib/storage/postgres-backup.mjs +1 -1
- package/lib/uninstall/uninstall.mjs +1 -1
- package/package.json +1 -1
- package/templates/docs/construct_guide.md +2 -2
- package/templates/homebrew/construct.rb +1 -1
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
|
|
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('
|
|
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 ||
|
|
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('
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
117
|
+
? `\n installable via construct init`
|
|
118
118
|
: '';
|
|
119
119
|
return ` ${status} ${probe.displayName}${v}${loc}${detail}${fallback}${install}`;
|
|
120
120
|
}
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -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
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
|
package/lib/embed/inbox.mjs
CHANGED
|
@@ -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
|
|
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
|
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
|
}));
|
package/lib/health-check.mjs
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* lib/health-check.mjs — reusable prerequisite and health checks.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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';
|
package/lib/init-unified.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
1069
|
+
const { homedir } = await import('node:os');
|
|
954
1070
|
const { detectDockerCompose } = await import('./setup.mjs');
|
|
955
1071
|
|
|
956
|
-
const homeDir =
|
|
1072
|
+
const homeDir = homedir();
|
|
957
1073
|
const composeRunner = detectDockerCompose();
|
|
958
1074
|
|
|
959
1075
|
const { results } = await startServices({
|