@geraldmaron/construct 1.0.24 → 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 +266 -13
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/audit-trail.mjs +77 -10
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +112 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -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/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +85 -10
- package/lib/document-ingest.mjs +97 -7
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/embed/semantic.mjs +5 -2
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/handoffs/cleanup.mjs +1 -1
- package/lib/hooks/audit-trail.mjs +14 -28
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp-catalog.json +1 -1
- package/lib/model-router.mjs +52 -1
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/persona-sections.mjs +66 -0
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +14 -9
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/server/index.mjs +1 -1
- package/lib/server/langfuse-login.mjs +3 -3
- package/lib/service-manager.mjs +42 -4
- package/lib/session-store.mjs +1 -1
- package/lib/setup.mjs +58 -2
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/storage/embeddings-engine.mjs +19 -5
- package/lib/storage/embeddings-local.mjs +6 -3
- package/lib/storage/file-lock.mjs +18 -11
- package/lib/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/package.json +4 -2
- package/personas/construct.md +12 -12
- package/platforms/capabilities.json +76 -0
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +277 -63
- package/skills/docs/init-project.md +1 -1
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-test-automation.md +12 -0
- 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
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* - local → ONNX Transformers (Xenova/all-MiniLM-L6-v2, 384d) — DEFAULT
|
|
9
9
|
* - openai → OpenAI API (text-embedding-3-small, 1536d)
|
|
10
10
|
* - ollama → Ollama server (nomic-embed-text, 768d)
|
|
11
|
-
* - hashing → Deterministic bag-of-words (256d) —
|
|
11
|
+
* - hashing → Deterministic bag-of-words (256d) — opt-in via the id only; never
|
|
12
|
+
* a silent fallback (256d has no semantics and mismatches the 384d
|
|
13
|
+
* vector schema, so an accidental fall-through degrades retrieval
|
|
14
|
+
* invisibly). An unknown CONSTRUCT_EMBEDDING_MODEL fails loud.
|
|
12
15
|
*
|
|
13
16
|
* OpenAI policy:
|
|
14
17
|
* - When CONSTRUCT_EMBEDDING_MODEL=openai and OPENAI_API_KEY is missing,
|
|
@@ -28,6 +31,8 @@ const ADAPTERS = {
|
|
|
28
31
|
hashing: () => import('./embeddings-legacy.mjs'),
|
|
29
32
|
};
|
|
30
33
|
|
|
34
|
+
const KNOWN_MODELS = new Set(Object.keys(ADAPTERS));
|
|
35
|
+
|
|
31
36
|
const DEFAULT_MODEL = 'local';
|
|
32
37
|
|
|
33
38
|
let openaiFallbackWarned = false;
|
|
@@ -39,7 +44,16 @@ let openaiFallbackWarned = false;
|
|
|
39
44
|
*/
|
|
40
45
|
function resolveModelId(env) {
|
|
41
46
|
const requested = (env.CONSTRUCT_EMBEDDING_MODEL || DEFAULT_MODEL).toLowerCase();
|
|
42
|
-
if (requested !== 'openai')
|
|
47
|
+
if (requested !== 'openai') {
|
|
48
|
+
if (!KNOWN_MODELS.has(requested)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`CONSTRUCT_EMBEDDING_MODEL='${requested}' is not a known embedding model ` +
|
|
51
|
+
`(${[...KNOWN_MODELS].join('|')}). Fix the value — falling through to the 256d ` +
|
|
52
|
+
`hashing adapter would silently degrade retrieval and mismatch the vector schema.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return requested;
|
|
56
|
+
}
|
|
43
57
|
|
|
44
58
|
if (env.OPENAI_API_KEY) return 'openai';
|
|
45
59
|
|
|
@@ -69,7 +83,7 @@ function resolveModelId(env) {
|
|
|
69
83
|
*/
|
|
70
84
|
export async function embedText(text, { env = process.env } = {}) {
|
|
71
85
|
const modelId = resolveModelId(env);
|
|
72
|
-
const loader = ADAPTERS[modelId]
|
|
86
|
+
const loader = ADAPTERS[modelId];
|
|
73
87
|
const { embed } = await loader();
|
|
74
88
|
return embed(text, { env });
|
|
75
89
|
}
|
|
@@ -82,7 +96,7 @@ export async function embedText(text, { env = process.env } = {}) {
|
|
|
82
96
|
*/
|
|
83
97
|
export async function embedBatch(texts, { env = process.env } = {}) {
|
|
84
98
|
const modelId = resolveModelId(env);
|
|
85
|
-
const loader = ADAPTERS[modelId]
|
|
99
|
+
const loader = ADAPTERS[modelId];
|
|
86
100
|
const { embedBatch } = await loader();
|
|
87
101
|
return embedBatch(texts, { env });
|
|
88
102
|
}
|
|
@@ -94,7 +108,7 @@ export async function embedBatch(texts, { env = process.env } = {}) {
|
|
|
94
108
|
*/
|
|
95
109
|
export async function getEmbeddingModelInfo({ env = process.env } = {}) {
|
|
96
110
|
const modelId = resolveModelId(env);
|
|
97
|
-
const loader = ADAPTERS[modelId]
|
|
111
|
+
const loader = ADAPTERS[modelId];
|
|
98
112
|
const { getModelInfo } = await loader();
|
|
99
113
|
return getModelInfo({ env });
|
|
100
114
|
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/telemetry/beads-fallback.mjs — count legacy-lock fallback firings.
|
|
3
|
+
*
|
|
4
|
+
* The beads write path is optimistic by default; the exclusive file-lock runs only
|
|
5
|
+
* as the PATH-3 fallback after optimistic retries exhaust. Whether that fallback
|
|
6
|
+
* ever fires in practice is the evidence that decides if the lock + wait-queue can
|
|
7
|
+
* be retired (bead construct-nhn5): one JSON line per firing to
|
|
8
|
+
* ~/.cx/beads-fallback.jsonl. Zero entries over a representative window = safe to
|
|
9
|
+
* remove; entries name the bd commands that actually contend.
|
|
10
|
+
*
|
|
11
|
+
* Errors here are non-fatal — telemetry must never add a failure mode to the
|
|
12
|
+
* fallback it observes.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { appendBounded } from '../logging/rotate.mjs';
|
|
17
|
+
import { cxDir } from '../paths.mjs';
|
|
18
|
+
|
|
19
|
+
export function defaultLogPath() {
|
|
20
|
+
return path.join(cxDir(), 'beads-fallback.jsonl');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function logBeadsFallback(event, opts = {}) {
|
|
24
|
+
const logPath = opts.logPath || defaultLogPath();
|
|
25
|
+
const entry = {
|
|
26
|
+
ts: new Date().toISOString(),
|
|
27
|
+
command: event?.command || 'unknown',
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
31
|
+
appendBounded('beads-fallback', logPath, JSON.stringify(entry) + '\n');
|
|
32
|
+
} catch { /* best-effort */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readBeadsFallbacks({ logPath = defaultLogPath() } = {}) {
|
|
36
|
+
if (!fs.existsSync(logPath)) return [];
|
|
37
|
+
return fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean).map((l) => {
|
|
38
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
39
|
+
}).filter(Boolean);
|
|
40
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/telemetry/hook-calls.mjs — measure which hooks fire and which actually block.
|
|
3
|
+
*
|
|
4
|
+
* Hooks carry @p95ms latency budgets but no record of how often each one fires or
|
|
5
|
+
* blocks, so a hook that gates real mistakes and one that has never fired once look
|
|
6
|
+
* identical. The central dispatcher (`construct hook <name>`) appends one JSON line
|
|
7
|
+
* per invocation to ~/.cx/hook-calls.jsonl with the hook id, the outcome derived
|
|
8
|
+
* from its exit code, and latency, so `construct hooks:usage` can answer which hooks
|
|
9
|
+
* earn their keep before any are consolidated.
|
|
10
|
+
*
|
|
11
|
+
* Outcome is what the dispatcher can observe centrally: exit 0 = ok, exit 2 = blocked
|
|
12
|
+
* (the Claude Code convention for a refused tool call — see block-no-verify), any
|
|
13
|
+
* other non-zero = error. A hook that mutates state without blocking reads as `ok`
|
|
14
|
+
* here; per-hook mutation reporting is a separate, opt-in signal.
|
|
15
|
+
*
|
|
16
|
+
* Disable with CONSTRUCT_HOOK_TELEMETRY=off. Errors here are non-fatal — telemetry
|
|
17
|
+
* must never change a hook's exit code or add a failure mode to the hot path.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { appendBounded } from '../logging/rotate.mjs';
|
|
23
|
+
import { resolveProjectScope } from '../project-root.mjs';
|
|
24
|
+
import { cxDir } from '../paths.mjs';
|
|
25
|
+
|
|
26
|
+
// cxDir() (not os.homedir()) so CX_HOME_OVERRIDE isolates the log in tests and the
|
|
27
|
+
// path stays consistent with the rest of ~/.cx. Resolved per-call so an override
|
|
28
|
+
// set after import still takes effect.
|
|
29
|
+
|
|
30
|
+
export function defaultLogPath() {
|
|
31
|
+
return path.join(cxDir(), 'hook-calls.jsonl');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Exit-code → outcome, using the Claude Code hook convention (2 = block a tool
|
|
35
|
+
// call). Anything else non-zero is a hook that errored rather than deliberately
|
|
36
|
+
// gated, which is itself a value signal (a hook erroring every fire is noise).
|
|
37
|
+
|
|
38
|
+
export function outcomeFromExit(code) {
|
|
39
|
+
if (code === 0 || code == null) return 'ok';
|
|
40
|
+
if (code === 2) return 'blocked';
|
|
41
|
+
return 'error';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fire-and-forget log of one hook invocation.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} event
|
|
48
|
+
* @param {string} event.hookId — the hook name, e.g. "block-no-verify"
|
|
49
|
+
* @param {number} [event.exitCode] — the hook's process exit code
|
|
50
|
+
* @param {'ok'|'blocked'|'error'} [event.outcome] — derived from exitCode if omitted
|
|
51
|
+
* @param {number} [event.latencyMs]
|
|
52
|
+
* @param {object} [opts]
|
|
53
|
+
* @param {string} [opts.logPath]
|
|
54
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
55
|
+
*/
|
|
56
|
+
export function logHookCall(event, opts = {}) {
|
|
57
|
+
const env = opts.env || process.env;
|
|
58
|
+
if (env.CONSTRUCT_HOOK_TELEMETRY === 'off') return;
|
|
59
|
+
if (!event || !event.hookId) return;
|
|
60
|
+
|
|
61
|
+
const logPath = opts.logPath || defaultLogPath();
|
|
62
|
+
const outcome = event.outcome || outcomeFromExit(event.exitCode);
|
|
63
|
+
const scope = resolveProjectScope();
|
|
64
|
+
const entry = {
|
|
65
|
+
ts: new Date().toISOString(),
|
|
66
|
+
hookId: event.hookId,
|
|
67
|
+
outcome,
|
|
68
|
+
...(event.exitCode != null ? { exitCode: event.exitCode } : {}),
|
|
69
|
+
...(scope?.projectId ? { projectId: scope.projectId } : {}),
|
|
70
|
+
...(event.latencyMs != null ? { latencyMs: event.latencyMs } : {}),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
75
|
+
appendBounded('hook-calls', logPath, JSON.stringify(entry) + '\n');
|
|
76
|
+
} catch {
|
|
77
|
+
// Append-only telemetry; an unwritable home dir is a silent skip rather than
|
|
78
|
+
// a new failure mode on the hook that triggered the call.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Roll a hook-calls log up into per-hook stats: fire count, blocked/error counts,
|
|
84
|
+
* most-recent timestamp, latency percentiles. A hook with calls but zero blocks/
|
|
85
|
+
* errors over a long window is a pure observer — a demotion candidate.
|
|
86
|
+
*/
|
|
87
|
+
export function summarizeHookCalls({ logPath = defaultLogPath(), since } = {}) {
|
|
88
|
+
if (!fs.existsSync(logPath)) return { totalEvents: 0, hooks: {} };
|
|
89
|
+
const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean);
|
|
90
|
+
const sinceTs = since ? _parseSince(since) : null;
|
|
91
|
+
const hooks = {};
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
let entry;
|
|
94
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
95
|
+
if (!entry?.hookId) continue;
|
|
96
|
+
if (sinceTs && entry.ts && entry.ts < sinceTs) continue;
|
|
97
|
+
const slot = hooks[entry.hookId] ||= { calls: 0, blocked: 0, errors: 0, latencies: [], lastCalledAt: null };
|
|
98
|
+
slot.calls += 1;
|
|
99
|
+
if (entry.outcome === 'blocked') slot.blocked += 1;
|
|
100
|
+
if (entry.outcome === 'error') slot.errors += 1;
|
|
101
|
+
if (entry.latencyMs != null) slot.latencies.push(entry.latencyMs);
|
|
102
|
+
if (entry.ts && (!slot.lastCalledAt || entry.ts > slot.lastCalledAt)) slot.lastCalledAt = entry.ts;
|
|
103
|
+
}
|
|
104
|
+
const result = {};
|
|
105
|
+
for (const [id, slot] of Object.entries(hooks)) {
|
|
106
|
+
result[id] = {
|
|
107
|
+
calls: slot.calls,
|
|
108
|
+
blocked: slot.blocked,
|
|
109
|
+
errors: slot.errors,
|
|
110
|
+
lastCalledAt: slot.lastCalledAt,
|
|
111
|
+
...(slot.latencies.length ? _latencyStats(slot.latencies) : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { totalEvents: lines.length, hooks: result };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Hooks with zero fire events in the window — they cost a settings entry and a
|
|
119
|
+
* spawn budget but have never run, so they are consolidation candidates.
|
|
120
|
+
*/
|
|
121
|
+
export function findIdleHooks({ logPath = defaultLogPath(), since = '30d', allHookIds = [] } = {}) {
|
|
122
|
+
const { hooks } = summarizeHookCalls({ logPath, since });
|
|
123
|
+
const active = new Set(Object.keys(hooks));
|
|
124
|
+
return allHookIds.filter((id) => !active.has(id));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _parseSince(since) {
|
|
128
|
+
const m = String(since).match(/^(\d+)(d|h|m)$/);
|
|
129
|
+
if (!m) return null;
|
|
130
|
+
const ms = parseInt(m[1], 10) * ({ d: 86400000, h: 3600000, m: 60000 }[m[2]]);
|
|
131
|
+
return new Date(Date.now() - ms).toISOString();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _latencyStats(arr) {
|
|
135
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
136
|
+
const p = (pct) => sorted[Math.floor((pct / 100) * (sorted.length - 1))];
|
|
137
|
+
return { p50LatencyMs: p(50), p95LatencyMs: p(95) };
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geraldmaron/construct",
|
|
3
|
-
"version": "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,11 +58,11 @@ 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
|
|
|
65
|
-
**Output style**: format human-facing output (terminal, prose, dashboard) for neurodivergent readers — answer first, clear hierarchy,
|
|
65
|
+
**Output style**: format human-facing output (terminal, prose, dashboard) for neurodivergent readers — answer first, clear hierarchy, plain language, explicit next step. Prose for reasoning; lists only for genuinely parallel items where scanning helps, never a wall of bullets. Never rely on color or motion alone; honor `NO_COLOR` and reduced-motion. Presentation only — never reshape machine-readable output (`--json`, parsed tokens, registries, contracts). See `rules/common/neurodivergent-output.md`.
|
|
66
66
|
|
|
67
67
|
**Tool invisibility**: deliverables are about the user's project, never Construct. Never name Construct, `cx-*` role ids, or internal orchestration mechanics in artifact content unless the subject project is Construct itself. Provenance goes in a comment, not the prose. See `rules/common/tool-invisibility.md`.
|
|
68
68
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../schemas/platform-capabilities.schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"hosts": {
|
|
5
|
+
"claude": {
|
|
6
|
+
"displayName": "Claude Code",
|
|
7
|
+
"hasNativeSubagents": false,
|
|
8
|
+
"instructionsOnly": false,
|
|
9
|
+
"supportsMcp": true,
|
|
10
|
+
"configFormat": "json",
|
|
11
|
+
"localModelProvisioning": "none",
|
|
12
|
+
"hooks": {
|
|
13
|
+
"supported": true,
|
|
14
|
+
"globalAllowlist": [
|
|
15
|
+
"pre:bash:block-no-verify",
|
|
16
|
+
"pre:bash:guard-dangerous",
|
|
17
|
+
"pre:edit:config-protection",
|
|
18
|
+
"pre:edit-guard",
|
|
19
|
+
"post:edit:json-validate",
|
|
20
|
+
"post:edit:scan-secrets"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"globalMcpAllowlist": ["context7"]
|
|
24
|
+
},
|
|
25
|
+
"codex": {
|
|
26
|
+
"displayName": "Codex",
|
|
27
|
+
"hasNativeSubagents": false,
|
|
28
|
+
"instructionsOnly": false,
|
|
29
|
+
"supportsMcp": true,
|
|
30
|
+
"configFormat": "toml",
|
|
31
|
+
"localModelProvisioning": "none",
|
|
32
|
+
"hooks": { "supported": false, "globalAllowlist": [] },
|
|
33
|
+
"globalMcpAllowlist": []
|
|
34
|
+
},
|
|
35
|
+
"copilot": {
|
|
36
|
+
"displayName": "Copilot",
|
|
37
|
+
"hasNativeSubagents": false,
|
|
38
|
+
"instructionsOnly": true,
|
|
39
|
+
"supportsMcp": false,
|
|
40
|
+
"configFormat": "markdown",
|
|
41
|
+
"localModelProvisioning": "none",
|
|
42
|
+
"hooks": { "supported": false, "globalAllowlist": [] },
|
|
43
|
+
"globalMcpAllowlist": []
|
|
44
|
+
},
|
|
45
|
+
"opencode": {
|
|
46
|
+
"displayName": "OpenCode",
|
|
47
|
+
"hasNativeSubagents": true,
|
|
48
|
+
"instructionsOnly": false,
|
|
49
|
+
"supportsMcp": true,
|
|
50
|
+
"configFormat": "json",
|
|
51
|
+
"localModelProvisioning": "modelfile",
|
|
52
|
+
"hooks": { "supported": false, "globalAllowlist": [] },
|
|
53
|
+
"globalMcpAllowlist": []
|
|
54
|
+
},
|
|
55
|
+
"vscode": {
|
|
56
|
+
"displayName": "VS Code",
|
|
57
|
+
"hasNativeSubagents": true,
|
|
58
|
+
"instructionsOnly": false,
|
|
59
|
+
"supportsMcp": true,
|
|
60
|
+
"configFormat": "json",
|
|
61
|
+
"localModelProvisioning": "none",
|
|
62
|
+
"hooks": { "supported": false, "globalAllowlist": [] },
|
|
63
|
+
"globalMcpAllowlist": []
|
|
64
|
+
},
|
|
65
|
+
"cursor": {
|
|
66
|
+
"displayName": "Cursor",
|
|
67
|
+
"hasNativeSubagents": true,
|
|
68
|
+
"instructionsOnly": false,
|
|
69
|
+
"supportsMcp": true,
|
|
70
|
+
"configFormat": "json",
|
|
71
|
+
"localModelProvisioning": "none",
|
|
72
|
+
"hooks": { "supported": false, "globalAllowlist": [] },
|
|
73
|
+
"globalMcpAllowlist": []
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -1,13 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* sync-config.mjs — Fetch free models from OpenRouter and
|
|
4
|
-
*
|
|
3
|
+
* sync-config.mjs — Fetch free models from OpenRouter and local models from Ollama,
|
|
4
|
+
* and sync into OpenCode config.
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
-
import {
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { readOpenCodeConfig, writeOpenCodeConfig, findOpenCodeConfigPath } from "../../lib/opencode-config.mjs";
|
|
9
|
+
import { ensureLocalContextVariants, modelDigest } from "../../lib/ollama/provision-context.mjs";
|
|
10
|
+
import { isKnownCollapsed } from "../../lib/ollama/capability-store.mjs";
|
|
11
|
+
|
|
12
|
+
const LOCAL_NUM_CTX = Number(process.env.CONSTRUCT_LOCAL_NUM_CTX) || 32768;
|
|
8
13
|
|
|
9
14
|
const configPath = findOpenCodeConfigPath();
|
|
10
15
|
|
|
16
|
+
async function fetchLocalOllamaModels() {
|
|
17
|
+
try {
|
|
18
|
+
const r = spawnSync("ollama", ["list"], { encoding: "utf8" });
|
|
19
|
+
if (r.status !== 0) return [];
|
|
20
|
+
|
|
21
|
+
const lines = r.stdout.trim().split("\n").slice(1);
|
|
22
|
+
const models = lines.map(line => {
|
|
23
|
+
const parts = line.split(/\s+/).filter(Boolean);
|
|
24
|
+
const id = parts[0];
|
|
25
|
+
const size = parts[2];
|
|
26
|
+
return { id, name: id, size };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Identify redundant tags (same family, many tags)
|
|
30
|
+
const familyMap = new Map();
|
|
31
|
+
for (const m of models) {
|
|
32
|
+
const family = m.id.split(":")[0];
|
|
33
|
+
if (!familyMap.has(family)) familyMap.set(family, []);
|
|
34
|
+
familyMap.get(family).push(m);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const [family, tags] of familyMap) {
|
|
38
|
+
if (tags.length > 2) {
|
|
39
|
+
console.log(`[cleanup] Potential redundant tags for ${family}: ${tags.map(t => t.id).join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return models;
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
11
49
|
async function fetchFreeModels() {
|
|
12
50
|
try {
|
|
13
51
|
const res = await fetch("https://openrouter.ai/api/v1/models");
|
|
@@ -31,39 +69,97 @@ async function main() {
|
|
|
31
69
|
|
|
32
70
|
const { config } = readOpenCodeConfig();
|
|
33
71
|
const freeModels = await fetchFreeModels();
|
|
72
|
+
const localModels = await fetchLocalOllamaModels();
|
|
34
73
|
|
|
35
|
-
if (freeModels.length === 0) {
|
|
36
|
-
console.log("No
|
|
74
|
+
if (freeModels.length === 0 && localModels.length === 0) {
|
|
75
|
+
console.log("No models fetched. Config unchanged.");
|
|
37
76
|
return;
|
|
38
77
|
}
|
|
39
78
|
|
|
40
79
|
if (!config.provider) config.provider = {};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
80
|
+
|
|
81
|
+
// Sync OpenRouter Free Models
|
|
82
|
+
if (freeModels.length > 0) {
|
|
83
|
+
if (!config.provider.openrouter) {
|
|
84
|
+
config.provider.openrouter = {
|
|
85
|
+
npm: "@ai-sdk/openai-compatible",
|
|
86
|
+
name: "OpenRouter",
|
|
87
|
+
options: {
|
|
88
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
89
|
+
headers: {}
|
|
90
|
+
},
|
|
91
|
+
models: {}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const existingOR = config.provider.openrouter.models ?? {};
|
|
95
|
+
for (const model of freeModels) {
|
|
96
|
+
const cleanName = model.name.replace(/\s*\(free\)/gi, "").trim();
|
|
97
|
+
existingOR[model.id] = { name: `[free] ${cleanName}` };
|
|
98
|
+
}
|
|
99
|
+
config.provider.openrouter.models = Object.fromEntries(
|
|
100
|
+
Object.entries(existingOR).sort((a, b) => (a[1].name ?? a[0]).localeCompare(b[1].name ?? b[0]))
|
|
101
|
+
);
|
|
51
102
|
}
|
|
52
103
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
104
|
+
// Sync Local Ollama Models
|
|
105
|
+
if (localModels.length > 0) {
|
|
106
|
+
if (!config.provider.ollama) {
|
|
107
|
+
config.provider.ollama = {
|
|
108
|
+
npm: "@ai-sdk/openai-compatible",
|
|
109
|
+
name: "Ollama",
|
|
110
|
+
options: {
|
|
111
|
+
baseURL: "http://localhost:11434/v1"
|
|
112
|
+
},
|
|
113
|
+
models: {}
|
|
114
|
+
};
|
|
58
115
|
}
|
|
59
|
-
|
|
116
|
+
// A raw model runs at Ollama's 4096 default over the /v1 path, which a Construct
|
|
117
|
+
// session's tool schemas overrun. Provision a context-extended Modelfile variant
|
|
118
|
+
// for each tool-capable model lacking a baked num_ctx (any size — capability does
|
|
119
|
+
// not track size) and register the variant in place of the raw tag so the model
|
|
120
|
+
// OpenCode actually talks to has a real context window.
|
|
121
|
+
|
|
122
|
+
const { mapping: variantMap, actions } = ensureLocalContextVariants({ numCtx: LOCAL_NUM_CTX });
|
|
123
|
+
for (const a of actions) {
|
|
124
|
+
if (a.action === "created" || a.action === "would-create") console.log(`[ollama] context variant ${a.action}: ${a.variant} (num_ctx ${LOCAL_NUM_CTX})`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const existingLocal = config.provider.ollama.models ?? {};
|
|
128
|
+
for (const model of localModels) {
|
|
129
|
+
const registerId = variantMap[model.id] || model.id;
|
|
130
|
+
// Register the context-extended variant in place of the raw tag so the model
|
|
131
|
+
// picker surfaces the windowed option, not the one Ollama serves at 4096.
|
|
60
132
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
133
|
+
if (registerId !== model.id) delete existingLocal[model.id];
|
|
134
|
+
existingLocal[registerId] = {
|
|
135
|
+
name: registerId.replace(/\s*\(local\)/gi, "").trim(),
|
|
136
|
+
family: registerId.includes("qwen") ? "qwen2" : "llama",
|
|
137
|
+
tool_call: true
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Capability honesty: a model the probe recorded as COLLAPSED (digest still
|
|
141
|
+
// matching) word-salads on the agentic loop. Warn rather than hide — the
|
|
142
|
+
// user keeps the choice and can re-probe — so a stale or false verdict never
|
|
143
|
+
// silently strands a working model.
|
|
144
|
+
if (isKnownCollapsed(model.id, modelDigest(model.id))) {
|
|
145
|
+
console.log(`[ollama] WARNING: ${model.id} probed COLLAPSED — not agentic-capable. Re-probe: construct doctor --probe-local`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
config.provider.ollama.models = Object.fromEntries(
|
|
149
|
+
Object.entries(existingLocal).sort((a, b) => (a[1].name ?? a[0]).localeCompare(b[1].name ?? b[0]))
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Agentic coherence is model-specific and not predictable from size: some small
|
|
153
|
+
// coder models collapse into repetition on an agentic system prompt while others
|
|
154
|
+
// run the same Construct payload cleanly. Surface the empirical probe rather than
|
|
155
|
+
// silently registering a model that will produce word salad.
|
|
156
|
+
|
|
157
|
+
console.log("[ollama] Verify a model handles agentic prompts before relying on it:");
|
|
158
|
+
console.log("[ollama] node lib/ollama/provision-context.mjs --probe --model=<id>");
|
|
159
|
+
}
|
|
64
160
|
|
|
65
161
|
writeOpenCodeConfig(config, configPath);
|
|
66
|
-
console.log(`Synced ${freeModels.length} free models
|
|
162
|
+
console.log(`Synced ${freeModels.length} free models and ${localModels.length} local models.`);
|
|
67
163
|
}
|
|
68
164
|
|
|
69
165
|
main();
|