@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.
- package/README.md +2 -0
- package/bin/construct +81 -10
- package/lib/audit-trail.mjs +77 -10
- package/lib/cli-commands.mjs +108 -1
- package/lib/demo.mjs +245 -0
- package/lib/diagram.mjs +300 -0
- package/lib/doctor/index.mjs +1 -1
- package/lib/doctor/watchers/process-pressure.mjs +1 -1
- package/lib/doctor/watchers/service-health.mjs +1 -1
- package/lib/document-extract.mjs +48 -0
- package/lib/document-ingest.mjs +7 -2
- package/lib/embed/semantic.mjs +5 -2
- package/lib/handoffs/cleanup.mjs +1 -1
- package/lib/hooks/audit-trail.mjs +14 -28
- package/lib/model-router.mjs +52 -1
- package/lib/persona-sections.mjs +66 -0
- package/lib/prompt-composer.js +2 -1
- package/lib/server/index.mjs +1 -1
- package/lib/server/langfuse-login.mjs +3 -3
- package/lib/service-manager.mjs +1 -1
- package/lib/session-store.mjs +1 -1
- package/lib/setup.mjs +39 -2
- package/lib/storage/embeddings-local.mjs +6 -3
- package/lib/storage/file-lock.mjs +18 -11
- package/package.json +4 -2
- package/personas/construct.md +11 -11
- package/scripts/sync-specialists.mjs +149 -35
- package/skills/docs/init-project.md +1 -1
- package/templates/docs/construct_guide.md +2 -2
- package/lib/ingest/chunker.mjs +0 -94
- package/lib/ingest/pipeline.mjs +0 -53
- package/lib/ingest/store.mjs +0 -82
- package/lib/mode-commands.mjs +0 -122
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/profiles/validate-custom.mjs +0 -114
- package/lib/services/telemetry-backend.mjs +0 -177
- package/lib/storage/fusion.mjs +0 -95
package/lib/embed/semantic.mjs
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
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,
|
package/lib/handoffs/cleanup.mjs
CHANGED
|
@@ -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
|
|
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
|
|
36
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
37
37
|
import { createHash } from 'node:crypto';
|
|
38
|
-
import { join, resolve
|
|
38
|
+
import { join, resolve } from 'node:path';
|
|
39
39
|
import { homedir } from 'node:os';
|
|
40
|
-
import {
|
|
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
|
-
|
|
158
|
-
|
|
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
|
}
|
package/lib/model-router.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/prompt-composer.js
CHANGED
|
@@ -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];
|
package/lib/server/index.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/lib/service-manager.mjs
CHANGED
|
@@ -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
|
|
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';
|
package/lib/session-store.mjs
CHANGED
|
@@ -350,7 +350,7 @@ export function buildResumeContext(session) {
|
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
/**
|
|
353
|
-
* Close all active sessions (called on construct
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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"
|
package/personas/construct.md
CHANGED
|
@@ -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
|
|