@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.
Files changed (90) hide show
  1. package/README.md +55 -18
  2. package/bin/construct +259 -11
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/lib/auto-docs.mjs +23 -22
  5. package/lib/cli-commands.mjs +2 -0
  6. package/lib/dashboard-static.mjs +7 -4
  7. package/lib/doc-stamp.mjs +16 -0
  8. package/lib/docs-verify.mjs +1 -8
  9. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  10. package/lib/embed/daemon.mjs +20 -0
  11. package/lib/embed/docs-lifecycle.mjs +19 -0
  12. package/lib/embed/inbox.mjs +85 -2
  13. package/lib/embed/recommendation-store.mjs +29 -0
  14. package/lib/gates-audit.mjs +18 -12
  15. package/lib/hooks/_lib/input.mjs +52 -0
  16. package/lib/hooks/adaptive-lint.mjs +4 -0
  17. package/lib/hooks/agent-tracker.mjs +59 -15
  18. package/lib/hooks/audit-reads.mjs +83 -42
  19. package/lib/hooks/audit-trail.mjs +28 -18
  20. package/lib/hooks/bash-output-logger.mjs +8 -2
  21. package/lib/hooks/block-no-verify.mjs +4 -0
  22. package/lib/hooks/ci-status-check.mjs +4 -0
  23. package/lib/hooks/comment-lint.mjs +6 -4
  24. package/lib/hooks/config-protection.mjs +4 -0
  25. package/lib/hooks/context-watch.mjs +4 -0
  26. package/lib/hooks/context-window-recovery.mjs +4 -0
  27. package/lib/hooks/dep-audit.mjs +12 -5
  28. package/lib/hooks/doc-coupling-check.mjs +5 -1
  29. package/lib/hooks/edit-accumulator.mjs +25 -10
  30. package/lib/hooks/edit-error-recovery.mjs +4 -0
  31. package/lib/hooks/edit-guard.mjs +4 -0
  32. package/lib/hooks/guard-bash.mjs +4 -0
  33. package/lib/hooks/mcp-audit.mjs +4 -0
  34. package/lib/hooks/mcp-health-check.mjs +4 -0
  35. package/lib/hooks/model-fallback.mjs +7 -11
  36. package/lib/hooks/policy-engine.mjs +4 -0
  37. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  38. package/lib/hooks/post-merge-tracking.mjs +82 -0
  39. package/lib/hooks/pre-compact.mjs +4 -0
  40. package/lib/hooks/pre-push-gate.mjs +84 -231
  41. package/lib/hooks/proactive-activation.mjs +5 -2
  42. package/lib/hooks/readme-age-check.mjs +4 -0
  43. package/lib/hooks/registry-sync.mjs +35 -20
  44. package/lib/hooks/rule-verifier.mjs +3 -0
  45. package/lib/hooks/scan-secrets.mjs +4 -0
  46. package/lib/hooks/session-optimize.mjs +4 -0
  47. package/lib/hooks/session-reflect.mjs +4 -0
  48. package/lib/hooks/session-start.mjs +48 -1
  49. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  50. package/lib/hooks/stop-notify.mjs +13 -2
  51. package/lib/hooks/stop-typecheck.mjs +4 -0
  52. package/lib/hooks/test-watch.mjs +8 -4
  53. package/lib/init-unified.mjs +90 -23
  54. package/lib/intake/attribution.mjs +77 -0
  55. package/lib/intake/intake-config.mjs +3 -0
  56. package/lib/intake/manifest.mjs +107 -0
  57. package/lib/intake/poll-lock.mjs +136 -0
  58. package/lib/intake/prepare.mjs +42 -4
  59. package/lib/logging/rotate.mjs +394 -0
  60. package/lib/mcp/tools/project.mjs +2 -2
  61. package/lib/mcp/tools/telemetry.mjs +1 -1
  62. package/lib/opencode-config.mjs +10 -3
  63. package/lib/orchestration/routing-tables.mjs +176 -0
  64. package/lib/orchestration-policy.mjs +18 -106
  65. package/lib/parity.mjs +48 -7
  66. package/lib/profiles/lifecycle.mjs +19 -1
  67. package/lib/project-init-shared.mjs +11 -5
  68. package/lib/project-root.mjs +104 -0
  69. package/lib/roles/catalog.mjs +1 -1
  70. package/lib/roles/event-bus.mjs +29 -9
  71. package/lib/roles/router.mjs +8 -7
  72. package/lib/server/index.mjs +31 -9
  73. package/lib/server/static/index.html +1 -15
  74. package/lib/specialist-contracts-enforce.mjs +24 -10
  75. package/lib/status.mjs +1 -1
  76. package/lib/storage/backup.mjs +2 -2
  77. package/lib/sync/skill-frontmatter.mjs +71 -0
  78. package/lib/telemetry/intent-verifications.mjs +16 -3
  79. package/lib/telemetry/skill-calls.mjs +12 -3
  80. package/lib/tracking-surfaces.mjs +375 -0
  81. package/lib/worker/trace.mjs +19 -2
  82. package/package.json +6 -2
  83. package/platforms/claude/settings.template.json +28 -27
  84. package/scripts/sync-specialists.mjs +296 -94
  85. package/specialists/registry.json +158 -13
  86. package/lib/hooks/env-check.mjs +0 -83
  87. package/lib/hooks/read-tracker.mjs +0 -61
  88. package/lib/policy/unified-gates.mjs +0 -96
  89. package/lib/server/static/assets/index-ab25c707.js +0 -70
  90. 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
+ }
@@ -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
- * Called by `InboxWatcher.poll()` after each successful file ingestion.
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. This separation keeps the
21
- * daemon cheap and predictable; the model spend stays with the agent.
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 entry = {
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, 'agents', 'registry.json')) ?? {};
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, 'agents', 'registry.json')) ?? {};
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, 'agents', 'registry.json')) ?? {};
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;
@@ -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
- fs.mkdirSync(path.dirname(canonical), { recursive: true });
52
- fs.writeFileSync(canonical, `${JSON.stringify(sanitizeOpenCodeConfig(config), null, 2)}\n`, "utf8");
53
- return canonical;
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
  }