@geraldmaron/construct 1.0.11 → 1.0.14
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 +55 -18
- package/bin/construct +259 -11
- package/bin/construct-postinstall.mjs +23 -2
- package/lib/auto-docs.mjs +23 -22
- package/lib/cli-commands.mjs +2 -0
- package/lib/dashboard-static.mjs +7 -4
- package/lib/doc-stamp.mjs +16 -0
- package/lib/docs-verify.mjs +1 -8
- package/lib/doctor/watchers/bd-watch.mjs +6 -2
- package/lib/embed/daemon.mjs +20 -0
- package/lib/embed/docs-lifecycle.mjs +19 -0
- package/lib/embed/inbox.mjs +85 -2
- package/lib/embed/recommendation-store.mjs +29 -0
- package/lib/gates-audit.mjs +18 -12
- package/lib/hooks/_lib/input.mjs +52 -0
- package/lib/hooks/adaptive-lint.mjs +4 -0
- package/lib/hooks/agent-tracker.mjs +59 -15
- package/lib/hooks/audit-reads.mjs +83 -42
- package/lib/hooks/audit-trail.mjs +28 -18
- package/lib/hooks/bash-output-logger.mjs +8 -2
- package/lib/hooks/block-no-verify.mjs +4 -0
- package/lib/hooks/ci-status-check.mjs +4 -0
- package/lib/hooks/comment-lint.mjs +6 -4
- package/lib/hooks/config-protection.mjs +4 -0
- package/lib/hooks/context-watch.mjs +4 -0
- package/lib/hooks/context-window-recovery.mjs +4 -0
- package/lib/hooks/dep-audit.mjs +12 -5
- package/lib/hooks/doc-coupling-check.mjs +5 -1
- package/lib/hooks/edit-accumulator.mjs +25 -10
- package/lib/hooks/edit-error-recovery.mjs +4 -0
- package/lib/hooks/edit-guard.mjs +4 -0
- package/lib/hooks/guard-bash.mjs +4 -0
- package/lib/hooks/mcp-audit.mjs +4 -0
- package/lib/hooks/mcp-health-check.mjs +4 -0
- package/lib/hooks/model-fallback.mjs +7 -11
- package/lib/hooks/policy-engine.mjs +4 -0
- package/lib/hooks/post-merge-docs-check.mjs +4 -0
- package/lib/hooks/post-merge-tracking.mjs +82 -0
- package/lib/hooks/pre-compact.mjs +4 -0
- package/lib/hooks/pre-push-gate.mjs +84 -231
- package/lib/hooks/proactive-activation.mjs +5 -2
- package/lib/hooks/readme-age-check.mjs +4 -0
- package/lib/hooks/registry-sync.mjs +35 -20
- package/lib/hooks/rule-verifier.mjs +3 -0
- package/lib/hooks/scan-secrets.mjs +4 -0
- package/lib/hooks/session-optimize.mjs +4 -0
- package/lib/hooks/session-reflect.mjs +4 -0
- package/lib/hooks/session-start.mjs +48 -1
- package/lib/hooks/session-tracking-refresh.mjs +70 -0
- package/lib/hooks/stop-notify.mjs +13 -2
- package/lib/hooks/stop-typecheck.mjs +4 -0
- package/lib/hooks/test-watch.mjs +8 -4
- package/lib/init-unified.mjs +90 -23
- package/lib/intake/attribution.mjs +77 -0
- package/lib/intake/intake-config.mjs +3 -0
- package/lib/intake/manifest.mjs +107 -0
- package/lib/intake/poll-lock.mjs +136 -0
- package/lib/intake/prepare.mjs +42 -4
- package/lib/logging/rotate.mjs +394 -0
- package/lib/mcp/tools/project.mjs +2 -2
- package/lib/mcp/tools/telemetry.mjs +1 -1
- package/lib/opencode-config.mjs +10 -3
- package/lib/orchestration/routing-tables.mjs +176 -0
- package/lib/orchestration-policy.mjs +18 -106
- package/lib/parity.mjs +48 -7
- package/lib/profiles/lifecycle.mjs +19 -1
- package/lib/project-init-shared.mjs +11 -5
- package/lib/project-root.mjs +104 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/event-bus.mjs +29 -9
- package/lib/roles/router.mjs +8 -7
- package/lib/server/index.mjs +31 -9
- package/lib/server/static/index.html +1 -15
- package/lib/specialist-contracts-enforce.mjs +24 -10
- package/lib/status.mjs +1 -1
- package/lib/storage/backup.mjs +2 -2
- package/lib/sync/skill-frontmatter.mjs +71 -0
- package/lib/telemetry/intent-verifications.mjs +16 -3
- package/lib/telemetry/skill-calls.mjs +12 -3
- package/lib/tracking-surfaces.mjs +375 -0
- package/lib/worker/trace.mjs +19 -2
- package/package.json +6 -2
- package/platforms/claude/settings.template.json +28 -27
- package/scripts/sync-specialists.mjs +296 -94
- package/specialists/registry.json +158 -13
- package/lib/hooks/env-check.mjs +0 -83
- package/lib/hooks/read-tracker.mjs +0 -61
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/server/static/assets/index-ab25c707.js +0 -70
- package/lib/server/static/assets/index-f0c80a2b.css +0 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/intake/poll-lock.mjs — pid-stamped file lock around InboxWatcher.poll().
|
|
3
|
+
*
|
|
4
|
+
* Two pollers (the embed daemon and a manual `construct intake process`)
|
|
5
|
+
* share the same state file + manifest. Without serialization, both can
|
|
6
|
+
* decide a freshly-dropped file is unprocessed in the window between
|
|
7
|
+
* "read state" and "write state," producing two intake packets for one
|
|
8
|
+
* source. The lock makes one poll iteration atomic from the system's
|
|
9
|
+
* perspective: the second caller waits, retries until the timeout, or
|
|
10
|
+
* raises POLL_LOCK_BUSY for the CLI to render a clear refusal.
|
|
11
|
+
*
|
|
12
|
+
* Lockfile shape (JSON at <rootDir>/.cx/runtime/inbox-poll.lock):
|
|
13
|
+
* { pid, actor, startedAt, timeoutAt? }
|
|
14
|
+
*
|
|
15
|
+
* Stale detection: a lock whose pid is gone, or whose timeoutAt has
|
|
16
|
+
* passed, is cleared on the next acquire attempt. Mirrors the
|
|
17
|
+
* lib/beads-lock.mjs API shape so callers familiar with one know the other.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
export const POLL_LOCK_REL_PATH = '.cx/runtime/inbox-poll.lock';
|
|
24
|
+
export const POLL_LOCK_BUSY = 'POLL_LOCK_BUSY';
|
|
25
|
+
|
|
26
|
+
function lockPathFor(rootDir) {
|
|
27
|
+
return join(rootDir, POLL_LOCK_REL_PATH);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nowIso() {
|
|
31
|
+
return new Date().toISOString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function afterMs(ms) {
|
|
35
|
+
return new Date(Date.now() + ms).toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function processExists(pid) {
|
|
39
|
+
if (!pid || typeof pid !== 'number') return false;
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readPollLock(rootDir) {
|
|
49
|
+
const p = lockPathFor(rootDir);
|
|
50
|
+
if (!existsSync(p)) return null;
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(readFileSync(p, 'utf8'));
|
|
53
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
54
|
+
return raw;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isPollLockStale(lock) {
|
|
61
|
+
if (!lock) return false;
|
|
62
|
+
if (lock.pid && !processExists(lock.pid)) return true;
|
|
63
|
+
if (lock.timeoutAt && new Date(lock.timeoutAt) < new Date()) return true;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clearStale(rootDir) {
|
|
68
|
+
const lock = readPollLock(rootDir);
|
|
69
|
+
if (lock && isPollLockStale(lock)) {
|
|
70
|
+
rmSync(lockPathFor(rootDir), { force: true });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeLockFile(rootDir, lock) {
|
|
77
|
+
const p = lockPathFor(rootDir);
|
|
78
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
79
|
+
writeFileSync(p, JSON.stringify(lock, null, 2) + '\n', 'utf8');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Acquire the intake poll lock. Resolves with the lock object on success,
|
|
84
|
+
* rejects with an Error whose `code` is POLL_LOCK_BUSY when the wait window
|
|
85
|
+
* elapses without acquisition. The rejection's `holder` field carries the
|
|
86
|
+
* blocking lock's metadata for downstream error rendering.
|
|
87
|
+
*/
|
|
88
|
+
export async function acquirePollLock({
|
|
89
|
+
rootDir,
|
|
90
|
+
actor = 'unknown',
|
|
91
|
+
command = '',
|
|
92
|
+
waitMs = 0,
|
|
93
|
+
timeoutMs = 5 * 60 * 1000,
|
|
94
|
+
pollIntervalMs = 100,
|
|
95
|
+
} = {}) {
|
|
96
|
+
if (!rootDir) throw new Error('acquirePollLock: rootDir is required');
|
|
97
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
98
|
+
|
|
99
|
+
while (true) {
|
|
100
|
+
clearStale(rootDir);
|
|
101
|
+
const existing = readPollLock(rootDir);
|
|
102
|
+
if (!existing) {
|
|
103
|
+
const lock = {
|
|
104
|
+
pid: process.pid,
|
|
105
|
+
actor,
|
|
106
|
+
command,
|
|
107
|
+
startedAt: nowIso(),
|
|
108
|
+
timeoutAt: timeoutMs > 0 ? afterMs(timeoutMs) : undefined,
|
|
109
|
+
};
|
|
110
|
+
writeLockFile(rootDir, lock);
|
|
111
|
+
return lock;
|
|
112
|
+
}
|
|
113
|
+
if (Date.now() >= deadline) {
|
|
114
|
+
const err = new Error(
|
|
115
|
+
`intake poll lock held by ${existing.actor || 'unknown'} (pid ${existing.pid}) since ${existing.startedAt}`,
|
|
116
|
+
);
|
|
117
|
+
err.code = POLL_LOCK_BUSY;
|
|
118
|
+
err.holder = existing;
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Release the lock iff the calling process owns it. Returns true on release,
|
|
127
|
+
* false when the lock was missing, expired, or owned by someone else.
|
|
128
|
+
*/
|
|
129
|
+
export function releasePollLock(rootDir) {
|
|
130
|
+
const lock = readPollLock(rootDir);
|
|
131
|
+
if (lock && lock.pid === process.pid) {
|
|
132
|
+
rmSync(lockPathFor(rootDir), { force: true });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
package/lib/intake/prepare.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/intake/prepare.mjs — turn a fresh inbox ingestion into an R&D intake packet.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Triggered by `InboxWatcher.poll()` after each successful file ingestion.
|
|
5
5
|
* Four deterministic preparation steps, then write the result to the
|
|
6
6
|
* intake queue:
|
|
7
7
|
*
|
|
@@ -14,11 +14,16 @@
|
|
|
14
14
|
* 4. Run classifyRdIntake to produce the R&D triage object (intake type,
|
|
15
15
|
* owner persona, recommended chain, action, risk).
|
|
16
16
|
*
|
|
17
|
+
* After enqueue, fires a signal.promoted lifecycle event when the triage
|
|
18
|
+
* confidence crosses SIGNAL_PROMOTION_THRESHOLD (matches the default
|
|
19
|
+
* tag-vocabulary auto_threshold), so subscribers can act on confidently
|
|
20
|
+
* typed signals without polling the queue.
|
|
21
|
+
*
|
|
17
22
|
* The agent — invoked manually or via session-start hook nudge — reads
|
|
18
23
|
* the resulting `.cx/intake/pending/<id>.json` and does the real
|
|
19
24
|
* comparison work (overlap with existing PRD? contradicts ADR? new RFC
|
|
20
|
-
* candidate?). The daemon never calls an LLM
|
|
21
|
-
* daemon cheap and predictable
|
|
25
|
+
* candidate?). The daemon never calls an LLM; the separation keeps the
|
|
26
|
+
* daemon cheap and predictable while model spend stays with the agent.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -30,11 +35,19 @@ import { createIntakeQueue } from './queue.mjs';
|
|
|
30
35
|
import { classifyRdIntake } from './classify.mjs';
|
|
31
36
|
import { detectCustomerMentions, linkSignalToCustomer, updateCustomerProfile } from '../embed/customer-profiles.mjs';
|
|
32
37
|
import { resolveActiveProfile } from '../profiles/loader.mjs';
|
|
38
|
+
import { emitBestEffort as emitRoleEvent } from '../roles/event-bus.mjs';
|
|
39
|
+
import { gatherAttribution, stampAttribution } from './attribution.mjs';
|
|
40
|
+
import { MANIFEST_REL_PATH } from './manifest.mjs';
|
|
33
41
|
|
|
34
42
|
const DEFAULT_RELATED_LIMIT = 5;
|
|
35
43
|
const EXCERPT_CHARS = 800;
|
|
36
44
|
const QUERY_CHARS = 500;
|
|
37
45
|
|
|
46
|
+
// Confidence at which a triage signal is considered "promoted" from
|
|
47
|
+
// uncertain to confidently classified. Mirrors the default tag-vocabulary
|
|
48
|
+
// auto_threshold.
|
|
49
|
+
const SIGNAL_PROMOTION_THRESHOLD = 0.70;
|
|
50
|
+
|
|
38
51
|
export async function prepareIntakeForIngestedFile({
|
|
39
52
|
rootDir,
|
|
40
53
|
ingestedFile,
|
|
@@ -85,7 +98,7 @@ export async function prepareIntakeForIngestedFile({
|
|
|
85
98
|
|
|
86
99
|
const droppedInfo = ingestedFile.droppedInfo ?? [];
|
|
87
100
|
|
|
88
|
-
const
|
|
101
|
+
const baseEntry = {
|
|
89
102
|
intake: {
|
|
90
103
|
sourcePath: ingestedFile.sourcePath,
|
|
91
104
|
outputPath: ingestedFile.outputPath,
|
|
@@ -101,10 +114,35 @@ export async function prepareIntakeForIngestedFile({
|
|
|
101
114
|
customers: customers.length ? customers : undefined,
|
|
102
115
|
};
|
|
103
116
|
|
|
117
|
+
// Capability detection mirrors inbox.mjs: stamp provenance onto the packet
|
|
118
|
+
// when the project's intake manifest is present (init scaffolds it for
|
|
119
|
+
// archetype-enabled profiles). Keeps non-archetype packets shape-identical
|
|
120
|
+
// to the pre-attribution behavior so downstream consumers see no diff.
|
|
121
|
+
|
|
122
|
+
const archetypeOn = existsSync(`${rootDir}/${MANIFEST_REL_PATH}`);
|
|
123
|
+
const entry = archetypeOn ? stampAttribution(baseEntry, gatherAttribution({ cwd: rootDir })) : baseEntry;
|
|
124
|
+
|
|
104
125
|
const intakeQueue = queue || createIntakeQueue(rootDir, env);
|
|
105
126
|
const result = intakeQueue.enqueue(entry);
|
|
106
127
|
const intakeId = result?.id || '';
|
|
107
128
|
|
|
129
|
+
// Promotion fires once a triage signal crosses the auto-classification
|
|
130
|
+
// threshold, giving subscribers a chance to act on a confidently typed
|
|
131
|
+
// signal without polling the intake queue.
|
|
132
|
+
|
|
133
|
+
if ((triage?.confidence ?? 0) >= SIGNAL_PROMOTION_THRESHOLD && triage?.intakeType && triage.intakeType !== 'unknown') {
|
|
134
|
+
emitRoleEvent('signal.promoted', {
|
|
135
|
+
summary: `${triage.intakeType} (${triage.confidence.toFixed(2)})`,
|
|
136
|
+
context: {
|
|
137
|
+
intakeId,
|
|
138
|
+
intakeType: triage.intakeType,
|
|
139
|
+
confidence: triage.confidence,
|
|
140
|
+
sourcePath: ingestedFile.sourcePath,
|
|
141
|
+
primaryOwner: triage.primaryOwner,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
108
146
|
// Update customer profiles with new evidence
|
|
109
147
|
for (const mention of customerMentions) {
|
|
110
148
|
try {
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/logging/rotate.mjs — shared file-rotation primitive.
|
|
3
|
+
*
|
|
4
|
+
* Two consumers need bounded log files: the trace writer
|
|
5
|
+
* (`.cx/traces/<date>.jsonl`, capped to avoid >100MB single-file commits
|
|
6
|
+
* that GitHub rejects) and the embed daemon stdout log
|
|
7
|
+
* (`~/.cx/runtime/embed-daemon.log`, capped so a stuck "Telemetry skipped"
|
|
8
|
+
* message can't fill the disk).
|
|
9
|
+
*
|
|
10
|
+
* `appendWithRotation` is for code paths that own the writes (the trace
|
|
11
|
+
* writer). `rotateIfOversized` is for externally-written files where Construct
|
|
12
|
+
* code only polls (the OS-supervisor stdout redirect — Construct never writes
|
|
13
|
+
* the embed log directly).
|
|
14
|
+
*
|
|
15
|
+
* Rotated segments are named `<base>.<n><ext>` where `n` starts at 1 and
|
|
16
|
+
* counts upward; with `gzip: true` segments get a `.gz` suffix and are
|
|
17
|
+
* compressed lazily on rotation. `maxSegments` (when set) drops the oldest
|
|
18
|
+
* `n>=maxSegments` segments on every rotation so the directory stays bounded.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, statSync, renameSync, readdirSync, readFileSync, unlinkSync, appendFileSync, createReadStream, createWriteStream, mkdirSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { createGzip, gunzipSync } from 'node:zlib';
|
|
24
|
+
import { pipeline } from 'node:stream/promises';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse `<base>.<ext>` from a full path. `app.log` → base=app, ext=.log.
|
|
28
|
+
* `2026-05-28.jsonl` → base=2026-05-28, ext=.jsonl.
|
|
29
|
+
*/
|
|
30
|
+
function splitBase(filePath) {
|
|
31
|
+
const dir = path.dirname(filePath);
|
|
32
|
+
const ext = path.extname(filePath);
|
|
33
|
+
const base = path.basename(filePath, ext);
|
|
34
|
+
return { dir, base, ext };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Enumerate existing rotated segments for a given file, sorted ascending by
|
|
39
|
+
* segment index. Includes both compressed and uncompressed forms.
|
|
40
|
+
*/
|
|
41
|
+
function listSegments(filePath) {
|
|
42
|
+
const { dir, base, ext } = splitBase(filePath);
|
|
43
|
+
if (!existsSync(dir)) return [];
|
|
44
|
+
|
|
45
|
+
// Pattern: <base>.<n>[<ext>][.gz]. Match any segment that starts with base
|
|
46
|
+
// and a digit suffix; tolerate both `.1.jsonl` and `.1.jsonl.gz`.
|
|
47
|
+
|
|
48
|
+
const pattern = new RegExp(`^${escapeRegex(base)}\\.(\\d+)${escapeRegex(ext)}(\\.gz)?$`);
|
|
49
|
+
const entries = readdirSync(dir)
|
|
50
|
+
.map((name) => {
|
|
51
|
+
const m = name.match(pattern);
|
|
52
|
+
return m ? { name, index: Number(m[1]), gzipped: !!m[2] } : null;
|
|
53
|
+
})
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.sort((a, b) => a.index - b.index);
|
|
56
|
+
return entries.map((e) => ({ ...e, fullPath: path.join(dir, e.name) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function escapeRegex(s) {
|
|
60
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Rename current → next-segment, optionally gzipping the previous tail. Old
|
|
65
|
+
* segments past `maxSegments` are deleted. Returns the new segment path or
|
|
66
|
+
* null when no rotation happened.
|
|
67
|
+
*/
|
|
68
|
+
export async function rotateIfOversized(filePath, { maxBytes, maxSegments = 0, gzip = false } = {}) {
|
|
69
|
+
if (!maxBytes || maxBytes <= 0) return null;
|
|
70
|
+
if (!existsSync(filePath)) return null;
|
|
71
|
+
|
|
72
|
+
let stat;
|
|
73
|
+
try { stat = statSync(filePath); }
|
|
74
|
+
catch { return null; }
|
|
75
|
+
if (!stat.isFile() || stat.size < maxBytes) return null;
|
|
76
|
+
|
|
77
|
+
const { dir, base, ext } = splitBase(filePath);
|
|
78
|
+
const existing = listSegments(filePath);
|
|
79
|
+
const nextIndex = (existing[existing.length - 1]?.index ?? 0) + 1;
|
|
80
|
+
|
|
81
|
+
// Move current → <base>.<nextIndex><ext>. Then optionally gzip the rotated
|
|
82
|
+
// file. Doing the rename first means a concurrent writer that reopens the
|
|
83
|
+
// path lands on a fresh file with no data loss.
|
|
84
|
+
|
|
85
|
+
const rotatedPlain = path.join(dir, `${base}.${nextIndex}${ext}`);
|
|
86
|
+
renameSync(filePath, rotatedPlain);
|
|
87
|
+
|
|
88
|
+
let finalPath = rotatedPlain;
|
|
89
|
+
if (gzip) {
|
|
90
|
+
finalPath = `${rotatedPlain}.gz`;
|
|
91
|
+
try {
|
|
92
|
+
await pipeline(createReadStream(rotatedPlain), createGzip(), createWriteStream(finalPath));
|
|
93
|
+
try { unlinkSync(rotatedPlain); } catch { /* already cleaned up */ }
|
|
94
|
+
} catch {
|
|
95
|
+
// Compression failed; leave the plain rotated segment in place rather
|
|
96
|
+
// than losing data.
|
|
97
|
+
|
|
98
|
+
finalPath = rotatedPlain;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pruneSegments(filePath, maxSegments);
|
|
103
|
+
return finalPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Drop oldest segments past `maxSegments`. With maxSegments=0, keep every
|
|
108
|
+
* segment (no pruning). Always preserves the active file (which has no
|
|
109
|
+
* numeric suffix and is therefore not in the segment list).
|
|
110
|
+
*/
|
|
111
|
+
export function pruneSegments(filePath, maxSegments = 0) {
|
|
112
|
+
if (!maxSegments || maxSegments <= 0) return [];
|
|
113
|
+
const segments = listSegments(filePath);
|
|
114
|
+
if (segments.length <= maxSegments) return [];
|
|
115
|
+
const drop = segments.slice(0, segments.length - maxSegments);
|
|
116
|
+
for (const seg of drop) {
|
|
117
|
+
try { unlinkSync(seg.fullPath); } catch { /* already gone */ }
|
|
118
|
+
}
|
|
119
|
+
return drop.map((s) => s.fullPath);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Append `line` to `filePath`, rotating beforehand if the file would exceed
|
|
124
|
+
* `maxBytes`. Creates the parent directory on first append.
|
|
125
|
+
*
|
|
126
|
+
* Synchronous + best-effort gzip. The cost of compressing on rotation is
|
|
127
|
+
* amortized over `maxBytes` worth of writes, so the rotation tail rarely
|
|
128
|
+
* blocks the calling code path. When gzip:false rotation is just a rename.
|
|
129
|
+
*/
|
|
130
|
+
export async function appendWithRotation(filePath, line, { maxBytes, maxSegments = 0, gzip = false } = {}) {
|
|
131
|
+
const dir = path.dirname(filePath);
|
|
132
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
133
|
+
|
|
134
|
+
if (maxBytes && maxBytes > 0 && existsSync(filePath)) {
|
|
135
|
+
try {
|
|
136
|
+
const stat = statSync(filePath);
|
|
137
|
+
// Rotate when this append would cross the cap, not after — keeps every
|
|
138
|
+
// single segment strictly under `maxBytes`.
|
|
139
|
+
|
|
140
|
+
if (stat.isFile() && stat.size + Buffer.byteLength(line) >= maxBytes) {
|
|
141
|
+
await rotateIfOversized(filePath, { maxBytes: 0, maxSegments, gzip });
|
|
142
|
+
}
|
|
143
|
+
} catch { /* fall through to plain append */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
appendFileSync(filePath, line);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Sync variant for hot paths that can't `await` rotation. Skips gzip (which
|
|
151
|
+
* requires streams) but still renames + prunes. Use this in trace writers and
|
|
152
|
+
* other latency-sensitive append loops.
|
|
153
|
+
*/
|
|
154
|
+
export function appendWithRotationSync(filePath, line, { maxBytes, maxSegments = 0 } = {}) {
|
|
155
|
+
const dir = path.dirname(filePath);
|
|
156
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
157
|
+
|
|
158
|
+
if (maxBytes && maxBytes > 0 && existsSync(filePath)) {
|
|
159
|
+
try {
|
|
160
|
+
const stat = statSync(filePath);
|
|
161
|
+
if (stat.isFile() && stat.size + Buffer.byteLength(line) >= maxBytes) {
|
|
162
|
+
const { dir: d, base, ext } = splitBase(filePath);
|
|
163
|
+
const existing = listSegments(filePath);
|
|
164
|
+
const nextIndex = (existing[existing.length - 1]?.index ?? 0) + 1;
|
|
165
|
+
renameSync(filePath, path.join(d, `${base}.${nextIndex}${ext}`));
|
|
166
|
+
pruneSegments(filePath, maxSegments);
|
|
167
|
+
}
|
|
168
|
+
} catch { /* fall through */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
appendFileSync(filePath, line);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Per-channel resource limits. Every Construct logger has a documented
|
|
176
|
+
* cap here so disk usage is bounded by design, not by hope. New loggers
|
|
177
|
+
* MUST be registered; the `appendBounded` helper refuses to write to an
|
|
178
|
+
* unregistered channel.
|
|
179
|
+
*
|
|
180
|
+
* Sizes are conservative defaults chosen for a single-developer machine.
|
|
181
|
+
* Override via env var on a per-channel basis when the workload genuinely
|
|
182
|
+
* exceeds these (CI runners, multi-tenant team installs, long-lived
|
|
183
|
+
* background daemons).
|
|
184
|
+
*/
|
|
185
|
+
export const LIMITS = {
|
|
186
|
+
// Trace shards under .cx/traces/<date>.jsonl — capped below GitHub's
|
|
187
|
+
// 100 MB single-file ceiling. Bead construct-1vv5.
|
|
188
|
+
|
|
189
|
+
trace: {
|
|
190
|
+
maxBytes: 100 * 1024 * 1024,
|
|
191
|
+
maxSegments: 0, // keep all history; rotation is for the size cap, not retention
|
|
192
|
+
gzip: false,
|
|
193
|
+
envOverride: 'CONSTRUCT_TRACE_MAX_MB',
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// OS-supervised stdout log at ~/.cx/runtime/embed-daemon.log. Bead
|
|
197
|
+
// construct-88i. Rotation is poll-style via the daemon scheduler.
|
|
198
|
+
|
|
199
|
+
'embed-daemon-log': {
|
|
200
|
+
maxBytes: 50 * 1024 * 1024,
|
|
201
|
+
maxSegments: 5,
|
|
202
|
+
gzip: true,
|
|
203
|
+
envOverride: 'CONSTRUCT_EMBED_LOG_MAX_MB',
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// Per-edit audit of file reads. High traffic in active sessions.
|
|
207
|
+
// ~/.cx/audit-reads.jsonl.
|
|
208
|
+
|
|
209
|
+
'audit-reads': {
|
|
210
|
+
maxBytes: 25 * 1024 * 1024,
|
|
211
|
+
maxSegments: 4,
|
|
212
|
+
gzip: true,
|
|
213
|
+
envOverride: 'CONSTRUCT_AUDIT_READS_MAX_MB',
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// Per-skill-call telemetry. ~/.cx/skill-calls.jsonl.
|
|
217
|
+
|
|
218
|
+
'skill-calls': {
|
|
219
|
+
maxBytes: 25 * 1024 * 1024,
|
|
220
|
+
maxSegments: 4,
|
|
221
|
+
gzip: true,
|
|
222
|
+
envOverride: 'CONSTRUCT_SKILL_CALLS_MAX_MB',
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
// Agent-dispatch log written by `lib/hooks/agent-tracker.mjs`. Path: ~/.cx/agent-log.jsonl.
|
|
226
|
+
|
|
227
|
+
'agent-log': {
|
|
228
|
+
maxBytes: 25 * 1024 * 1024,
|
|
229
|
+
maxSegments: 4,
|
|
230
|
+
gzip: true,
|
|
231
|
+
envOverride: 'CONSTRUCT_AGENT_LOG_MAX_MB',
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// Pending role invocations across all projects. ~/.cx/role-pending.jsonl.
|
|
235
|
+
|
|
236
|
+
'role-pending': {
|
|
237
|
+
maxBytes: 10 * 1024 * 1024,
|
|
238
|
+
maxSegments: 2,
|
|
239
|
+
gzip: true,
|
|
240
|
+
envOverride: 'CONSTRUCT_ROLE_PENDING_MAX_MB',
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// Intent verifications. ~/.cx/intent-verifications.jsonl.
|
|
244
|
+
|
|
245
|
+
'intent-verifications': {
|
|
246
|
+
maxBytes: 10 * 1024 * 1024,
|
|
247
|
+
maxSegments: 2,
|
|
248
|
+
gzip: true,
|
|
249
|
+
envOverride: 'CONSTRUCT_INTENT_VERIFICATIONS_MAX_MB',
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// Contract postcondition violations. ~/.cx/contract-violations.jsonl.
|
|
253
|
+
|
|
254
|
+
'contract-violations': {
|
|
255
|
+
maxBytes: 10 * 1024 * 1024,
|
|
256
|
+
maxSegments: 2,
|
|
257
|
+
gzip: true,
|
|
258
|
+
envOverride: 'CONSTRUCT_CONTRACT_VIOLATIONS_MAX_MB',
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
// Bash-output warning flags appended by the bash-output-logger hook on
|
|
262
|
+
// every Bash tool use over the size threshold. ~/.cx/warn-flags.txt.
|
|
263
|
+
|
|
264
|
+
'bash-warn-flags': {
|
|
265
|
+
maxBytes: 5 * 1024 * 1024,
|
|
266
|
+
maxSegments: 2,
|
|
267
|
+
gzip: false,
|
|
268
|
+
envOverride: 'CONSTRUCT_BASH_WARN_FLAGS_MAX_MB',
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
// Per-turn cost ledger written by the Stop hook. Cross-project (so the
|
|
272
|
+
// user has one place to see spend across every project) — each entry
|
|
273
|
+
// carries a projectId tag so readers can split by project. Path:
|
|
274
|
+
// ~/.cx/session-cost.jsonl.
|
|
275
|
+
|
|
276
|
+
'session-cost': {
|
|
277
|
+
maxBytes: 25 * 1024 * 1024,
|
|
278
|
+
maxSegments: 4,
|
|
279
|
+
gzip: true,
|
|
280
|
+
envOverride: 'CONSTRUCT_SESSION_COST_MAX_MB',
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
// Tamper-evident audit trail of every mutation Construct (or a dispatched
|
|
284
|
+
// subagent) makes. Project-scoped. Path: <project>/.cx/audit-trail.jsonl.
|
|
285
|
+
|
|
286
|
+
'audit-trail': {
|
|
287
|
+
maxBytes: 50 * 1024 * 1024,
|
|
288
|
+
maxSegments: 4,
|
|
289
|
+
gzip: true,
|
|
290
|
+
envOverride: 'CONSTRUCT_AUDIT_TRAIL_MAX_MB',
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
// Pending typecheck queue written by the edit-accumulator hook on every
|
|
294
|
+
// Edit/Write of a TS/JS file. Path: ~/.cx/pending-typecheck.txt.
|
|
295
|
+
|
|
296
|
+
'edit-accumulator': {
|
|
297
|
+
maxBytes: 5 * 1024 * 1024,
|
|
298
|
+
maxSegments: 2,
|
|
299
|
+
gzip: false,
|
|
300
|
+
envOverride: 'CONSTRUCT_EDIT_ACCUMULATOR_MAX_MB',
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the effective cap for a channel, honoring env-var overrides
|
|
306
|
+
* (interpreted as megabytes). Returns the byte budget; 0 disables rotation.
|
|
307
|
+
*/
|
|
308
|
+
function resolveCap(channel, env = process.env) {
|
|
309
|
+
const def = LIMITS[channel];
|
|
310
|
+
if (!def) throw new Error(`appendBounded: unknown channel "${channel}". Register it in lib/logging/rotate.mjs#LIMITS.`);
|
|
311
|
+
const raw = def.envOverride ? env[def.envOverride] : undefined;
|
|
312
|
+
if (raw === undefined) return def.maxBytes;
|
|
313
|
+
const mb = Number(raw);
|
|
314
|
+
if (!Number.isFinite(mb) || mb < 0) return def.maxBytes;
|
|
315
|
+
return Math.floor(mb * 1024 * 1024);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Append a line to `filePath` with rotation governed by the named channel's
|
|
320
|
+
* registered limit. Synchronous; renames the active file on overflow and
|
|
321
|
+
* prunes oldest segments past the channel's maxSegments. The registry
|
|
322
|
+
* guarantees nothing grows unbounded — every logger appending to a known
|
|
323
|
+
* channel inherits its documented disk budget.
|
|
324
|
+
*/
|
|
325
|
+
export function appendBounded(channel, filePath, line, env = process.env) {
|
|
326
|
+
const def = LIMITS[channel];
|
|
327
|
+
if (!def) throw new Error(`appendBounded: unknown channel "${channel}". Register it in lib/logging/rotate.mjs#LIMITS.`);
|
|
328
|
+
const maxBytes = resolveCap(channel, env);
|
|
329
|
+
return appendWithRotationSync(filePath, line, {
|
|
330
|
+
maxBytes,
|
|
331
|
+
maxSegments: def.maxSegments,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Last non-empty line written under the channel, looking across the active
|
|
337
|
+
* file and the rotated segments. Resolves the right value for chain-hash
|
|
338
|
+
* fields (`prev_line_hash`) on the first write after a rotation, where the
|
|
339
|
+
* active file is empty but the chain head lives at the tail of the most
|
|
340
|
+
* recent rotated segment (`<base>.<n><ext>` with the highest `n`, possibly
|
|
341
|
+
* `.gz`-suffixed).
|
|
342
|
+
*
|
|
343
|
+
* Sync. Reads the WHOLE most-recent gzipped segment in memory when it has
|
|
344
|
+
* to fall back to one; segment caps in LIMITS keep the worst case bounded
|
|
345
|
+
* (audit-trail tops out at 50 MB raw, ~5–10 MB gzipped).
|
|
346
|
+
*
|
|
347
|
+
* Returns the line WITHOUT its trailing newline, or null when no data has
|
|
348
|
+
* ever been written for the channel.
|
|
349
|
+
*/
|
|
350
|
+
export function readLastLineAcrossSegments(filePath) {
|
|
351
|
+
if (existsSync(filePath)) {
|
|
352
|
+
const lastFromActive = readLastLineOfFile(filePath);
|
|
353
|
+
if (lastFromActive !== null) return lastFromActive;
|
|
354
|
+
}
|
|
355
|
+
const segments = listSegments(filePath);
|
|
356
|
+
if (segments.length === 0) return null;
|
|
357
|
+
const newest = segments[segments.length - 1];
|
|
358
|
+
try {
|
|
359
|
+
const raw = readFileSync(newest.fullPath);
|
|
360
|
+
const decoded = newest.gzipped ? gunzipSync(raw) : raw;
|
|
361
|
+
return lastLineOfBuffer(decoded);
|
|
362
|
+
} catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function lastLineOfBuffer(buf) {
|
|
368
|
+
let end = buf.length;
|
|
369
|
+
while (end > 0 && buf[end - 1] === 0x0a) end -= 1;
|
|
370
|
+
if (end === 0) return null;
|
|
371
|
+
let start = end - 1;
|
|
372
|
+
while (start > 0 && buf[start - 1] !== 0x0a) start -= 1;
|
|
373
|
+
return buf.slice(start, end).toString('utf8');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function readLastLineOfFile(filePath) {
|
|
377
|
+
try {
|
|
378
|
+
if (statSync(filePath).size === 0) return null;
|
|
379
|
+
|
|
380
|
+
// Read the whole file and walk back to the last newline boundary. A
|
|
381
|
+
// bounded tail read (e.g. last 8 KB) would truncate JSONL records that
|
|
382
|
+
// exceed the window — the audit-trail's `content_hash` field can put a
|
|
383
|
+
// record over 50 KB — and a truncated string would break the
|
|
384
|
+
// prev_line_hash chain it feeds. Per-channel size caps in LIMITS bound
|
|
385
|
+
// the worst-case allocation here.
|
|
386
|
+
|
|
387
|
+
return lastLineOfBuffer(readFileSync(filePath));
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Re-export for convenient testing. */
|
|
394
|
+
export const __test = { listSegments, splitBase, resolveCap, readLastLineOfFile };
|
|
@@ -281,7 +281,7 @@ export function projectContext(args, { ROOT_DIR }) {
|
|
|
281
281
|
const has_context_file = contextInspection.hasFile;
|
|
282
282
|
const workflow = loadWorkflow(cwd);
|
|
283
283
|
const mergedEnv = loadConstructEnv({ rootDir: ROOT_DIR, homeDir: homedir(), env: process.env });
|
|
284
|
-
const registry = readJSON(join(ROOT_DIR, '
|
|
284
|
+
const registry = readJSON(join(ROOT_DIR, 'specialists', 'registry.json')) ?? {};
|
|
285
285
|
const executionContractModel = resolveExecutionContractModelMetadata({
|
|
286
286
|
envValues: mergedEnv,
|
|
287
287
|
registryModels: registry.models ?? {},
|
|
@@ -323,7 +323,7 @@ export function workflowStatus(args, { ROOT_DIR }) {
|
|
|
323
323
|
const workflow = loadWorkflow(cwd);
|
|
324
324
|
const workflowHealth = inspectWorkflowHealth(workflow, { cwd });
|
|
325
325
|
const mergedEnv = loadConstructEnv({ rootDir: ROOT_DIR, homeDir: homedir(), env: process.env });
|
|
326
|
-
const registry = readJSON(join(ROOT_DIR, '
|
|
326
|
+
const registry = readJSON(join(ROOT_DIR, 'specialists', 'registry.json')) ?? {};
|
|
327
327
|
const executionContractModel = resolveExecutionContractModelMetadata({
|
|
328
328
|
envValues: mergedEnv,
|
|
329
329
|
registryModels: registry.models ?? {},
|
|
@@ -70,7 +70,7 @@ function resolveSessionContext() {
|
|
|
70
70
|
|
|
71
71
|
export async function cxTrace(args, { ROOT_DIR }) {
|
|
72
72
|
const ctx = resolveSessionContext();
|
|
73
|
-
const registry = readJSON(join(ROOT_DIR, '
|
|
73
|
+
const registry = readJSON(join(ROOT_DIR, 'specialists', 'registry.json')) ?? {};
|
|
74
74
|
const registryModels = registry.models ?? {};
|
|
75
75
|
const currentModels = readCurrentModels(join(ROOT_DIR, '.env'), registryModels, process.env);
|
|
76
76
|
const route = typeof args.input === 'string' ? routeRequest({ request: args.input }) : null;
|
package/lib/opencode-config.mjs
CHANGED
|
@@ -47,8 +47,15 @@ export function sanitizeOpenCodeConfig(config) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export function writeOpenCodeConfig(config, file = findOpenCodeConfigPath()) {
|
|
50
|
+
// Treat the canonical home config as the default target; any explicit path
|
|
51
|
+
// outside the canonical home dir (e.g. project-scoped .opencode/config.json)
|
|
52
|
+
// is honored as-is so per-project sync writes to the right tree.
|
|
53
|
+
|
|
50
54
|
const canonical = getCanonicalOpenCodeConfigPath();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
const target = file && path.resolve(file) !== path.resolve(findOpenCodeConfigPath())
|
|
56
|
+
? file
|
|
57
|
+
: canonical;
|
|
58
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
59
|
+
fs.writeFileSync(target, `${JSON.stringify(sanitizeOpenCodeConfig(config), null, 2)}\n`, "utf8");
|
|
60
|
+
return target;
|
|
54
61
|
}
|