@geraldmaron/construct 1.0.6 → 1.0.7

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 (123) hide show
  1. package/README.md +19 -17
  2. package/agents/contracts.json +617 -2
  3. package/agents/contracts.schema.json +83 -0
  4. package/agents/prompts/cx-accessibility.md +2 -0
  5. package/agents/prompts/cx-ai-engineer.md +2 -0
  6. package/agents/prompts/cx-architect.md +2 -0
  7. package/agents/prompts/cx-business-strategist.md +2 -0
  8. package/agents/prompts/cx-data-analyst.md +2 -0
  9. package/agents/prompts/cx-data-engineer.md +2 -0
  10. package/agents/prompts/cx-debugger.md +2 -0
  11. package/agents/prompts/cx-designer.md +2 -0
  12. package/agents/prompts/cx-devil-advocate.md +2 -0
  13. package/agents/prompts/cx-docs-keeper.md +2 -0
  14. package/agents/prompts/cx-engineer.md +2 -0
  15. package/agents/prompts/cx-evaluator.md +2 -0
  16. package/agents/prompts/cx-explorer.md +2 -0
  17. package/agents/prompts/cx-legal-compliance.md +2 -0
  18. package/agents/prompts/cx-operations.md +2 -0
  19. package/agents/prompts/cx-orchestrator.md +2 -0
  20. package/agents/prompts/cx-platform-engineer.md +2 -0
  21. package/agents/prompts/cx-product-manager.md +2 -0
  22. package/agents/prompts/cx-qa.md +2 -0
  23. package/agents/prompts/cx-rd-lead.md +2 -0
  24. package/agents/prompts/cx-release-manager.md +2 -0
  25. package/agents/prompts/cx-researcher.md +2 -0
  26. package/agents/prompts/cx-reviewer.md +2 -0
  27. package/agents/prompts/cx-security.md +2 -0
  28. package/agents/prompts/cx-sre.md +2 -0
  29. package/agents/prompts/cx-test-automation.md +2 -0
  30. package/agents/prompts/cx-trace-reviewer.md +2 -0
  31. package/agents/prompts/cx-ux-researcher.md +2 -0
  32. package/agents/registry.json +8 -3
  33. package/bin/construct +209 -32
  34. package/commands/build/feature.md +1 -1
  35. package/commands/build/fix.md +1 -1
  36. package/commands/design/access.md +1 -1
  37. package/commands/design/flow.md +1 -1
  38. package/commands/design/ui.md +1 -1
  39. package/commands/measure/experiment.md +1 -1
  40. package/commands/measure/metrics.md +1 -1
  41. package/commands/measure/results.md +1 -1
  42. package/commands/plan/api.md +1 -1
  43. package/commands/plan/challenge.md +1 -1
  44. package/commands/plan/decide.md +1 -1
  45. package/commands/plan/feature.md +1 -1
  46. package/commands/plan/requirements.md +1 -1
  47. package/commands/remember/context.md +1 -1
  48. package/commands/remember/handoff.md +1 -1
  49. package/commands/remember/runbook.md +1 -1
  50. package/commands/review/code.md +1 -1
  51. package/commands/review/quality.md +1 -1
  52. package/commands/review/security.md +1 -1
  53. package/commands/ship/ready.md +1 -1
  54. package/commands/ship/release.md +1 -1
  55. package/commands/ship/status.md +1 -1
  56. package/commands/understand/docs.md +1 -1
  57. package/commands/understand/this.md +1 -1
  58. package/commands/understand/why.md +1 -1
  59. package/commands/work/clean.md +1 -1
  60. package/commands/work/drive.md +1 -1
  61. package/commands/work/optimize-prompts.md +1 -1
  62. package/commands/work/parallel-review.md +1 -1
  63. package/lib/auto-docs.mjs +7 -7
  64. package/lib/boundary.mjs +126 -0
  65. package/lib/cache-strategy-google.js +26 -31
  66. package/lib/comment-lint.mjs +134 -0
  67. package/lib/contracts/validate.mjs +323 -0
  68. package/lib/daemons/contract.mjs +210 -0
  69. package/lib/docs-verify.mjs +59 -6
  70. package/lib/doctor/cli.mjs +16 -1
  71. package/lib/doctor/index.mjs +3 -1
  72. package/lib/doctor/watchers/consistency.mjs +310 -0
  73. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  74. package/lib/document-extract.mjs +211 -1
  75. package/lib/embed/cli.mjs +124 -3
  76. package/lib/embed/daemon.mjs +43 -4
  77. package/lib/embed/docs-lifecycle.mjs +1 -1
  78. package/lib/embed/inbox.mjs +2 -0
  79. package/lib/embed/scheduler.mjs +33 -5
  80. package/lib/evaluator-optimizer.mjs +2 -3
  81. package/lib/hooks/comment-lint.mjs +16 -0
  82. package/lib/hooks/mcp-audit.mjs +2 -1
  83. package/lib/hooks/proactive-activation.mjs +0 -14
  84. package/lib/hooks/rule-verifier.mjs +217 -0
  85. package/lib/hooks/session-optimize.mjs +2 -1
  86. package/lib/init-unified.mjs +55 -65
  87. package/lib/intake/classify.mjs +108 -24
  88. package/lib/intake/daemon.mjs +121 -0
  89. package/lib/intake/filesystem-queue.mjs +6 -1
  90. package/lib/intake/intake-config.mjs +2 -1
  91. package/lib/intake/prepare.mjs +0 -1
  92. package/lib/intake/session-prelude.mjs +7 -1
  93. package/lib/intake/traceability.mjs +90 -0
  94. package/lib/knowledge/research-store.mjs +2 -0
  95. package/lib/maintenance/cleanup.mjs +315 -0
  96. package/lib/mcp/memory-bridge.mjs +276 -0
  97. package/lib/mcp/server.mjs +2 -0
  98. package/lib/mcp/tools/workflow.mjs +25 -0
  99. package/lib/mcp-catalog.json +12 -8
  100. package/lib/mcp-platform-config.mjs +16 -8
  101. package/lib/migrations/index.mjs +106 -0
  102. package/lib/migrations/v1-baseline.mjs +33 -0
  103. package/lib/observation-store.mjs +9 -4
  104. package/lib/outcomes/record.mjs +2 -0
  105. package/lib/profiles/rebrand.mjs +46 -0
  106. package/lib/project-init-shared.mjs +12 -0
  107. package/lib/provider-capabilities.js +20 -7
  108. package/lib/providers/auth-manager.mjs +58 -17
  109. package/lib/reflect.mjs +49 -12
  110. package/lib/server/index.mjs +22 -28
  111. package/lib/session-store.mjs +6 -4
  112. package/lib/setup.mjs +14 -3
  113. package/lib/telemetry/client.mjs +5 -1
  114. package/lib/version.mjs +51 -0
  115. package/lib/worker/trace.mjs +5 -1
  116. package/package.json +4 -1
  117. package/personas/construct.md +3 -1
  118. package/rules/common/development-workflow.md +2 -1
  119. package/rules/common/no-fabrication.md +69 -0
  120. package/rules/common/review-before-change.md +58 -0
  121. package/scripts/sync-agents.mjs +45 -14
  122. package/templates/docs/construct_guide.md +6 -6
  123. package/templates/docs/skill-artifact.md +1 -1
@@ -10,6 +10,11 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
10
10
  import { basename, extname, resolve } from 'node:path';
11
11
  import { spawnSync } from 'node:child_process';
12
12
 
13
+ // 50 MB hard cap mirrors lib/embed/inbox.mjs and lib/observation-store.mjs.
14
+ // Anything larger is intentionally not extracted to keep the daemon path
15
+ // bounded; callers receive an empty text plus a `skipped` marker.
16
+ const MAX_EML_BYTES = 50 * 1024 * 1024;
17
+
13
18
  export const UTF8_TEXT_EXTS = new Set([
14
19
  '.md', '.txt', '.rst', '.adoc', '.json', '.yaml', '.yml', '.toml',
15
20
  '.js', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.sh', '.bash',
@@ -21,11 +26,13 @@ export const UTF8_TEXT_EXTS = new Set([
21
26
  export const ZIP_DOCUMENT_EXTS = new Set(['.docx', '.xlsx', '.pptx', '.odt', '.ods']);
22
27
  export const RICH_TEXT_EXTS = new Set(['.doc', '.rtf']);
23
28
  export const MDLS_DOCUMENT_EXTS = new Set(['.xls', '.ppt', '.pages', '.numbers', '.key']);
29
+ export const EMAIL_DOCUMENT_EXTS = new Set(['.eml']);
24
30
  export const EXTRACTABLE_DOCUMENT_EXTS = new Set([
25
31
  ...UTF8_TEXT_EXTS,
26
32
  ...ZIP_DOCUMENT_EXTS,
27
33
  ...RICH_TEXT_EXTS,
28
34
  ...MDLS_DOCUMENT_EXTS,
35
+ ...EMAIL_DOCUMENT_EXTS,
29
36
  '.pdf',
30
37
  ]);
31
38
 
@@ -176,6 +183,203 @@ export function isExtractableDocumentPath(filePath) {
176
183
  return EXTRACTABLE_DOCUMENT_EXTS.has(extname(filePath).toLowerCase());
177
184
  }
178
185
 
186
+ // Minimal RFC 5322 header decoder. Unfolds continuation lines (a line that
187
+ // begins with whitespace continues the previous header) and splits each
188
+ // header into (name, value). Stops at the first blank line, which RFC 5322
189
+ // defines as the header/body separator.
190
+ function parseRfc5322Headers(headerBlock) {
191
+ const lines = headerBlock.split('\n');
192
+ const unfolded = [];
193
+ for (const line of lines) {
194
+ if (/^[ \t]/.test(line) && unfolded.length > 0) {
195
+ unfolded[unfolded.length - 1] += ' ' + line.trim();
196
+ } else {
197
+ unfolded.push(line);
198
+ }
199
+ }
200
+ const headers = {};
201
+ for (const line of unfolded) {
202
+ const idx = line.indexOf(':');
203
+ if (idx === -1) continue;
204
+ const name = line.slice(0, idx).trim().toLowerCase();
205
+ const value = line.slice(idx + 1).trim();
206
+ if (!name) continue;
207
+ // Multiple Received headers are common; keep the first occurrence of
208
+ // any header so From/Subject/Date pick the outermost value.
209
+ if (!(name in headers)) headers[name] = value;
210
+ }
211
+ return headers;
212
+ }
213
+
214
+ // Split a Content-Type header value into the bare type and a parameter map.
215
+ // Returns { type, params } where type is lowercased and stripped, params is
216
+ // keyed by lowercased name with values stripped of surrounding quotes.
217
+ function parseContentType(value) {
218
+ if (!value) return { type: '', params: {} };
219
+ const parts = value.split(';').map((s) => s.trim()).filter(Boolean);
220
+ const type = (parts.shift() || '').toLowerCase();
221
+ const params = {};
222
+ for (const part of parts) {
223
+ const idx = part.indexOf('=');
224
+ if (idx === -1) continue;
225
+ const k = part.slice(0, idx).trim().toLowerCase();
226
+ let v = part.slice(idx + 1).trim();
227
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
228
+ params[k] = v;
229
+ }
230
+ return { type, params };
231
+ }
232
+
233
+ // Split a multipart body on the boundary marker. Each returned segment is
234
+ // itself a mini MIME entity (its own header block, blank line, body). The
235
+ // closing `--boundary--` terminator is honored by stripping anything past
236
+ // it. Boundaries that do not appear in the body produce a single segment.
237
+ function splitMultipart(body, boundary) {
238
+ if (!boundary) return [body];
239
+ const marker = '--' + boundary;
240
+ const closer = marker + '--';
241
+ const closeIdx = body.indexOf(closer);
242
+ const scoped = closeIdx === -1 ? body : body.slice(0, closeIdx);
243
+ const segments = scoped.split(marker);
244
+ // The piece before the first boundary is the preamble; discard it.
245
+ return segments.slice(1).map((seg) => seg.replace(/^\r?\n/, '').replace(/\r?\n$/, ''));
246
+ }
247
+
248
+ // Decode a quoted-printable body. Tolerant: malformed `=XX` sequences are
249
+ // passed through verbatim rather than throwing, which matches real-world
250
+ // mailers more closely than a strict decoder.
251
+ function decodeQuotedPrintable(text) {
252
+ return text
253
+ .replace(/=\r?\n/g, '')
254
+ .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
255
+ }
256
+
257
+ function decodeTransferEncoded(text, encoding) {
258
+ const enc = String(encoding || '').toLowerCase();
259
+ if (enc === 'quoted-printable') return decodeQuotedPrintable(text);
260
+ if (enc === 'base64') {
261
+ try { return Buffer.from(text.replace(/\s+/g, ''), 'base64').toString('utf8'); } catch { return text; }
262
+ }
263
+ return text;
264
+ }
265
+
266
+ // Walk a parsed MIME entity, returning the first text/plain body it finds
267
+ // and a flat list of attachment filenames encountered along the way. The
268
+ // walker stops at depth 8 so a hand-crafted pathological message cannot
269
+ // exhaust the stack.
270
+ function walkMimeForPlainText(rawEntity, depth = 0) {
271
+ if (depth > 8) return { text: '', attachments: [] };
272
+ const sepIdx = rawEntity.indexOf('\n\n') !== -1
273
+ ? rawEntity.indexOf('\n\n')
274
+ : rawEntity.indexOf('\r\n\r\n');
275
+ if (sepIdx === -1) return { text: '', attachments: [] };
276
+ const headerBlock = rawEntity.slice(0, sepIdx);
277
+ const body = rawEntity.slice(sepIdx).replace(/^\r?\n\r?\n/, '');
278
+ const headers = parseRfc5322Headers(headerBlock);
279
+ const { type, params } = parseContentType(headers['content-type'] || 'text/plain');
280
+ const disposition = parseContentType(headers['content-disposition'] || '');
281
+ const filename = disposition.params.filename || params.name || null;
282
+
283
+ if (type.startsWith('multipart/')) {
284
+ const attachments = [];
285
+ let firstPlain = '';
286
+ for (const seg of splitMultipart(body, params.boundary)) {
287
+ const child = walkMimeForPlainText(seg, depth + 1);
288
+ attachments.push(...child.attachments);
289
+ if (!firstPlain && child.text) firstPlain = child.text;
290
+ }
291
+ return { text: firstPlain, attachments };
292
+ }
293
+
294
+ if (disposition.type === 'attachment' && filename) {
295
+ return { text: '', attachments: [filename] };
296
+ }
297
+
298
+ if (type === '' || type === 'text/plain') {
299
+ return {
300
+ text: decodeTransferEncoded(body, headers['content-transfer-encoding']),
301
+ attachments: [],
302
+ };
303
+ }
304
+
305
+ // Non-plain leaves with an explicit filename are treated as attachments.
306
+ if (filename) return { text: '', attachments: [filename] };
307
+ return { text: '', attachments: [] };
308
+ }
309
+
310
+ /**
311
+ * Extract a normalized representation of an .eml message.
312
+ *
313
+ * Returns { text, headers, attachments, skipped } where:
314
+ * text concatenates Subject, From, Date and the first text/plain
315
+ * body, suitable for direct hand-off to the classifier.
316
+ * headers decoded From, Subject, Date, To (raw strings).
317
+ * attachments filenames of every non-plain part with a Content-Disposition
318
+ * of `attachment` or a filename parameter. Bodies are skipped.
319
+ * skipped set to `'too large'` if the message exceeded MAX_EML_BYTES;
320
+ * in that case text is empty and no parsing was attempted.
321
+ *
322
+ * The parser is intentionally minimal: it understands header folding, the
323
+ * `--boundary` framing of multipart messages, and quoted-printable / base64
324
+ * transfer encodings. It does not decode RFC 2047 encoded-word headers, does
325
+ * not chase nested message/rfc822 envelopes, and does not validate signatures.
326
+ */
327
+ export function extractEmlMessage(filePath) {
328
+ const stat = statSync(filePath);
329
+ if (stat.size > MAX_EML_BYTES) {
330
+ return { text: '', headers: {}, attachments: [], skipped: 'too large' };
331
+ }
332
+ const raw = readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
333
+ const sepIdx = raw.indexOf('\n\n');
334
+ if (sepIdx === -1) {
335
+ // No header/body separator. Treat the whole file as the body.
336
+ return {
337
+ text: normalizeText(raw),
338
+ headers: {},
339
+ attachments: [],
340
+ };
341
+ }
342
+ const headerBlock = raw.slice(0, sepIdx);
343
+ const body = raw.slice(sepIdx + 2);
344
+ const headers = parseRfc5322Headers(headerBlock);
345
+ const { type, params } = parseContentType(headers['content-type'] || 'text/plain');
346
+
347
+ let bodyText = '';
348
+ const attachments = [];
349
+ if (type.startsWith('multipart/')) {
350
+ for (const seg of splitMultipart(body, params.boundary)) {
351
+ const child = walkMimeForPlainText(seg);
352
+ attachments.push(...child.attachments);
353
+ if (!bodyText && child.text) bodyText = child.text;
354
+ }
355
+ } else {
356
+ bodyText = decodeTransferEncoded(body, headers['content-transfer-encoding']);
357
+ }
358
+
359
+ const subject = headers.subject || '';
360
+ const from = headers.from || '';
361
+ const date = headers.date || '';
362
+ const to = headers.to || '';
363
+ const text = normalizeText(
364
+ `Subject: ${subject}\nFrom: ${from}\nDate: ${date}\n\n${bodyText}`,
365
+ );
366
+ return {
367
+ text,
368
+ headers: { subject, from, date, to },
369
+ attachments,
370
+ };
371
+ }
372
+
373
+ function extractEml(filePath) {
374
+ const message = extractEmlMessage(filePath);
375
+ return {
376
+ text: message.text,
377
+ method: message.skipped ? 'eml-skipped' : 'eml',
378
+ skipped: message.skipped || null,
379
+ attachments: message.attachments,
380
+ };
381
+ }
382
+
179
383
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
180
384
  const URL_RE = /https?:\/\/[^\s)>\]"'`]+/g;
181
385
  const DATE_KEYS = ['date', 'created', 'created_at', 'updated', 'updated_at', 'published', 'last_modified'];
@@ -268,6 +472,7 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
268
472
  else if (ZIP_DOCUMENT_EXTS.has(extension)) extracted = extractZipDocument(resolvedPath, extension);
269
473
  else if (RICH_TEXT_EXTS.has(extension)) extracted = extractRichText(resolvedPath);
270
474
  else if (MDLS_DOCUMENT_EXTS.has(extension) && process.platform === 'darwin' && commandExists('mdls')) extracted = { text: extractWithMdls(resolvedPath), method: 'mdls' };
475
+ else if (EMAIL_DOCUMENT_EXTS.has(extension)) extracted = extractEml(resolvedPath);
271
476
  else if (extension === '.pdf') extracted = extractPdf(resolvedPath);
272
477
  else throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
273
478
 
@@ -277,7 +482,7 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
277
482
  : null;
278
483
  const truncated = limit !== null && text.length > limit;
279
484
 
280
- return {
485
+ const result = {
281
486
  filePath: resolvedPath,
282
487
  extension,
283
488
  extractionMethod: extracted.method,
@@ -285,4 +490,9 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
285
490
  truncated,
286
491
  characters: text.length,
287
492
  };
493
+ // Surface eml-specific metadata so callers (intake, ingest) can route
494
+ // attachment filenames and oversize-skip markers without re-parsing.
495
+ if (extracted.attachments) result.attachments = extracted.attachments;
496
+ if (extracted.skipped) result.skipped = extracted.skipped;
497
+ return result;
288
498
  }
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
 
@@ -29,6 +29,7 @@ import { addObservation } from '../observation-store.mjs';
29
29
  import { inferKnowledgeTarget, knowledgeDirForCategory } from '../knowledge/layout.mjs';
30
30
  import { docLaneDir, suggestDocsLaneForFile } from '../docs-routing.mjs';
31
31
  import { shouldCreateCx } from '../project-detection.mjs';
32
+ import { ensureCxDir } from '../project-init-shared.mjs';
32
33
  import { prepareIntakeForIngestedFile } from '../intake/prepare.mjs';
33
34
  import { loadIntakeConfig, INTAKE_DEFAULT_MAX_DEPTH } from '../intake/intake-config.mjs';
34
35
 
@@ -90,6 +91,7 @@ function writeState(rootDir, state) {
90
91
 
91
92
  // Only create .cx directories if this is an initialized project
92
93
  if (shouldCreateCx(rootDir)) {
94
+ ensureCxDir(rootDir);
93
95
  mkdirSync(join(rootDir, '.cx', 'runtime'), { recursive: true });
94
96
  writeFileSync(p, JSON.stringify(state, null, 2) + '\n');
95
97
  }
@@ -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`);
@@ -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 = [];
@@ -40,8 +40,24 @@ const rootDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
40
40
  const result = lintFile(filePath, { rootDir });
41
41
  if (!result.errors.length && !result.warnings.length) process.exit(0);
42
42
 
43
+ // Artifact-prose violations are advisory at write-time (no-fabrication policy
44
+ // enforces them at CI/release-gate time via CONSTRUCT_ARTIFACT_LINT_MODE=block).
45
+ // Surface them on stderr but do not block the edit.
46
+ const artifactOnly = (
47
+ result.errors.length === 0 &&
48
+ result.warnings.every((w) => w.kind === 'artifact')
49
+ );
50
+
43
51
  const { output } = formatResults([result]);
44
52
  process.stderr.write(output);
53
+
54
+ if (artifactOnly) {
55
+ process.stderr.write(
56
+ '\nArtifact-lint advisory (no-fabrication). The edit is not blocked, but these will fail the release gate. Fix before push.\n',
57
+ );
58
+ process.exit(0);
59
+ }
60
+
45
61
  process.stderr.write(
46
62
  '\nComment policy blocked this edit. Remove the violations or set CONSTRUCT_SKIP_COMMENT_LINT=1.\n',
47
63
  );
@@ -10,6 +10,7 @@
10
10
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
11
11
  import { join } from 'path';
12
12
  import { shouldCreateCx } from '../project-detection.mjs';
13
+ import { ensureCxDir } from '../project-init-shared.mjs';
13
14
 
14
15
  let input = {};
15
16
  try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
@@ -47,7 +48,7 @@ if (audit[mcpServer].length > 200) audit[mcpServer] = audit[mcpServer].slice(-20
47
48
 
48
49
  try {
49
50
  if (shouldCreateCxDir) {
50
- mkdirSync(join(cwd, '.cx'), { recursive: true });
51
+ ensureCxDir(cwd);
51
52
  writeFileSync(auditPath, JSON.stringify(audit, null, 2));
52
53
  }
53
54
  } catch { /* best effort */ }