@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.
Files changed (87) hide show
  1. package/README.md +2 -0
  2. package/bin/construct +266 -13
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/audit-trail.mjs +77 -10
  7. package/lib/beads-client.mjs +9 -0
  8. package/lib/beads-optimistic.mjs +23 -71
  9. package/lib/bridges/copilot-proxy.mjs +116 -0
  10. package/lib/cli-commands.mjs +112 -1
  11. package/lib/comment-lint.mjs +1 -1
  12. package/lib/config/schema.mjs +1 -1
  13. package/lib/demo.mjs +245 -0
  14. package/lib/diagram.mjs +300 -0
  15. package/lib/doctor/index.mjs +1 -1
  16. package/lib/doctor/watchers/process-pressure.mjs +1 -1
  17. package/lib/doctor/watchers/service-health.mjs +1 -1
  18. package/lib/document-extract/docling-client.mjs +16 -6
  19. package/lib/document-extract/docling-sidecar.py +32 -2
  20. package/lib/document-extract.mjs +85 -10
  21. package/lib/document-ingest.mjs +97 -7
  22. package/lib/embed/roadmap.mjs +16 -1
  23. package/lib/embed/semantic.mjs +5 -2
  24. package/lib/engine/consolidate.mjs +160 -3
  25. package/lib/engine/contradiction-judge.mjs +71 -0
  26. package/lib/engine/contradiction.mjs +74 -0
  27. package/lib/handoffs/cleanup.mjs +1 -1
  28. package/lib/hooks/audit-trail.mjs +14 -28
  29. package/lib/host-capabilities.mjs +30 -0
  30. package/lib/ingest/docling-remote.mjs +90 -0
  31. package/lib/ingest/strategy.mjs +1 -1
  32. package/lib/init-unified.mjs +9 -13
  33. package/lib/logging/rotate.mjs +18 -0
  34. package/lib/mcp/server.mjs +124 -12
  35. package/lib/mcp/tool-budget.mjs +53 -0
  36. package/lib/mcp-catalog.json +1 -1
  37. package/lib/model-router.mjs +52 -1
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration-policy.mjs +41 -6
  43. package/lib/persona-sections.mjs +66 -0
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +14 -9
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reflect/extractor.mjs +14 -1
  48. package/lib/reflect/salience.mjs +65 -0
  49. package/lib/rules-delivery.mjs +122 -0
  50. package/lib/runtime/uv-bootstrap.mjs +32 -17
  51. package/lib/server/index.mjs +1 -1
  52. package/lib/server/langfuse-login.mjs +3 -3
  53. package/lib/service-manager.mjs +42 -4
  54. package/lib/session-store.mjs +1 -1
  55. package/lib/setup.mjs +58 -2
  56. package/lib/specialists/prompt-schema.mjs +162 -0
  57. package/lib/specialists/scaffold.mjs +109 -0
  58. package/lib/storage/embeddings-engine.mjs +19 -5
  59. package/lib/storage/embeddings-local.mjs +6 -3
  60. package/lib/storage/file-lock.mjs +18 -11
  61. package/lib/telemetry/beads-fallback.mjs +40 -0
  62. package/lib/telemetry/hook-calls.mjs +138 -0
  63. package/package.json +4 -2
  64. package/personas/construct.md +12 -12
  65. package/platforms/capabilities.json +76 -0
  66. package/platforms/opencode/sync-config.mjs +121 -25
  67. package/rules/common/neurodivergent-output.md +1 -1
  68. package/rules/web/coding-style.md +8 -0
  69. package/rules/web/design-quality.md +8 -0
  70. package/rules/web/hooks.md +8 -0
  71. package/rules/web/patterns.md +8 -0
  72. package/rules/web/performance.md +8 -0
  73. package/rules/web/security.md +8 -0
  74. package/rules/web/testing.md +8 -0
  75. package/scripts/sync-specialists.mjs +277 -63
  76. package/skills/docs/init-project.md +1 -1
  77. package/specialists/prompts/cx-architect.md +20 -0
  78. package/specialists/prompts/cx-test-automation.md +12 -0
  79. package/templates/docs/construct_guide.md +2 -2
  80. package/lib/ingest/chunker.mjs +0 -94
  81. package/lib/ingest/pipeline.mjs +0 -53
  82. package/lib/ingest/store.mjs +0 -82
  83. package/lib/mode-commands.mjs +0 -122
  84. package/lib/policy/unified-gates.mjs +0 -96
  85. package/lib/profiles/validate-custom.mjs +0 -114
  86. package/lib/services/telemetry-backend.mjs +0 -177
  87. 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) — fallback / test fixture only
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') return requested;
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] || ADAPTERS.hashing;
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] || ADAPTERS.hashing;
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] || ADAPTERS.hashing;
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, 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
 
@@ -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.0.24",
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,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, scannable chunks, plain language, explicit next step; 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`.
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 sync into OpenCode config.
4
- * Run periodically or during install to keep the free model list current.
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 { readOpenCodeConfig, writeOpenCodeConfig, findOpenCodeConfigPath } from "../lib/opencode-config.mjs";
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 free models fetched. Config unchanged.");
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
- if (!config.provider.openrouter) {
42
- config.provider.openrouter = {
43
- npm: "@ai-sdk/openai-compatible",
44
- name: "OpenRouter",
45
- options: {
46
- baseURL: "https://openrouter.ai/api/v1",
47
- headers: {}
48
- },
49
- models: {}
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
- const existingModels = config.provider.openrouter.models ?? {};
54
- const merged = { ...existingModels };
55
- for (const model of freeModels) {
56
- if (!merged[model.id]) {
57
- merged[model.id] = { name: `${model.name} (free)` };
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
- config.provider.openrouter.models = Object.fromEntries(
62
- Object.entries(merged).sort((a, b) => (a[1].name ?? a[0]).localeCompare(b[1].name ?? b[0]))
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 from OpenRouter.`);
162
+ console.log(`Synced ${freeModels.length} free models and ${localModels.length} local models.`);
67
163
  }
68
164
 
69
165
  main();