@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.
@@ -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
- producer,
162
- consumer,
163
- id,
164
- artifact,
165
- packet,
166
- contractsPath = CONTRACTS_PATH,
167
- repoRoot = REPO_ROOT,
168
- enforcement = 'block',
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 file = logFile();
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
- ...extra,
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 } = {}) {
@@ -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 search = await hybridSearchFn(rootDir, query, { limit: relatedLimit, env });
70
- related = (search?.results || []).map((hit) => ({
71
- path: hit.source_path || hit.id,
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
- * @returns {Promise<{ ok: boolean, externalUrl: string|null, externalId: string|null, error?: string }>}
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 {
@@ -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
 
@@ -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: '0 2 * * *',
100
- mode: 'solo',
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 mode = getDeploymentMode(env);
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',
@@ -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 — Interactive first-run setup wizard for Construct.
3
+ * lib/setup.mjs — Machine-scoped first-run setup wizard for Construct.
4
4
  *
5
- * Guides users through provider selection, API key entry, and model tier
6
- * assignment. Writes the resulting config to ~/.cx/env and optionally to
7
- * project-level .env. Invoked by `construct init` and on first init.
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 init [--yes] [--no-docker]
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 init --yes');
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) throw 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
  }