@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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/intake/daemon.mjs — Continuous intake daemon with safeguards.
|
|
3
|
+
*
|
|
4
|
+
* Polls `.cx/inbox/` for new files, classifies them via the existing intake
|
|
5
|
+
* classifier, and writes packets to `.cx/intake/pending/`. Packets past their
|
|
6
|
+
* TTL move to `.cx/intake/dead-letter/`; failed classification retries up to
|
|
7
|
+
* the budget then dead-letters with the failure reason.
|
|
8
|
+
*
|
|
9
|
+
* Built on lib/daemons/contract.mjs — every safeguard (bounded lifetime,
|
|
10
|
+
* idle shutdown, heartbeat, killswitch, single-writer lock) applies.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { join, basename } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
import { createDaemon, classifyPacket } from '../daemons/contract.mjs';
|
|
18
|
+
|
|
19
|
+
const KILLSWITCH_ENV = 'CONSTRUCT_INTAKE_DAEMON';
|
|
20
|
+
|
|
21
|
+
function inboxDir(cwd) { return join(cwd, '.cx', 'inbox'); }
|
|
22
|
+
function pendingDir(cwd) { return join(cwd, '.cx', 'intake', 'pending'); }
|
|
23
|
+
function deadLetterDir(cwd) { return join(cwd, '.cx', 'intake', 'dead-letter'); }
|
|
24
|
+
function heartbeatPath() { return join(homedir(), '.construct', 'intake-daemon.heartbeat'); }
|
|
25
|
+
|
|
26
|
+
function ensureDir(dir) {
|
|
27
|
+
try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function listInboxFiles(cwd) {
|
|
31
|
+
const dir = inboxDir(cwd);
|
|
32
|
+
if (!existsSync(dir)) return [];
|
|
33
|
+
let entries;
|
|
34
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
35
|
+
return entries
|
|
36
|
+
.filter((e) => e.isFile() && !e.name.startsWith('.'))
|
|
37
|
+
.map((e) => join(dir, e.name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Process one inbox file: classify, persist packet to pending, remove the
|
|
42
|
+
* inbox source on success, or move to dead-letter on persistent failure.
|
|
43
|
+
*
|
|
44
|
+
* Returns one of:
|
|
45
|
+
* { didWork: true, route: 'pending'|'dead-letter', packetId }
|
|
46
|
+
* { didWork: false, reason }
|
|
47
|
+
*/
|
|
48
|
+
export async function processInboxFile(filePath, { cwd, classify, now = () => new Date() }) {
|
|
49
|
+
ensureDir(pendingDir(cwd));
|
|
50
|
+
ensureDir(deadLetterDir(cwd));
|
|
51
|
+
|
|
52
|
+
let body;
|
|
53
|
+
try { body = readFileSync(filePath, 'utf8'); }
|
|
54
|
+
catch (err) { return { didWork: false, reason: `read failed: ${err.message}` }; }
|
|
55
|
+
|
|
56
|
+
const id = `intake-${now().getTime()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
57
|
+
const packet = {
|
|
58
|
+
id,
|
|
59
|
+
firstSeenAt: new Date(statSync(filePath).mtimeMs).toISOString(),
|
|
60
|
+
sourcePath: filePath,
|
|
61
|
+
excerpt: body.slice(0, 500),
|
|
62
|
+
attempts: 0,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const decision = classifyPacket(packet);
|
|
66
|
+
if (decision.route === 'dead-letter') {
|
|
67
|
+
return persistDeadLetter(filePath, packet, decision.reason, cwd);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
packet.triage = await classify({ sourcePath: filePath, extractedText: body });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
packet.attempts++;
|
|
74
|
+
packet.lastError = err.message;
|
|
75
|
+
if (packet.attempts >= 3) {
|
|
76
|
+
return persistDeadLetter(filePath, packet, 'retry-budget-exhausted', cwd);
|
|
77
|
+
}
|
|
78
|
+
return { didWork: false, reason: `classify failed, will retry: ${err.message}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const packetPath = join(pendingDir(cwd), `${id}.json`);
|
|
82
|
+
writeFileSync(packetPath, JSON.stringify(packet, null, 2));
|
|
83
|
+
try {
|
|
84
|
+
const consumedPath = join(pendingDir(cwd), `.${basename(filePath)}.consumed`);
|
|
85
|
+
renameSync(filePath, consumedPath);
|
|
86
|
+
} catch { /* ignore consume failure */ }
|
|
87
|
+
return { didWork: true, route: 'pending', packetId: id };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function persistDeadLetter(filePath, packet, reason, cwd) {
|
|
91
|
+
const dlPath = join(deadLetterDir(cwd), `${packet.id}.json`);
|
|
92
|
+
writeFileSync(dlPath, JSON.stringify({ ...packet, deadLetterReason: reason, deadLetteredAt: new Date().toISOString() }, null, 2));
|
|
93
|
+
try { renameSync(filePath, join(deadLetterDir(cwd), `.source.${basename(filePath)}`)); } catch { /* ignore */ }
|
|
94
|
+
return { didWork: true, route: 'dead-letter', packetId: packet.id, reason };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a DaemonRunner for the intake daemon. Caller is responsible for
|
|
99
|
+
* calling .run() — typically construct intake daemon start.
|
|
100
|
+
*/
|
|
101
|
+
export function buildIntakeDaemon({ cwd = process.cwd(), intervalMs = 60_000, classify } = {}) {
|
|
102
|
+
const classifyFn = classify || (async () => ({ intakeType: 'unknown', rdStage: 'triage', primaryOwner: 'product-manager', recommendedAction: 'review' }));
|
|
103
|
+
return createDaemon({
|
|
104
|
+
name: 'intake',
|
|
105
|
+
intervalMs,
|
|
106
|
+
killswitchEnv: KILLSWITCH_ENV,
|
|
107
|
+
heartbeatPath: heartbeatPath(),
|
|
108
|
+
maxRuntimeMs: 24 * 60 * 60 * 1000,
|
|
109
|
+
maxIdleTicks: 6,
|
|
110
|
+
async tick() {
|
|
111
|
+
const files = listInboxFiles(cwd);
|
|
112
|
+
if (files.length === 0) return { didWork: false };
|
|
113
|
+
let workedAny = false;
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
const result = await processInboxFile(file, { cwd, classify: classifyFn });
|
|
116
|
+
if (result.didWork) workedAny = true;
|
|
117
|
+
}
|
|
118
|
+
return { didWork: workedAny };
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
13
14
|
|
|
14
15
|
const QUEUE_SUBDIR = '.cx/intake';
|
|
15
16
|
|
|
@@ -55,6 +56,7 @@ export class FilesystemIntakeQueue {
|
|
|
55
56
|
enqueue(entry) {
|
|
56
57
|
if (!entry?.intake?.sourcePath) throw new Error('enqueue: entry.intake.sourcePath is required');
|
|
57
58
|
const dir = pendingDir(this.rootDir);
|
|
59
|
+
ensureCxDir(this.rootDir);
|
|
58
60
|
mkdirSync(dir, { recursive: true });
|
|
59
61
|
|
|
60
62
|
const ts = timestamp();
|
|
@@ -112,6 +114,7 @@ export class FilesystemIntakeQueue {
|
|
|
112
114
|
if (notes) data.notes = notes;
|
|
113
115
|
|
|
114
116
|
const dst = path.join(processedDir(this.rootDir), `${id}.json`);
|
|
117
|
+
ensureCxDir(this.rootDir);
|
|
115
118
|
mkdirSync(path.dirname(dst), { recursive: true });
|
|
116
119
|
writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
117
120
|
rmSync(src);
|
|
@@ -128,6 +131,7 @@ export class FilesystemIntakeQueue {
|
|
|
128
131
|
if (reason) data.reason = reason;
|
|
129
132
|
|
|
130
133
|
const dst = path.join(skippedDir(this.rootDir), `${id}.json`);
|
|
134
|
+
ensureCxDir(this.rootDir);
|
|
131
135
|
mkdirSync(path.dirname(dst), { recursive: true });
|
|
132
136
|
writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
133
137
|
rmSync(src);
|
|
@@ -148,7 +152,8 @@ export class FilesystemIntakeQueue {
|
|
|
148
152
|
delete data.reason;
|
|
149
153
|
|
|
150
154
|
const dst = path.join(pendingDir(this.rootDir), `${id}.json`);
|
|
151
|
-
|
|
155
|
+
ensureCxDir(this.rootDir);
|
|
156
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
152
157
|
writeFileSync(dst, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
153
158
|
rmSync(src);
|
|
154
159
|
return { id, filePath: dst, from: path.basename(dir) };
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
26
26
|
import { shouldCreateCx } from '../project-detection.mjs';
|
|
27
|
+
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
27
28
|
|
|
28
29
|
export const INTAKE_CONFIG_FILE = '.cx/intake-config.json';
|
|
29
30
|
export const INTAKE_DEFAULT_MAX_DEPTH = 4;
|
|
@@ -115,7 +116,7 @@ export function saveIntakeConfig(rootDir, patch = {}) {
|
|
|
115
116
|
};
|
|
116
117
|
|
|
117
118
|
const file = intakeConfigPath(rootDir);
|
|
118
|
-
|
|
119
|
+
ensureCxDir(rootDir);
|
|
119
120
|
writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
|
|
120
121
|
return next;
|
|
121
122
|
}
|
package/lib/intake/prepare.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createIntakeQueue } from './queue.mjs';
|
|
14
14
|
import { formatTriageLine } from './classify.mjs';
|
|
15
15
|
import { isBrokered } from '../mcp/broker.mjs';
|
|
16
|
+
import { getRebrand } from '../profiles/rebrand.mjs';
|
|
16
17
|
|
|
17
18
|
export function buildIntakePrelude({ cwd, env = process.env } = {}) {
|
|
18
19
|
if (!cwd) return '';
|
|
@@ -20,11 +21,16 @@ export function buildIntakePrelude({ cwd, env = process.env } = {}) {
|
|
|
20
21
|
const queue = createIntakeQueue(cwd, env);
|
|
21
22
|
const pending = queue.listPending();
|
|
22
23
|
if (!pending.length) return '';
|
|
24
|
+
const { intakeQueueLabel, signalNoun } = getRebrand(cwd);
|
|
23
25
|
const recent = pending.slice(-3).map((p) => {
|
|
24
26
|
const src = p.intake?.sourcePath || p.id;
|
|
25
27
|
return `- ${formatTriageLine(src, p.triage)}`;
|
|
26
28
|
});
|
|
27
|
-
|
|
29
|
+
// Strip a trailing " queue" / " intake" so the heading reads
|
|
30
|
+
// "## Pending R&D intake (N)" rather than the redundant
|
|
31
|
+
// "## Pending R&D intake queue queue (N)".
|
|
32
|
+
const heading = intakeQueueLabel.replace(/\s+queue$/i, '');
|
|
33
|
+
return `\n## Pending ${heading} (${pending.length})\n${recent.join('\n')}\nEach packet at \`.cx/intake/pending/<id>.json\` carries the new ${signalNoun}, a triage block (intakeType, rdStage, primaryOwner, recommendedChain, recommendedAction, risk), related existing docs, and an excerpt. Process via the recommended chain, then close via \`construct intake done <id>\`.\n`;
|
|
28
34
|
} catch {
|
|
29
35
|
return '';
|
|
30
36
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/intake/traceability.mjs — stamps intake provenance into artifact frontmatter.
|
|
3
|
+
*
|
|
4
|
+
* Backs `construct intake done <id> --output=<path>`: reads the intake packet,
|
|
5
|
+
* extracts (id, confidence, rationale), and writes them into the artifact's
|
|
6
|
+
* YAML frontmatter. Refuses to overwrite a different intake_id (an artifact
|
|
7
|
+
* can be linked to exactly one intake source). Idempotent on re-stamping.
|
|
8
|
+
*
|
|
9
|
+
* Read by docs:verify checkIntakeTraceability to surface artifacts in
|
|
10
|
+
* intake-fed locations that lack a reference.
|
|
11
|
+
*
|
|
12
|
+
* Field schema (in artifact YAML frontmatter):
|
|
13
|
+
* intake_id: construct-xxx
|
|
14
|
+
* intake_confidence: 0.7
|
|
15
|
+
* intake_rationale: "Matched 3 keywords: dashboard, latency, p95"
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
21
|
+
|
|
22
|
+
export function parseArtifactFrontmatter(content) {
|
|
23
|
+
const m = content.match(FRONTMATTER_RE);
|
|
24
|
+
if (!m) return { frontmatter: {}, body: content, hasBlock: false };
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const line of m[1].split('\n')) {
|
|
27
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
28
|
+
const idx = line.indexOf(':');
|
|
29
|
+
if (idx === -1) continue;
|
|
30
|
+
const key = line.slice(0, idx).trim();
|
|
31
|
+
const rawValue = line.slice(idx + 1).trim();
|
|
32
|
+
if (!key) continue;
|
|
33
|
+
const unquoted = rawValue.replace(/^["']|["']$/g, '');
|
|
34
|
+
if (/^-?\d+(\.\d+)?$/.test(unquoted)) out[key] = Number(unquoted);
|
|
35
|
+
else out[key] = unquoted;
|
|
36
|
+
}
|
|
37
|
+
return { frontmatter: out, body: content.slice(m[0].length), hasBlock: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function serializeFrontmatter(fm) {
|
|
41
|
+
const lines = ['---'];
|
|
42
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
43
|
+
if (v === null || v === undefined) continue;
|
|
44
|
+
if (typeof v === 'number' || typeof v === 'boolean') {
|
|
45
|
+
lines.push(`${k}: ${v}`);
|
|
46
|
+
} else {
|
|
47
|
+
const s = String(v);
|
|
48
|
+
const needsQuoting = /[:#"'\n]|^\s|\s$/.test(s);
|
|
49
|
+
lines.push(`${k}: ${needsQuoting ? JSON.stringify(s) : s}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
lines.push('---');
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stampIntakeProvenance(artifactPath, { intakeId, confidence, rationale }) {
|
|
57
|
+
if (!existsSync(artifactPath)) {
|
|
58
|
+
throw new Error(`Output artifact not found: ${artifactPath}`);
|
|
59
|
+
}
|
|
60
|
+
if (!intakeId) throw new Error('intakeId required for stamping');
|
|
61
|
+
const content = readFileSync(artifactPath, 'utf8');
|
|
62
|
+
const { frontmatter, body, hasBlock } = parseArtifactFrontmatter(content);
|
|
63
|
+
|
|
64
|
+
const existing = frontmatter.intake_id;
|
|
65
|
+
if (existing && existing !== intakeId) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Refusing to overwrite intake_id: artifact already references ${existing}, not ${intakeId}. Resolve manually or supersede.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const next = {
|
|
72
|
+
...frontmatter,
|
|
73
|
+
intake_id: intakeId,
|
|
74
|
+
...(confidence !== undefined && confidence !== null ? { intake_confidence: confidence } : {}),
|
|
75
|
+
...(rationale ? { intake_rationale: rationale } : {}),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const fmText = serializeFrontmatter(next);
|
|
79
|
+
const out = hasBlock ? `${fmText}\n${body}` : `${fmText}\n\n${content}`;
|
|
80
|
+
writeFileSync(artifactPath, out, 'utf8');
|
|
81
|
+
return { intake_id: intakeId, intake_confidence: next.intake_confidence, intake_rationale: next.intake_rationale };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function hasIntakeReference(artifactPath) {
|
|
85
|
+
if (!existsSync(artifactPath)) return false;
|
|
86
|
+
const { frontmatter } = parseArtifactFrontmatter(readFileSync(artifactPath, 'utf8'));
|
|
87
|
+
if (frontmatter.intake_id) return true;
|
|
88
|
+
if (frontmatter.intake === 'none') return true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { syncFileStateToSql } from '../storage/sync.mjs';
|
|
21
21
|
import { resolveActiveProfile } from '../profiles/loader.mjs';
|
|
22
|
+
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
22
23
|
|
|
23
24
|
const ROOT = '.cx/knowledge/external/research';
|
|
24
25
|
const MAX_BYTES = 50 * 1024;
|
|
@@ -68,6 +69,7 @@ export async function addResearchFinding({
|
|
|
68
69
|
const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
|
|
69
70
|
|
|
70
71
|
const outDir = join(cwd, ROOT);
|
|
72
|
+
ensureCxDir(cwd);
|
|
71
73
|
mkdirSync(outDir, { recursive: true });
|
|
72
74
|
const outPath = join(outDir, `${slug}.md`);
|
|
73
75
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/maintenance/cleanup.mjs — Construct's self-maintenance primitives.
|
|
3
|
+
*
|
|
4
|
+
* Every artifact Construct writes has a lifecycle. This module collects the
|
|
5
|
+
* primitives that enforce that lifecycle: rotate oversized logs, prune
|
|
6
|
+
* rotated segments, age-out cache directories. Each primitive is pure and
|
|
7
|
+
* returns a structured summary; runFullCleanup orchestrates them with sane
|
|
8
|
+
* defaults.
|
|
9
|
+
*
|
|
10
|
+
* Triggered from two entry points:
|
|
11
|
+
* - `construct cleanup` — manual sweep, optionally --dry-run
|
|
12
|
+
* - bin/construct startup check — automatic when the installed version
|
|
13
|
+
* differs from ~/.construct/.cleanup-stamp,
|
|
14
|
+
* so an upgrade reclaims disk without
|
|
15
|
+
* requiring a manual command.
|
|
16
|
+
*
|
|
17
|
+
* Scope is deliberately narrow: only artifacts whose retention is owned by
|
|
18
|
+
* the runtime (logs, caches, rotated segments). Knowledge stores
|
|
19
|
+
* (.cx/observations/, .cx/handoffs/, .cx/intake/) require explicit retention
|
|
20
|
+
* policy and are left for a follow-up after policy is agreed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
const HARD_BUDGET_MS = 5000;
|
|
28
|
+
|
|
29
|
+
// Size caps in bytes. Each entry: [filename relative to ~/.cx/, maxBytes].
|
|
30
|
+
// Files that exceed maxBytes are truncated (head-preserving rotation is not
|
|
31
|
+
// useful for append-only event logs — the most-recent events are what matter,
|
|
32
|
+
// not the oldest; tail-preserving truncation keeps the last N% by recopying).
|
|
33
|
+
const JSONL_LOG_CAPS_BYTES = {
|
|
34
|
+
'audit.jsonl': 25 * 1024 * 1024,
|
|
35
|
+
'contract-violations.jsonl': 25 * 1024 * 1024,
|
|
36
|
+
'doctor-log.jsonl': 25 * 1024 * 1024,
|
|
37
|
+
'role-pending.jsonl': 5 * 1024 * 1024,
|
|
38
|
+
'role-events.jsonl': 25 * 1024 * 1024,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const EMBED_LOG_DEFAULT_MAX_MB = 50;
|
|
42
|
+
const EMBED_LOG_DEFAULT_KEEP = 3;
|
|
43
|
+
const EMBED_LOG_HARD_CAP_MAX_MB = 500;
|
|
44
|
+
const EMBED_LOG_HARD_CAP_KEEP = 20;
|
|
45
|
+
|
|
46
|
+
function fileSizeOrZero(p) {
|
|
47
|
+
try { return fs.statSync(p).size; } catch { return 0; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function rmSafe(p, dryRun) {
|
|
51
|
+
if (dryRun) return;
|
|
52
|
+
try { fs.rmSync(p, { force: true }); } catch (err) {
|
|
53
|
+
process.stderr.write(`[cleanup] failed to remove ${p}: ${err.message}\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tail-preserving truncate: keeps the last `keepBytes` of `file`, drops the
|
|
59
|
+
* rest. Used for append-only event logs where recent events are valuable but
|
|
60
|
+
* historical bloat is not.
|
|
61
|
+
*/
|
|
62
|
+
function tailTruncate(file, keepBytes, dryRun) {
|
|
63
|
+
if (dryRun) return;
|
|
64
|
+
let stat;
|
|
65
|
+
try { stat = fs.statSync(file); } catch { return; }
|
|
66
|
+
if (stat.size <= keepBytes) return;
|
|
67
|
+
const start = stat.size - keepBytes;
|
|
68
|
+
const buf = Buffer.alloc(keepBytes);
|
|
69
|
+
const fd = fs.openSync(file, 'r');
|
|
70
|
+
try { fs.readSync(fd, buf, 0, keepBytes, start); } finally { fs.closeSync(fd); }
|
|
71
|
+
// Find the first newline so we don't start mid-record.
|
|
72
|
+
const nl = buf.indexOf(0x0a);
|
|
73
|
+
const payload = nl >= 0 && nl < buf.length - 1 ? buf.subarray(nl + 1) : buf;
|
|
74
|
+
fs.writeFileSync(file, payload);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function rotationConfig(env = process.env) {
|
|
78
|
+
const maxMbRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_MAX_MB ?? '', 10);
|
|
79
|
+
const keepRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_KEEP ?? '', 10);
|
|
80
|
+
const maxMb = Number.isFinite(maxMbRaw) && maxMbRaw > 0 ? Math.min(maxMbRaw, EMBED_LOG_HARD_CAP_MAX_MB) : EMBED_LOG_DEFAULT_MAX_MB;
|
|
81
|
+
const keep = Number.isFinite(keepRaw) && keepRaw >= 0 ? Math.min(keepRaw, EMBED_LOG_HARD_CAP_KEEP) : EMBED_LOG_DEFAULT_KEEP;
|
|
82
|
+
return { maxBytes: maxMb * 1024 * 1024, keep };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Walk the embed log + every rotated segment (.1, .2, ...) and drop the ones
|
|
87
|
+
* that exceed the per-segment cap OR sit beyond the keep count.
|
|
88
|
+
*
|
|
89
|
+
* Unlike rotateEmbedLogIfNeeded in lib/embed/cli.mjs (which is called at
|
|
90
|
+
* daemon spawn time and only rotates the live log), this is the post-upgrade
|
|
91
|
+
* cleanup: it handles the case where an existing install has segments that
|
|
92
|
+
* were oversized before the cap existed.
|
|
93
|
+
*/
|
|
94
|
+
export function cleanupEmbedLog({ homeDir = os.homedir(), env = process.env, dryRun = false } = {}) {
|
|
95
|
+
const dir = path.join(homeDir, '.cx', 'runtime');
|
|
96
|
+
const baseLog = path.join(dir, 'embed-daemon.log');
|
|
97
|
+
const { maxBytes, keep } = rotationConfig(env);
|
|
98
|
+
|
|
99
|
+
const summary = { removed: [], truncated: [], freedBytes: 0, baseLog };
|
|
100
|
+
|
|
101
|
+
// Every existing segment (.log, .log.1, .log.2, ...)
|
|
102
|
+
if (!fs.existsSync(dir)) return summary;
|
|
103
|
+
const entries = fs.readdirSync(dir).filter(n => n === 'embed-daemon.log' || n.startsWith('embed-daemon.log.'));
|
|
104
|
+
|
|
105
|
+
for (const name of entries) {
|
|
106
|
+
const full = path.join(dir, name);
|
|
107
|
+
const size = fileSizeOrZero(full);
|
|
108
|
+
|
|
109
|
+
// Drop segments beyond the keep horizon entirely.
|
|
110
|
+
const dotMatch = name.match(/^embed-daemon\.log\.(\d+)$/);
|
|
111
|
+
if (dotMatch && Number.parseInt(dotMatch[1], 10) > keep) {
|
|
112
|
+
summary.removed.push({ path: full, size });
|
|
113
|
+
summary.freedBytes += size;
|
|
114
|
+
rmSafe(full, dryRun);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The live log: if oversized, truncate to 0 (it will rotate cleanly on next
|
|
119
|
+
// daemon spawn via rotateEmbedLogIfNeeded). Truncating in place keeps any
|
|
120
|
+
// open daemon FDs valid (writes resume at byte 0 on next O_APPEND write).
|
|
121
|
+
if (name === 'embed-daemon.log' && size > maxBytes) {
|
|
122
|
+
summary.truncated.push({ path: full, sizeWas: size });
|
|
123
|
+
summary.freedBytes += size;
|
|
124
|
+
if (!dryRun) {
|
|
125
|
+
try {
|
|
126
|
+
fs.truncateSync(full, 0);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
process.stderr.write(`[cleanup] failed to truncate ${full}: ${err.message}\n`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Rotated segments that are individually oversized: drop them. They are
|
|
135
|
+
// archived noise; keeping them around defeats the cap.
|
|
136
|
+
if (dotMatch && size > maxBytes) {
|
|
137
|
+
summary.removed.push({ path: full, size });
|
|
138
|
+
summary.freedBytes += size;
|
|
139
|
+
rmSafe(full, dryRun);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return summary;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Apply per-file size caps to the user-level JSONL event logs at ~/.cx/.
|
|
148
|
+
* Truncates oldest events rather than removing the whole file so the most
|
|
149
|
+
* recent records survive.
|
|
150
|
+
*/
|
|
151
|
+
export function cleanupJsonlLogs({ homeDir = os.homedir(), dryRun = false } = {}) {
|
|
152
|
+
const dir = path.join(homeDir, '.cx');
|
|
153
|
+
const summary = { truncated: [], freedBytes: 0 };
|
|
154
|
+
if (!fs.existsSync(dir)) return summary;
|
|
155
|
+
|
|
156
|
+
for (const [name, capBytes] of Object.entries(JSONL_LOG_CAPS_BYTES)) {
|
|
157
|
+
const file = path.join(dir, name);
|
|
158
|
+
const size = fileSizeOrZero(file);
|
|
159
|
+
if (size <= capBytes) continue;
|
|
160
|
+
// Retain the most recent half-cap so headroom exists before next truncate.
|
|
161
|
+
const keepBytes = Math.floor(capBytes / 2);
|
|
162
|
+
summary.truncated.push({ path: file, sizeWas: size, sizeNow: keepBytes });
|
|
163
|
+
summary.freedBytes += size - keepBytes;
|
|
164
|
+
tailTruncate(file, keepBytes, dryRun);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return summary;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Age out ~/.construct/cache/ entries older than `maxAgeDays`. Cache hits are
|
|
172
|
+
* idempotent and re-fetched on demand, so pruning is safe.
|
|
173
|
+
*/
|
|
174
|
+
export function cleanupCacheDir({ homeDir = os.homedir(), maxAgeDays = 30, dryRun = false } = {}) {
|
|
175
|
+
const dir = path.join(homeDir, '.construct', 'cache');
|
|
176
|
+
const summary = { removed: [], freedBytes: 0 };
|
|
177
|
+
if (!fs.existsSync(dir)) return summary;
|
|
178
|
+
|
|
179
|
+
const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
|
|
180
|
+
const walk = (current) => {
|
|
181
|
+
let entries;
|
|
182
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
const full = path.join(current, entry.name);
|
|
185
|
+
if (entry.isDirectory()) { walk(full); continue; }
|
|
186
|
+
let stat;
|
|
187
|
+
try { stat = fs.statSync(full); } catch { continue; }
|
|
188
|
+
if (stat.mtimeMs < cutoff) {
|
|
189
|
+
summary.removed.push({ path: full, size: stat.size });
|
|
190
|
+
summary.freedBytes += stat.size;
|
|
191
|
+
rmSafe(full, dryRun);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
walk(dir);
|
|
196
|
+
return summary;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Orchestrator. Runs each primitive under a hard time budget and returns an
|
|
201
|
+
* aggregated summary. Individual failures are non-fatal: a primitive that
|
|
202
|
+
* throws is recorded as an error in the summary but does not block the rest.
|
|
203
|
+
*/
|
|
204
|
+
export function runFullCleanup({ homeDir = os.homedir(), env = process.env, dryRun = false } = {}) {
|
|
205
|
+
const startedAt = Date.now();
|
|
206
|
+
const summary = {
|
|
207
|
+
dryRun,
|
|
208
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
209
|
+
durationMs: 0,
|
|
210
|
+
freedBytes: 0,
|
|
211
|
+
embedLog: null,
|
|
212
|
+
jsonlLogs: null,
|
|
213
|
+
cacheDir: null,
|
|
214
|
+
errors: [],
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const safelyRun = (label, fn) => {
|
|
218
|
+
if (Date.now() - startedAt > HARD_BUDGET_MS) {
|
|
219
|
+
summary.errors.push({ step: label, error: 'time budget exceeded; skipped' });
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
try { return fn(); }
|
|
223
|
+
catch (err) { summary.errors.push({ step: label, error: err.message }); return null; }
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
summary.embedLog = safelyRun('embedLog', () => cleanupEmbedLog({ homeDir, env, dryRun }));
|
|
227
|
+
summary.jsonlLogs = safelyRun('jsonlLogs', () => cleanupJsonlLogs({ homeDir, dryRun }));
|
|
228
|
+
summary.cacheDir = safelyRun('cacheDir', () => cleanupCacheDir({ homeDir, dryRun }));
|
|
229
|
+
|
|
230
|
+
for (const part of [summary.embedLog, summary.jsonlLogs, summary.cacheDir]) {
|
|
231
|
+
if (part?.freedBytes) summary.freedBytes += part.freedBytes;
|
|
232
|
+
}
|
|
233
|
+
summary.durationMs = Date.now() - startedAt;
|
|
234
|
+
return summary;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Format bytes for one-line operator output. 1234567 → "1.2 MB".
|
|
239
|
+
*/
|
|
240
|
+
export function formatBytes(bytes) {
|
|
241
|
+
if (!bytes || bytes < 0) return '0 B';
|
|
242
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
243
|
+
let n = bytes;
|
|
244
|
+
let unit = 0;
|
|
245
|
+
while (n >= 1024 && unit < units.length - 1) { n /= 1024; unit += 1; }
|
|
246
|
+
return `${n.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Version stamp — drives automatic cleanup on upgrade.
|
|
251
|
+
//
|
|
252
|
+
// ~/.construct/.cleanup-stamp records the last version that ran cleanup.
|
|
253
|
+
// On every CLI invocation, bin/construct compares the current package
|
|
254
|
+
// version to the stamp. A mismatch triggers a single cleanup pass and
|
|
255
|
+
// writes the new version into the stamp. The first ever invocation also
|
|
256
|
+
// triggers cleanup (no stamp present) — this is what unblocks existing
|
|
257
|
+
// installs that already have 34 GB logs sitting around.
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
const STAMP_RELATIVE = path.join('.construct', '.cleanup-stamp');
|
|
261
|
+
|
|
262
|
+
export function stampPath(homeDir = os.homedir()) {
|
|
263
|
+
return path.join(homeDir, STAMP_RELATIVE);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function readVersionStamp({ homeDir = os.homedir() } = {}) {
|
|
267
|
+
const p = stampPath(homeDir);
|
|
268
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
269
|
+
catch { return null; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function writeVersionStamp({ homeDir = os.homedir(), version, summary } = {}) {
|
|
273
|
+
const p = stampPath(homeDir);
|
|
274
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
275
|
+
const payload = {
|
|
276
|
+
version,
|
|
277
|
+
ranAt: new Date().toISOString(),
|
|
278
|
+
freedBytes: summary?.freedBytes ?? 0,
|
|
279
|
+
durationMs: summary?.durationMs ?? 0,
|
|
280
|
+
};
|
|
281
|
+
fs.writeFileSync(p, JSON.stringify(payload, null, 2));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Run cleanup if and only if the installed version differs from the stamp.
|
|
286
|
+
* Returns { ran: bool, reason, summary?, previousVersion? }.
|
|
287
|
+
*
|
|
288
|
+
* Designed to be called at CLI startup. Never throws — cleanup failures are
|
|
289
|
+
* surfaced via the returned summary and never block the actual command.
|
|
290
|
+
*/
|
|
291
|
+
export function maybeRunCleanupOnUpgrade({
|
|
292
|
+
homeDir = os.homedir(),
|
|
293
|
+
env = process.env,
|
|
294
|
+
currentVersion,
|
|
295
|
+
force = false,
|
|
296
|
+
} = {}) {
|
|
297
|
+
if (env.CONSTRUCT_DISABLE_AUTO_CLEANUP === '1') {
|
|
298
|
+
return { ran: false, reason: 'disabled via CONSTRUCT_DISABLE_AUTO_CLEANUP' };
|
|
299
|
+
}
|
|
300
|
+
const stamp = readVersionStamp({ homeDir });
|
|
301
|
+
if (!force && stamp && stamp.version === currentVersion) {
|
|
302
|
+
return { ran: false, reason: 'version matches stamp' };
|
|
303
|
+
}
|
|
304
|
+
let summary;
|
|
305
|
+
try { summary = runFullCleanup({ homeDir, env, dryRun: false }); }
|
|
306
|
+
catch (err) { return { ran: false, reason: `cleanup threw: ${err.message}` }; }
|
|
307
|
+
try { writeVersionStamp({ homeDir, version: currentVersion, summary }); }
|
|
308
|
+
catch (err) { /* stamp write failure is non-fatal */ void err; }
|
|
309
|
+
return {
|
|
310
|
+
ran: true,
|
|
311
|
+
reason: stamp ? 'version changed' : 'first run',
|
|
312
|
+
previousVersion: stamp?.version ?? null,
|
|
313
|
+
summary,
|
|
314
|
+
};
|
|
315
|
+
}
|