@geraldmaron/construct 1.0.24 → 1.1.0

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 (60) hide show
  1. package/bin/construct +185 -3
  2. package/lib/agent-instructions/inject.mjs +25 -4
  3. package/lib/audit-rules.mjs +127 -0
  4. package/lib/audit-skills.mjs +43 -1
  5. package/lib/beads-client.mjs +9 -0
  6. package/lib/beads-optimistic.mjs +23 -71
  7. package/lib/bridges/copilot-proxy.mjs +116 -0
  8. package/lib/cli-commands.mjs +5 -1
  9. package/lib/comment-lint.mjs +1 -1
  10. package/lib/config/schema.mjs +1 -1
  11. package/lib/document-extract/docling-client.mjs +16 -6
  12. package/lib/document-extract/docling-sidecar.py +32 -2
  13. package/lib/document-extract.mjs +37 -10
  14. package/lib/document-ingest.mjs +90 -5
  15. package/lib/embed/roadmap.mjs +16 -1
  16. package/lib/engine/consolidate.mjs +160 -3
  17. package/lib/engine/contradiction-judge.mjs +71 -0
  18. package/lib/engine/contradiction.mjs +74 -0
  19. package/lib/host-capabilities.mjs +30 -0
  20. package/lib/ingest/docling-remote.mjs +90 -0
  21. package/lib/ingest/strategy.mjs +1 -1
  22. package/lib/init-unified.mjs +9 -13
  23. package/lib/logging/rotate.mjs +18 -0
  24. package/lib/mcp/server.mjs +124 -12
  25. package/lib/mcp/tool-budget.mjs +53 -0
  26. package/lib/mcp-catalog.json +1 -1
  27. package/lib/ollama/capability-store.mjs +78 -0
  28. package/lib/ollama/provision-context.mjs +344 -0
  29. package/lib/opencode-config.mjs +148 -0
  30. package/lib/opencode-telemetry.mjs +7 -0
  31. package/lib/orchestration-policy.mjs +41 -6
  32. package/lib/platforms/capabilities.mjs +100 -0
  33. package/lib/prompt-composer.js +12 -8
  34. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  35. package/lib/reflect/extractor.mjs +14 -1
  36. package/lib/reflect/salience.mjs +65 -0
  37. package/lib/rules-delivery.mjs +122 -0
  38. package/lib/runtime/uv-bootstrap.mjs +32 -17
  39. package/lib/service-manager.mjs +41 -3
  40. package/lib/setup.mjs +21 -2
  41. package/lib/specialists/prompt-schema.mjs +162 -0
  42. package/lib/specialists/scaffold.mjs +109 -0
  43. package/lib/storage/embeddings-engine.mjs +19 -5
  44. package/lib/telemetry/beads-fallback.mjs +40 -0
  45. package/lib/telemetry/hook-calls.mjs +138 -0
  46. package/package.json +1 -1
  47. package/personas/construct.md +1 -1
  48. package/platforms/capabilities.json +76 -0
  49. package/platforms/opencode/sync-config.mjs +121 -25
  50. package/rules/common/neurodivergent-output.md +1 -1
  51. package/rules/web/coding-style.md +8 -0
  52. package/rules/web/design-quality.md +8 -0
  53. package/rules/web/hooks.md +8 -0
  54. package/rules/web/patterns.md +8 -0
  55. package/rules/web/performance.md +8 -0
  56. package/rules/web/security.md +8 -0
  57. package/rules/web/testing.md +8 -0
  58. package/scripts/sync-specialists.mjs +139 -39
  59. package/specialists/prompts/cx-architect.md +20 -0
  60. package/specialists/prompts/cx-test-automation.md +12 -0
package/bin/construct CHANGED
@@ -169,6 +169,44 @@ function runNodeScript(scriptPath, args = [], extraEnv = {}, { exitOnError = tru
169
169
  return status;
170
170
  }
171
171
 
172
+ async function cmdRegistryStatus(args = []) {
173
+ const { readFileSync, existsSync } = await import('node:fs');
174
+ const { join } = await import('node:path');
175
+ const matrixPath = join(ROOT_DIR, 'tests', 'registry', 'capability-matrix.json');
176
+
177
+ if (!existsSync(matrixPath)) {
178
+ errorln('Capability matrix not found. Run "construct sync" or ensure tests/registry/capability-matrix.json exists.');
179
+ process.exit(1);
180
+ }
181
+
182
+ const { capabilities } = JSON.parse(readFileSync(matrixPath, 'utf8'));
183
+ const jsonOutput = args.includes('--json');
184
+
185
+ if (jsonOutput) {
186
+ println(JSON.stringify(capabilities, null, 2));
187
+ return;
188
+ }
189
+
190
+ println(`${COLORS.bold}Workflow & Surface Capability Registry${COLORS.reset}`);
191
+ println('='.repeat(40));
192
+ println('');
193
+
194
+ for (const cap of capabilities) {
195
+ const tierColor = cap.criticality === 'P0' ? COLORS.red : cap.criticality === 'P1' ? COLORS.yellow : COLORS.blue;
196
+ println(`${tierColor}[${cap.criticality}]${COLORS.reset} ${COLORS.bold}${cap.name}${COLORS.reset} (${cap.id})`);
197
+ println(` ${COLORS.dim}${cap.description}${COLORS.reset}`);
198
+
199
+ const surfaces = Object.entries(cap.surfaces);
200
+ for (const [name, status] of surfaces) {
201
+ const icon = status.quality_score === null ? '⚪' : status.quality_score >= 0.9 ? '🟢' : status.quality_score >= 0.7 ? '🟡' : '🔴';
202
+ const score = status.quality_score !== null ? `${(status.quality_score * 100).toFixed(0)}%` : 'N/A';
203
+ const date = status.last_validated ? new Date(status.last_validated).toLocaleDateString() : 'never';
204
+ println(` ${icon} ${name.padEnd(10)} | Score: ${score.padEnd(5)} | Validated: ${date}`);
205
+ }
206
+ println('');
207
+ }
208
+ }
209
+
172
210
  async function cmdStatus() {
173
211
  const jsonOutput = restArgsCache.includes('--json');
174
212
  const status = await buildStatus({ rootDir: ROOT_DIR, cwd: process.cwd(), homeDir: HOME, env: process.env });
@@ -1051,6 +1089,69 @@ async function cmdDoctor() {
1051
1089
  add(`Reconciliation drift check failed: ${err.message}`, false, true);
1052
1090
  }
1053
1091
 
1092
+ // Docling document-extraction runtime (Python venv via uv). Provisioned lazily
1093
+ // on first document ingest, or eagerly via `construct install --with-docling`.
1094
+ // Advisory: absence is fine until a document is ingested.
1095
+ try {
1096
+ const { describeDoclingRuntime } = await import('../lib/runtime/uv-bootstrap.mjs');
1097
+ const docling = describeDoclingRuntime();
1098
+ if (docling.available) {
1099
+ add(`Docling runtime ready (docling ${docling.marker?.doclingVersion ?? '?'})`, true, true);
1100
+ } else {
1101
+ add('Docling runtime not provisioned (`construct install --with-docling`, or auto on first ingest)', false, true);
1102
+ }
1103
+ } catch (err) {
1104
+ add(`Docling runtime check failed: ${err.message}`, false, true);
1105
+ }
1106
+
1107
+ // Capability honesty without re-probing: if the OpenCode default model is one the
1108
+ // probe recorded as COLLAPSED (and its digest still matches), flag it at health
1109
+ // time. Reads the persisted store only — the slow probe stays behind --probe-local.
1110
+ try {
1111
+ const { readOpenCodeConfig } = await import('../lib/opencode-config.mjs');
1112
+ const { isKnownCollapsed } = await import('../lib/ollama/capability-store.mjs');
1113
+ const { modelDigest } = await import('../lib/ollama/provision-context.mjs');
1114
+ const { config } = readOpenCodeConfig();
1115
+ const defaultModel = (config?.model || config?.defaultModel || '').replace(/^ollama\//, '');
1116
+ if (defaultModel && isKnownCollapsed(defaultModel, modelDigest(defaultModel))) {
1117
+ add(`Default local model ${defaultModel} probed COLLAPSED — not agentic-capable (re-verify: construct doctor --probe-local)`, false, true);
1118
+ }
1119
+ } catch { /* advisory */ }
1120
+
1121
+ // Opt-in agentic-coherence probe for local models (`construct doctor --probe-local`).
1122
+ // Loads each registered Ollama model and measures repetition collapse on an agentic
1123
+ // prompt, so it is gated behind the flag. COLLAPSED models produce word salad no
1124
+ // matter how context and tools are tuned (capability is not predictable from size).
1125
+ if (args.includes('--probe-local')) {
1126
+ try {
1127
+ const { ollamaAvailable, listModels, probeAgenticCoherence, probeStreamingToolCallLeak, modelDigest } = await import('../lib/ollama/provision-context.mjs');
1128
+ const { recordProbeResult } = await import('../lib/ollama/capability-store.mjs');
1129
+ // --probe-stream additionally drives a streaming turn and flags a tool-call
1130
+ // leaked into the text channel (`<function=…>`) — the artifact the buffered
1131
+ // probe cannot reproduce. It reloads each model, so it stays opt-in.
1132
+ const probeStream = args.includes('--probe-stream');
1133
+ if (!ollamaAvailable()) {
1134
+ add('Local-model probe: Ollama not available', false, true);
1135
+ } else {
1136
+ const models = listModels().filter((m) => !/embed/i.test(m));
1137
+ for (const m of models) {
1138
+ const r = await probeAgenticCoherence(m);
1139
+ if (r.ok) recordProbeResult(m, r, modelDigest(m));
1140
+ const verdict = r.ok ? (r.coherent ? `COHERENT (repeat ${r.repeatRatio})` : `COLLAPSED (repeat ${r.repeatRatio})`) : `error: ${r.reason}`;
1141
+ add(`Local probe ${m}: ${verdict}`, r.ok && r.coherent, true);
1142
+ if (probeStream && r.ok && r.coherent) {
1143
+ const s = await probeStreamingToolCallLeak(m);
1144
+ if (s.ok) add(`Stream probe ${m}: ${s.leaked ? `TOOL-CALL LEAK (${s.marker})` : 'clean (native tool_calls)'}`, !s.leaked, true);
1145
+ }
1146
+ }
1147
+ add('Quantization: prefer Q4_K_M+ — low quants are a known word-salad source', true, true);
1148
+ add('Verified agentic-capable locals: qwen3-coder:32k, devstral:24b', true, true);
1149
+ }
1150
+ } catch (err) {
1151
+ add(`Local-model probe failed: ${err.message}`, false, true);
1152
+ }
1153
+ }
1154
+
1054
1155
  println('Construct Health Check');
1055
1156
  println('══════════════════════');
1056
1157
  println('');
@@ -4131,15 +4232,29 @@ async function cmdMemory(args) {
4131
4232
  const { getEngine } = await import('../lib/engine/index.mjs');
4132
4233
  const engine = await getEngine({ rootDir: process.cwd() });
4133
4234
  const summariser = engine.layers.compressor;
4235
+
4236
+ // The judge resolves the value-swap contradictions the heuristic abstains on
4237
+ // when a local model is available; null offline, so consolidation runs the
4238
+ // same with heuristic-only.
4239
+ const { createContradictionJudge } = await import('../lib/engine/contradiction-judge.mjs');
4240
+ const contradictionJudge = createContradictionJudge();
4134
4241
  const thresholdArg = args.find((a) => a.startsWith('--threshold='));
4135
4242
  const archiveDaysArg = args.find((a) => a.startsWith('--archive-days='));
4136
- const opts = { summariser };
4243
+ const opts = { summariser, contradictionJudge };
4137
4244
  if (thresholdArg) opts.similarityThreshold = Number(thresholdArg.split('=')[1]);
4138
4245
  if (archiveDaysArg) opts.archiveAfterDays = Number(archiveDaysArg.split('=')[1]);
4246
+ if (args.includes('--no-supersede')) opts.supersedeDuplicates = false;
4247
+ if (args.includes('--no-contradictions')) opts.detectContradictions = false;
4139
4248
  const result = await consolidate(process.cwd(), opts);
4249
+ const superseded = result.superseded ?? [];
4250
+ const contradictions = superseded.filter((s) => s.reason === 'contradiction').length;
4251
+ const restatements = superseded.length - contradictions;
4252
+ const agedOut = result.archived.length - superseded.length;
4140
4253
  process.stdout.write(
4141
4254
  `consolidate: ${result.clustersBefore} observations → ${result.clusters} insights, ` +
4142
- `${result.archived.length} archived, ${result.archivePruned ?? 0} pruned from archive\n`
4255
+ `${restatements} restated, ${contradictions} contradicted, ${agedOut} aged-out, ` +
4256
+ `${result.archivePruned ?? 0} pruned from archive` +
4257
+ `${result.contradictionScanSkipped ? ' (contradiction scan skipped: store too large)' : ''}\n`
4143
4258
  );
4144
4259
  return;
4145
4260
  }
@@ -4454,6 +4569,59 @@ async function cmdLintContracts() {
4454
4569
  process.exit(1);
4455
4570
  }
4456
4571
 
4572
+ async function cmdLintPrompts() {
4573
+ const { validatePromptFiles } = await import('../lib/specialists/prompt-schema.mjs');
4574
+ const { errors, warnings, total, converted } = validatePromptFiles({ rootDir: ROOT_DIR });
4575
+ for (const w of warnings) console.warn(` warn ${w}`);
4576
+ if (errors.length === 0) {
4577
+ console.log(`specialist prompts: ${total} files (${converted} converted), 0 errors, ${warnings.length} warning(s)`);
4578
+ return;
4579
+ }
4580
+ console.error(`specialist prompts: ${errors.length} error(s) across ${total} files`);
4581
+ for (const err of errors) console.error(` ${err}`);
4582
+ process.exit(1);
4583
+ }
4584
+
4585
+ async function cmdSpecialist(args) {
4586
+ const sub = args[0];
4587
+ if (sub === 'lint') return cmdLintPrompts();
4588
+
4589
+ if (sub === 'create') {
4590
+ const role = args[1];
4591
+ if (!role) { errorln('Usage: construct specialist create <role>'); process.exit(1); }
4592
+ const { createSpecialistDraft } = await import('../lib/specialists/scaffold.mjs');
4593
+ try {
4594
+ const { relPath } = createSpecialistDraft({ rootDir: ROOT_DIR, role });
4595
+ println(`Created ${relPath} — fill in the stubbed sections, add it to specialists/registry.json, then run \`construct specialist lint\`.`);
4596
+ } catch (e) { errorln(e.message); process.exit(1); }
4597
+ return;
4598
+ }
4599
+
4600
+ if (sub === 'edit') {
4601
+ const role = args[1];
4602
+ if (!role) { errorln('Usage: construct specialist edit <role> [--set-bias=… --set-tension=… --add-overlay=… --bump-version]'); process.exit(1); }
4603
+ const { editSpecialistFrontmatter } = await import('../lib/specialists/scaffold.mjs');
4604
+ const setPerspective = {};
4605
+ for (const f of ['bias', 'tension', 'openingQuestion', 'failureMode']) {
4606
+ const a = args.find((x) => x.startsWith(`--set-${f}=`));
4607
+ if (a) setPerspective[f] = a.slice(`--set-${f}=`.length);
4608
+ }
4609
+ const overlayArg = args.find((x) => x.startsWith('--add-overlay='));
4610
+ try {
4611
+ const { relPath } = editSpecialistFrontmatter({
4612
+ rootDir: ROOT_DIR, role, setPerspective,
4613
+ addOverlay: overlayArg ? overlayArg.slice('--add-overlay='.length) : undefined,
4614
+ bumpVersion: args.includes('--bump-version'),
4615
+ });
4616
+ println(`Updated ${relPath || `specialists/prompts/cx-${role}.md`} — run \`construct specialist lint\` to confirm.`);
4617
+ } catch (e) { errorln(e.message); process.exit(1); }
4618
+ return;
4619
+ }
4620
+
4621
+ errorln(`Unknown specialist subcommand: ${sub || '(none)'}. Available: create, edit, lint`);
4622
+ process.exit(1);
4623
+ }
4624
+
4457
4625
  async function cmdBackup(args) {
4458
4626
  // Postgres-era backups were removed with the SQL backend (lib/storage/backup.mjs).
4459
4627
  // State is now Git-backed (.cx/) plus the local LanceDB index under .cx/lancedb.
@@ -5000,8 +5168,19 @@ async function cmdHook(args) {
5000
5168
  errorln(`Hook not found: ${hookPath}`);
5001
5169
  process.exit(1);
5002
5170
  }
5171
+ const started = Date.now();
5003
5172
  const result = spawnSync(process.execPath, [hookPath, ...args.slice(1)], { stdio: 'inherit' });
5004
- process.exit(result.status ?? 0);
5173
+ const exitCode = result.status ?? 0;
5174
+
5175
+ // Record fire + outcome so hooks can be judged on whether they ever gate
5176
+ // anything, not just on latency. Dynamic import keeps non-hook commands from
5177
+ // paying the load; a telemetry failure must never change the hook's exit code.
5178
+ try {
5179
+ const { logHookCall } = await import('../lib/telemetry/hook-calls.mjs');
5180
+ logHookCall({ hookId: safe, exitCode, latencyMs: Date.now() - started });
5181
+ } catch { /* telemetry is best-effort */ }
5182
+
5183
+ process.exit(exitCode);
5005
5184
  }
5006
5185
 
5007
5186
  const [command, ...rest] = process.argv.slice(2);
@@ -5057,6 +5236,7 @@ const handlers = new Map([
5057
5236
  ['infer', cmdInfer],
5058
5237
  ['search', cmdSearch],
5059
5238
  ['storage', cmdStorage],
5239
+ ['registry:status', cmdRegistryStatus],
5060
5240
  // Pricing / cost readouts are stubbed out: the ledger writes, model-pricing
5061
5241
  // catalog, and per-turn accounting still run, but no CLI surface exposes
5062
5242
  // them to the user. Handlers (cmdPricing, cmdCosts, cmdCost) remain in this
@@ -5240,6 +5420,8 @@ const handlers = new Map([
5240
5420
  ['lint:research', cmdLintResearch],
5241
5421
  ['lint:agents', cmdLintAgents],
5242
5422
  ['lint:contracts', cmdLintContracts],
5423
+ ['lint:prompts', cmdLintPrompts],
5424
+ ['specialist', cmdSpecialist],
5243
5425
  ['decisions', async (args) => {
5244
5426
  const { runDecisionsCli } = await import('../lib/decisions/registry.mjs');
5245
5427
  return runDecisionsCli(args);
@@ -22,7 +22,17 @@ import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { createHash } from 'node:crypto';
24
24
 
25
- export const CONSTRUCT_INTEGRATION_VERSION = 1;
25
+ export const CONSTRUCT_INTEGRATION_VERSION = 2;
26
+
27
+ // AGENTS.md is the cross-platform single source (the agents.md standard read by
28
+ // Codex/OpenCode/etc.); CLAUDE.md gets a thin pointer whose `@AGENTS.md` import
29
+ // makes Claude Code load the same content at session start — the pattern the
30
+ // Claude Code memory docs recommend for repos that carry both files. One mapping,
31
+ // shared by init and reconcile, so the two writers cannot disagree.
32
+
33
+ export function variantForFile(filePath) {
34
+ return path.basename(filePath) === 'CLAUDE.md' ? 'pointer' : 'full';
35
+ }
26
36
 
27
37
  const BEGIN_PREFIX = '<!-- BEGIN CONSTRUCT INTEGRATION';
28
38
  const END_MARKER = '<!-- END CONSTRUCT INTEGRATION -->';
@@ -37,7 +47,18 @@ function shortHash(body) {
37
47
  // with Construct (≤60 lines). When a Beads Integration block is already present
38
48
  // the tracker line defers to it rather than duplicating `bd` commands.
39
49
 
40
- export function buildConstructIntegrationBody({ hasBeadsBlock = false } = {}) {
50
+ export function buildConstructIntegrationBody({ hasBeadsBlock = false, variant = 'full' } = {}) {
51
+ if (variant === 'pointer') {
52
+ return [
53
+ '## Construct integration',
54
+ '',
55
+ '@AGENTS.md',
56
+ '',
57
+ 'The Construct integration guidance for this project lives in `AGENTS.md`, imported',
58
+ 'above so Claude Code loads it at session start. Address `@construct` and ask for the',
59
+ 'outcome — Construct routes to the right specialist chain.',
60
+ ].join('\n');
61
+ }
41
62
  const tracker = hasBeadsBlock
42
63
  ? '- **Tracker**: see the Beads Integration block below for `bd` commands.'
43
64
  : '- **Tracker**: use Beads (`bd`) for all task tracking — run `bd prime` for the workflow. Do not use ad-hoc TODO lists.';
@@ -78,11 +99,11 @@ export function injectConstructBlock(content, body, version = CONSTRUCT_INTEGRAT
78
99
  // (with `header`) when missing; otherwise injects/updates in place, preserving
79
100
  // all surrounding content. Writes only when the content changes.
80
101
 
81
- export function injectIntoAgentFile(filePath, { version = CONSTRUCT_INTEGRATION_VERSION, header = '' } = {}) {
102
+ export function injectIntoAgentFile(filePath, { version = CONSTRUCT_INTEGRATION_VERSION, header = '', variant = null } = {}) {
82
103
  const existed = fs.existsSync(filePath);
83
104
  const current = existed ? fs.readFileSync(filePath, 'utf8') : '';
84
105
  const hasBeadsBlock = BEADS_BLOCK_RE.test(current);
85
- const body = buildConstructIntegrationBody({ hasBeadsBlock });
106
+ const body = buildConstructIntegrationBody({ hasBeadsBlock, variant: variant ?? variantForFile(filePath) });
86
107
  const base = !existed && header ? (header.endsWith('\n') ? header : `${header}\n`) : current;
87
108
  const { content, action } = injectConstructBlock(base, body, version);
88
109
  const changed = !existed || action !== 'unchanged';
@@ -0,0 +1,127 @@
1
+ /**
2
+ * lib/audit-rules.mjs — static reference audit for the rules/ corpus.
3
+ *
4
+ * Rules are not retrieved into agent context through any single runtime path the
5
+ * way skills are (no `logSkillCall` analog fits — they are named by path in prose
6
+ * the host's Read tool resolves, or read by enforcement tooling), so "which rules
7
+ * earn their keep" is a STATIC question. A rule is load-bearing two ways:
8
+ * 1. GLOB-SCOPED — its frontmatter declares `paths:` globs (the Cline/Cursor
9
+ * pattern), so it is intended to activate on a matching edit. Whether those
10
+ * globs are actually delivered to each host's rule config is a separate
11
+ * concern; here they mark the rule as intentionally scoped, not an orphan.
12
+ * 2. REFERENCED — the canonical `rules/<dir>/<name>` path token (with or without
13
+ * the .md) appears somewhere in the active surface (personas, CLAUDE.md,
14
+ * skills, all of lib/ except the minified dashboard bundles, registry/
15
+ * contracts, other rules, scripts, the CLI).
16
+ * A rule that is neither glob-activated nor referenced is a true orphan — a
17
+ * pruning candidate. A rule's own file never counts as referencing itself.
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+
25
+ // Directories and files that make up the "active surface" a rule could be cited
26
+ // from. Code and prose both count — a rule named in a hook or in bin/construct is
27
+ // as load-bearing as one named in a persona.
28
+
29
+ const SURFACE_DIRS = ['personas', 'skills', 'rules', 'lib', 'specialists', 'scripts'];
30
+ const SURFACE_FILES = ['CLAUDE.md', 'AGENTS.md', 'bin/construct', 'claude/settings.template.json'];
31
+ const TEXT_EXT = new Set(['.md', '.mjs', '.js', '.json', '.txt', '.mdx']);
32
+
33
+ // The minified Next.js dashboard bundles contain unrelated "rules/" tokens (the
34
+ // markdown lexer's own grammar) that would read as false references.
35
+ const SURFACE_SKIP = ['lib/server/static'];
36
+
37
+ function isSkipped(full, root) {
38
+ const rel = path.relative(root, full);
39
+ return SURFACE_SKIP.some((s) => rel === s || rel.startsWith(`${s}${path.sep}`));
40
+ }
41
+
42
+ // A rule with `paths:` globs in its frontmatter is glob-activated by the host on a
43
+ // matching edit — load-bearing regardless of whether it is named anywhere.
44
+ function hasPathGlobs(absPath) {
45
+ try {
46
+ const head = fs.readFileSync(absPath, 'utf8').slice(0, 1200);
47
+ const fm = head.match(/^---\n([\s\S]*?)\n---/);
48
+ return Boolean(fm && /\n?paths:\s*\n\s*-\s+/.test(fm[1]));
49
+ } catch { return false; }
50
+ }
51
+
52
+ function collectRuleFiles(rulesDir) {
53
+ const out = [];
54
+ const walk = (dir) => {
55
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
56
+ const full = path.join(dir, ent.name);
57
+ if (ent.isDirectory()) walk(full);
58
+ else if (ent.name.endsWith('.md')) out.push(path.relative(rulesDir, full).replace(/\.md$/, ''));
59
+ }
60
+ };
61
+ if (fs.existsSync(rulesDir)) walk(rulesDir);
62
+ return out;
63
+ }
64
+
65
+ function collectSurfaceText(root, ruleFileAbsSet) {
66
+ const texts = [];
67
+ const add = (full) => {
68
+ if (ruleFileAbsSet.has(full)) return;
69
+ if (!TEXT_EXT.has(path.extname(full))) return;
70
+ try { texts.push(fs.readFileSync(full, 'utf8')); } catch { /* unreadable — skip */ }
71
+ };
72
+ const walk = (dir) => {
73
+ let ents;
74
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
75
+ for (const ent of ents) {
76
+ const full = path.join(dir, ent.name);
77
+ if (isSkipped(full, root)) continue;
78
+ if (ent.isDirectory()) walk(full);
79
+ else add(full);
80
+ }
81
+ };
82
+ for (const d of SURFACE_DIRS) walk(path.join(root, d));
83
+ for (const f of SURFACE_FILES) add(path.join(root, f));
84
+ return texts.join('\n');
85
+ }
86
+
87
+ /**
88
+ * Audit which rules are referenced in the active surface.
89
+ * @returns {{ total: number, referenced: Array<{rule:string,refs:number}>, orphans: string[], issues: object[] }}
90
+ */
91
+ export function auditRules({ rootDir, silent = false } = {}) {
92
+ const root = rootDir ?? REPO_ROOT;
93
+ const rulesDir = path.join(root, 'rules');
94
+ const ruleFiles = collectRuleFiles(rulesDir);
95
+ const ruleAbsSet = new Set(ruleFiles.map((r) => path.join(rulesDir, `${r}.md`)));
96
+ const surface = collectSurfaceText(root, ruleAbsSet);
97
+
98
+ const referenced = [];
99
+ const globScoped = [];
100
+ const orphans = [];
101
+ for (const rule of ruleFiles) {
102
+ // Count `rules/<rule>` occurrences (with or without the .md suffix) so a
103
+ // citation in any surface file marks the rule load-bearing.
104
+ const token = `rules/${rule}`;
105
+ const refs = surface.split(token).length - 1;
106
+ if (refs > 0) referenced.push({ rule, refs });
107
+ else if (hasPathGlobs(path.join(rulesDir, `${rule}.md`))) globScoped.push(rule);
108
+ else orphans.push(rule);
109
+ }
110
+ referenced.sort((a, b) => b.refs - a.refs);
111
+
112
+ const issues = [];
113
+ if (orphans.length > 0) issues.push({ kind: 'orphan-rules', items: orphans });
114
+
115
+ if (!silent) {
116
+ const line = (s) => process.stdout.write(`${s}\n`);
117
+ line(`Rule reference audit (${ruleFiles.length} rules: ${referenced.length} referenced, ${globScoped.length} glob-scoped, ${orphans.length} orphan):`);
118
+ if (orphans.length === 0) {
119
+ line(' ✓ Every rule is referenced or glob-scoped');
120
+ } else {
121
+ line(` ⚠ Rules neither referenced nor glob-scoped (${orphans.length}) — pruning candidates:`);
122
+ for (const r of orphans) line(` - rules/${r}.md`);
123
+ }
124
+ }
125
+
126
+ return { total: ruleFiles.length, referenced, globScoped, orphans, issues };
127
+ }
@@ -4,6 +4,12 @@
4
4
  * Reports: (a) skills with no agent owner, (b) agents with no skill bindings,
5
5
  * (c) skill paths declared in registry but missing on disk.
6
6
  * Called by 'construct audit skills' and incorporated into 'construct doctor'.
7
+ *
8
+ * A `roles/<base>[.<flavor>]` skill is owned when `<base>` is a specialist OR a role
9
+ * named in any profile (profiles/*.json) — a role skill loads when its specialist or
10
+ * profile role runs, even without a direct registry `skills:` binding. Counting only
11
+ * registry bindings over-reported orphans (it missed profile roles like `operator`
12
+ * from operations.json and conditional specialist flavors); see bead construct-ksfa.
7
13
  */
8
14
  import fs from 'node:fs';
9
15
  import path from 'node:path';
@@ -34,6 +40,34 @@ function collectSkillFiles(skillsDir) {
34
40
  return results;
35
41
  }
36
42
 
43
+ // Collect every role name a profile declares — top-level `roles` and nested
44
+ // `departments[].roles` — so a role skill counts as owned when a profile uses its
45
+ // base even if no specialist declares the skill path directly.
46
+ function collectProfileRoles(root) {
47
+ const dir = path.join(root, 'profiles');
48
+ const roles = new Set();
49
+ const scan = (node) => {
50
+ if (Array.isArray(node)) { node.forEach(scan); return; }
51
+ if (node && typeof node === 'object') {
52
+ if (Array.isArray(node.roles)) for (const r of node.roles) if (typeof r === 'string') roles.add(r);
53
+ for (const v of Object.values(node)) scan(v);
54
+ }
55
+ };
56
+ try {
57
+ for (const f of fs.readdirSync(dir)) {
58
+ if (!f.endsWith('.json')) continue;
59
+ try { scan(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); } catch { /* skip malformed */ }
60
+ }
61
+ } catch { /* no profiles dir */ }
62
+ return roles;
63
+ }
64
+
65
+ // The base of a role skill: roles/architect.data → architect; roles/qa → qa.
66
+ function roleBase(skill) {
67
+ const m = skill.match(/^roles\/([^.]+)(?:\..*)?$/);
68
+ return m ? m[1] : null;
69
+ }
70
+
37
71
  export function auditSkills({ rootDir, silent = false } = {}) {
38
72
  const root = rootDir ?? findConstructRoot();
39
73
  const registryPath = path.join(root, 'specialists', 'registry.json');
@@ -59,7 +93,15 @@ export function auditSkills({ rootDir, silent = false } = {}) {
59
93
  }
60
94
  }
61
95
 
62
- const orphanSkills = [...allSkillFiles].filter((s) => !declaredSkills.has(s));
96
+ // Owning role bases = specialist names (registry) profile role names. A role
97
+ // skill is owned when its base is in this set, even without a direct binding.
98
+ const ownerBases = new Set([
99
+ ...(registry.specialists ?? []).map((a) => a.name.replace(/^cx-/, '')),
100
+ ...collectProfileRoles(root),
101
+ ]);
102
+ const isOwned = (s) => declaredSkills.has(s) || (ownerBases.has(roleBase(s)));
103
+
104
+ const orphanSkills = [...allSkillFiles].filter((s) => !isOwned(s));
63
105
 
64
106
  const issues = [];
65
107
  if (agentsWithNoSkills.length > 0) issues.push({ kind: 'agents-no-skills', items: agentsWithNoSkills });
@@ -252,6 +252,15 @@ export async function runBd(args, options = {}) {
252
252
  * Used as fallback when optimistic locking fails.
253
253
  */
254
254
  async function runWithLegacyLock(args, opts, cwd, commandDesc) {
255
+ // Telemetry: every legacy-fallback firing is recorded so the lock+queue removal
256
+ // decision rests on observed data — if this never fires over a representative
257
+ // window, the whole exclusive-lock path is removable; if it fires, the entries
258
+ // name the bd errors that actually need handling (bead construct-nhn5).
259
+ try {
260
+ const { logBeadsFallback } = await import('./telemetry/beads-fallback.mjs');
261
+ logBeadsFallback({ command: commandDesc || args[0] || 'unknown' });
262
+ } catch { /* telemetry is best-effort */ }
263
+
255
264
  // Clean up any stale locks before trying
256
265
  cleanupStaleLock({ cwd });
257
266
  cleanupStaleQueue({ cwd });
@@ -1,14 +1,14 @@
1
1
  /**
2
- * lib/beads-optimistic.mjs — Optimistic locking and concurrent read support for beads.
2
+ * lib/beads-optimistic.mjs — concurrent reads + retry-on-conflict writes for beads.
3
3
  *
4
- * Addresses the beads lock contention issue by:
5
- * 1. Allowing concurrent reads (no lock required)
6
- * 2. Using optimistic locking for writes (version-based conflict detection)
7
- * 3. Automatic retry with exponential backoff on conflict
8
- * 4. Read-replicas for team mode (when available)
9
- *
10
- * Replaces the exclusive lock model with a more scalable approach
11
- * while maintaining consistency guarantees.
4
+ * Dolt commits are atomic, so it is the serializer: a write is executed and, on a
5
+ * transient conflict, retried with exponential backoff. There is no separate
6
+ * read-then-compare version check that spanned two `bd` processes (a `bd show`
7
+ * to read the commit hash, then a `bd update`) and guarded nothing, since `bd
8
+ * update` accepts no expected version, so the window between the read and the
9
+ * write left the "optimistic lock" unable to actually detect a conflicting commit
10
+ * (bead construct-iufy). Reads run lock-free; `getBeadVersion` remains as an
11
+ * advisory reader, not a write-path primitive.
12
12
  */
13
13
 
14
14
  import { spawnSync } from 'node:child_process';
@@ -34,8 +34,9 @@ const DEFAULT_RETRY_OPTIONS = {
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
36
  /**
37
- * Read the current version of a bead from the database.
38
- * In Dolt, this uses the commit hash as a logical version.
37
+ * Read the current version of a bead from the database (the Dolt commit hash).
38
+ * Advisory only exposed for callers that want to observe a bead's version; the
39
+ * write path does not gate on it (see file header).
39
40
  */
40
41
  export async function getBeadVersion(beadId, cwd = process.cwd()) {
41
42
  try {
@@ -56,75 +57,37 @@ export async function getBeadVersion(beadId, cwd = process.cwd()) {
56
57
  }
57
58
 
58
59
  /**
59
- * Execute a write operation with optimistic locking.
60
- *
60
+ * Execute a bead write, retrying on a transient conflict. Dolt's atomic commit is
61
+ * the serializer; this does not pre-read or compare a version (see file header).
62
+ *
61
63
  * @param {Object} options
62
- * @param {string} options.beadId - The bead being modified
63
64
  * @param {Function} options.execute - Async function that performs the write
64
- * @param {string} options.expectedVersion - Version we expect the bead to have
65
65
  * @param {Object} options.retry - Retry configuration
66
66
  * @returns {Promise<{success: boolean, result: any, attempts: number}>}
67
67
  */
68
68
  export async function optimisticWrite({
69
- beadId,
70
69
  execute,
71
- expectedVersion,
72
70
  retry = DEFAULT_RETRY_OPTIONS,
73
- cwd = process.cwd(),
74
71
  } = {}) {
75
72
  let attempts = 0;
76
73
  let delay = retry.baseDelayMs;
77
-
74
+
78
75
  while (attempts < retry.maxRetries) {
79
76
  attempts++;
80
-
81
- // Verify version hasn't changed (optimistic check)
82
- const currentVersion = await getBeadVersion(beadId, cwd);
83
-
84
- if (expectedVersion && currentVersion !== expectedVersion) {
85
- // Conflict detected - another writer modified the bead
86
- if (attempts >= retry.maxRetries) {
87
- return {
88
- success: false,
89
- error: `Optimistic lock conflict after ${attempts} attempts. Bead ${beadId} was modified by another process.`,
90
- attempts,
91
- currentVersion,
92
- expectedVersion,
93
- };
94
- }
95
-
96
- // Wait with exponential backoff and jitter
97
- const jitter = Math.random() * 50;
98
- await new Promise(r => setTimeout(r, delay + jitter));
99
- delay = Math.min(delay * retry.backoffMultiplier, retry.maxDelayMs);
100
- continue;
101
- }
102
-
103
77
  try {
104
- // Execute the write operation
105
78
  const result = await execute();
106
79
  return { success: true, result, attempts };
107
80
  } catch (error) {
108
81
  if (attempts >= retry.maxRetries) {
109
- return {
110
- success: false,
111
- error: error.message,
112
- attempts,
113
- };
82
+ return { success: false, error: error.message, attempts };
114
83
  }
115
-
116
- // Retry on transient errors
117
84
  const jitter = Math.random() * 50;
118
- await new Promise(r => setTimeout(r, delay + jitter));
85
+ await new Promise((r) => setTimeout(r, delay + jitter));
119
86
  delay = Math.min(delay * retry.backoffMultiplier, retry.maxDelayMs);
120
87
  }
121
88
  }
122
-
123
- return {
124
- success: false,
125
- error: `Max retries (${retry.maxRetries}) exceeded`,
126
- attempts,
127
- };
89
+
90
+ return { success: false, error: `Max retries (${retry.maxRetries}) exceeded`, attempts };
128
91
  }
129
92
 
130
93
  // ---------------------------------------------------------------------------
@@ -187,21 +150,10 @@ export async function updateBeadOptimistic(
187
150
  options = {}
188
151
  ) {
189
152
  const { actor = 'construct', cwd = process.cwd(), notes } = options;
190
-
191
- // Get current version before attempting update
192
- const currentVersion = await getBeadVersion(beadId, cwd);
193
-
194
- if (!currentVersion) {
195
- return {
196
- success: false,
197
- error: `Bead ${beadId} not found`,
198
- };
199
- }
200
-
153
+
154
+ // A `bd update` on a missing bead exits non-zero and surfaces as a failed
155
+ // result, so no separate existence pre-check is needed.
201
156
  return optimisticWrite({
202
- beadId,
203
- expectedVersion: currentVersion,
204
- cwd,
205
157
  execute: async () => {
206
158
  const args = ['update', beadId];
207
159