@geraldmaron/construct 1.0.15 → 1.0.16
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 +15 -2
- package/bin/construct +144 -81
- package/db/schema/010_cx_scores.sql +51 -0
- package/lib/cli-commands.mjs +280 -146
- package/lib/contracts/validate.mjs +20 -10
- package/lib/contracts/violation-log.mjs +27 -12
- package/lib/hooks/stop-notify.mjs +7 -0
- package/lib/install/first-invocation.mjs +5 -1
- package/lib/intake/prepare.mjs +22 -4
- package/lib/integrations/intake-integrations.mjs +28 -2
- package/lib/mcp/tools/telemetry.mjs +30 -0
- package/lib/orchestration-policy.mjs +15 -0
- package/lib/roles/gateway.mjs +30 -1
- package/lib/scheduler/index.mjs +24 -4
- package/lib/server/insights.mjs +12 -0
- package/lib/setup.mjs +54 -9
- package/lib/update.mjs +31 -3
- package/lib/upgrade.mjs +31 -4
- package/package.json +1 -1
- package/personas/construct.md +2 -0
- package/platforms/claude/CLAUDE.md +43 -15
- package/scripts/sync-specialists.mjs +20 -2
|
@@ -157,16 +157,24 @@ export function findContract({ producer, consumer, id, contractsPath = CONTRACTS
|
|
|
157
157
|
* itself a contract violation (MCP best-practice 2025 — no silent skip on
|
|
158
158
|
* tool-argument omission).
|
|
159
159
|
*/
|
|
160
|
-
export function validateHandoff({
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
160
|
+
export function validateHandoff(opts = {}) {
|
|
161
|
+
const {
|
|
162
|
+
producer,
|
|
163
|
+
consumer,
|
|
164
|
+
id,
|
|
165
|
+
artifact,
|
|
166
|
+
packet,
|
|
167
|
+
contractsPath = CONTRACTS_PATH,
|
|
168
|
+
repoRoot = REPO_ROOT,
|
|
169
|
+
enforcement = 'block',
|
|
170
|
+
} = opts;
|
|
171
|
+
// Distinguish caller-supplied repoRoot from the default. Only an explicit
|
|
172
|
+
// repoRoot routes the violation log write — the default REPO_ROOT is for
|
|
173
|
+
// joining schema paths in this module, and forwarding it to the logger
|
|
174
|
+
// would override the cwd-based project-scope resolver that the test
|
|
175
|
+
// suite (and runtime project state) relies on.
|
|
176
|
+
const explicitRepoRoot = Object.prototype.hasOwnProperty.call(opts, 'repoRoot') ? repoRoot : undefined;
|
|
177
|
+
|
|
170
178
|
const errors = [];
|
|
171
179
|
const postconditionFailures = [];
|
|
172
180
|
|
|
@@ -193,6 +201,7 @@ export function validateHandoff({
|
|
|
193
201
|
logViolation(`${producer}->${consumer}`, 'output', errors, packet ?? artifact, {
|
|
194
202
|
verdict: postconditionFailures.length > 0 ? 'BLOCKED_CONTRACT' : 'CONTRACT_VIOLATION',
|
|
195
203
|
postconditionFailures,
|
|
204
|
+
repoRoot: explicitRepoRoot,
|
|
196
205
|
});
|
|
197
206
|
return enforcement === 'block'
|
|
198
207
|
? { ok: false, status: 'BLOCKED_CONTRACT', errors: [`no contract found for ${producer}→${consumer}${id ? ` id=${id}` : ''}`, ...errors], contract: null }
|
|
@@ -247,6 +256,7 @@ export function validateHandoff({
|
|
|
247
256
|
logViolation(contract.id, 'output', errors, packet ?? artifact, {
|
|
248
257
|
verdict,
|
|
249
258
|
postconditionFailures,
|
|
259
|
+
repoRoot: explicitRepoRoot,
|
|
250
260
|
});
|
|
251
261
|
|
|
252
262
|
if (enforcement === 'block') return { ok: false, status: 'BLOCKED_CONTRACT', errors, contract };
|
|
@@ -39,10 +39,22 @@ const LAST_AGENT = join(CX_DIR, 'last-agent.json');
|
|
|
39
39
|
// cwd/HOME changes inside the same process (tests, harness reuse) route
|
|
40
40
|
// correctly.
|
|
41
41
|
|
|
42
|
-
function logFile() {
|
|
42
|
+
function logFile(repoRoot) {
|
|
43
|
+
// Explicit repoRoot bypasses the marker-walk in resolveProjectScope: the
|
|
44
|
+
// caller has already decided the scope (test fixture, sandbox, embedded
|
|
45
|
+
// worktree). Without this, a fixture without a `.cx/` marker falls back
|
|
46
|
+
// to ~/.cx/ and pollutes the developer's home log.
|
|
47
|
+
if (repoRoot) return join(repoRoot, '.cx', 'contract-violations.jsonl');
|
|
43
48
|
return resolveProjectScopedPath('contract-violations.jsonl', { ensureDir: false });
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
// Exposed for diagnostic surfaces (e.g. doctor) that need to print the
|
|
52
|
+
// real path. Resolves on every call so cwd/HOME changes inside the same
|
|
53
|
+
// process route correctly. `repoRoot` overrides cwd-based resolution so
|
|
54
|
+
// callers running against a fixture (tests, sandboxes) can isolate their
|
|
55
|
+
// log writes from the developer's project log.
|
|
56
|
+
export function violationLogPath(repoRoot) { return logFile(repoRoot); }
|
|
57
|
+
|
|
46
58
|
function sha256(input) { return createHash('sha256').update(input).digest('hex'); }
|
|
47
59
|
|
|
48
60
|
function readLastAgent() {
|
|
@@ -50,44 +62,47 @@ function readLastAgent() {
|
|
|
50
62
|
catch { return 'construct'; }
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
function readTailRecord() {
|
|
54
|
-
const file = logFile();
|
|
65
|
+
function readTailRecord(repoRoot) {
|
|
66
|
+
const file = logFile(repoRoot);
|
|
55
67
|
const lastLine = readLastLineAcrossSegments(file);
|
|
56
68
|
if (!lastLine) return null;
|
|
57
69
|
try { return JSON.parse(lastLine); }
|
|
58
70
|
catch { return null; }
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
function readPrevLineHash() {
|
|
62
|
-
const file = logFile();
|
|
73
|
+
function readPrevLineHash(repoRoot) {
|
|
74
|
+
const file = logFile(repoRoot);
|
|
63
75
|
const lastLine = readLastLineAcrossSegments(file);
|
|
64
76
|
return lastLine ? sha256(lastLine) : null;
|
|
65
77
|
}
|
|
66
78
|
|
|
67
|
-
function nextSequence() {
|
|
68
|
-
const tail = readTailRecord();
|
|
79
|
+
function nextSequence(repoRoot) {
|
|
80
|
+
const tail = readTailRecord(repoRoot);
|
|
69
81
|
const prior = Number.isInteger(tail?.sequence) ? tail.sequence : 0;
|
|
70
82
|
return prior + 1;
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
/**
|
|
74
86
|
* Append a violation record. Best-effort: file I/O failures are swallowed
|
|
75
|
-
* so logging never crashes the caller.
|
|
87
|
+
* so logging never crashes the caller. `extra.repoRoot` (extracted, not
|
|
88
|
+
* persisted) routes the write to a fixture log when callers run against
|
|
89
|
+
* a tmpdir — required for test isolation.
|
|
76
90
|
*/
|
|
77
91
|
export function logViolation(contractId, direction, missing, packet, extra = {}) {
|
|
78
92
|
try {
|
|
79
|
-
const
|
|
93
|
+
const { repoRoot, ...persistedExtra } = extra || {};
|
|
94
|
+
const file = logFile(repoRoot);
|
|
80
95
|
mkdirSync(dirname(file), { recursive: true });
|
|
81
96
|
const record = {
|
|
82
97
|
ts: new Date().toISOString(),
|
|
83
|
-
sequence: nextSequence(),
|
|
98
|
+
sequence: nextSequence(repoRoot),
|
|
84
99
|
agent: readLastAgent(),
|
|
85
100
|
contractId,
|
|
86
101
|
direction,
|
|
87
102
|
missing,
|
|
88
103
|
packet_keys: packet && typeof packet === 'object' ? Object.keys(packet) : null,
|
|
89
|
-
prev_line_hash: readPrevLineHash(),
|
|
90
|
-
...
|
|
104
|
+
prev_line_hash: readPrevLineHash(repoRoot),
|
|
105
|
+
...persistedExtra,
|
|
91
106
|
};
|
|
92
107
|
appendBounded('contract-violations', file, JSON.stringify(record) + '\n');
|
|
93
108
|
} catch { /* logging is best-effort */ }
|
|
@@ -26,6 +26,7 @@ import { listSessions, loadSession, updateSession } from '../session-store.mjs';
|
|
|
26
26
|
import { captureSessionArtifacts } from '../artifact-capture.mjs';
|
|
27
27
|
import { appendSessionStats } from '../memory-stats.mjs';
|
|
28
28
|
import { estimateUsageCost } from '../telemetry/model-pricing-catalog.mjs';
|
|
29
|
+
import { flushReadTrackerDeltas } from '../read-tracker-store.mjs';
|
|
29
30
|
|
|
30
31
|
function loadTranscriptCheckpoints(checkpointPath) {
|
|
31
32
|
try {
|
|
@@ -140,6 +141,12 @@ let raw = '';
|
|
|
140
141
|
try { raw = readFileSync(0, 'utf8'); } catch { /* non-critical */ }
|
|
141
142
|
if (raw) process.stdout.write(raw);
|
|
142
143
|
|
|
144
|
+
// PostToolUse Read hooks append delta lines instead of rewriting the full
|
|
145
|
+
// read-tracker JSON on every call. Stop fires once per session end; this
|
|
146
|
+
// is the canonical point to fold accumulated deltas back into the JSON.
|
|
147
|
+
// Already wired into pre-compact + audit-reads; Stop is the third anchor.
|
|
148
|
+
try { flushReadTrackerDeltas({ env: process.env }); } catch { /* non-critical */ }
|
|
149
|
+
|
|
143
150
|
const home = homedir();
|
|
144
151
|
const tsResultPath = join(home, '.cx', 'ts-result.txt');
|
|
145
152
|
const warnFlagsPath = join(home, '.cx', 'warn-flags.txt');
|
|
@@ -24,8 +24,12 @@ import { parseEnvFile, writeEnvValues, getUserEnvPath } from '../env-config.mjs'
|
|
|
24
24
|
import { probeAll, formatProbe } from '../bootstrap/resources.mjs';
|
|
25
25
|
|
|
26
26
|
const BOOTSTRAP_CHECKED_KEY = 'BOOTSTRAP_CHECKED';
|
|
27
|
+
// 'install' is the actual handler name in bin/construct's dispatch map;
|
|
28
|
+
// 'setup' is kept for backwards-compat in case anything still routes via
|
|
29
|
+
// that key. Without 'install' the probe will re-prompt the user while
|
|
30
|
+
// they're already running the very command meant to satisfy it.
|
|
27
31
|
const SKIP_COMMANDS = new Set([
|
|
28
|
-
'hook', 'setup', 'uninstall', 'version', 'help', 'completions', 'doctor',
|
|
32
|
+
'hook', 'install', 'setup', 'uninstall', 'version', 'help', 'completions', 'doctor', 'upgrade', 'update',
|
|
29
33
|
]);
|
|
30
34
|
|
|
31
35
|
export function shouldSkipProbe({ command, env = process.env, stdin = process.stdin } = {}) {
|
package/lib/intake/prepare.mjs
CHANGED
|
@@ -32,12 +32,13 @@ import path from 'node:path';
|
|
|
32
32
|
import { buildHybridSearchResultsAsync } from '../storage/hybrid-query.mjs';
|
|
33
33
|
import { suggestDocsLaneForFile } from '../docs-routing.mjs';
|
|
34
34
|
import { createIntakeQueue } from './queue.mjs';
|
|
35
|
-
import { classifyRdIntake } from './classify.mjs';
|
|
35
|
+
import { classifyRdIntake, suggestTags } from './classify.mjs';
|
|
36
36
|
import { detectCustomerMentions, linkSignalToCustomer, updateCustomerProfile } from '../embed/customer-profiles.mjs';
|
|
37
37
|
import { resolveActiveProfile } from '../profiles/loader.mjs';
|
|
38
38
|
import { emitBestEffort as emitRoleEvent } from '../roles/event-bus.mjs';
|
|
39
39
|
import { gatherAttribution, stampAttribution } from './attribution.mjs';
|
|
40
40
|
import { MANIFEST_REL_PATH } from './manifest.mjs';
|
|
41
|
+
import { loadVocabulary } from '../tags/vocabulary.mjs';
|
|
41
42
|
|
|
42
43
|
const DEFAULT_RELATED_LIMIT = 5;
|
|
43
44
|
const EXCERPT_CHARS = 800;
|
|
@@ -66,12 +67,18 @@ export async function prepareIntakeForIngestedFile({
|
|
|
66
67
|
|
|
67
68
|
let related = [];
|
|
68
69
|
try {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const raw = await hybridSearchFn(rootDir, query, { limit: relatedLimit, env });
|
|
71
|
+
// Test mocks return an array directly; production wrapper returns
|
|
72
|
+
// { results: [...] }. Accept both shapes so suggestTags sees the
|
|
73
|
+
// same `related` regardless of caller. Preserve `tags` for the
|
|
74
|
+
// related-inherit suggestion path.
|
|
75
|
+
const hits = Array.isArray(raw) ? raw : (raw?.results || []);
|
|
76
|
+
related = hits.map((hit) => ({
|
|
77
|
+
path: hit.source_path || hit.path || hit.id,
|
|
72
78
|
title: hit.title || hit.id,
|
|
73
79
|
score: hit.score,
|
|
74
80
|
summary: hit.summary || '',
|
|
81
|
+
tags: Array.isArray(hit.tags) ? hit.tags : undefined,
|
|
75
82
|
}));
|
|
76
83
|
} catch (err) {
|
|
77
84
|
related = [];
|
|
@@ -98,6 +105,16 @@ export async function prepareIntakeForIngestedFile({
|
|
|
98
105
|
|
|
99
106
|
const droppedInfo = ingestedFile.droppedInfo ?? [];
|
|
100
107
|
|
|
108
|
+
// Tag auto-attribution: deterministic suggestions from triage + related
|
|
109
|
+
// doc inheritance, filtered against the project tag vocabulary. Vocab
|
|
110
|
+
// load failures fall through with empty suggestions — never block the
|
|
111
|
+
// packet write on a missing/malformed vocab file.
|
|
112
|
+
|
|
113
|
+
let vocab = null;
|
|
114
|
+
try { vocab = loadVocabulary(rootDir); }
|
|
115
|
+
catch { vocab = null; }
|
|
116
|
+
const tagSuggestions = suggestTags(triage, related, vocab);
|
|
117
|
+
|
|
101
118
|
const baseEntry = {
|
|
102
119
|
intake: {
|
|
103
120
|
sourcePath: ingestedFile.sourcePath,
|
|
@@ -112,6 +129,7 @@ export async function prepareIntakeForIngestedFile({
|
|
|
112
129
|
excerpt: extracted.slice(0, EXCERPT_CHARS),
|
|
113
130
|
query,
|
|
114
131
|
customers: customers.length ? customers : undefined,
|
|
132
|
+
tags: tagSuggestions.length > 0 ? tagSuggestions : undefined,
|
|
115
133
|
};
|
|
116
134
|
|
|
117
135
|
// Capability detection mirrors inbox.mjs: stamp provenance onto the packet
|
|
@@ -88,6 +88,22 @@ function resolveConfluenceAuth(homeDir) {
|
|
|
88
88
|
|
|
89
89
|
// ── GitHub Issues ────────────────────────────────────────────────────────
|
|
90
90
|
|
|
91
|
+
// A packet that originated from a demo/fixture/inbox-test source must not
|
|
92
|
+
// be published to a real GitHub repo. The caller can override via
|
|
93
|
+
// `publishDemo: true` when the demo run is intentional (e.g. an explicit
|
|
94
|
+
// integration test that creates and then deletes the issue). Default-safe.
|
|
95
|
+
|
|
96
|
+
export function isDemoIntakePacket(packet) {
|
|
97
|
+
if (process.env.CONSTRUCT_DEMO === '1') return true;
|
|
98
|
+
if (process.env.CONSTRUCT_INTAKE_DEMO === '1') return true;
|
|
99
|
+
const sourcePath = String(packet?.intake?.sourcePath || packet?.sourcePath || '');
|
|
100
|
+
if (!sourcePath) return false;
|
|
101
|
+
if (sourcePath.includes('/tests/fixtures/')) return true;
|
|
102
|
+
if (sourcePath.includes('/.cx/intake/demo/')) return true;
|
|
103
|
+
if (sourcePath.includes('/cx-intake-demo')) return true;
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
/**
|
|
92
108
|
* Create a GitHub issue from an intake packet.
|
|
93
109
|
*
|
|
@@ -98,9 +114,19 @@ function resolveConfluenceAuth(homeDir) {
|
|
|
98
114
|
* @param {string} [opts.apiUrl] - GitHub API URL (default: api.github.com)
|
|
99
115
|
* @param {string} [opts.host] - GitHub host (default: github.com)
|
|
100
116
|
* @param {object} [opts.fetchImpl]
|
|
101
|
-
* @
|
|
117
|
+
* @param {boolean} [opts.publishDemo] - Override the demo-source gate
|
|
118
|
+
* @returns {Promise<{ ok: boolean, externalUrl: string|null, externalId: string|null, error?: string, skipped?: string }>}
|
|
102
119
|
*/
|
|
103
|
-
export async function createGitHubIssue(packet, { repo, token, apiUrl, host, fetchImpl = globalThis.fetch } = {}) {
|
|
120
|
+
export async function createGitHubIssue(packet, { repo, token, apiUrl, host, fetchImpl = globalThis.fetch, publishDemo = false } = {}) {
|
|
121
|
+
if (!publishDemo && isDemoIntakePacket(packet)) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
externalUrl: null,
|
|
125
|
+
externalId: null,
|
|
126
|
+
skipped: 'demo-source',
|
|
127
|
+
error: 'Refused to publish: packet originated from a demo/fixture path (set CONSTRUCT_DEMO=0 and re-run with --publish-issues to override).',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
104
130
|
const hd = homedir();
|
|
105
131
|
const auth = resolveGitHubAuth(hd);
|
|
106
132
|
const resolvedRepo = repo || auth.repo || process.env.GITHUB_REPO;
|
|
@@ -18,6 +18,7 @@ import { buildStatus } from '../../status.mjs';
|
|
|
18
18
|
import { readEfficiencyLog, buildCompactEfficiencyDigest } from '../../efficiency.mjs';
|
|
19
19
|
import { addObservation } from '../../observation-store.mjs';
|
|
20
20
|
import { loadConstructEnv } from '../../env-config.mjs';
|
|
21
|
+
import { createSqlClient, closeSqlClient } from '../../storage/backend.mjs';
|
|
21
22
|
|
|
22
23
|
// Load config.env once at module init so config.env values win over shell env
|
|
23
24
|
// (shell env may have stale/truncated credentials from earlier sessions)
|
|
@@ -243,6 +244,35 @@ export async function cxScore(args) {
|
|
|
243
244
|
}
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
// Postgres write-through (team/enterprise). Backs the
|
|
248
|
+
// construct_skill_quality_correlation view that `construct skills
|
|
249
|
+
// correlate-quality` reads — without this, the view stays empty and
|
|
250
|
+
// the CLI surface that promised correlation data delivered nothing.
|
|
251
|
+
// Best-effort: a missing DATABASE_URL or schema misalignment never
|
|
252
|
+
// blocks the score from reaching the remote telemetry or the local
|
|
253
|
+
// observation store above.
|
|
254
|
+
|
|
255
|
+
if (Number.isFinite(numericValue)) {
|
|
256
|
+
const sqlClient = createSqlClient(process.env);
|
|
257
|
+
if (sqlClient) {
|
|
258
|
+
try {
|
|
259
|
+
await sqlClient`
|
|
260
|
+
insert into construct_cx_scores (ts, trace_id, session_id, agent_id, name, value, comment)
|
|
261
|
+
values (
|
|
262
|
+
${new Date().toISOString()},
|
|
263
|
+
${traceId},
|
|
264
|
+
${args.session_id ?? null},
|
|
265
|
+
${args.agent_id ?? args.name ?? null},
|
|
266
|
+
${args.name ?? 'quality'},
|
|
267
|
+
${numericValue},
|
|
268
|
+
${args.comment ?? null}
|
|
269
|
+
)
|
|
270
|
+
`;
|
|
271
|
+
} catch { /* best-effort write — never block on DB issues */ }
|
|
272
|
+
finally { await closeSqlClient(sqlClient).catch(() => {}); }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
246
276
|
return { ok: true, traceId, backend: client.backend, remoteStatus: client.remoteStatus };
|
|
247
277
|
} catch (err) {
|
|
248
278
|
return { ok: false, error: err.message };
|
|
@@ -352,6 +352,21 @@ export function classifyRoleFlavors(request = '') {
|
|
|
352
352
|
};
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
// One line per non-null flavor: "cx-<role>: loaded <role>.<flavor>
|
|
356
|
+
// overlay". Verbose chat surface + cx_trace span attribute; lets a
|
|
357
|
+
// post-hoc reviewer see which overlays drove a given dispatch.
|
|
358
|
+
|
|
359
|
+
export function formatOverlaySelection(roleFlavors) {
|
|
360
|
+
if (!roleFlavors || typeof roleFlavors !== 'object') return [];
|
|
361
|
+
const lines = [];
|
|
362
|
+
for (const [role, flavor] of Object.entries(roleFlavors)) {
|
|
363
|
+
if (!flavor) continue;
|
|
364
|
+
const specialist = `cx-${role.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
|
365
|
+
lines.push(`${specialist}: loaded ${role}.${flavor} overlay`);
|
|
366
|
+
}
|
|
367
|
+
return lines;
|
|
368
|
+
}
|
|
369
|
+
|
|
355
370
|
export function detectRiskFlags(request = '') {
|
|
356
371
|
const text = String(request).toLowerCase();
|
|
357
372
|
return {
|
package/lib/roles/gateway.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
14
|
+
import { homedir, tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
17
|
import { emit, _paths } from './event-bus.mjs';
|
|
@@ -54,7 +54,36 @@ function appendPending(entry) {
|
|
|
54
54
|
appendFileSync(pendingPath(), JSON.stringify(entry) + '\n');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Paths under the OS tmpdir are by definition test fixtures or sandbox
|
|
58
|
+
// runs, not real project state. Escalating from them spawns persistent
|
|
59
|
+
// beads for ephemeral files — pure tracker noise. macOS resolves
|
|
60
|
+
// $TMPDIR through a /private/var/folders/... symlink; the raw tmpdir
|
|
61
|
+
// and its /private prefix are both checked so either resolved form
|
|
62
|
+
// matches.
|
|
63
|
+
|
|
64
|
+
export function isTestFixturePath(p) {
|
|
65
|
+
if (!p || typeof p !== 'string') return false;
|
|
66
|
+
const tmp = tmpdir();
|
|
67
|
+
if (p.startsWith(tmp)) return true;
|
|
68
|
+
if (p.startsWith('/private' + tmp)) return true;
|
|
69
|
+
if (p.startsWith('/tmp/')) return true;
|
|
70
|
+
// macOS canonical user-tmp prefix when $TMPDIR is set elsewhere
|
|
71
|
+
if (/^\/private\/var\/folders\/[^/]+\/[^/]+\/T\//.test(p)) return true;
|
|
72
|
+
if (/^\/var\/folders\/[^/]+\/[^/]+\/T\//.test(p)) return true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function eventIsFromTestFixture(event) {
|
|
77
|
+
if (isTestFixturePath(event?.cwd)) return true;
|
|
78
|
+
if (isTestFixturePath(event?.project)) return true;
|
|
79
|
+
if (isTestFixturePath(event?.context?.filePath)) return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
57
83
|
export function shouldEscalate(event, manifest, now = Date.now()) {
|
|
84
|
+
if (eventIsFromTestFixture(event)) {
|
|
85
|
+
return { escalate: false, reason: 'test-fixture-path' };
|
|
86
|
+
}
|
|
58
87
|
const fp = event.fingerprint;
|
|
59
88
|
const pending = readPending();
|
|
60
89
|
|
package/lib/scheduler/index.mjs
CHANGED
|
@@ -94,14 +94,34 @@ registerJob({
|
|
|
94
94
|
handler: async () => ({ status: 'noop', reason: 'not yet implemented' }),
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
// Doc hygiene scan cadence is deployment-aware. Solo runs nightly because
|
|
98
|
+
// a single contributor's doc drift accumulates slowly. Team and enterprise
|
|
99
|
+
// runs hourly because many writers can shift the surface within a workday,
|
|
100
|
+
// and the higher limit (50 vs 25) gives the reconcile worker enough headroom
|
|
101
|
+
// to keep up. Mode/schedule resolved at module-load so a single registry
|
|
102
|
+
// entry covers both topologies.
|
|
103
|
+
|
|
104
|
+
const DOC_HYGIENE_SOLO_CRON = '0 2 * * *';
|
|
105
|
+
const DOC_HYGIENE_TEAM_CRON = '0 * * * *';
|
|
106
|
+
|
|
107
|
+
export function resolveDocHygieneSchedule(env = process.env) {
|
|
108
|
+
const mode = getDeploymentMode(env);
|
|
109
|
+
const isTeamish = mode === 'team' || mode === 'enterprise';
|
|
110
|
+
return {
|
|
111
|
+
mode: isTeamish ? 'team' : 'solo',
|
|
112
|
+
schedule: isTeamish ? DOC_HYGIENE_TEAM_CRON : DOC_HYGIENE_SOLO_CRON,
|
|
113
|
+
limit: isTeamish ? 50 : 25,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const docHygiene = resolveDocHygieneSchedule();
|
|
97
118
|
registerJob({
|
|
98
119
|
id: 'doc-hygiene-scan',
|
|
99
|
-
schedule:
|
|
100
|
-
mode:
|
|
120
|
+
schedule: docHygiene.schedule,
|
|
121
|
+
mode: docHygiene.mode,
|
|
101
122
|
handler: async ({ cwd, env }) => {
|
|
102
123
|
const { findHygieneCandidates } = await import('../hygiene/scan.mjs');
|
|
103
|
-
const
|
|
104
|
-
const limit = mode === 'solo' ? 25 : 50;
|
|
124
|
+
const { limit } = resolveDocHygieneSchedule(env);
|
|
105
125
|
const candidates = findHygieneCandidates({ cwd, limit });
|
|
106
126
|
return {
|
|
107
127
|
status: 'ok',
|
package/lib/server/insights.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import os from 'node:os';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { spawnSync } from 'node:child_process';
|
|
16
16
|
import { readCostLog, summarizeCostData } from '../cost.mjs';
|
|
17
|
+
import { getRebrand } from '../profiles/rebrand.mjs';
|
|
17
18
|
|
|
18
19
|
const TELEMETRY_TIMEOUT_MS = 3500;
|
|
19
20
|
|
|
@@ -309,12 +310,23 @@ function summarizeIntakeQueue(env, cwd = process.cwd()) {
|
|
|
309
310
|
}
|
|
310
311
|
} catch { /* skip */ }
|
|
311
312
|
|
|
313
|
+
// Profile-aware labels: the active profile's rebrand block (e.g.
|
|
314
|
+
// rnd → "Intake queue"/"signal"; sales-ops → "Lead inbox"/"lead")
|
|
315
|
+
// travels in the payload so the dashboard renders the right noun
|
|
316
|
+
// without hardcoding "intake" anywhere user-facing.
|
|
317
|
+
|
|
318
|
+
let rebrand;
|
|
319
|
+
try { rebrand = getRebrand(cwd); }
|
|
320
|
+
catch { rebrand = { intakeQueueLabel: 'Intake queue', signalNoun: 'signal' }; }
|
|
321
|
+
|
|
312
322
|
return {
|
|
313
323
|
state: pendingCount === 0 && processedCount === 0 && skippedCount === 0 ? 'empty' : 'ok',
|
|
314
324
|
pending: pendingCount,
|
|
315
325
|
processed: processedCount,
|
|
316
326
|
skipped: skippedCount,
|
|
317
327
|
customerLinked,
|
|
328
|
+
label: rebrand.intakeQueueLabel,
|
|
329
|
+
itemNoun: rebrand.signalNoun,
|
|
318
330
|
};
|
|
319
331
|
} catch (err) {
|
|
320
332
|
return { state: 'unreachable', message: err.message };
|
package/lib/setup.mjs
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* lib/setup.mjs —
|
|
3
|
+
* lib/setup.mjs — Machine-scoped first-run setup wizard for Construct.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Installs cm/cass, configures managed defaults, starts local Postgres
|
|
6
|
+
* (consent-driven), pre-warms the embedding model, wires MCP integrations,
|
|
7
|
+
* and writes ~/.construct/config.env. Invoked by `construct install` and
|
|
8
|
+
* by lib/install/first-invocation.mjs when a TTY user is missing resources.
|
|
9
|
+
*
|
|
10
|
+
* Project-scoped scaffolding (`.cx/`, AGENTS.md, plan.md, adapters) lives
|
|
11
|
+
* in lib/init-unified.mjs — invoked by `construct init`. These two flows
|
|
12
|
+
* are intentionally separate: install runs once per machine, init runs
|
|
13
|
+
* once per repo.
|
|
8
14
|
*/
|
|
9
15
|
|
|
10
16
|
import fs from 'node:fs';
|
|
@@ -36,10 +42,10 @@ const LOCAL_POSTGRES_DB = 'construct';
|
|
|
36
42
|
const LOCAL_DATABASE_URL = `postgresql://${LOCAL_POSTGRES_USER}:${LOCAL_POSTGRES_PASSWORD}@127.0.0.1:${LOCAL_POSTGRES_PORT}/${LOCAL_POSTGRES_DB}`;
|
|
37
43
|
|
|
38
44
|
function printHelp() {
|
|
39
|
-
console.log(`Construct setup
|
|
45
|
+
console.log(`Construct install — machine setup (once per machine)
|
|
40
46
|
|
|
41
47
|
Usage:
|
|
42
|
-
construct
|
|
48
|
+
construct install [--yes] [--no-docker]
|
|
43
49
|
|
|
44
50
|
What it does:
|
|
45
51
|
- creates ~/.construct/config.env
|
|
@@ -52,6 +58,7 @@ What it does:
|
|
|
52
58
|
- runs construct doctor
|
|
53
59
|
- detects the project tech stack and writes .cx/project-profile.json
|
|
54
60
|
|
|
61
|
+
For project setup (once per repo): construct init
|
|
55
62
|
Use --yes to run without prompts and accept detected environment defaults.`);
|
|
56
63
|
}
|
|
57
64
|
|
|
@@ -465,6 +472,35 @@ export function ensureGitHooksPath({ cwd = process.cwd() } = {}) {
|
|
|
465
472
|
};
|
|
466
473
|
}
|
|
467
474
|
|
|
475
|
+
// $HOME/.construct/lib/hooks/* is the resolution path for Claude Code
|
|
476
|
+
// Stop-hook commands. Absent symlink → every Stop hook fails silently,
|
|
477
|
+
// including stop-notify (dashboard cost feed). Idempotent: matching
|
|
478
|
+
// symlink left alone, stale symlink replaced, real dir refused.
|
|
479
|
+
|
|
480
|
+
export function ensureLibSymlink({ homeDir = HOME, rootDir = ROOT_DIR } = {}) {
|
|
481
|
+
const target = path.join(homeDir, '.construct', 'lib');
|
|
482
|
+
const source = path.join(rootDir, 'lib');
|
|
483
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
484
|
+
let stat = null;
|
|
485
|
+
try { stat = fs.lstatSync(target); } catch { stat = null; }
|
|
486
|
+
if (!stat) {
|
|
487
|
+
fs.symlinkSync(source, target, 'dir');
|
|
488
|
+
return { status: 'created', target, source };
|
|
489
|
+
}
|
|
490
|
+
if (stat.isSymbolicLink()) {
|
|
491
|
+
const current = fs.readlinkSync(target);
|
|
492
|
+
if (current === source) return { status: 'kept', target, source };
|
|
493
|
+
fs.unlinkSync(target);
|
|
494
|
+
fs.symlinkSync(source, target, 'dir');
|
|
495
|
+
return { status: 'replaced', target, source, previous: current };
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
status: 'conflict',
|
|
499
|
+
target,
|
|
500
|
+
message: `${target} exists and is not a symlink — leaving alone. Move or remove it, then re-run \`construct install\` to wire the hooks.`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
468
504
|
export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME } = {}) {
|
|
469
505
|
const argSet = new Set(args);
|
|
470
506
|
const isYes = argSet.has('--yes');
|
|
@@ -480,9 +516,17 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
480
516
|
|
|
481
517
|
const envPath = ensureUserConfig(homeDir);
|
|
482
518
|
const opencodePath = ensureOpenCodeConfig();
|
|
519
|
+
const libLink = ensureLibSymlink({ homeDir, rootDir });
|
|
483
520
|
|
|
484
521
|
console.log(`User config: ${envPath}`);
|
|
485
522
|
console.log(`OpenCode config: ${opencodePath}`);
|
|
523
|
+
if (libLink.status === 'created' || libLink.status === 'replaced') {
|
|
524
|
+
console.log(`Hook lib link: ${libLink.target} → ${libLink.source} (${libLink.status})`);
|
|
525
|
+
} else if (libLink.status === 'kept') {
|
|
526
|
+
console.log(`Hook lib link: ${libLink.target} already in place`);
|
|
527
|
+
} else if (libLink.status === 'conflict') {
|
|
528
|
+
console.log(`Hook lib link: ${libLink.message}`);
|
|
529
|
+
}
|
|
486
530
|
warnIfGlobalCommandIsUnavailable();
|
|
487
531
|
|
|
488
532
|
const cmInstall = ensureCmInstalled({ env: process.env });
|
|
@@ -582,6 +626,7 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
582
626
|
});
|
|
583
627
|
if (cheapestConsent.decision) {
|
|
584
628
|
try {
|
|
629
|
+
const { applyToEnv } = await import('./model-router.mjs');
|
|
585
630
|
const selections = await selectCheapestForAllTiers({ env: process.env });
|
|
586
631
|
const applied = {};
|
|
587
632
|
for (const tier of ['reasoning', 'standard', 'fast']) {
|
|
@@ -637,7 +682,7 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
637
682
|
|
|
638
683
|
if (isYes) {
|
|
639
684
|
console.log('\nManaged setup:');
|
|
640
|
-
console.log(` Deployment mode: ${getDeploymentMode(env) || DEFAULT_DEPLOYMENT_MODE} (set in construct.config.json — runtime env override available via ${DEPLOYMENT_MODE_ENV_KEY})`);
|
|
685
|
+
console.log(` Deployment mode: ${getDeploymentMode(process.env) || DEFAULT_DEPLOYMENT_MODE} (set in construct.config.json — runtime env override available via ${DEPLOYMENT_MODE_ENV_KEY})`);
|
|
641
686
|
console.log(` Vector index: ${managedValues.CONSTRUCT_VECTOR_INDEX_PATH}`);
|
|
642
687
|
console.log(` Vector model: ${managedValues.CONSTRUCT_VECTOR_MODEL}`);
|
|
643
688
|
console.log(` Trace backend: ${managedValues.CONSTRUCT_TRACE_BACKEND}`);
|
|
@@ -723,12 +768,12 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
|
|
|
723
768
|
runConstruct(['mcp', 'add', 'github', '--auto'], { optional: true });
|
|
724
769
|
} else {
|
|
725
770
|
console.log('\nManaged defaults written:');
|
|
726
|
-
console.log(` Deployment mode: ${getDeploymentMode(env) || DEFAULT_DEPLOYMENT_MODE} (set in construct.config.json — runtime env override available via ${DEPLOYMENT_MODE_ENV_KEY})`);
|
|
771
|
+
console.log(` Deployment mode: ${getDeploymentMode(process.env) || DEFAULT_DEPLOYMENT_MODE} (set in construct.config.json — runtime env override available via ${DEPLOYMENT_MODE_ENV_KEY})`);
|
|
727
772
|
console.log(` Vector index: ${managedValues.CONSTRUCT_VECTOR_INDEX_PATH}`);
|
|
728
773
|
console.log(` Trace backend: ${managedValues.CONSTRUCT_TRACE_BACKEND}${managedValues.CONSTRUCT_TELEMETRY_URL ? ` (${managedValues.CONSTRUCT_TELEMETRY_URL})` : ''}`);
|
|
729
774
|
console.log(` Pressure guard: swap ${managedValues.CONSTRUCT_PRESSURE_GUARD_SWAP_GB} GiB, opencode max ${managedValues.CONSTRUCT_PRESSURE_GUARD_MAX_OPENCODE}`);
|
|
730
775
|
console.log('\nFor unattended setup, including local Postgres when Docker is running:');
|
|
731
|
-
console.log(' construct
|
|
776
|
+
console.log(' construct install --yes');
|
|
732
777
|
}
|
|
733
778
|
|
|
734
779
|
runConstruct(['sync']);
|
package/lib/update.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import fs from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
12
|
|
|
13
|
+
import { writeVersionStamp } from './maintenance/cleanup.mjs';
|
|
14
|
+
|
|
13
15
|
const PACKAGE_NAME = '@geraldmaron/construct';
|
|
14
16
|
|
|
15
17
|
function readPackageJson(dir) {
|
|
@@ -70,6 +72,15 @@ export function buildUpdatePlan({ cwd }) {
|
|
|
70
72
|
args: [binPath, 'sync', '--no-docs'],
|
|
71
73
|
cwd: sourceRoot,
|
|
72
74
|
},
|
|
75
|
+
// Sweep legacy cx-*.md/.toml/.prompt.md left over from older releases
|
|
76
|
+
// at user scope, so the next doctor doesn't surface the warning.
|
|
77
|
+
{
|
|
78
|
+
label: 'Sweep legacy agents at user scope',
|
|
79
|
+
command: process.execPath,
|
|
80
|
+
args: [binPath, 'doctor', '--fix-legacy-agents'],
|
|
81
|
+
cwd: sourceRoot,
|
|
82
|
+
optional: true,
|
|
83
|
+
},
|
|
73
84
|
{
|
|
74
85
|
label: 'Run health checks',
|
|
75
86
|
command: process.execPath,
|
|
@@ -84,14 +95,24 @@ function formatCommand(command, args) {
|
|
|
84
95
|
return [command, ...args].join(' ');
|
|
85
96
|
}
|
|
86
97
|
|
|
87
|
-
function runStep(step, { spawn, env }) {
|
|
98
|
+
function runStep(step, { spawn, env, stdout }) {
|
|
88
99
|
const result = spawn(step.command, step.args, {
|
|
89
100
|
cwd: step.cwd,
|
|
90
101
|
env,
|
|
91
102
|
stdio: 'inherit',
|
|
92
103
|
});
|
|
93
|
-
if (result?.error)
|
|
104
|
+
if (result?.error) {
|
|
105
|
+
if (step.optional) {
|
|
106
|
+
stdout?.write?.(` (skipped: ${result.error.message})\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
throw result.error;
|
|
110
|
+
}
|
|
94
111
|
if ((result?.status ?? 1) !== 0) {
|
|
112
|
+
if (step.optional) {
|
|
113
|
+
stdout?.write?.(` (skipped: exit ${result?.status ?? 1})\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
95
116
|
throw new Error(`${step.label} failed with exit code ${result.status ?? 1}`);
|
|
96
117
|
}
|
|
97
118
|
}
|
|
@@ -107,9 +128,16 @@ export function runUpdate({ cwd, env = process.env, spawn = spawnSync, stdout =
|
|
|
107
128
|
for (const step of plan.steps) {
|
|
108
129
|
stdout.write(`→ ${step.label}\n`);
|
|
109
130
|
stdout.write(` ${formatCommand(step.command, step.args)}\n`);
|
|
110
|
-
runStep(step, { spawn, env });
|
|
131
|
+
runStep(step, { spawn, env, stdout });
|
|
111
132
|
}
|
|
112
133
|
|
|
134
|
+
// Advance the cleanup stamp to the checkout version so the next CLI
|
|
135
|
+
// invocation doesn't run a full cleanup pass on the assumption that
|
|
136
|
+
// an upgrade just happened.
|
|
137
|
+
try {
|
|
138
|
+
writeVersionStamp({ version: plan.version, summary: { freedBytes: 0, durationMs: 0 } });
|
|
139
|
+
} catch { /* stamp write is best-effort */ }
|
|
140
|
+
|
|
113
141
|
stdout.write('\n✓ Construct updated from current checkout.\n');
|
|
114
142
|
return plan;
|
|
115
143
|
}
|