@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,375 @@
1
+ /**
2
+ * lib/tracking-surfaces.mjs — automated maintenance for project-tracking files.
3
+ *
4
+ * Owns WHAT to refresh; the hooks that call these functions own WHEN. Three
5
+ * surfaces are covered:
6
+ *
7
+ * - `.cx/context.md` + `.cx/context.json` — project state. Refreshed from
8
+ * recent observations, commits, and bead state at the end of each
9
+ * session via `refreshContextMd()`.
10
+ * - `plan.md` — local working plan. Bead-status table synced via
11
+ * `syncPlanFile()` (thin wrapper around `syncPlanWithBeads` in
12
+ * lib/beads-automation.mjs). When every referenced bead is closed,
13
+ * `archivePlanIfLanded()` stamps a landed footer, copies the plan into
14
+ * `.cx/handoffs/`, and resets `plan.md` to the standard template.
15
+ * - Beads — `closeBeadsFromPrRefs()` parses a merged PR's body for
16
+ * `Refs:` / `Closes:` / `Fixes:` lines and closes the named beads with
17
+ * a "Merged via PR #N (sha)" reason.
18
+ *
19
+ * Every export is best-effort: caught failures degrade to a structured
20
+ * result object, never throw. Caller hooks log to stderr via logHookFailure
21
+ * if they care about the failure mode.
22
+ */
23
+
24
+ import { spawnSync } from 'node:child_process';
25
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ import { extractBeadsFromPlan, syncPlanWithBeads } from './beads-automation.mjs';
29
+ import { buildPlanTemplate } from './project-init-shared.mjs';
30
+
31
+ const CONTEXT_RECENT_DAYS = 30;
32
+ const CONTEXT_MAX_ITEMS_PER_SECTION = 10;
33
+
34
+ const SECTION_HEADERS = {
35
+ activeWork: '## Active Work',
36
+ recentDecisions: '## Recent Decisions',
37
+ architectureNotes: '## Architecture Notes',
38
+ openQuestions: '## Open Questions',
39
+ };
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // context.md / context.json refresh
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Pull recent activity from observations, git, and beads into the managed
47
+ * sections of `.cx/context.md`. Preserves any non-managed sections the user
48
+ * has authored. Stamps `.cx/context.json` with the refresh timestamp and
49
+ * derived counts so a session-start digest can tell when content is fresh.
50
+ */
51
+ export async function refreshContextMd({ rootDir, now = new Date() } = {}) {
52
+ if (!rootDir) return { ok: false, reason: 'rootDir-required' };
53
+ const contextMdPath = join(rootDir, '.cx', 'context.md');
54
+ const contextJsonPath = join(rootDir, '.cx', 'context.json');
55
+ if (!existsSync(contextMdPath)) return { ok: false, reason: 'no-context-md' };
56
+
57
+ const cutoff = new Date(now.getTime() - CONTEXT_RECENT_DAYS * 24 * 60 * 60 * 1000);
58
+ const observations = collectRecentObservations(rootDir, cutoff);
59
+ const commits = collectRecentCommits(rootDir, cutoff);
60
+ const closedBeads = collectRecentlyClosedBeads(rootDir, cutoff);
61
+ const openBeads = collectInProgressBeads(rootDir);
62
+
63
+ const sections = {
64
+ activeWork: formatActiveWork(openBeads),
65
+ recentDecisions: formatRecentDecisions(observations, commits, closedBeads),
66
+ architectureNotes: formatArchitectureNotes(observations),
67
+ };
68
+
69
+ const before = readFileSync(contextMdPath, 'utf8');
70
+ const after = rewriteManagedSections(before, sections);
71
+ if (after !== before) {
72
+ writeFileSync(contextMdPath, after, 'utf8');
73
+ }
74
+
75
+ const json = readJsonSafe(contextJsonPath) ?? { format: 'json', source: 'construct' };
76
+ json.lastRefreshAt = now.toISOString();
77
+ json.activeWork = openBeads.map((b) => ({ id: b.id, title: b.title, status: b.status }));
78
+ json.recentDecisions = closedBeads.slice(0, CONTEXT_MAX_ITEMS_PER_SECTION).map((b) => ({
79
+ id: b.id,
80
+ title: b.title,
81
+ closedAt: b.closed || b.updated || b.updatedAt,
82
+ }));
83
+ json.architectureNotes = observations
84
+ .filter((o) => o.category === 'architecture' || o.category === 'decision')
85
+ .slice(0, CONTEXT_MAX_ITEMS_PER_SECTION)
86
+ .map((o) => ({ summary: o.summary, ts: o.ts }));
87
+ writeFileSync(contextJsonPath, JSON.stringify(json, null, 2) + '\n', 'utf8');
88
+
89
+ return {
90
+ ok: true,
91
+ changed: after !== before,
92
+ counts: {
93
+ activeWork: openBeads.length,
94
+ recentDecisions: closedBeads.length,
95
+ observations: observations.length,
96
+ commits: commits.length,
97
+ },
98
+ };
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // plan.md sync and archive
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Run the existing plan↔beads sync. Wrapper so the Stop hook calls one
107
+ * stable name even if the underlying implementation changes later.
108
+ */
109
+ export async function syncPlanFile({ rootDir }) {
110
+ if (!rootDir) return { ok: false, reason: 'rootDir-required' };
111
+ try {
112
+ const changed = await syncPlanWithBeads({ cwd: rootDir });
113
+ return { ok: true, changed };
114
+ } catch (err) {
115
+ return { ok: false, reason: 'sync-threw', error: err?.message };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * When every bead referenced in `plan.md` is closed AND the plan has not
121
+ * been touched in the last hour, stamp a "Landed" footer, copy the plan to
122
+ * `.cx/handoffs/<date>-plan-landed.md`, and reset `plan.md` to the template.
123
+ * Otherwise no-op.
124
+ */
125
+ export async function archivePlanIfLanded({ rootDir, now = new Date() } = {}) {
126
+ if (!rootDir) return { ok: false, reason: 'rootDir-required' };
127
+ const planPath = join(rootDir, 'plan.md');
128
+ if (!existsSync(planPath)) return { ok: false, reason: 'no-plan' };
129
+
130
+ let stat;
131
+ try { stat = statSync(planPath); }
132
+ catch { return { ok: false, reason: 'stat-failed' }; }
133
+ const ONE_HOUR_MS = 60 * 60 * 1000;
134
+ if (now.getTime() - stat.mtimeMs < ONE_HOUR_MS) {
135
+ return { ok: false, reason: 'plan-recently-touched' };
136
+ }
137
+
138
+ const content = readFileSync(planPath, 'utf8');
139
+ const beadRefs = extractBeadsFromPlan(content);
140
+ if (beadRefs.length === 0) return { ok: false, reason: 'no-bead-refs' };
141
+
142
+ const uniqueIds = [...new Set(beadRefs.map((b) => b.beadId))];
143
+ const statuses = uniqueIds.map((id) => readBeadStatus(rootDir, id));
144
+ if (statuses.some((s) => s !== 'closed')) {
145
+ return { ok: false, reason: 'beads-still-open' };
146
+ }
147
+
148
+ const handoffsDir = join(rootDir, '.cx', 'handoffs');
149
+ mkdirSync(handoffsDir, { recursive: true });
150
+ const date = now.toISOString().slice(0, 10);
151
+ const archivePath = join(handoffsDir, `${date}-plan-landed.md`);
152
+ const archiveBody = `# Landed plan — ${date}\n\nBeads closed: ${uniqueIds.join(', ')}\n\nArchived from \`plan.md\` on ${now.toISOString()} by the session-tracking-refresh hook.\n\n---\n\n${content}`;
153
+ writeFileSync(archivePath, archiveBody, 'utf8');
154
+
155
+ writeFileSync(planPath, buildPlanTemplate(), 'utf8');
156
+
157
+ return { ok: true, changed: true, archivePath, beadsClosed: uniqueIds };
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Bead closure from merged PR
162
+ // ---------------------------------------------------------------------------
163
+
164
+ const PR_REFS_PATTERN = /(?:Refs|Closes?|Fixes?|Resolves?)\s*:?\s*(?:#?\d+|(?:construct-[a-z0-9]+(?:\s*,\s*construct-[a-z0-9]+)*))/gi;
165
+ const BEAD_ID_PATTERN = /construct-[a-z0-9]+/g;
166
+
167
+ /**
168
+ * Parse a merged PR's body for bead references and close each open one.
169
+ * Idempotent: skips beads already closed. Best-effort: each `bd close`
170
+ * failure is captured in the result, no throws.
171
+ *
172
+ * @returns {Promise<{ ok: boolean, closed: string[], skipped: string[], errors: object[] }>}
173
+ */
174
+ export async function closeBeadsFromPrRefs({ prNumber, mergeCommitSha = '', cwd = process.cwd() } = {}) {
175
+ if (!prNumber) return { ok: false, reason: 'pr-number-required', closed: [], skipped: [], errors: [] };
176
+
177
+ const body = readPrBody(prNumber, cwd);
178
+ if (!body) return { ok: false, reason: 'pr-body-unavailable', closed: [], skipped: [], errors: [] };
179
+
180
+ const beadIds = new Set();
181
+ for (const match of body.match(PR_REFS_PATTERN) ?? []) {
182
+ for (const id of match.match(BEAD_ID_PATTERN) ?? []) beadIds.add(id);
183
+ }
184
+ if (beadIds.size === 0) return { ok: true, closed: [], skipped: [], errors: [] };
185
+
186
+ const closed = [];
187
+ const skipped = [];
188
+ const errors = [];
189
+ const reason = mergeCommitSha
190
+ ? `Merged via PR #${prNumber} (${mergeCommitSha.slice(0, 12)})`
191
+ : `Merged via PR #${prNumber}`;
192
+
193
+ for (const id of beadIds) {
194
+ const status = readBeadStatus(cwd, id);
195
+ if (status === 'closed') { skipped.push(id); continue; }
196
+ if (status === null) { errors.push({ id, reason: 'bd-show-failed' }); continue; }
197
+ try {
198
+ const result = spawnSync('bd', ['close', id, '--reason', reason], { cwd, encoding: 'utf8', timeout: 5000 });
199
+ if (result.status === 0) closed.push(id);
200
+ else errors.push({ id, reason: 'bd-close-failed', detail: (result.stderr || '').slice(0, 200) });
201
+ } catch (err) {
202
+ errors.push({ id, reason: 'bd-close-threw', detail: err?.message });
203
+ }
204
+ }
205
+
206
+ return { ok: true, closed, skipped, errors };
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Internal helpers
211
+ // ---------------------------------------------------------------------------
212
+
213
+ function collectRecentObservations(rootDir, cutoff) {
214
+ const dir = join(rootDir, '.cx', 'observations');
215
+ if (!existsSync(dir)) return [];
216
+ const out = [];
217
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
218
+ if (entry.isDirectory()) {
219
+ const subdir = join(dir, entry.name);
220
+ for (const file of safeReaddir(subdir)) {
221
+ const obs = readJsonSafe(join(subdir, file));
222
+ if (!obs) continue;
223
+ const ts = obs.timestamp || obs.ts;
224
+ if (!ts || new Date(ts) < cutoff) continue;
225
+ out.push({ ...obs, ts });
226
+ }
227
+ } else if (entry.name.endsWith('.json') || entry.name.endsWith('.jsonl')) {
228
+ const obs = readJsonSafe(join(dir, entry.name));
229
+ if (!obs) continue;
230
+ const ts = obs.timestamp || obs.ts;
231
+ if (!ts || new Date(ts) < cutoff) continue;
232
+ out.push({ ...obs, ts });
233
+ }
234
+ }
235
+ out.sort((a, b) => new Date(b.ts) - new Date(a.ts));
236
+ return out;
237
+ }
238
+
239
+ function collectRecentCommits(rootDir, cutoff) {
240
+ try {
241
+ const result = spawnSync('git', ['log', '--since', cutoff.toISOString(), '--pretty=format:%H|%s|%cI'], {
242
+ cwd: rootDir,
243
+ encoding: 'utf8',
244
+ timeout: 3000,
245
+ });
246
+ if (result.status !== 0) return [];
247
+ return result.stdout.split('\n').filter(Boolean).map((line) => {
248
+ const [sha, subject, committerDate] = line.split('|');
249
+ return { sha, subject, committerDate };
250
+ });
251
+ } catch {
252
+ return [];
253
+ }
254
+ }
255
+
256
+ function collectRecentlyClosedBeads(rootDir, cutoff) {
257
+ const beads = runBdListJson(rootDir, ['list', '--status', 'closed', '--json']);
258
+ return beads
259
+ .filter((b) => {
260
+ const ts = b.closed || b.updated || b.updatedAt;
261
+ return ts && new Date(ts) >= cutoff;
262
+ })
263
+ .sort((a, b) => new Date(b.closed || b.updated || b.updatedAt) - new Date(a.closed || a.updated || a.updatedAt));
264
+ }
265
+
266
+ function collectInProgressBeads(rootDir) {
267
+ return runBdListJson(rootDir, ['list', '--status', 'in_progress', '--json']);
268
+ }
269
+
270
+ function runBdListJson(rootDir, args) {
271
+ try {
272
+ const result = spawnSync('bd', args, { cwd: rootDir, encoding: 'utf8', timeout: 5000 });
273
+ if (result.status !== 0) return [];
274
+ const parsed = JSON.parse(result.stdout);
275
+ if (Array.isArray(parsed)) return parsed;
276
+ if (parsed && Array.isArray(parsed.issues)) return parsed.issues;
277
+ return [];
278
+ } catch {
279
+ return [];
280
+ }
281
+ }
282
+
283
+ function readBeadStatus(cwd, id) {
284
+ try {
285
+ const result = spawnSync('bd', ['show', id, '--json'], { cwd, encoding: 'utf8', timeout: 3000 });
286
+ if (result.status !== 0) return null;
287
+ const parsed = JSON.parse(result.stdout);
288
+ return parsed?.status || null;
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+
294
+ function readPrBody(prNumber, cwd) {
295
+ try {
296
+ const result = spawnSync('gh', ['pr', 'view', String(prNumber), '--json', 'body'], {
297
+ cwd,
298
+ encoding: 'utf8',
299
+ timeout: 10000,
300
+ });
301
+ if (result.status !== 0) return null;
302
+ const parsed = JSON.parse(result.stdout);
303
+ return parsed?.body || null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ function safeReaddir(dir) {
310
+ try { return readdirSync(dir); }
311
+ catch { return []; }
312
+ }
313
+
314
+ function readJsonSafe(path) {
315
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
316
+ catch { return null; }
317
+ }
318
+
319
+ function formatActiveWork(openBeads) {
320
+ if (openBeads.length === 0) return '_None in progress._';
321
+ return openBeads
322
+ .slice(0, CONTEXT_MAX_ITEMS_PER_SECTION)
323
+ .map((b) => `- **${b.id}** · ${b.title}`)
324
+ .join('\n');
325
+ }
326
+
327
+ function formatRecentDecisions(observations, commits, closedBeads) {
328
+ const items = [];
329
+ for (const bead of closedBeads.slice(0, CONTEXT_MAX_ITEMS_PER_SECTION)) {
330
+ items.push(`- closed **${bead.id}** · ${bead.title}`);
331
+ }
332
+ for (const commit of commits.slice(0, CONTEXT_MAX_ITEMS_PER_SECTION)) {
333
+ items.push(`- commit \`${commit.sha.slice(0, 10)}\` — ${commit.subject}`);
334
+ }
335
+ for (const obs of observations.filter((o) => o.category === 'decision').slice(0, CONTEXT_MAX_ITEMS_PER_SECTION)) {
336
+ items.push(`- decision — ${obs.summary}`);
337
+ }
338
+ if (items.length === 0) return '_No recent decisions captured._';
339
+ return items.slice(0, CONTEXT_MAX_ITEMS_PER_SECTION * 2).join('\n');
340
+ }
341
+
342
+ function formatArchitectureNotes(observations) {
343
+ const arch = observations
344
+ .filter((o) => o.category === 'architecture' || /architect|contract|boundary|invariant/i.test(o.summary || ''))
345
+ .slice(0, CONTEXT_MAX_ITEMS_PER_SECTION);
346
+ if (arch.length === 0) return '_No new architecture notes._';
347
+ return arch.map((o) => `- ${o.summary}`).join('\n');
348
+ }
349
+
350
+ function rewriteManagedSections(content, sections) {
351
+ let out = content;
352
+ for (const [key, header] of Object.entries(SECTION_HEADERS)) {
353
+ if (!(key in sections)) continue;
354
+ const body = sections[key];
355
+ out = replaceSection(out, header, body);
356
+ }
357
+ return out;
358
+ }
359
+
360
+ function replaceSection(content, header, body) {
361
+ const lines = content.split('\n');
362
+ const startIdx = lines.findIndex((l) => l.trim() === header);
363
+ if (startIdx === -1) return content;
364
+
365
+ let endIdx = lines.length;
366
+ for (let i = startIdx + 1; i < lines.length; i++) {
367
+ if (lines[i].startsWith('## ') && lines[i].trim() !== header) {
368
+ endIdx = i;
369
+ break;
370
+ }
371
+ }
372
+ const before = lines.slice(0, startIdx + 1);
373
+ const after = lines.slice(endIdx);
374
+ return [...before, '', body, '', ...after].join('\n');
375
+ }
@@ -26,15 +26,29 @@
26
26
  * memory.written — durable memory record persisted
27
27
  */
28
28
 
29
- import { existsSync, mkdirSync, appendFileSync } from 'node:fs';
29
+ import { existsSync, mkdirSync } from 'node:fs';
30
30
  import { randomBytes } from 'node:crypto';
31
31
  import path from 'node:path';
32
32
 
33
33
  import { createTelemetryClient } from '../telemetry/client.mjs';
34
34
  import { ensureCxDir } from '../project-init-shared.mjs';
35
+ import { appendWithRotationSync } from '../logging/rotate.mjs';
35
36
 
36
37
  const TRACE_SUBDIR = '.cx/traces';
37
38
 
39
+ // GitHub rejects single files > 100 MB. Keep every trace shard strictly
40
+ // under 100 MB so an accidental commit + push doesn't brick the repo. Cap
41
+ // is configurable via CONSTRUCT_TRACE_MAX_MB; default 100. Override of 0
42
+ // disables rotation entirely (not recommended).
43
+
44
+ function traceMaxBytes(env = process.env) {
45
+ const raw = env.CONSTRUCT_TRACE_MAX_MB;
46
+ if (raw === undefined) return 100 * 1024 * 1024;
47
+ const parsed = Number(raw);
48
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
49
+ return Math.floor(parsed * 1024 * 1024);
50
+ }
51
+
38
52
  export const TRACE_EVENT_TYPES = [
39
53
  'daemon.started',
40
54
  'daemon.heartbeat',
@@ -168,7 +182,10 @@ export function emitTraceEvent({
168
182
  metadata,
169
183
  createdAt: new Date().toISOString(),
170
184
  };
171
- appendFileSync(path.join(dir, `${todayShard()}.jsonl`), `${JSON.stringify(event)}\n`, 'utf8');
185
+ const shardPath = path.join(dir, `${todayShard()}.jsonl`);
186
+ appendWithRotationSync(shardPath, `${JSON.stringify(event)}\n`, {
187
+ maxBytes: traceMaxBytes(env),
188
+ });
172
189
 
173
190
  // Fire-and-forget export to the configured remote adapter. Silent no-op when not configured.
174
191
  exportToRemote(event, env, rootDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.11",
3
+ "version": "1.0.14",
4
4
  "type": "module",
5
5
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
6
6
  "bin": {
@@ -27,6 +27,10 @@
27
27
  "engines": {
28
28
  "node": ">=20"
29
29
  },
30
+ "workspaces": [
31
+ "apps/*",
32
+ "packages/*"
33
+ ],
30
34
  "license": "Apache-2.0",
31
35
  "publishConfig": {
32
36
  "access": "public"
@@ -42,7 +46,7 @@
42
46
  "scripts": {
43
47
  "mcp": "node lib/mcp/server.mjs",
44
48
  "test": "node scripts/run-tests.mjs",
45
- "test:functional": "node --test --test-timeout=30000 tests/functional/*.functional.test.mjs",
49
+ "test:functional": "node --test --test-timeout=120000 --test-concurrency=1 tests/functional/*.functional.test.mjs",
46
50
  "test:unit": "node scripts/run-tests.mjs --exclude=tests/functional",
47
51
  "docs:init": "node lib/init-docs.mjs --yes",
48
52
  "docs:update": "node ./bin/construct docs:update",
@@ -14,19 +14,7 @@
14
14
  }
15
15
  ],
16
16
  "id": "session:start",
17
- "description": "Load prior project context and git status at session start"
18
- },
19
- {
20
- "matcher": "*",
21
- "hooks": [
22
- {
23
- "type": "command",
24
- "command": "node \"$HOME/.construct/lib/hooks/env-check.mjs\"",
25
- "timeout": 5
26
- }
27
- ],
28
- "id": "session:env-check",
29
- "description": "Warn about missing required env vars at session open"
17
+ "description": "Load prior project context, git status, and missing-env-var notice at session start"
30
18
  }
31
19
  ],
32
20
  "PreToolUse": [
@@ -215,19 +203,6 @@
215
203
  "id": "post:edit:dep-audit",
216
204
  "description": "Audit dependencies for vulnerabilities after manifest changes"
217
205
  },
218
- {
219
- "matcher": "Read",
220
- "hooks": [
221
- {
222
- "type": "command",
223
- "command": "node \"$HOME/.construct/lib/hooks/read-tracker.mjs\"",
224
- "timeout": 3,
225
- "async": true
226
- }
227
- ],
228
- "id": "post:read-tracker",
229
- "description": "Record file content hash after Read for staleness detection in edit-guard"
230
- },
231
206
  {
232
207
  "matcher": "Read",
233
208
  "hooks": [
@@ -239,7 +214,7 @@
239
214
  }
240
215
  ],
241
216
  "id": "post:audit-reads",
242
- "description": "Tamper-evident chain log of every Read tool call. Off by default enable with CONSTRUCT_AUDIT_READS=1."
217
+ "description": "Update file-hashes.json for edit-guard staleness detection on every Read; optionally write a tamper-evident audit chain when CONSTRUCT_AUDIT_READS=1."
243
218
  },
244
219
  {
245
220
  "matcher": "mcp__.*",
@@ -305,6 +280,19 @@
305
280
  "id": "post:bash:post-merge-docs-check",
306
281
  "description": "Emit doc-drift events when a merge lands without matching docs/CHANGELOG updates"
307
282
  },
283
+ {
284
+ "matcher": "Bash",
285
+ "hooks": [
286
+ {
287
+ "type": "command",
288
+ "command": "node \"$HOME/.construct/lib/hooks/post-merge-tracking.mjs\"",
289
+ "timeout": 5,
290
+ "async": true
291
+ }
292
+ ],
293
+ "id": "post:bash:post-merge-tracking",
294
+ "description": "After `gh pr merge` succeeds, parse the PR body's Refs/Closes/Fixes lines and close every referenced construct-XXX bead so plan.md and .cx/context.md don't drift behind merged work"
295
+ },
308
296
  {
309
297
  "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash",
310
298
  "hooks": [
@@ -473,6 +461,19 @@
473
461
  ],
474
462
  "id": "stop:auto-reflect",
475
463
  "description": "Deterministic session-end reflect: extracts a transcript summary into .cx/observations (500ms hard budget; CONSTRUCT_REFLECT_AUTO=off to disable)"
464
+ },
465
+ {
466
+ "matcher": "*",
467
+ "hooks": [
468
+ {
469
+ "type": "command",
470
+ "command": "node \"$HOME/.construct/lib/hooks/session-tracking-refresh.mjs\"",
471
+ "timeout": 5,
472
+ "async": true
473
+ }
474
+ ],
475
+ "id": "stop:tracking-refresh",
476
+ "description": "Auto-refresh .cx/context.{md,json} from recent observations + commits + closed beads; sync plan.md bead-status table; archive plan.md to .cx/handoffs/ when all referenced beads are closed"
476
477
  }
477
478
  ],
478
479
  "UserPromptSubmit": [