@geraldmaron/construct 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +1 -1
  2. package/README.md +4 -4
  3. package/agents/prompts/cx-ai-engineer.md +6 -26
  4. package/agents/prompts/cx-architect.md +1 -0
  5. package/agents/prompts/cx-business-strategist.md +2 -0
  6. package/agents/prompts/cx-data-analyst.md +6 -26
  7. package/agents/prompts/cx-docs-keeper.md +1 -31
  8. package/agents/prompts/cx-explorer.md +1 -0
  9. package/agents/prompts/cx-orchestrator.md +40 -112
  10. package/agents/prompts/cx-platform-engineer.md +2 -22
  11. package/agents/prompts/cx-product-manager.md +2 -1
  12. package/agents/prompts/cx-qa.md +0 -20
  13. package/agents/prompts/cx-rd-lead.md +2 -0
  14. package/agents/prompts/cx-researcher.md +77 -31
  15. package/agents/prompts/cx-security.md +11 -49
  16. package/agents/prompts/cx-sre.md +9 -43
  17. package/agents/prompts/cx-ux-researcher.md +1 -0
  18. package/agents/role-manifests.json +4 -4
  19. package/bin/construct +72 -11
  20. package/bin/construct-postinstall.mjs +1 -1
  21. package/db/schema/004_recommendations.sql +46 -0
  22. package/db/schema/005_strategy.sql +21 -0
  23. package/lib/auto-docs.mjs +1 -2
  24. package/lib/beads-automation.mjs +16 -7
  25. package/lib/bootstrap/resources.mjs +2 -2
  26. package/lib/cli-commands.mjs +8 -2
  27. package/lib/document-ingest.mjs +6 -5
  28. package/lib/embed/cli.mjs +16 -3
  29. package/lib/embed/conflict-detection.mjs +26 -9
  30. package/lib/embed/customer-profiles.mjs +38 -18
  31. package/lib/embed/daemon.mjs +59 -50
  32. package/lib/embed/inbox.mjs +30 -0
  33. package/lib/embed/recommendation-store.mjs +214 -15
  34. package/lib/embed/workspaces.mjs +53 -18
  35. package/lib/evaluator-optimizer.mjs +0 -2
  36. package/lib/features.mjs +11 -0
  37. package/lib/gates-audit.mjs +3 -3
  38. package/lib/health-check.mjs +3 -5
  39. package/lib/hooks/pre-compact.mjs +3 -0
  40. package/lib/hooks/read-tracker.mjs +10 -101
  41. package/lib/host-capabilities.mjs +90 -1
  42. package/lib/init-unified.mjs +119 -3
  43. package/lib/init-update.mjs +246 -131
  44. package/lib/install/first-invocation.mjs +4 -4
  45. package/lib/intake/queue.mjs +1 -1
  46. package/lib/integrations/intake-integrations.mjs +4 -5
  47. package/lib/intent-classifier.mjs +1 -0
  48. package/lib/knowledge/layout.mjs +10 -0
  49. package/lib/knowledge/rag.mjs +16 -0
  50. package/lib/mcp/tools/telemetry.mjs +30 -78
  51. package/lib/model-cheapest-provider.mjs +231 -0
  52. package/lib/model-router.mjs +68 -9
  53. package/lib/ollama-manager.mjs +1 -1
  54. package/lib/opencode-telemetry.mjs +4 -5
  55. package/lib/orchestration-policy.mjs +9 -0
  56. package/lib/parity.mjs +124 -21
  57. package/lib/project-profile.mjs +1 -1
  58. package/lib/prompt-composer.js +106 -29
  59. package/lib/read-tracker-store.mjs +149 -0
  60. package/lib/roles/catalog.mjs +133 -0
  61. package/lib/roles/preference.mjs +74 -0
  62. package/lib/server/index.mjs +109 -47
  63. package/lib/server/insights.mjs +1 -1
  64. package/lib/server/telemetry-login.mjs +17 -25
  65. package/lib/service-manager.mjs +32 -24
  66. package/lib/services/local-postgres.mjs +15 -0
  67. package/lib/services/telemetry-backend.mjs +2 -3
  68. package/lib/setup-prompts.mjs +2 -2
  69. package/lib/setup.mjs +55 -46
  70. package/lib/status.mjs +56 -8
  71. package/lib/storage/backend.mjs +12 -2
  72. package/lib/storage/postgres-backup.mjs +1 -1
  73. package/lib/strategy-store.mjs +371 -0
  74. package/lib/telemetry/backends/local.mjs +6 -4
  75. package/lib/telemetry/client.mjs +185 -0
  76. package/lib/telemetry/ingest.mjs +13 -5
  77. package/lib/telemetry/team-rollup.mjs +9 -2
  78. package/lib/uninstall/uninstall.mjs +1 -1
  79. package/lib/worker/trace.mjs +17 -27
  80. package/package.json +5 -2
  81. package/rules/common/research.md +44 -12
  82. package/skills/docs/backlog-proposal-workflow.md +2 -2
  83. package/skills/docs/customer-profile-workflow.md +1 -1
  84. package/skills/docs/evidence-ingest-workflow.md +5 -5
  85. package/skills/docs/prfaq-workflow.md +1 -1
  86. package/skills/docs/product-intelligence-review.md +1 -1
  87. package/skills/docs/product-intelligence-workflow.md +3 -3
  88. package/skills/docs/product-signal-workflow.md +48 -18
  89. package/skills/docs/research-workflow.md +26 -14
  90. package/skills/docs/strategy-workflow.md +36 -0
  91. package/skills/roles/data-analyst.product-intelligence.md +1 -1
  92. package/skills/roles/researcher.md +28 -15
  93. package/skills/routing.md +8 -1
  94. package/templates/docs/construct_guide.md +2 -2
  95. package/templates/docs/research-brief.md +63 -9
  96. package/templates/docs/strategy.md +36 -0
  97. package/templates/homebrew/construct.rb +7 -7
@@ -79,7 +79,13 @@ export const CLI_COMMANDS = [
79
79
  category: 'Core',
80
80
  core: true,
81
81
  description: 'Sync agent adapters to AI tools',
82
- usage: 'construct sync',
82
+ usage: 'construct sync [--project] [--dry-run] [--no-docs] [--compress-personas]',
83
+ options: [
84
+ { flag: '--project', desc: 'Write project-local Claude adapters into the current repo only' },
85
+ { flag: '--dry-run', desc: 'Preview adapter changes without writing files' },
86
+ { flag: '--no-docs', desc: 'Skip AUTO docs regeneration after syncing adapters' },
87
+ { flag: '--compress-personas', desc: 'Write compressed runtime persona prompts without changing the source prompts' },
88
+ ],
83
89
  },
84
90
  {
85
91
  name: 'intake',
@@ -475,7 +481,7 @@ export const CLI_COMMANDS = [
475
481
  category: 'Advanced',
476
482
  core: false,
477
483
  description: 'Show version',
478
- usage: 'construct version',
484
+ usage: 'construct version | construct --version',
479
485
  },
480
486
  {
481
487
  name: 'cleanup',
@@ -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 });
@@ -11,18 +11,33 @@
11
11
  * 2. Contradiction detection — new signal contradicts a documented decision
12
12
  *
13
13
  * Storage:
14
- * ~/.cx/product-intel/artifact-index.json — cached artifact embeddings
14
+ * ~/.cx/knowledge/reference/artifact-index.json — cached artifact embeddings
15
15
  * Rebuilt on first use or when artifact file count changes.
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, renameSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
- import { homedir } from 'node:os';
21
20
  import { embed, cosineSimilarity, extractTextFromPacket } from './semantic.mjs';
22
21
  import { listArtifacts } from './artifact.mjs';
22
+ import { cxDir } from '../paths.mjs';
23
+ import { knowledgeReferenceStore } from '../knowledge/layout.mjs';
23
24
 
24
- const INDEX_DIR = join(homedir(), '.cx', 'product-intel');
25
- const INDEX_FILE = join(INDEX_DIR, 'artifact-index.json');
25
+ function migrateLegacyIndex(indexFile, legacyFile) {
26
+ if (existsSync(indexFile) || !existsSync(legacyFile)) return;
27
+ mkdirSync(join(indexFile, '..'), { recursive: true });
28
+ try { renameSync(legacyFile, indexFile); } catch { /* compatibility-only */ }
29
+ }
30
+
31
+ function indexPaths({ migrate = false } = {}) {
32
+ const indexDir = join(cxDir(), knowledgeReferenceStore());
33
+ const legacyFile = join(cxDir(), 'product-intel', 'artifact-index.json');
34
+ const indexFile = join(indexDir, 'artifact-index.json');
35
+ if (migrate) migrateLegacyIndex(indexFile, legacyFile);
36
+ return {
37
+ indexDir,
38
+ indexFile,
39
+ };
40
+ }
26
41
 
27
42
  const OVERLAP_THRESHOLD = 0.6;
28
43
  const CONTRADICTION_THRESHOLD = 0.7;
@@ -32,7 +47,8 @@ const STALE_INDEX_HOURS = 24;
32
47
  * Ensure index directory exists.
33
48
  */
34
49
  function ensureDir() {
35
- if (!existsSync(INDEX_DIR)) mkdirSync(INDEX_DIR, { recursive: true });
50
+ const { indexDir } = indexPaths({ migrate: true });
51
+ if (!existsSync(indexDir)) mkdirSync(indexDir, { recursive: true });
36
52
  }
37
53
 
38
54
  let _artifactIndex = null;
@@ -55,9 +71,10 @@ export async function buildArtifactIndex({ rootDir, forceRebuild = false } = {})
55
71
  const cacheHash = cacheKey.length;
56
72
 
57
73
  // Check if cached index is fresh
58
- if (!forceRebuild && existsSync(INDEX_FILE)) {
74
+ const { indexFile } = indexPaths();
75
+ if (!forceRebuild && existsSync(indexFile)) {
59
76
  try {
60
- const cached = JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
77
+ const cached = JSON.parse(readFileSync(indexFile, 'utf8'));
61
78
  if (cached.cacheHash === cacheHash && cached.index?.length === artifacts.length) {
62
79
  const age = Date.now() - new Date(cached.builtAt).getTime();
63
80
  if (age < STALE_INDEX_HOURS * 60 * 60 * 1000) {
@@ -96,7 +113,7 @@ export async function buildArtifactIndex({ rootDir, forceRebuild = false } = {})
96
113
  embedding: a.embedding ? Array.from(a.embedding) : null,
97
114
  }));
98
115
 
99
- writeFileSync(INDEX_FILE, JSON.stringify({
116
+ writeFileSync(indexFile, JSON.stringify({
100
117
  cacheHash,
101
118
  builtAt: new Date().toISOString(),
102
119
  artifactCount: artifacts.length,
@@ -1,31 +1,47 @@
1
1
  /**
2
2
  * lib/embed/customer-profiles.mjs — Global customer profile store.
3
3
  *
4
- * Maintains durable customer/account memory in ~/.cx/product-intel/customer-profiles/.
4
+ * Maintains durable customer/account memory in ~/.cx/knowledge/internal/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
- * ~/.cx/product-intel/customer-profiles/<customer-id>.md
10
- * ~/.cx/product-intel/customer-profiles/index.json — quick lookup by name/email
9
+ * ~/.cx/knowledge/internal/customer-profiles/<customer-id>.md
10
+ * ~/.cx/knowledge/internal/customer-profiles/index.json — quick lookup by name/email
11
11
  *
12
12
  * Each profile follows templates/docs/customer-profile.md schema.
13
13
  */
14
14
 
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, appendFileSync } from 'node:fs';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, appendFileSync, renameSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { randomUUID } from 'node:crypto';
18
- import { homedir } from 'node:os';
18
+ import { cxDir } from '../paths.mjs';
19
+ import { knowledgeInternalStore } from '../knowledge/layout.mjs';
19
20
 
20
- const PROFILES_DIR = join(homedir(), '.cx', 'product-intel', 'customer-profiles');
21
- const INDEX_FILE = join(PROFILES_DIR, 'index.json');
21
+ function migrateLegacyDir(modernDir, legacyDir) {
22
+ if (existsSync(modernDir) || !existsSync(legacyDir)) return;
23
+ mkdirSync(join(modernDir, '..'), { recursive: true });
24
+ try { renameSync(legacyDir, modernDir); } catch { /* compatibility-only */ }
25
+ }
26
+
27
+ function profilePaths({ migrate = false } = {}) {
28
+ const modernDir = join(cxDir(), knowledgeInternalStore('customer-profiles'));
29
+ const legacyDir = join(cxDir(), 'product-intel', 'customer-profiles');
30
+ if (migrate) migrateLegacyDir(modernDir, legacyDir);
31
+ const profilesDir = existsSync(modernDir) || !existsSync(legacyDir) ? modernDir : legacyDir;
32
+ return {
33
+ profilesDir,
34
+ indexFile: join(profilesDir, 'index.json'),
35
+ };
36
+ }
22
37
 
23
38
  /**
24
39
  * Ensure profile directory exists.
25
40
  */
26
41
  function ensureDir() {
27
- if (!existsSync(PROFILES_DIR)) {
28
- mkdirSync(PROFILES_DIR, { recursive: true });
42
+ const { profilesDir } = profilePaths({ migrate: true });
43
+ if (!existsSync(profilesDir)) {
44
+ mkdirSync(profilesDir, { recursive: true });
29
45
  }
30
46
  }
31
47
 
@@ -34,9 +50,10 @@ function ensureDir() {
34
50
  * @returns {{ [key: string]: { id: string, name: string, status: string, updatedAt: string } }}
35
51
  */
36
52
  function readIndex() {
37
- if (!existsSync(INDEX_FILE)) return {};
53
+ const { indexFile } = profilePaths();
54
+ if (!existsSync(indexFile)) return {};
38
55
  try {
39
- return JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
56
+ return JSON.parse(readFileSync(indexFile, 'utf8'));
40
57
  } catch (err) {
41
58
  process.stderr.write('[customer-profiles.mjs] readIndex: ' + (err?.message ?? String(err)) + '\n');
42
59
  return {};
@@ -48,8 +65,9 @@ function readIndex() {
48
65
  * @param {object} index
49
66
  */
50
67
  function writeIndex(index) {
68
+ const { indexFile } = profilePaths();
51
69
  ensureDir();
52
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
70
+ writeFileSync(indexFile, JSON.stringify(index, null, 2) + '\n');
53
71
  }
54
72
 
55
73
  /**
@@ -95,7 +113,7 @@ export function createCustomerProfile({ name, owner, workspace, status = 'active
95
113
  const id = generateCustomerId();
96
114
  const slug = slugify(name);
97
115
  const filename = `${slug}-${id}.md`;
98
- const filePath = join(PROFILES_DIR, filename);
116
+ const filePath = join(profilePaths().profilesDir, filename);
99
117
 
100
118
  const date = new Date().toISOString().slice(0, 10);
101
119
  const profile = {
@@ -219,12 +237,13 @@ TODO
219
237
  */
220
238
  export function getCustomerProfile(customerId) {
221
239
  ensureDir();
240
+ const { profilesDir } = profilePaths();
222
241
 
223
242
  // Try direct lookup by ID in filename
224
- const files = readdirSync(PROFILES_DIR).filter(f => f.endsWith('.md'));
243
+ const files = readdirSync(profilesDir).filter(f => f.endsWith('.md'));
225
244
  for (const file of files) {
226
245
  if (file.includes(customerId)) {
227
- const filePath = join(PROFILES_DIR, file);
246
+ const filePath = join(profilesDir, file);
228
247
  return parseProfileFile(filePath);
229
248
  }
230
249
  }
@@ -239,6 +258,7 @@ export function getCustomerProfile(customerId) {
239
258
  */
240
259
  export function searchCustomerProfiles(query) {
241
260
  ensureDir();
261
+ const { profilesDir } = profilePaths();
242
262
 
243
263
  const index = readIndex();
244
264
  const querySlug = slugify(query);
@@ -268,7 +288,7 @@ export function searchCustomerProfiles(query) {
268
288
  id: entry.id,
269
289
  name: entry.name,
270
290
  status: entry.status,
271
- path: join(PROFILES_DIR, `${slugify(entry.name)}-${entry.id}.md`),
291
+ path: join(profilesDir, `${slugify(entry.name)}-${entry.id}.md`),
272
292
  });
273
293
  }
274
294
  }
@@ -328,7 +348,7 @@ export function updateCustomerProfile(customerId, updates) {
328
348
  throw new Error(`Customer profile not found: ${customerId}`);
329
349
  }
330
350
 
331
- const filePath = join(PROFILES_DIR, `${slugify(profile.name)}-${customerId}.md`);
351
+ const filePath = join(profilePaths().profilesDir, `${slugify(profile.name)}-${customerId}.md`);
332
352
  let content = readFileSync(filePath, 'utf8');
333
353
  const date = new Date().toISOString().slice(0, 10);
334
354
 
@@ -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];
@@ -467,15 +467,16 @@ export class EmbedDaemon {
467
467
  process.stderr.write(`[embed] Distilled ${itemsWritten} snapshot item(s) into observation store\n`);
468
468
  }
469
469
 
470
- // Regenerate roadmap each tick so .cx/roadmap.md stays current and the
471
- // missing-focal-resource gap clears on the next snapshot.
472
- try {
473
- const roadmap = generateRoadmap({ targetPath: this.#rootDir, snapshot, roles: this.#config?.roles });
474
- if (!roadmap.skipped) {
475
- process.stderr.write(`[embed] Roadmap refreshed: ${roadmap.itemCount} open item(s) → ${roadmap.path}\n`);
470
+ // Regenerate roadmap each tick unless disabled by the host config.
471
+ if (process.env.CONSTRUCT_EMBED_ROADMAP_ENABLED !== '0') {
472
+ try {
473
+ const roadmap = generateRoadmap({ targetPath: this.#rootDir, snapshot, roles: this.#config?.roles });
474
+ if (!roadmap.skipped) {
475
+ process.stderr.write(`[embed] Roadmap refreshed: ${roadmap.itemCount} open item(s) → ${roadmap.path}\n`);
476
+ }
477
+ } catch (err) {
478
+ process.stderr.write(`[embed] Roadmap refresh failed: ${err.message}\n`);
476
479
  }
477
- } catch (err) {
478
- process.stderr.write(`[embed] Roadmap refresh failed: ${err.message}\n`);
479
480
  }
480
481
  },
481
482
  { runImmediately: true },
@@ -575,7 +576,8 @@ this.#scheduler.register(
575
576
  const ok = result.results.filter(r => r.success).length;
576
577
  const total = result.results.length;
577
578
  process.stderr.write(`[embed] Telemetry setup: ${ok}/${total} resources configured\n`);
578
- } else if (!result.ok) {
579
+ } else if (!result.ok && !result.error?.includes('required')) {
580
+ // Suppress the "keys required" message — normal for solo mode without telemetry configured.
579
581
  process.stderr.write(`[embed] Telemetry setup skipped: ${result.error}\n`);
580
582
  } else if (result.results?.some(r => !r.success)) {
581
583
  const failures = result.results.filter(r => !r.success);
@@ -743,6 +745,7 @@ this.#scheduler.register(
743
745
  'roadmap',
744
746
  60 * 60_000,
745
747
  async () => {
748
+ if (process.env.CONSTRUCT_EMBED_ROADMAP_ENABLED === '0') return;
746
749
  if (!this.#lastSnapshot) return; // wait until at least one snapshot exists
747
750
  try {
748
751
  const result = generateRoadmap({ targetPath: this.#rootDir, snapshot: this.#lastSnapshot, roles: this.#config?.roles });
@@ -840,21 +843,23 @@ this.#scheduler.register(
840
843
  meta: { gaps: result.gaps.length, conflicts: result.conflicts?.length || 0, recommendations: result.recommendations?.length || 0, autoFixed, queued },
841
844
  });
842
845
  }
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(() => {});
846
+ try {
847
+ emitTraceEvent({
848
+ rootDir: this.#rootDir,
849
+ eventType: 'lifecycle.completed',
850
+ traceId: this.#daemonTraceId,
851
+ spanId: newSpanId(),
852
+ env: this.#env,
853
+ metadata: {
854
+ gaps: result.gaps.length,
855
+ conflicts: result.conflicts?.length || 0,
856
+ recommendations: result.recommendations?.length || 0,
857
+ autoFixed,
858
+ queued,
859
+ targets: result.targets,
860
+ },
861
+ });
862
+ } catch { /* observability must not break the caller */ }
858
863
  } catch (err) {
859
864
  process.stderr.write(`[embed] Docs lifecycle error: ${err.message}\n`);
860
865
  }
@@ -1114,18 +1119,20 @@ this.#scheduler.register(
1114
1119
 
1115
1120
  this.#status = 'running';
1116
1121
  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(() => {});
1122
+ try {
1123
+ emitTraceEvent({
1124
+ rootDir: this.#rootDir,
1125
+ eventType: 'daemon.started',
1126
+ traceId: this.#daemonTraceId,
1127
+ spanId: newSpanId(),
1128
+ env: this.#env,
1129
+ metadata: {
1130
+ pid: process.pid,
1131
+ configSnapshotIntervalMs: this.#config.snapshot.intervalMs,
1132
+ version: process.env.CONSTRUCT_VERSION || '0.0.0',
1133
+ },
1134
+ });
1135
+ } catch { /* observability must not break the caller */ }
1129
1136
  process.stderr.write(`[embed] Daemon started. Snapshot interval: ${this.#config.snapshot.intervalMs}ms\n`);
1130
1137
  }
1131
1138
 
@@ -1134,14 +1141,16 @@ this.#scheduler.register(
1134
1141
  this.#inboxLiveWatcher = null;
1135
1142
  this.#scheduler?.stop();
1136
1143
  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(() => {});
1144
+ try {
1145
+ emitTraceEvent({
1146
+ rootDir: this.#rootDir,
1147
+ eventType: 'daemon.stopped',
1148
+ traceId: this.#daemonTraceId,
1149
+ spanId: newSpanId(),
1150
+ env: this.#env,
1151
+ metadata: { pid: process.pid, uptimeMs: process.uptime() * 1000 },
1152
+ });
1153
+ } catch { /* observability must not break the caller */ }
1145
1154
  process.stderr.write('[embed] Daemon stopped.\n');
1146
1155
  }
1147
1156
 
@@ -1182,10 +1191,10 @@ this.#scheduler.register(
1182
1191
  try {
1183
1192
  // Query strategy/PRDs/RFCs from knowledge base
1184
1193
 
1185
- const strategyDocs = searchObservations(this.#rootDir, 'PRD RFC strategy', {
1186
- category: 'knowledge',
1187
- limit: 50,
1188
- });
1194
+ const strategyDocs = searchObservations(this.#rootDir, 'PRD RFC strategy', {
1195
+ category: 'insight',
1196
+ limit: 50,
1197
+ });
1189
1198
 
1190
1199
  // Query existing Jira tickets via Atlassian provider
1191
1200
 
@@ -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
  }