@geraldmaron/construct 1.0.6 → 1.0.8

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 (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
package/lib/embed/cli.mjs CHANGED
@@ -41,6 +41,83 @@ function logPath(homeDir = os.homedir()) {
41
41
  return path.join(runtimeDir(homeDir), LOG_FILE);
42
42
  }
43
43
 
44
+ // ---------------------------------------------------------------------------
45
+ // Log rotation
46
+ //
47
+ // The embed daemon writes its stdout/stderr to a single appended file. The
48
+ // file descriptor is opened by the parent process at spawn time; the daemon
49
+ // has no opportunity to reopen mid-flight. Rotation therefore happens at
50
+ // each daemon spawn: if the existing log exceeds the size cap we shift
51
+ // segments (.log → .log.1 → .log.2 → …) and drop the oldest, then the
52
+ // spawn opens a fresh empty file.
53
+ //
54
+ // CONSTRUCT_EMBED_LOG_MAX_MB sets the rotation threshold (default 50 MB).
55
+ // CONSTRUCT_EMBED_LOG_KEEP sets retained segment count (default 3).
56
+ // Worst-case disk footprint is (MAX_MB * (KEEP + 1)).
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const DEFAULT_MAX_MB = 50;
60
+ const DEFAULT_KEEP = 3;
61
+ const HARD_CAP_MAX_MB = 500; // refuse absurd configured values
62
+ const HARD_CAP_KEEP = 20;
63
+
64
+ function rotationConfig(env = process.env) {
65
+ const maxMbRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_MAX_MB ?? '', 10);
66
+ const keepRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_KEEP ?? '', 10);
67
+ const maxMb = Number.isFinite(maxMbRaw) && maxMbRaw > 0 ? Math.min(maxMbRaw, HARD_CAP_MAX_MB) : DEFAULT_MAX_MB;
68
+ const keep = Number.isFinite(keepRaw) && keepRaw >= 0 ? Math.min(keepRaw, HARD_CAP_KEEP) : DEFAULT_KEEP;
69
+ return { maxBytes: maxMb * 1024 * 1024, keep };
70
+ }
71
+
72
+ /**
73
+ * Rotate the embed daemon log if it exceeds the size cap. Exported for tests.
74
+ *
75
+ * @param {string} log - Absolute path to the live log file
76
+ * @param {object} [env] - Env object (default: process.env)
77
+ * @returns {{ rotated: boolean, sizeBytes: number, droppedSegment?: string }}
78
+ */
79
+ export function rotateEmbedLogIfNeeded(log, env = process.env) {
80
+ let sizeBytes = 0;
81
+ try {
82
+ sizeBytes = fs.statSync(log).size;
83
+ } catch (err) {
84
+ if (err.code === 'ENOENT') return { rotated: false, sizeBytes: 0 };
85
+ process.stderr.write(`[cli.mjs] rotateEmbedLog stat: ${err.message}\n`);
86
+ return { rotated: false, sizeBytes: 0 };
87
+ }
88
+
89
+ const { maxBytes, keep } = rotationConfig(env);
90
+ if (sizeBytes <= maxBytes) return { rotated: false, sizeBytes };
91
+
92
+ // Shift segments oldest-first: .{keep} gets dropped, .{n} → .{n+1}, .log → .log.1
93
+ let droppedSegment;
94
+ try {
95
+ const oldest = `${log}.${keep}`;
96
+ if (keep === 0) {
97
+ // Retention of 0 means rotate-and-discard: the existing log is removed,
98
+ // no segment is kept.
99
+ fs.rmSync(log, { force: true });
100
+ droppedSegment = log;
101
+ return { rotated: true, sizeBytes, droppedSegment };
102
+ }
103
+ if (fs.existsSync(oldest)) {
104
+ fs.rmSync(oldest, { force: true });
105
+ droppedSegment = oldest;
106
+ }
107
+ for (let n = keep - 1; n >= 1; n--) {
108
+ const from = `${log}.${n}`;
109
+ const to = `${log}.${n + 1}`;
110
+ if (fs.existsSync(from)) fs.renameSync(from, to);
111
+ }
112
+ fs.renameSync(log, `${log}.1`);
113
+ } catch (err) {
114
+ process.stderr.write(`[cli.mjs] rotateEmbedLog: ${err.message}\n`);
115
+ return { rotated: false, sizeBytes };
116
+ }
117
+
118
+ return { rotated: true, sizeBytes, droppedSegment };
119
+ }
120
+
44
121
  // ---------------------------------------------------------------------------
45
122
  // State helpers
46
123
  // ---------------------------------------------------------------------------
@@ -140,6 +217,7 @@ export async function autoStartEmbedIfNeeded(env = process.env, rootDir, homeDir
140
217
  const workerPath = path.join(resolvedRoot, 'lib', 'embed', 'worker.mjs');
141
218
  const log = logPath(homeDir);
142
219
  fs.mkdirSync(path.dirname(log), { recursive: true });
220
+ rotateEmbedLogIfNeeded(log, env);
143
221
  const fd = fs.openSync(log, 'a');
144
222
 
145
223
  const child = spawn(process.execPath, [workerPath], {
@@ -149,7 +227,18 @@ export async function autoStartEmbedIfNeeded(env = process.env, rootDir, homeDir
149
227
  });
150
228
  child.unref();
151
229
 
152
- writeState({ pid: child.pid, configPath: 'auto', startedAt: new Date().toISOString() }, homeDir);
230
+ let resolvedRootDir = homeDir;
231
+ try {
232
+ const { resolveRootDir } = await import('./daemon.mjs');
233
+ resolvedRootDir = resolveRootDir(env, process.cwd());
234
+ } catch { /* fall back to homeDir */ }
235
+
236
+ writeState({
237
+ pid: child.pid,
238
+ configPath: 'auto',
239
+ startedAt: new Date().toISOString(),
240
+ rootDir: resolvedRootDir,
241
+ }, homeDir);
153
242
  return { started: true, pid: child.pid };
154
243
  } catch (err) {
155
244
  return { started: false, reason: `spawn_failed: ${err.message}` };
@@ -196,6 +285,7 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPat
196
285
  const workerArgs = configPath ? [workerPath, '--config', configPath] : [workerPath];
197
286
  const log = logPath(homeDir);
198
287
  fs.mkdirSync(path.dirname(log), { recursive: true });
288
+ rotateEmbedLogIfNeeded(log, process.env);
199
289
  const fd = fs.openSync(log, 'a');
200
290
 
201
291
  const child = spawn(process.execPath, workerArgs, {
@@ -206,7 +296,21 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPat
206
296
  fs.closeSync(fd);
207
297
  child.unref();
208
298
 
209
- writeState({ pid: child.pid, configPath: configPath ?? 'auto', startedAt: new Date().toISOString() }, homeDir);
299
+ // Resolve rootDir using the same precedence the spawned worker will so the
300
+ // operator can see which inbox is being watched via `construct embed status`.
301
+
302
+ let resolvedRootDir = homeDir;
303
+ try {
304
+ const { resolveRootDir } = await import('./daemon.mjs');
305
+ resolvedRootDir = resolveRootDir(process.env, process.cwd());
306
+ } catch { /* fall back to homeDir */ }
307
+
308
+ writeState({
309
+ pid: child.pid,
310
+ configPath: configPath ?? 'auto',
311
+ startedAt: new Date().toISOString(),
312
+ rootDir: resolvedRootDir,
313
+ }, homeDir);
210
314
 
211
315
  // Brief liveness check — detects immediate crashes (e.g. missing module, bad config path)
212
316
  await new Promise(r => setTimeout(r, _livenessCheckMs));
@@ -245,15 +349,32 @@ function cmdEmbedStop(_args, { homeDir = os.homedir() } = {}) {
245
349
  // Subcommand: status
246
350
  // ---------------------------------------------------------------------------
247
351
 
248
- function cmdEmbedStatus(_args, { homeDir = os.homedir() } = {}) {
352
+ async function cmdEmbedStatus(_args, { homeDir = os.homedir() } = {}) {
249
353
  const state = readRunningState(homeDir);
250
354
  if (!state) {
251
355
  process.stdout.write('embed daemon: stopped\n');
252
356
  return;
253
357
  }
358
+
359
+ // Re-resolve rootDir using the same precedence the daemon would so the
360
+ // operator sees which inbox is actually being watched (CX_DATA_DIR override,
361
+ // walked-up project root, or homedir fallback).
362
+
363
+ let rootDir = state.rootDir;
364
+ if (!rootDir) {
365
+ try {
366
+ const { resolveRootDir } = await import('./daemon.mjs');
367
+ rootDir = resolveRootDir(process.env, process.cwd());
368
+ } catch {
369
+ rootDir = homeDir;
370
+ }
371
+ }
372
+
254
373
  process.stdout.write(`embed daemon: running\n`);
255
374
  process.stdout.write(` pid: ${state.pid}\n`);
256
375
  process.stdout.write(` config: ${state.configPath}\n`);
376
+ process.stdout.write(` rootDir: ${rootDir}\n`);
377
+ process.stdout.write(` inbox: ${path.join(rootDir, '.cx', 'inbox')}\n`);
257
378
  process.stdout.write(` started at: ${state.startedAt}\n`);
258
379
  process.stdout.write(` log: ${logPath(homeDir)}\n`);
259
380
  }
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
28
28
  import { homedir } from 'node:os';
29
- import { join } from 'node:path';
29
+ import { dirname, join } from 'node:path';
30
30
 
31
31
  import { loadEmbedConfig } from './config.mjs';
32
32
  import { Scheduler } from './scheduler.mjs';
@@ -109,8 +109,44 @@ async function resolveLLMKey(varName, env) {
109
109
 
110
110
  // ─── Root dir resolution ─────────────────────────────────────────────────────
111
111
 
112
- function resolveRootDir(env = process.env) {
113
- return (env.CX_DATA_DIR ?? '').trim() || homedir();
112
+ // Cap upward search so a daemon launched from deep in the filesystem cannot
113
+ // fan out to thousands of stat() calls in pathological layouts.
114
+
115
+ const PROJECT_WALKUP_MAX = 10;
116
+
117
+ /**
118
+ * Walk up from `startDir` looking for a directory that contains `.cx/context.md`.
119
+ * Returns the project root path, or null if nothing is found within the cap.
120
+ * Exported for unit tests.
121
+ */
122
+ export function findProjectRoot(startDir, { maxLevels = PROJECT_WALKUP_MAX } = {}) {
123
+ if (!startDir || typeof startDir !== 'string') return null;
124
+ let current = startDir;
125
+ for (let i = 0; i <= maxLevels; i++) {
126
+ try {
127
+ if (existsSync(join(current, '.cx', 'context.md'))) return current;
128
+ } catch { /* skip unreadable dir */ }
129
+ const parent = dirname(current);
130
+ if (!parent || parent === current) return null;
131
+ current = parent;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Resolve the daemon's rootDir.
138
+ *
139
+ * Precedence:
140
+ * 1. `CX_DATA_DIR` env override (operator authority)
141
+ * 2. Project root discovered by walking up from `cwd` looking for `.cx/context.md`
142
+ * 3. `homedir()` fallback
143
+ */
144
+ export function resolveRootDir(env = process.env, cwd = process.cwd()) {
145
+ const override = (env.CX_DATA_DIR ?? '').trim();
146
+ if (override) return override;
147
+ const projectRoot = findProjectRoot(cwd);
148
+ if (projectRoot) return projectRoot;
149
+ return homedir();
114
150
  }
115
151
 
116
152
  function resolveWorkspaceDir(env = process.env) {
@@ -708,7 +744,10 @@ this.#scheduler.register(
708
744
  async () => {
709
745
  const result = await this.#inboxWatcher.poll();
710
746
  if (result.processed.length) {
711
- process.stderr.write(`[embed] Inbox: ingested ${result.processed.length} file(s) from [${result.dirs.join(', ')}]\n`);
747
+ const { getRebrand } = await import('../profiles/rebrand.mjs');
748
+ const { signalNoun } = getRebrand(this.#rootDir);
749
+ const noun = result.processed.length === 1 ? signalNoun : `${signalNoun}s`;
750
+ process.stderr.write(`[embed] Classified ${result.processed.length} ${noun} from [${result.dirs.join(', ')}]\n`);
712
751
  addObservation(this.#rootDir, {
713
752
  role: 'construct',
714
753
  category: 'insight',
@@ -64,7 +64,7 @@ export function detectDocGaps(target, opts = {}) {
64
64
  const gaps = [];
65
65
 
66
66
  if (target.access === 'remote') {
67
- // Remote-only targets: can't inspect filesystem, skip for now
67
+ // Remote-only targets have no local filesystem to inspect; gap detection requires the local docs tree.
68
68
  return gaps;
69
69
  }
70
70
 
@@ -21,14 +21,16 @@
21
21
  * // result: { processed: [...], skipped: number, errors: [...] }
22
22
  */
23
23
 
24
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
25
25
  import { join, resolve, isAbsolute } from 'node:path';
26
- import { isExtractableDocumentPath } from '../document-extract.mjs';
26
+ import { homedir } from 'node:os';
27
+ import { isExtractableDocumentPath, AUDIO_VIDEO_EXTS } from '../document-extract.mjs';
27
28
  import { ingestDocuments } from '../document-ingest.mjs';
28
29
  import { addObservation } from '../observation-store.mjs';
29
30
  import { inferKnowledgeTarget, knowledgeDirForCategory } from '../knowledge/layout.mjs';
30
31
  import { docLaneDir, suggestDocsLaneForFile } from '../docs-routing.mjs';
31
32
  import { shouldCreateCx } from '../project-detection.mjs';
33
+ import { ensureCxDir } from '../project-init-shared.mjs';
32
34
  import { prepareIntakeForIngestedFile } from '../intake/prepare.mjs';
33
35
  import { loadIntakeConfig, INTAKE_DEFAULT_MAX_DEPTH } from '../intake/intake-config.mjs';
34
36
 
@@ -38,6 +40,37 @@ const DOCS_INTAKE_SUBDIR = 'docs/intake';
38
40
  const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB hard cap
39
41
  const STALE_STATE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
40
42
 
43
+ // ─── Extraction-warning helpers ───────────────────────────────────────────────
44
+
45
+ function extractionWarningsLog() {
46
+ return join(homedir(), '.cx', 'intake', 'extraction-warnings.jsonl');
47
+ }
48
+
49
+ function needsAsrDir() {
50
+ return join(homedir(), '.cx', 'intake', 'needs-asr');
51
+ }
52
+
53
+ function writeExtractionWarning({ sourcePath, droppedInfo }) {
54
+ if (!droppedInfo?.length) return;
55
+ const logPath = extractionWarningsLog();
56
+ try {
57
+ mkdirSync(join(homedir(), '.cx', 'intake'), { recursive: true });
58
+ const line = JSON.stringify({ ts: new Date().toISOString(), sourcePath, droppedInfo }) + '\n';
59
+ appendFileSync(logPath, line);
60
+ } catch { /* non-fatal */ }
61
+ }
62
+
63
+ function writeAsrPacket({ sourcePath, extension, errorMessage }) {
64
+ try {
65
+ const dir = needsAsrDir();
66
+ mkdirSync(dir, { recursive: true });
67
+ const id = `asr-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
68
+ const packet = { id, ts: new Date().toISOString(), sourcePath, extension, error: errorMessage };
69
+ writeFileSync(join(dir, `${id}.json`), JSON.stringify(packet, null, 2));
70
+ return id;
71
+ } catch { return null; }
72
+ }
73
+
41
74
  function nextAvailablePath(targetPath) {
42
75
  if (!existsSync(targetPath)) return targetPath;
43
76
  const dot = targetPath.lastIndexOf('.');
@@ -90,6 +123,7 @@ function writeState(rootDir, state) {
90
123
 
91
124
  // Only create .cx directories if this is an initialized project
92
125
  if (shouldCreateCx(rootDir)) {
126
+ ensureCxDir(rootDir);
93
127
  mkdirSync(join(rootDir, '.cx', 'runtime'), { recursive: true });
94
128
  writeFileSync(p, JSON.stringify(state, null, 2) + '\n');
95
129
  }
@@ -304,6 +338,10 @@ export class InboxWatcher {
304
338
  if (contentIdx !== -1) extractedText = md.slice(contentIdx + 20).trim();
305
339
  } catch (err) { process.stderr.write('[inbox.mjs] read-extracted-text: ' + (err?.message ?? String(err)) + '\n'); }
306
340
 
341
+ // Write extraction warnings JSONL for any dropped structured content.
342
+ const droppedInfo = fileResult.droppedInfo ?? [];
343
+ writeExtractionWarning({ sourcePath: candidate.path, droppedInfo });
344
+
307
345
  recordInboxObservation(this.#rootDir, {
308
346
  sourcePath: candidate.path,
309
347
  outputPath: fileResult.outputPath,
@@ -331,6 +369,7 @@ export class InboxWatcher {
331
369
  outputPath: fileResult.outputPath,
332
370
  characters: fileResult.characters,
333
371
  knowledgeSubdir,
372
+ droppedInfo,
334
373
  },
335
374
  env: this.#env,
336
375
  });
@@ -345,7 +384,15 @@ export class InboxWatcher {
345
384
  processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId });
346
385
  }
347
386
  } catch (err) {
348
- errors.push({ path: candidate.path, error: err.message });
387
+ // Audio/video files cannot be text-extracted without ASR. Route to
388
+ // the needs-asr queue so the signal is preserved for later processing.
389
+ if (err.code === 'ASR_REQUIRED') {
390
+ const asrId = writeAsrPacket({ sourcePath: candidate.path, extension: err.extension, errorMessage: err.message });
391
+ state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), needsAsr: true, asrId };
392
+ processed.push({ path: candidate.path, needsAsr: true, asrId });
393
+ } else {
394
+ errors.push({ path: candidate.path, error: err.message });
395
+ }
349
396
  }
350
397
  }
351
398
  }
@@ -12,7 +12,7 @@ import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
14
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
- const REGISTRY_PATH = join(__dirname, '..', '..', 'agents', 'registry.json');
15
+ const REGISTRY_PATH = join(__dirname, '..', '..', 'specialists', 'registry.json');
16
16
 
17
17
  let _registryCache = null;
18
18
 
@@ -22,7 +22,7 @@ function loadRegistry() {
22
22
  _registryCache = JSON.parse(readFileSync(REGISTRY_PATH, 'utf8'));
23
23
  } catch (err) {
24
24
  process.stderr.write('[role-framing.mjs] loadRegistry: ' + (err?.message ?? String(err)) + '\n');
25
- _registryCache = { agents: [] };
25
+ _registryCache = { specialists: [] };
26
26
  }
27
27
  return _registryCache;
28
28
  }
@@ -35,7 +35,7 @@ function loadRegistry() {
35
35
  export function getOrientation(roleName) {
36
36
  if (!roleName) return null;
37
37
  const registry = loadRegistry();
38
- const agent = (registry.agents ?? []).find((a) => a.name === roleName);
38
+ const agent = (registry.specialists ?? []).find((a) => a.name === roleName);
39
39
  return agent?.embedOrientation ?? null;
40
40
  }
41
41
 
@@ -4,24 +4,30 @@
4
4
  * Lightweight alternative to a full cron library — runs tasks at fixed
5
5
  * intervals, tracks last-run times, and supports graceful shutdown.
6
6
  * Zero external deps.
7
+ *
8
+ * One-shot startup jobs MUST set `repeat: false`. With `repeat: true`
9
+ * (the default) and `intervalMs: 0`, the timer fires on every event-loop
10
+ * tick — the bug that filled embed-daemon.log to 34 GB before this option
11
+ * was added.
7
12
  */
8
13
 
9
14
  export class Scheduler {
10
- #tasks = new Map(); // id → { fn, intervalMs, lastRun, timer, label }
15
+ #tasks = new Map(); // id → { fn, intervalMs, lastRun, timer, label, repeat }
11
16
  #running = false;
12
17
 
13
18
  /**
14
19
  * Register a task. Returns the task id.
15
20
  * @param {string} label - Human-readable name for logging
16
- * @param {number} intervalMs - How often to run (ms)
21
+ * @param {number} intervalMs - How often to run (ms); ignored when repeat is false
17
22
  * @param {Function} fn - Async function to call; receives { label, lastRun }
18
23
  * @param {object} [opts]
19
24
  * @param {boolean} [opts.runImmediately=false] - Run once before first interval fires
20
25
  * @param {boolean} [opts.unref=false] - Call timer.unref() so this task does not keep the event loop alive (use in tests)
26
+ * @param {boolean} [opts.repeat=true] - When false, the task is one-shot: it runs once (immediately if runImmediately, otherwise after intervalMs) and never re-arms. Required for startup jobs that pass intervalMs: 0.
21
27
  */
22
- register(label, intervalMs, fn, { runImmediately = false, unref = false } = {}) {
28
+ register(label, intervalMs, fn, { runImmediately = false, unref = false, repeat = true } = {}) {
23
29
  const id = `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
24
- const task = { label, intervalMs, fn, lastRun: null, timer: null, runImmediately, unref };
30
+ const task = { label, intervalMs, fn, lastRun: null, timer: null, runImmediately, unref, repeat };
25
31
  this.#tasks.set(id, task);
26
32
 
27
33
  if (this.#running) {
@@ -50,6 +56,7 @@ export class Scheduler {
50
56
  for (const task of this.#tasks.values()) {
51
57
  if (task.timer) {
52
58
  clearInterval(task.timer);
59
+ clearTimeout(task.timer);
53
60
  task.timer = null;
54
61
  }
55
62
  }
@@ -60,7 +67,10 @@ export class Scheduler {
60
67
  */
61
68
  unregister(id) {
62
69
  const task = this.#tasks.get(id);
63
- if (task?.timer) clearInterval(task.timer);
70
+ if (task?.timer) {
71
+ clearInterval(task.timer);
72
+ clearTimeout(task.timer);
73
+ }
64
74
  this.#tasks.delete(id);
65
75
  }
66
76
 
@@ -74,6 +84,7 @@ export class Scheduler {
74
84
  intervalMs: t.intervalMs,
75
85
  lastRun: t.lastRun,
76
86
  active: Boolean(t.timer),
87
+ repeat: t.repeat,
77
88
  }));
78
89
  }
79
90
 
@@ -83,6 +94,23 @@ export class Scheduler {
83
94
  process.stderr.write(`[scheduler] ${task.label} error: ${err.message}\n`);
84
95
  });
85
96
  }
97
+
98
+ // One-shot jobs do not re-arm. When runImmediately is true, the task has
99
+ // already executed and no further scheduling is needed. When false, a
100
+ // single setTimeout fires once at intervalMs.
101
+ if (!task.repeat) {
102
+ if (!runImmediately) {
103
+ task.timer = setTimeout(() => {
104
+ task.timer = null;
105
+ this.#runTask(task).catch((err) => {
106
+ process.stderr.write(`[scheduler] ${task.label} error: ${err.message}\n`);
107
+ });
108
+ }, task.intervalMs);
109
+ if (task.unref && task.timer.unref) task.timer.unref();
110
+ }
111
+ return;
112
+ }
113
+
86
114
  task.timer = setInterval(() => {
87
115
  this.#runTask(task).catch((err) => {
88
116
  process.stderr.write(`[scheduler] ${task.label} error: ${err.message}\n`);
@@ -97,11 +97,17 @@ export function loadConstructEnv({ rootDir, homeDir = os.homedir(), env = proces
97
97
  for (const key of Object.keys(env)) {
98
98
  if (ENV_SHADOW_IGNORE.has(key)) continue;
99
99
  if (!ENV_SHADOW_WARN.has(key)) continue;
100
+ // Only warn when process.env has a non-empty competing value.
101
+ // An empty-string env var (e.g. Claude for Desktop clears ANTHROPIC_API_KEY
102
+ // so its internal auth is used instead) is not a real conflict — config.env wins
103
+ // silently and no user action is needed.
104
+ if (!env[key]) continue;
100
105
  if (key in fileEnv && fileEnv[key] !== env[key]) {
101
106
  process.stderr.write(
102
- `[construct] WARNING: process.env.${key} (${env[key].slice(0, 6)}…) shadows ` +
103
- `config.env value (${fileEnv[key].slice(0, 6)}…). ` +
104
- `Unset the shell variable or update ~/.construct/config.env.\n`,
107
+ `[construct] WARNING: process.env.${key} (${env[key].slice(0, 6)}…) ` +
108
+ `differs from config.env value (${fileEnv[key].slice(0, 6)}…). ` +
109
+ `config.env will be used. To silence: unset the shell variable or ` +
110
+ `update ~/.construct/config.env to match.\n`,
105
111
  );
106
112
  }
107
113
  }
@@ -95,9 +95,8 @@ export async function evaluateDocument({ content, docType, evaluatorFn = null })
95
95
  return await evaluatorFn({ content, docType, rubric });
96
96
  }
97
97
 
98
- // Default: deterministic rubric-based evaluation
99
- // In production, this would call an LLM evaluator
100
- // For now, we use structural checks as a proxy
98
+ // Default path: deterministic structural-rubric evaluation.
99
+ // Consumers wanting LLM-graded evaluation supply `evaluatorFn` (handled above).
101
100
 
102
101
  const scores = [];
103
102
  const feedback = [];
@@ -0,0 +1,173 @@
1
+ /**
2
+ * lib/extractors/calendar.mjs — RFC 5545 iCalendar (.ics) structured extraction.
3
+ *
4
+ * Parses VCALENDAR / VEVENT blocks without external dependencies. Handles
5
+ * multi-line property continuation (lines starting with whitespace), extracts
6
+ * SUMMARY, DTSTART, DTEND, ORGANIZER, ATTENDEE, DESCRIPTION, LOCATION, and
7
+ * preserves RRULE as a raw string for recurring events.
8
+ *
9
+ * Returns the universal { text, structured, droppedInfo } envelope where
10
+ * structured = { events: [{ summary, dtstart, dtend, organizer,
11
+ * attendees: [], description, location, rrule }] }.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { makeEnvelope, makeDropInfo } from './shared/drop-info.mjs';
15
+
16
+ // ─── RFC 5545 helpers ─────────────────────────────────────────────────────────
17
+
18
+ // Unfold continuation lines (RFC 5545 §3.1 — a line beginning with SPACE or TAB
19
+ // is a continuation of the previous property line).
20
+ function unfoldLines(raw) {
21
+ return raw.replace(/\r\n/g, '\n').replace(/\n[ \t]/g, '');
22
+ }
23
+
24
+ // Split a property line into its name, params object, and value.
25
+ // e.g. "DTSTART;TZID=America/New_York:20240101T090000" → name=DTSTART, params={TZID:...}, value=...
26
+ function parseProperty(line) {
27
+ const colonIdx = line.indexOf(':');
28
+ if (colonIdx === -1) return { name: line.toUpperCase(), params: {}, value: '' };
29
+ const nameAndParams = line.slice(0, colonIdx);
30
+ const value = line.slice(colonIdx + 1).trim();
31
+ const parts = nameAndParams.split(';');
32
+ const name = (parts[0] || '').toUpperCase().trim();
33
+ const params = {};
34
+ for (const part of parts.slice(1)) {
35
+ const eqIdx = part.indexOf('=');
36
+ if (eqIdx === -1) continue;
37
+ params[part.slice(0, eqIdx).toUpperCase()] = part.slice(eqIdx + 1);
38
+ }
39
+ return { name, params, value };
40
+ }
41
+
42
+ // Extract a human-readable name from an ORGANIZER or ATTENDEE value.
43
+ // Tries the CN param first, then strips mailto: prefix.
44
+ function extractName(value, params) {
45
+ if (params.CN) return params.CN.replace(/^["']|["']$/g, '');
46
+ return value.replace(/^mailto:/i, '');
47
+ }
48
+
49
+ // Parse an iCal date/datetime string into an ISO string. Tolerant of both
50
+ // date-only (YYYYMMDD) and datetime (YYYYMMDDTHHMMSS[Z]) forms.
51
+ function parseIcalDate(value) {
52
+ if (!value) return null;
53
+ const v = value.replace('Z', '').trim();
54
+ if (v.length === 8) {
55
+ // Date-only: YYYYMMDD
56
+ return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
57
+ }
58
+ if (v.length >= 15 && v[8] === 'T') {
59
+ // Datetime: YYYYMMDDTHHMMSS
60
+ return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}T${v.slice(9, 11)}:${v.slice(11, 13)}:${v.slice(13, 15)}${value.endsWith('Z') ? 'Z' : ''}`;
61
+ }
62
+ return value;
63
+ }
64
+
65
+ // ─── VEVENT block parser ──────────────────────────────────────────────────────
66
+
67
+ function parseVEvent(lines) {
68
+ const event = {
69
+ summary: null,
70
+ dtstart: null,
71
+ dtend: null,
72
+ organizer: null,
73
+ attendees: [],
74
+ description: null,
75
+ location: null,
76
+ rrule: null,
77
+ };
78
+
79
+ for (const line of lines) {
80
+ if (!line.includes(':')) continue;
81
+ const { name, params, value } = parseProperty(line);
82
+ switch (name) {
83
+ case 'SUMMARY': event.summary = value; break;
84
+ case 'DTSTART': event.dtstart = parseIcalDate(value); break;
85
+ case 'DTEND': event.dtend = parseIcalDate(value); break;
86
+ case 'DTDUE': event.dtend = event.dtend ?? parseIcalDate(value); break;
87
+ case 'ORGANIZER': event.organizer = extractName(value, params); break;
88
+ case 'ATTENDEE': event.attendees.push(extractName(value, params)); break;
89
+ case 'DESCRIPTION': event.description = value.replace(/\\n/g, '\n').replace(/\\,/g, ','); break;
90
+ case 'LOCATION': event.location = value; break;
91
+ case 'RRULE': event.rrule = value; break;
92
+ }
93
+ }
94
+ return event;
95
+ }
96
+
97
+ // ─── VCALENDAR parser ─────────────────────────────────────────────────────────
98
+
99
+ function parseIcs(raw) {
100
+ const unfolded = unfoldLines(raw);
101
+ const lines = unfolded.split('\n').map((l) => l.trim()).filter(Boolean);
102
+
103
+ if (!lines.some((l) => l === 'BEGIN:VCALENDAR')) {
104
+ return makeEnvelope({
105
+ text: raw.replace(/\r\n/g, '\n').trim(),
106
+ structured: null,
107
+ droppedInfo: [makeDropInfo({ kind: 'attachment', count: 1, reason: 'No BEGIN:VCALENDAR found; treating as plain text', recoverable: false })],
108
+ });
109
+ }
110
+
111
+ const events = [];
112
+ let inEvent = false;
113
+ let eventLines = [];
114
+ let droppedTodosVjournals = 0;
115
+ let inOther = false;
116
+
117
+ for (const line of lines) {
118
+ if (line === 'BEGIN:VEVENT') {
119
+ inEvent = true;
120
+ eventLines = [];
121
+ } else if (line === 'END:VEVENT') {
122
+ events.push(parseVEvent(eventLines));
123
+ inEvent = false;
124
+ } else if (line === 'BEGIN:VTODO' || line === 'BEGIN:VJOURNAL') {
125
+ inOther = true;
126
+ droppedTodosVjournals++;
127
+ } else if (line === 'END:VTODO' || line === 'END:VJOURNAL') {
128
+ inOther = false;
129
+ } else if (inEvent) {
130
+ eventLines.push(line);
131
+ }
132
+ }
133
+
134
+ const droppedInfo = [];
135
+ if (droppedTodosVjournals > 0) {
136
+ droppedInfo.push(makeDropInfo({
137
+ kind: 'attachment',
138
+ count: droppedTodosVjournals,
139
+ reason: 'VTODO and VJOURNAL components omitted (only VEVENT extracted)',
140
+ recoverable: false,
141
+ }));
142
+ }
143
+
144
+ // Build text representation
145
+ const textParts = events.map((e) => {
146
+ const parts = [];
147
+ if (e.summary) parts.push(`Event: ${e.summary}`);
148
+ if (e.dtstart) parts.push(`Start: ${e.dtstart}`);
149
+ if (e.dtend) parts.push(`End: ${e.dtend}`);
150
+ if (e.organizer) parts.push(`Organizer: ${e.organizer}`);
151
+ if (e.attendees.length) parts.push(`Attendees: ${e.attendees.join(', ')}`);
152
+ if (e.location) parts.push(`Location: ${e.location}`);
153
+ if (e.description) parts.push(`\n${e.description}`);
154
+ return parts.join('\n');
155
+ });
156
+
157
+ return makeEnvelope({
158
+ text: textParts.join('\n\n').trim(),
159
+ structured: { events },
160
+ droppedInfo,
161
+ });
162
+ }
163
+
164
+ // ─── Public API ───────────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Extract structured calendar data from a .ics file path.
168
+ * Returns the universal { text, structured, droppedInfo } envelope.
169
+ */
170
+ export function extractCalendar(filePath) {
171
+ const raw = readFileSync(filePath, 'utf8');
172
+ return parseIcs(raw);
173
+ }