@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.
- package/README.md +19 -17
- package/agents/contracts.json +617 -2
- package/agents/contracts.schema.json +83 -0
- package/agents/prompts/cx-accessibility.md +2 -0
- package/agents/prompts/cx-ai-engineer.md +2 -0
- package/agents/prompts/cx-architect.md +2 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +2 -0
- package/agents/prompts/cx-data-engineer.md +2 -0
- package/agents/prompts/cx-debugger.md +2 -0
- package/agents/prompts/cx-designer.md +2 -0
- package/agents/prompts/cx-devil-advocate.md +2 -0
- package/agents/prompts/cx-docs-keeper.md +2 -0
- package/agents/prompts/cx-engineer.md +2 -0
- package/agents/prompts/cx-evaluator.md +2 -0
- package/agents/prompts/cx-explorer.md +2 -0
- package/agents/prompts/cx-legal-compliance.md +2 -0
- package/agents/prompts/cx-operations.md +2 -0
- package/agents/prompts/cx-orchestrator.md +2 -0
- package/agents/prompts/cx-platform-engineer.md +2 -0
- package/agents/prompts/cx-product-manager.md +2 -0
- package/agents/prompts/cx-qa.md +2 -0
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-release-manager.md +2 -0
- package/agents/prompts/cx-researcher.md +2 -0
- package/agents/prompts/cx-reviewer.md +2 -0
- package/agents/prompts/cx-security.md +2 -0
- package/agents/prompts/cx-sre.md +2 -0
- package/agents/prompts/cx-test-automation.md +2 -0
- package/agents/prompts/cx-trace-reviewer.md +2 -0
- package/agents/prompts/cx-ux-researcher.md +2 -0
- package/agents/registry.json +8 -3
- package/bin/construct +209 -32
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +1 -1
- package/commands/work/parallel-review.md +1 -1
- package/lib/auto-docs.mjs +7 -7
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/comment-lint.mjs +134 -0
- package/lib/contracts/validate.mjs +323 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +310 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +211 -1
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +2 -0
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/mcp-audit.mjs +2 -1
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/init-unified.mjs +55 -65
- package/lib/intake/classify.mjs +108 -24
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +6 -1
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/prepare.mjs +0 -1
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +2 -0
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/observation-store.mjs +9 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/reflect.mjs +49 -12
- package/lib/server/index.mjs +22 -28
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/telemetry/client.mjs +5 -1
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/package.json +4 -1
- package/personas/construct.md +3 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/sync-agents.mjs +45 -14
- package/templates/docs/construct_guide.md +6 -6
- package/templates/docs/skill-artifact.md +1 -1
package/lib/document-extract.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/lib/embed/inbox.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/embed/scheduler.mjs
CHANGED
|
@@ -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)
|
|
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
|
|
99
|
-
//
|
|
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
|
);
|
package/lib/hooks/mcp-audit.mjs
CHANGED
|
@@ -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
|
-
|
|
51
|
+
ensureCxDir(cwd);
|
|
51
52
|
writeFileSync(auditPath, JSON.stringify(audit, null, 2));
|
|
52
53
|
}
|
|
53
54
|
} catch { /* best effort */ }
|