@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,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
|
+
}
|
package/lib/worker/trace.mjs
CHANGED
|
@@ -26,15 +26,29 @@
|
|
|
26
26
|
* memory.written — durable memory record persisted
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { existsSync, mkdirSync
|
|
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
|
-
|
|
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.
|
|
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=
|
|
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
|
|
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": "
|
|
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": [
|