@geraldmaron/construct 1.1.0 → 1.1.1

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.
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * Generates embeddings via @huggingface/transformers, caches them to disk,
5
5
  * computes cosine similarity, and clusters related signals by topic.
6
- * All local no external API calls. Default model is all-MiniLM-L6-v2
7
- * (384-dimensional vectors, ~50MB on disk, fast inference).
6
+ * Inference is local and offline (allowRemoteModels=false): the model loads
7
+ * from local cache only and returns null when no cached weights are present,
8
+ * never reaching the network. Default model is all-MiniLM-L6-v2 (384-dimensional
9
+ * vectors, ~50MB on disk, fast inference).
8
10
  *
9
11
  * Storage:
10
12
  * ~/.cx/cache/embeddings/<sha256>.json — single embedding vector
@@ -37,6 +39,7 @@ async function getEmbedder() {
37
39
  try {
38
40
  const { pipeline, env: hfEnv } = await import('@huggingface/transformers');
39
41
  hfEnv.allowLocalModels = true;
42
+ hfEnv.allowRemoteModels = false;
40
43
  hfEnv.useBrowserCache = false;
41
44
  embedder = await pipeline('feature-extraction', MODEL_NAME, {
42
45
  quantized: true,
@@ -10,7 +10,7 @@
10
10
  * surfaced as a warning but never deleted — open work is sacred.
11
11
  *
12
12
  * The function is idempotent and runs from:
13
- * - `construct down` (best effort, time-boxed)
13
+ * - `construct stop` (best effort, time-boxed)
14
14
  * - doctor daemon tick (lib/doctor/watchers/handoffs.mjs)
15
15
  * - `construct handoffs prune` (manual)
16
16
  *
@@ -33,11 +33,11 @@
33
33
  * @matcher Edit|Write|MultiEdit|NotebookEdit|Bash
34
34
  * @exits 0 = pass
35
35
  */
36
- import { readFileSync, existsSync, mkdirSync } from 'node:fs';
36
+ import { readFileSync, existsSync } from 'node:fs';
37
37
  import { createHash } from 'node:crypto';
38
- import { join, resolve, dirname } from 'node:path';
38
+ import { join, resolve } from 'node:path';
39
39
  import { homedir } from 'node:os';
40
- import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
40
+ import { appendAuditRecord } from '../audit-trail.mjs';
41
41
  import { resolveProjectScopedPath } from '../project-root.mjs';
42
42
  import { logHookFailure } from './_lib/log.mjs';
43
43
 
@@ -77,16 +77,6 @@ function readLastAgent() {
77
77
  }
78
78
  }
79
79
 
80
- function readPrevLineHash() {
81
- try {
82
- const lastLine = readLastLineAcrossSegments(AUDIT_FILE);
83
- if (lastLine === null) return null;
84
- return sha256(lastLine);
85
- } catch {
86
- return null;
87
- }
88
- }
89
-
90
80
  function truncateSnippet(s, limit = 280) {
91
81
  if (!s) return '';
92
82
  const str = String(s);
@@ -140,22 +130,18 @@ if (toolName !== 'Bash' && target) {
140
130
  }
141
131
  }
142
132
 
143
- const record = {
144
- ts: new Date().toISOString(),
145
- session_id: input?.session_id || null,
146
- tool: toolName,
147
- agent: readLastAgent(),
148
- task: null,
149
- cwd,
150
- target,
151
- detail,
152
- content_hash: contentHash,
153
- prev_line_hash: readPrevLineHash(),
154
- };
155
-
156
133
  try {
157
- mkdirSync(dirname(AUDIT_FILE), { recursive: true });
158
- appendBounded('audit-trail', AUDIT_FILE, JSON.stringify(record) + '\n');
134
+ appendAuditRecord({
135
+ ts: new Date().toISOString(),
136
+ session_id: input?.session_id || null,
137
+ tool: toolName,
138
+ agent: readLastAgent(),
139
+ task: null,
140
+ cwd,
141
+ target,
142
+ detail,
143
+ content_hash: contentHash,
144
+ }, { file: AUDIT_FILE });
159
145
  } catch (err) {
160
146
  logHookFailure({ hook: 'audit-trail', err, phase: 'append' });
161
147
  }
@@ -25,6 +25,7 @@ import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { spawnSync } from "child_process";
27
27
  import { findOpenCodeConfigPath } from "./opencode-config.mjs";
28
+ import { isLocalModel } from "./mcp/tool-budget.mjs";
28
29
  import {
29
30
  isFreeModel,
30
31
  pollFreeModels,
@@ -79,10 +80,21 @@ function normalizeModelOperatingProfile(value) {
79
80
  return MODEL_OPERATING_PROFILES[normalized] ? normalized : null;
80
81
  }
81
82
 
83
+ function parseModelSizeB(model) {
84
+ const size = String(model || "").toLowerCase().match(/(?:[:/-])(\d+(?:\.\d+)?)b\b/);
85
+ return size ? parseFloat(size[1]) : null;
86
+ }
87
+
82
88
  function inferSmallModelProfile(selectedModel) {
83
89
  const model = String(selectedModel || '').toLowerCase();
84
90
  if (!model) return false;
85
- if (/^(ollama|local)\//.test(model) && /(?:[:/-])(3b|7b|8b|13b|14b|32b)\b/.test(model)) return true;
91
+ // Match any parameter-count marker (7b, 24b, 30b, 32b, …) rather than a fixed list —
92
+ // the old list silently missed 24b/30b, so Devstral-24B and qwen3-coder-30B resolved
93
+ // to the balanced profile. Treat <=34B local models as small.
94
+ if (/^(ollama|local)\//.test(model)) {
95
+ const size = parseModelSizeB(model);
96
+ if (size !== null && size <= 34) return true;
97
+ }
86
98
  if (/^(anthropic|openrouter\/anthropic)\/.*haiku/.test(model)) return true;
87
99
  if (/gpt-5\.1-mini|gemma-3|gemma-4|phi3:mini/.test(model)) return true;
88
100
  return false;
@@ -100,6 +112,45 @@ export function resolveModelOperatingProfile({
100
112
  return MODEL_OPERATING_PROFILES.balanced;
101
113
  }
102
114
 
115
+ // Small local models follow large multi-instruction prompts poorly; the binding
116
+ // constraint is instruction-following capacity, not the token window (which the cx32k
117
+ // Modelfile variants already widen). Map a model to the persona section tier it can
118
+ // comply with — floor (must-keep only), mid, or full. A COLLAPSED probe verdict forces
119
+ // floor; otherwise local size estimates the tier and any cloud model gets the full
120
+ // persona, so cloud configs are never slimmed.
121
+
122
+ export function resolveCapabilityTier({ model, verdict = null } = {}) {
123
+ if (!isLocalModel(model)) return 'full';
124
+ if (verdict === 'COLLAPSED') return 'floor';
125
+ const size = parseModelSizeB(model);
126
+ if (size === null) return 'floor';
127
+ if (size >= 24) return 'mid';
128
+ return 'floor';
129
+ }
130
+
131
+ const CODE_MODEL_RE = /coder|codellama|starcoder|deepseek-coder|devstral/i;
132
+
133
+ // Pick the model for the narrow local editor (construct-local) from the DECLARED local
134
+ // inventory rather than the generic fast-tier default (which, for an Ollama family,
135
+ // resolves to a non-code generalist like llama3.2:3b). The editor does bounded code
136
+ // edits, so prefer a code-specialized model in the reliable size band [7,34]B (smallest =
137
+ // cheapest competent editor), then the smallest code model, then the smallest local model
138
+ // of any kind. Candidates should already exclude probe-COLLAPSED models. Returns null only
139
+ // when there are no candidates, so the caller keeps its own fallback.
140
+
141
+ export function selectLocalEditorModel(candidates = []) {
142
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
143
+ const sized = candidates.map((m) => ({ m, size: parseModelSizeB(m) }));
144
+ const pick = (arr) => {
145
+ const band = arr.filter((x) => x.size !== null && x.size >= 7 && x.size <= 34).sort((a, b) => a.size - b.size);
146
+ if (band.length) return band[0].m;
147
+ const anySized = arr.filter((x) => x.size !== null).sort((a, b) => a.size - b.size);
148
+ return anySized.length ? anySized[0].m : arr[0].m;
149
+ };
150
+ const coders = sized.filter((x) => CODE_MODEL_RE.test(x.m));
151
+ return coders.length ? pick(coders) : pick(sized);
152
+ }
153
+
103
154
  /**
104
155
  * Provider-family definitions. Each entry contains:
105
156
  * - `test`: RegExp that matches provider URLs.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * lib/persona-sections.mjs — Capability-tiered persona section rendering.
3
+ *
4
+ * A persona is authored once. Each `## ` section carries an inline `<!-- cx:prio=N -->`
5
+ * marker on its heading; the preamble before the first heading is always prio 1
6
+ * (identity + anti-fabrication). Small local models follow large multi-instruction
7
+ * prompts poorly — instruction-following degrades well before the context window fills —
8
+ * so a weak model receives only the sections it can actually comply with. Tiers:
9
+ * floor (prio 1, must-keep), mid (prio <= 2), full (all). The marker is stripped from the
10
+ * emitted text. Untagged `## ` sections default to prio 2 so authoring omissions degrade
11
+ * gracefully rather than vanishing or forcing a floor model to read everything.
12
+ */
13
+
14
+ const TIER_MAX_PRIO = Object.freeze({ floor: 1, mid: 2, full: 3 });
15
+ const PRIO_MARKER = /<!--\s*cx:prio=(\d+)\s*-->/;
16
+ const PRIO_MARKER_GLOBAL = /[ \t]*<!--\s*cx:prio=\d+\s*-->/g;
17
+
18
+ // The markers are authoring metadata; no emitted prompt (full or tiered) may carry them.
19
+ // Tiered render strips them via section parsing; full render (composePrompt) calls this.
20
+
21
+ export function stripSectionMarkers(text) {
22
+ return String(text || "").replace(PRIO_MARKER_GLOBAL, "");
23
+ }
24
+
25
+ // `## ` (exactly two hashes) is the only section boundary; `### ` subheadings stay with
26
+ // their parent section. The preamble (before the first `## `) is implicit prio 1.
27
+
28
+ export function parsePersonaSections(body) {
29
+ const lines = String(body || "").split("\n");
30
+ const sections = [];
31
+ let current = { heading: null, prio: 1, lines: [] };
32
+
33
+ const flush = () => {
34
+ const content = current.lines.join("\n").trim();
35
+ if (content || current.heading) {
36
+ sections.push({ heading: current.heading, prio: current.prio, content });
37
+ }
38
+ };
39
+
40
+ for (const line of lines) {
41
+ if (line.startsWith("## ")) {
42
+ flush();
43
+ const marker = line.match(PRIO_MARKER);
44
+ const prio = marker ? Number(marker[1]) : 2;
45
+ const heading = line.replace(PRIO_MARKER, "").trimEnd();
46
+ current = { heading, prio, lines: [heading] };
47
+ } else {
48
+ current.lines.push(line);
49
+ }
50
+ }
51
+ flush();
52
+ return sections;
53
+ }
54
+
55
+ export function renderPersonaForTier(body, tier = "full") {
56
+ const maxPrio = TIER_MAX_PRIO[tier] ?? TIER_MAX_PRIO.full;
57
+ return parsePersonaSections(body)
58
+ .filter((section) => section.prio <= maxPrio)
59
+ .map((section) => stripSectionMarkers(section.content))
60
+ .filter(Boolean)
61
+ .join("\n\n");
62
+ }
63
+
64
+ export function personaTierMaxPrio(tier) {
65
+ return TIER_MAX_PRIO[tier] ?? TIER_MAX_PRIO.full;
66
+ }
@@ -30,6 +30,7 @@ import {
30
30
  import { listObservations } from './observation-store.mjs';
31
31
  import { routeRequest } from './orchestration-policy.mjs';
32
32
  import { resolvePromptEntry, resolvePromptMetadata } from './prompt-metadata.mjs';
33
+ import { stripSectionMarkers } from './persona-sections.mjs';
33
34
  import { readRoleFile } from './role-preload.mjs';
34
35
  import { estimateTokens, estimatePromptTokens, estimateTokensSync } from './token-engine.js';
35
36
  import { cxDir } from './paths.mjs';
@@ -212,7 +213,7 @@ export function composePrompt(agentName, {
212
213
  selectedModel: executionContractModel?.selectedModel ?? modelId,
213
214
  });
214
215
 
215
- fragments.push({ type: 'core', priority: PRIORITY['core'], label: entry.name, content: readPromptBody(entry.promptFile, rootDir), tokenBudget: null });
216
+ fragments.push({ type: 'core', priority: PRIORITY['core'], label: entry.name, content: stripSectionMarkers(readPromptBody(entry.promptFile, rootDir)), tokenBudget: null });
216
217
 
217
218
  const shortName = String(agentName).replace(/^cx-/, '');
218
219
  const flavorMapping = AGENT_FLAVOR_MAP[shortName];
@@ -343,7 +343,7 @@ async function buildStatus() {
343
343
  rootDir: ROOT_DIR,
344
344
  cwd: process.cwd(),
345
345
  homeDir: HOME,
346
- // The dashboard is long-lived and `construct up` rewrites managed values in
346
+ // The dashboard is long-lived and `construct dev` rewrites managed values in
347
347
  // ~/.construct/config.env. Read fresh config on every request instead of
348
348
  // letting inherited process env shadow updated ports/credentials.
349
349
  env: {},
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/server/langfuse-login.mjs — Magic-link bridge for local Langfuse.
3
3
  *
4
- * `construct up` seeds Langfuse with deterministic admin creds via the
4
+ * `construct dev` seeds Langfuse with deterministic admin creds via the
5
5
  * LANGFUSE_INIT_* env vars in langfuse/docker-compose.yml. This bridge turns
6
6
  * the "Open Langfuse" link in the dashboard into a one-click sign-in: the
7
7
  * server fetches a CSRF token from local Langfuse, then returns an HTML
@@ -28,7 +28,7 @@ export async function handleLangfuseLogin(req, res, { baseUrl = LANGFUSE_LOCAL.b
28
28
  const csrfResponse = await fetch(`${baseUrl}/api/auth/csrf`, { headers: { Accept: 'application/json' } });
29
29
  if (!csrfResponse.ok) {
30
30
  res.writeHead(502, HTML_HEADERS);
31
- res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse at ${escapeHtml(baseUrl)}. Status: ${csrfResponse.status}. Try \`construct up\` first.</pre>`);
31
+ res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse at ${escapeHtml(baseUrl)}. Status: ${csrfResponse.status}. Try \`construct dev\` first.</pre>`);
32
32
  return;
33
33
  }
34
34
  const { csrfToken } = await csrfResponse.json();
@@ -53,6 +53,6 @@ export async function handleLangfuseLogin(req, res, { baseUrl = LANGFUSE_LOCAL.b
53
53
  </body></html>`);
54
54
  } catch (err) {
55
55
  res.writeHead(502, HTML_HEADERS);
56
- res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse: ${escapeHtml(err?.message || 'unknown error')}. Try \`construct up\` first.</pre>`);
56
+ res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse: ${escapeHtml(err?.message || 'unknown error')}. Try \`construct dev\` first.</pre>`);
57
57
  }
58
58
  }
@@ -2,7 +2,7 @@
2
2
  * lib/service-manager.mjs — start, stop, and describe Construct runtime services.
3
3
  *
4
4
  * Manages the dashboard process, memory server (cm), and OpenCode.
5
- * startServices() is the single entry point for `construct up` —
5
+ * startServices() is the single entry point for `construct dev` —
6
6
  * spawns whatever is available and returns a result per service.
7
7
  */
8
8
  import { spawn, spawnSync } from 'node:child_process';
@@ -350,7 +350,7 @@ export function buildResumeContext(session) {
350
350
  }
351
351
 
352
352
  /**
353
- * Close all active sessions (called on construct down).
353
+ * Close all active sessions (called on construct stop).
354
354
  */
355
355
  export function closeAllSessions(rootDir) {
356
356
  const index = readIndex(rootDir);
package/lib/setup.mjs CHANGED
@@ -40,7 +40,7 @@ function printHelp() {
40
40
  console.log(`Construct install — machine setup (once per machine)
41
41
 
42
42
  Usage:
43
- construct install [--scope=project|user|both] [--yes] [--no-launch-agent] [--reconfigure] [--with-docling]
43
+ construct install [--scope=project|user|both] [--yes] [--dry-run] [--no-launch-agent] [--reconfigure] [--with-docling]
44
44
 
45
45
  Flags:
46
46
  --scope=<s> project | user | both (default: project — see ADR-0029)
@@ -50,6 +50,7 @@ Flags:
50
50
  ~/.claude/* via consent-gated sync).
51
51
  both: print project guidance, then run user-scope install.
52
52
  --yes accept detected defaults without prompting
53
+ --dry-run print the install plan and exit without writing anything
53
54
  --no-launch-agent skip macOS LaunchAgent background service registration
54
55
  --reconfigure re-prompt for service consent, ignoring cached answers
55
56
  --with-docling eagerly provision the docling document-extraction venv now
@@ -306,6 +307,36 @@ function printProjectScopeGuidance() {
306
307
  console.log(' construct install --scope=both');
307
308
  }
308
309
 
310
+ // Dry-run renders the user-scope plan as intent only — every line names a write,
311
+ // network call, or service registration that the real run would perform — and
312
+ // returns before touching disk so the preview is provably side-effect-free.
313
+
314
+ function printInstallPlan({ scope, homeDir, withDocling, noLaunchAgent }) {
315
+ console.log('Construct install — dry-run (no changes written)');
316
+ console.log('────────────────────────────────────────────────');
317
+ console.log(`Scope: ${scope}`);
318
+ if (scope === 'both') {
319
+ console.log(' · would print project-scope guidance, then run the user-scope plan below');
320
+ }
321
+ console.log('\nWould write:');
322
+ console.log(` · ${getUserEnvPath(homeDir)} (user config, managed defaults)`);
323
+ console.log(` · ${getCanonicalOpenCodeConfigPath(homeDir)} (OpenCode config)`);
324
+ console.log(` · ${path.join(homeDir, '.construct', 'lib')} → ${path.join(ROOT_DIR, 'lib')} (hook lib symlink)`);
325
+ console.log(` · ${path.join(homeDir, '.construct', 'workspace')} (workspace docs scaffold)`);
326
+ console.log(` · ${path.dirname(defaultVectorIndexPath(homeDir))} (LanceDB vector store dir)`);
327
+ console.log('\nWould check / install runtime tooling:');
328
+ console.log(' · cm (memory CLI) and cass (session search) when available on PATH');
329
+ console.log('\nWould reach the network / external services:');
330
+ console.log(' · embedding model warmup (loads from local cache; offline-safe, no download)');
331
+ if (withDocling) console.log(' · docling Python venv provisioning via uv (~10 min)');
332
+ console.log(' · construct sync --quiet, construct sync --global, construct doctor');
333
+ if (process.platform === 'darwin' && !noLaunchAgent) {
334
+ console.log('\nWould register macOS LaunchAgent:');
335
+ console.log(` · ${path.join(homeDir, 'Library', 'LaunchAgents', 'dev.construct.pressure-release.plist')} (consent-gated)`);
336
+ }
337
+ console.log('\nNo files were written. Re-run without --dry-run to apply.');
338
+ }
339
+
309
340
  export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME } = {}) {
310
341
  const argSet = new Set(args);
311
342
  const isYes = argSet.has('--yes');
@@ -316,7 +347,8 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
316
347
  return;
317
348
  }
318
349
 
319
- const KNOWN_FLAGS = new Set(['--yes', '--no-launch-agent', '--reconfigure', '--with-docling', '--help', '-h']);
350
+ const KNOWN_FLAGS = new Set(['--yes', '--dry-run', '--no-launch-agent', '--reconfigure', '--with-docling', '--help', '-h']);
351
+ const dryRun = argSet.has('--dry-run');
320
352
  const withDocling = argSet.has('--with-docling');
321
353
  const unknownFlags = args.filter((a) => {
322
354
  if (!a.startsWith('-')) return false;
@@ -342,6 +374,11 @@ export async function runSetup({ rootDir = ROOT_DIR, args = [], homeDir = HOME }
342
374
  return;
343
375
  }
344
376
 
377
+ if (dryRun) {
378
+ printInstallPlan({ scope, homeDir, withDocling, noLaunchAgent: argSet.has('--no-launch-agent') });
379
+ return;
380
+ }
381
+
345
382
  if (scope === 'both') {
346
383
  printProjectScopeGuidance();
347
384
  console.log('');
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * lib/storage/embeddings-local.mjs — Local ONNX embedding via @huggingface/transformers.
3
3
  *
4
- * Lazy-loads the model, caches to disk, uses a single-worker queue to avoid
5
- * parallel inference contention. Falls back to hashing-bow-v1 if the model
6
- * fails to load.
4
+ * Lazy-loads the model from local cache only and runs inference offline. Falls
5
+ * back to hashing-bow-v1 when the model is absent or fails to load. Remote model
6
+ * fetching is disabled (allowRemoteModels=false): the runtime path never reaches
7
+ * the network, so a first run with no cached weights degrades to the hashing
8
+ * backend rather than downloading from the HuggingFace hub.
7
9
  *
8
10
  * Model: Xenova/all-MiniLM-L6-v2 (384 dimensions, quantized)
9
11
  */
@@ -29,6 +31,7 @@ async function getExtractor(cacheDir) {
29
31
  modelPromise = (async () => {
30
32
  const { pipeline, env: hfEnv } = await import('@huggingface/transformers');
31
33
  hfEnv.allowLocalModels = true;
34
+ hfEnv.allowRemoteModels = false;
32
35
  hfEnv.useBrowserCache = false;
33
36
  try {
34
37
  const ex = await pipeline('feature-extraction', MODEL_ID, {
@@ -50,17 +50,24 @@ function tryAcquire(lockPath) {
50
50
  return true;
51
51
  } catch (err) {
52
52
  if (err.code !== 'EEXIST') throw err;
53
- let holder = null;
54
- try { holder = Number(readFileSync(lockPath, 'utf8').trim()); } catch { /* unreadable */ }
55
- if (!isHolderAlive(holder)) {
56
- try { unlinkSync(lockPath); } catch { /* race */ }
57
- try {
58
- writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
59
- heldLocks.add(lockPath);
60
- return true;
61
- } catch { return false; }
62
- }
63
- return false;
53
+
54
+ // Steal only from a holder we can positively read as dead. An empty or
55
+ // unreadable pidfile is the mid-create window of a live holder, not a
56
+ // crash; stealing it would admit two writers and break an append chain.
57
+ // The acquire timeout still bounds the wait if the holder truly crashed.
58
+
59
+ let raw = '';
60
+ try { raw = readFileSync(lockPath, 'utf8').trim(); } catch { return false; }
61
+ if (raw === '') return false;
62
+ const holder = Number(raw);
63
+ if (!Number.isFinite(holder) || isHolderAlive(holder)) return false;
64
+
65
+ try { unlinkSync(lockPath); } catch { /* another stealer won */ }
66
+ try {
67
+ writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
68
+ heldLocks.add(lockPath);
69
+ return true;
70
+ } catch { return false; }
64
71
  }
65
72
  }
66
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "packageManager": "npm@11.5.1",
6
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
@@ -92,7 +92,9 @@
92
92
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
93
93
  "@opentelemetry/resources": "^2.7.1",
94
94
  "@opentelemetry/sdk-trace-node": "^1.25.0",
95
- "@opentelemetry/semantic-conventions": "^1.25.0"
95
+ "@opentelemetry/semantic-conventions": "^1.25.0",
96
+ "mammoth": "^1.12.0",
97
+ "unpdf": "^1.6.2"
96
98
  },
97
99
  "overrides": {
98
100
  "express-rate-limit": "8.5.1"
@@ -6,7 +6,7 @@ You are Construct. The user talks only to you; internal routing and specialist d
6
6
 
7
7
  **Anti-fabrication contract**: every load-bearing claim cites a verifiable source. Missing source becomes `unknown` or `[unverified]`. Specialists tailor; the persona never weakens. See `rules/common/no-fabrication.md`.
8
8
 
9
- ## Start of every session
9
+ ## Start of every session <!-- cx:prio=3 -->
10
10
 
11
11
  Before responding, run in parallel. do not narrate:
12
12
  1. `project_context`. state from `.cx/context.md`
@@ -23,7 +23,7 @@ Honor the project operating hierarchy:
23
23
 
24
24
  Use the single-writer rule whenever multiple sessions are active: if two sessions would touch the same file, one session owns the edit and the other reviews, researches, or waits for handoff.
25
25
 
26
- ## Classify before acting
26
+ ## Classify before acting <!-- cx:prio=1 -->
27
27
 
28
28
  Before any non-trivial request, CALL the code-backed orchestration policy via the `orchestration_policy` MCP tool with the request text and your `fileCount` / `moduleCount` / `introducesContract` estimate. Do not classify from memory. Honor the returned `track` and `specialists`. When `track` is `orchestrated` you may not author the deliverable yourself: emit the task-packet, dispatch the chain, return in Construct's voice after verdicts. Visual deliverables (wireframes, diagrams, decks) use real visual tools, not bullet prose.
29
29
 
@@ -31,7 +31,7 @@ Tracks: immediate (act directly), focused (one bounded specialist), orchestrated
31
31
 
32
32
  Orchestrated dispatches emit a task-packet with `goal`, `intent`, `workCategory`, `riskFlags`, `acceptanceCriteria` before naming specialists (`specialists/contracts.json:construct-to-orchestrator`).
33
33
 
34
- ## Gates and contracts (org-in-a-box)
34
+ ## Gates and contracts (org-in-a-box) <!-- cx:prio=2 -->
35
35
 
36
36
  `orchestration_policy` returns three artifacts; honor all three:
37
37
 
@@ -41,16 +41,16 @@ Orchestrated dispatches emit a task-packet with `goal`, `intent`, `workCategory`
41
41
 
42
42
  Before DONE: postconditions met · sources cited · framing logged · ADRs have Rejected alternatives.
43
43
 
44
- ## Branch + commit approval
44
+ ## Branch + commit approval <!-- cx:prio=1 -->
45
45
 
46
46
  - **Working branch is surfaced every session** at the top of session-start. Restate it before any mutating operation.
47
47
  - **Never commit, push, or merge without asking first.** Before `git commit`, `git push`, or `gh pr merge`: state branch, show the proposed message / refspec / PR number verbatim, wait for explicit yes. A batch go-ahead covers a defined sequence; new commits later are their own gate. See `rules/common/commit-approval.md`.
48
48
 
49
- ## Intake surface
49
+ ## Intake surface <!-- cx:prio=3 -->
50
50
 
51
51
  The active profile (`construct profile show`) sets the intake taxonomy. Session-start surfaces pending intake at `.cx/intake/pending/<id>.json`. Read with `construct intake show <id>`; the triage block names the primary owner, recommended chain, and next action. For non-trivial signals, plan with `construct graph from-intake <id>` and update node status with evidence (`construct graph status … done --evidence=…`). A node cannot reach `done` without an evidence record. Team / enterprise mode wraps tool calls in the MCP broker; when it returns `ApprovalRequired`, surface the question and never bypass.
52
52
 
53
- ## Action discipline
53
+ ## Action discipline <!-- cx:prio=1 -->
54
54
 
55
55
  - Dispatch, don't solo-plan: 3+ files, 2+ modules, or a new contract → cx-architect owns the plan.
56
56
  - Ask or look up, don't speculate: call `context7_query-docs` / `WebFetch`, ask, or commit to a default. Never a fourth round of internal debate.
@@ -58,7 +58,7 @@ The active profile (`construct profile show`) sets the intake taxonomy. Session-
58
58
  - Probe before bulk read: check size via `Glob` / `wc -l` or a `limit: 50` probe before `Read` with `limit > 200`.
59
59
  - Start-of-task: parallel bootstrap (above) + `cx_trace` before anything mutating.
60
60
 
61
- ## Communication + state
61
+ ## Communication + state <!-- cx:prio=2 -->
62
62
 
63
63
  Lead with the answer. One question when blocked. Confirm what changed when done.
64
64
 
@@ -70,7 +70,7 @@ Non-trivial work: update Beads (`bd note <id>`), `plan.md`, docs with owner / ac
70
70
 
71
71
  Load-bearing state: `AGENTS.md`, `.cx/context.md`/`.json`, `docs/README.md`, `docs/architecture.md` (read at session start, update before DONE, prune stale sections). `plan.md` is local-only.
72
72
 
73
- ## Quality gates
73
+ ## Quality gates <!-- cx:prio=2 -->
74
74
 
75
75
  After any implementation, dispatch validation before marking done:
76
76
  1. cx-reviewer. correctness, regression, coverage
@@ -79,17 +79,17 @@ After any implementation, dispatch validation before marking done:
79
79
 
80
80
  Do not mark `done` until cx-reviewer and cx-qa return verdicts. BLOCKED or any CRITICAL finding stops shipping.
81
81
 
82
- ## Hard release gates
82
+ ## Hard release gates <!-- cx:prio=3 -->
83
83
 
84
84
  Run `npm run release:check` before any commit or push. never wait for CI. Commits follow `.gitmessage`; PRs follow `.github/pull_request_template.md`. Full policy: `rules/common/release-gates.md`.
85
85
 
86
- ## Loop guard
86
+ ## Loop guard <!-- cx:prio=1 -->
87
87
 
88
88
  Same action 3+ times with no state change → stop. Report what was tried, what blocked progress, what decision is needed.
89
89
 
90
90
  Before stopping: surface incomplete tracker-linked plan slices and unmet acceptance criteria. Do not stop silently with work in-flight.
91
91
 
92
- ## Drive mode
92
+ ## Drive mode <!-- cx:prio=3 -->
93
93
 
94
94
  Activates on word-boundary triggers. `/work:drive`, standalone `drive`, or `full send`. Substring matches do not count.
95
95