@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
package/README.md
CHANGED
|
@@ -149,6 +149,8 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
|
|
|
149
149
|
| `construct ask` | One-shot ask against the active knowledge index |
|
|
150
150
|
| `construct bootstrap` | Import seed observation corpus into local memory store for cold-start acceleration |
|
|
151
151
|
| `construct customer` | Manage customer profiles for product intelligence |
|
|
152
|
+
| `construct demo` | Record reproducible terminal demos via VHS/asciinema (optional system binaries; ADR-0001) |
|
|
153
|
+
| `construct diagram` | Render code-driven diagrams via D2/Graphviz (optional system binaries; ADR-0001) |
|
|
152
154
|
| `construct distill` | Distill documents with query-focused chunking |
|
|
153
155
|
| `construct drop` | Ingest file from Downloads/Desktop |
|
|
154
156
|
| `construct export` | Export markdown to PDF/DOCX/HTML via Pandoc + Typst (optional system binaries; ADR-0024) |
|
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('');
|
|
@@ -1128,7 +1229,7 @@ async function resolveServiceSelection(args, selectable) {
|
|
|
1128
1229
|
return null;
|
|
1129
1230
|
}
|
|
1130
1231
|
|
|
1131
|
-
async function
|
|
1232
|
+
async function cmdDev(args = []) {
|
|
1132
1233
|
const { SELECTABLE_SERVICES } = await import('../lib/service-manager.mjs');
|
|
1133
1234
|
|
|
1134
1235
|
// --only=a,b,c picks services non-interactively; --select opens a checklist.
|
|
@@ -1264,7 +1365,7 @@ async function cmdUp(args = []) {
|
|
|
1264
1365
|
opLog.close(failed.length ? 'degraded' : 'ok', { failed: failed.map((r) => r.name) });
|
|
1265
1366
|
}
|
|
1266
1367
|
|
|
1267
|
-
async function
|
|
1368
|
+
async function cmdStop() {
|
|
1268
1369
|
const { stopServices } = await import('../lib/service-manager.mjs');
|
|
1269
1370
|
const result = await stopServices({ homeDir: HOME, rootDir: ROOT_DIR });
|
|
1270
1371
|
for (const svc of result.results) {
|
|
@@ -1283,7 +1384,7 @@ async function cmdDown() {
|
|
|
1283
1384
|
} catch { /* best effort */ }
|
|
1284
1385
|
}
|
|
1285
1386
|
|
|
1286
|
-
async function
|
|
1387
|
+
async function cmdDashboard() {
|
|
1287
1388
|
if (restArgsCache.includes('--token')) {
|
|
1288
1389
|
const { generateToken, setDashboardToken, getDashboardToken } = await import('../lib/server/auth.mjs');
|
|
1289
1390
|
const existing = getDashboardToken();
|
|
@@ -1850,7 +1951,7 @@ async function cmdHosts() {
|
|
|
1850
1951
|
printHostCapabilities();
|
|
1851
1952
|
}
|
|
1852
1953
|
}
|
|
1853
|
-
async function
|
|
1954
|
+
async function cmdInstall(args) {
|
|
1854
1955
|
const { startOpLog } = await import('../lib/op-log.mjs');
|
|
1855
1956
|
const opLog = startOpLog('install', { homeDir: HOME });
|
|
1856
1957
|
opLog.event('args', { args });
|
|
@@ -2301,7 +2402,7 @@ async function cmdIntakeProcess(args, cwd) {
|
|
|
2301
2402
|
errorln('Choose one:');
|
|
2302
2403
|
errorln(' construct intake process --wait Block until the holder finishes (default 30s).');
|
|
2303
2404
|
errorln(' construct intake process --wait=<sec> Block for a custom window.');
|
|
2304
|
-
errorln(' construct
|
|
2405
|
+
errorln(' construct stop Stop background services, then retry.');
|
|
2305
2406
|
process.exit(2);
|
|
2306
2407
|
}
|
|
2307
2408
|
throw err;
|
|
@@ -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
|
-
`${
|
|
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
|
-
|
|
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);
|
|
@@ -5030,11 +5209,11 @@ async function readStdin() {
|
|
|
5030
5209
|
|
|
5031
5210
|
const handlers = new Map([
|
|
5032
5211
|
// Core
|
|
5033
|
-
['dev',
|
|
5034
|
-
['dashboard',
|
|
5035
|
-
['stop',
|
|
5212
|
+
['dev', cmdDev],
|
|
5213
|
+
['dashboard', cmdDashboard],
|
|
5214
|
+
['stop', cmdStop],
|
|
5036
5215
|
['status', cmdStatus],
|
|
5037
|
-
['install',
|
|
5216
|
+
['install', cmdInstall],
|
|
5038
5217
|
['config', cmdConfig],
|
|
5039
5218
|
['intake', cmdIntake],
|
|
5040
5219
|
['recommendations', cmdRecommendations],
|
|
@@ -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);
|
|
@@ -5656,6 +5838,14 @@ const handlers = new Map([
|
|
|
5656
5838
|
const { runWireframeCli } = await import('../lib/wireframe.mjs');
|
|
5657
5839
|
return runWireframeCli(args);
|
|
5658
5840
|
}],
|
|
5841
|
+
['diagram', async (args) => {
|
|
5842
|
+
const { runDiagramCli } = await import('../lib/diagram.mjs');
|
|
5843
|
+
return runDiagramCli(args);
|
|
5844
|
+
}],
|
|
5845
|
+
['demo', async (args) => {
|
|
5846
|
+
const { runDemoCli } = await import('../lib/demo.mjs');
|
|
5847
|
+
return runDemoCli(args);
|
|
5848
|
+
}],
|
|
5659
5849
|
['skills', async (args) => {
|
|
5660
5850
|
const sub = args[0];
|
|
5661
5851
|
if (sub === 'scope' || !sub) {
|
|
@@ -6173,6 +6363,67 @@ if (command === '--version' || command === '-V') {
|
|
|
6173
6363
|
process.exit(0);
|
|
6174
6364
|
}
|
|
6175
6365
|
|
|
6366
|
+
// Unknown-flag guidance for commands that fully declare their flags (strictFlags
|
|
6367
|
+
// in the spec). A stray or typo'd flag on these is a user error, not a no-op:
|
|
6368
|
+
// reject it with the nearest declared flag and the command's help, matching how
|
|
6369
|
+
// install (lib/setup.mjs KNOWN_FLAGS) already behaves, so no flag is silently
|
|
6370
|
+
// swallowed. Opt-in keeps commands that accept undeclared flags unaffected.
|
|
6371
|
+
|
|
6372
|
+
function validateKnownFlags(name, argv) {
|
|
6373
|
+
const spec = CLI_COMMANDS.find((c) => c.name === name);
|
|
6374
|
+
if (!spec || !spec.strictFlags) return;
|
|
6375
|
+
|
|
6376
|
+
const exact = new Set(['--help', '-h']);
|
|
6377
|
+
const prefixes = [];
|
|
6378
|
+
for (const opt of spec.options || []) {
|
|
6379
|
+
for (const part of String(opt.flag).split(',')) {
|
|
6380
|
+
const token = part.trim().split('=')[0].split(/\s/)[0];
|
|
6381
|
+
if (!token.startsWith('-')) continue;
|
|
6382
|
+
if (/-<[^>]+>$/.test(token)) prefixes.push(token.replace(/<[^>]+>$/, ''));
|
|
6383
|
+
else exact.add(token);
|
|
6384
|
+
}
|
|
6385
|
+
}
|
|
6386
|
+
|
|
6387
|
+
const declared = [...exact].filter((f) => f.startsWith('--'));
|
|
6388
|
+
const dist = (a, b) => {
|
|
6389
|
+
const row = Array(b.length + 1).fill(0).map((_, i) => i);
|
|
6390
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
6391
|
+
let prev = i;
|
|
6392
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
6393
|
+
const cur = a[i - 1] === b[j - 1] ? row[j - 1] : 1 + Math.min(row[j - 1], row[j], prev);
|
|
6394
|
+
row[j - 1] = prev;
|
|
6395
|
+
prev = cur;
|
|
6396
|
+
}
|
|
6397
|
+
row[b.length] = prev;
|
|
6398
|
+
}
|
|
6399
|
+
return row[b.length];
|
|
6400
|
+
};
|
|
6401
|
+
|
|
6402
|
+
const unknown = [];
|
|
6403
|
+
for (const tok of argv) {
|
|
6404
|
+
if (tok === '--') break;
|
|
6405
|
+
if (!/^--?[A-Za-z]/.test(tok)) continue;
|
|
6406
|
+
const bare = tok.split('=')[0];
|
|
6407
|
+
if (exact.has(bare) || prefixes.some((p) => bare.startsWith(p))) continue;
|
|
6408
|
+
unknown.push(bare);
|
|
6409
|
+
}
|
|
6410
|
+
if (unknown.length === 0) return;
|
|
6411
|
+
|
|
6412
|
+
for (const flag of unknown) {
|
|
6413
|
+
errorln(`Unknown flag: ${flag}`);
|
|
6414
|
+
let best = 4;
|
|
6415
|
+
let suggestion = null;
|
|
6416
|
+
for (const cand of declared) {
|
|
6417
|
+
const d = dist(flag, cand);
|
|
6418
|
+
if (d < best) { best = d; suggestion = cand; }
|
|
6419
|
+
}
|
|
6420
|
+
if (suggestion) errorln(`Did you mean: ${suggestion}?`);
|
|
6421
|
+
}
|
|
6422
|
+
println('');
|
|
6423
|
+
println(`${COLORS.dim}Run 'construct ${name} --help' for valid options${COLORS.reset}`);
|
|
6424
|
+
process.exit(1);
|
|
6425
|
+
}
|
|
6426
|
+
|
|
6176
6427
|
const handler = handlers.get(command);
|
|
6177
6428
|
if (!handler) {
|
|
6178
6429
|
errorln(`Unknown command: ${command}`);
|
|
@@ -6251,7 +6502,9 @@ if (command !== 'hook') {
|
|
|
6251
6502
|
|
|
6252
6503
|
const probe = await maybeFirstInvocationProbe({ command, homeDir: HOME, env: process.env });
|
|
6253
6504
|
if (probe?.runSetup) {
|
|
6254
|
-
await
|
|
6505
|
+
await cmdInstall([]);
|
|
6255
6506
|
}
|
|
6256
6507
|
|
|
6508
|
+
validateKnownFlags(command, rest);
|
|
6509
|
+
|
|
6257
6510
|
await handler(rest);
|
|
@@ -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 =
|
|
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
|
+
}
|
package/lib/audit-skills.mjs
CHANGED
|
@@ -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
|
-
|
|
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 });
|