@geraldmaron/construct 1.0.7 → 1.0.8
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 +20 -17
- package/bin/construct +1199 -69
- package/commands/work/optimize-prompts.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +2 -2
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +5 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +65 -17
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/doctor/watchers/consistency.mjs +62 -12
- package/lib/document-extract.mjs +221 -28
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/inbox.mjs +48 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/env-config.mjs +9 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +12 -0
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +18 -0
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +222 -55
- package/lib/intake/filesystem-queue.mjs +25 -5
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -0
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/mcp/server.mjs +9 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +1 -1
- package/lib/migrations/index.mjs +1 -1
- package/lib/model-registry.mjs +1 -1
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +7 -1
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +13 -3
- package/personas/construct.md +2 -2
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/no-fabrication.md +1 -1
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/{agents → specialists}/contracts.schema.json +1 -1
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-architect.md +1 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-debugger.md +1 -1
- package/{agents → specialists}/prompts/cx-designer.md +1 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
- package/{agents → specialists}/prompts/cx-engineer.md +2 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
- package/{agents → specialists}/prompts/cx-explorer.md +1 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
- package/{agents → specialists}/prompts/cx-operations.md +1 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-qa.md +1 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-researcher.md +1 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
- package/{agents → specialists}/prompts/cx-security.md +1 -1
- package/{agents → specialists}/prompts/cx-sre.md +2 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
- package/{agents → specialists}/registry.json +651 -423
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- /package/{agents → specialists}/contracts.json +0 -0
- /package/{agents → specialists}/teams.json +0 -0
package/bin/construct
CHANGED
|
@@ -19,7 +19,7 @@ import { readCurrentModels, readOpenRouterApiKeyFromOpenCodeConfig, applyToEnv,
|
|
|
19
19
|
import { pollFreeModels, topForTier, selectForTier } from '../lib/model-free-selector.mjs';
|
|
20
20
|
import { cmdMcpList, cmdMcpAdd, cmdMcpRemove, cmdMcpInfo } from '../lib/mcp-manager.mjs';
|
|
21
21
|
import { cmdOllama } from '../lib/ollama-manager.mjs';
|
|
22
|
-
import { printHostCapabilities } from '../lib/host-capabilities.mjs';
|
|
22
|
+
import { printHostCapabilities, formatHostCapabilitiesJson } from '../lib/host-capabilities.mjs';
|
|
23
23
|
import { getPluginById, initPluginManifest, loadPluginRegistry } from '../lib/plugin-registry.mjs';
|
|
24
24
|
import { runSetup } from '../lib/setup.mjs';
|
|
25
25
|
import { runUpdate } from '../lib/update.mjs';
|
|
@@ -249,7 +249,7 @@ async function cmdShow() {
|
|
|
249
249
|
|
|
250
250
|
async function cmdSync(args) {
|
|
251
251
|
generateCompletions();
|
|
252
|
-
runNodeScript(path.join(ROOT_DIR, 'scripts', 'sync-
|
|
252
|
+
runNodeScript(path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs'), args);
|
|
253
253
|
if (args.includes('--no-docs')) return;
|
|
254
254
|
const { regenerateDocs } = await import('../lib/auto-docs.mjs');
|
|
255
255
|
const { changed } = await regenerateDocs({ rootDir: ROOT_DIR });
|
|
@@ -277,7 +277,7 @@ async function cmdCompletions(args) {
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
async function cmdList() {
|
|
280
|
-
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '
|
|
280
|
+
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
|
|
281
281
|
println('Construct Agents\n');
|
|
282
282
|
println('Personas:');
|
|
283
283
|
for (const persona of registry.personas ?? []) {
|
|
@@ -418,8 +418,8 @@ async function cmdDoctor() {
|
|
|
418
418
|
|
|
419
419
|
const checks = [];
|
|
420
420
|
const add = (label, pass, optional = false) => checks.push({ label, pass, optional });
|
|
421
|
-
add('registry.json exists', fs.existsSync(path.join(ROOT_DIR, '
|
|
422
|
-
add('sync-
|
|
421
|
+
add('registry.json exists', fs.existsSync(path.join(ROOT_DIR, 'specialists', 'registry.json')));
|
|
422
|
+
add('sync-specialists.mjs exists', fs.existsSync(path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs')));
|
|
423
423
|
|
|
424
424
|
// Tier model selection. Construct ships with no default — at least
|
|
425
425
|
// one tier must be configured (registry.json primary OR CX_MODEL_*
|
|
@@ -456,7 +456,14 @@ async function cmdDoctor() {
|
|
|
456
456
|
add('Claude Code agents dir', fs.existsSync(path.join(HOME, '.claude', 'agents')), true);
|
|
457
457
|
add('Codex agents dir', fs.existsSync(path.join(HOME, '.codex', 'agents')), true);
|
|
458
458
|
add('Copilot prompts dir', fs.existsSync(path.join(HOME, '.github', 'prompts')), true);
|
|
459
|
-
|
|
459
|
+
// Only check Cursor MCP config when Cursor is actually installed (its home
|
|
460
|
+
// directory exists). Absent Cursor, no warning — the adapter parity check
|
|
461
|
+
// already surfaces 'cursor: not installed' for informational purposes.
|
|
462
|
+
const cursorHomeDir = path.join(HOME, '.cursor');
|
|
463
|
+
if (fs.existsSync(cursorHomeDir)) {
|
|
464
|
+
add('Cursor MCP config', fs.existsSync(path.join(cursorHomeDir, 'mcp.json')), true);
|
|
465
|
+
}
|
|
466
|
+
|
|
460
467
|
const vscodeSettingsPaths = process.platform === 'darwin'
|
|
461
468
|
? [
|
|
462
469
|
path.join(HOME, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
|
|
@@ -471,11 +478,35 @@ async function cmdDoctor() {
|
|
|
471
478
|
path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code', 'User', 'settings.json'),
|
|
472
479
|
path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code - Insiders', 'User', 'settings.json'),
|
|
473
480
|
];
|
|
474
|
-
|
|
481
|
+
// Only check VS Code settings when VS Code is actually installed. On macOS,
|
|
482
|
+
// Code's Application Support directory is the installation signal.
|
|
483
|
+
const vscodeAppSupportRoots = process.platform === 'darwin'
|
|
484
|
+
? [
|
|
485
|
+
path.join(HOME, 'Library', 'Application Support', 'Code'),
|
|
486
|
+
path.join(HOME, 'Library', 'Application Support', 'Code - Insiders'),
|
|
487
|
+
]
|
|
488
|
+
: process.platform === 'linux'
|
|
489
|
+
? [path.join(HOME, '.config', 'Code'), path.join(HOME, '.config', 'Code - Insiders')]
|
|
490
|
+
: [
|
|
491
|
+
path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code'),
|
|
492
|
+
path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code - Insiders'),
|
|
493
|
+
];
|
|
494
|
+
if (vscodeAppSupportRoots.some((r) => fs.existsSync(r))) {
|
|
495
|
+
// VS Code is installed but has no settings.json — the user needs to create
|
|
496
|
+
// one manually or run `construct sync` after VS Code sync support lands.
|
|
497
|
+
const vscodeSettingsOk = vscodeSettingsPaths.some((candidate) => fs.existsSync(candidate));
|
|
498
|
+
add(
|
|
499
|
+
vscodeSettingsOk
|
|
500
|
+
? 'VS Code settings file'
|
|
501
|
+
: 'VS Code settings missing — create settings.json or wait for `construct sync` VS Code support',
|
|
502
|
+
vscodeSettingsOk,
|
|
503
|
+
true,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
475
506
|
add('User config ready', fs.existsSync(getUserEnvPath(HOME)) || fs.existsSync(path.join(HOME, '.construct')), true);
|
|
476
507
|
add('skills/ directory', fs.existsSync(path.join(ROOT_DIR, 'skills')));
|
|
477
508
|
add('rules/common/', fs.existsSync(path.join(ROOT_DIR, 'rules', 'common')));
|
|
478
|
-
add('
|
|
509
|
+
add('specialists/prompts/ directory', fs.existsSync(path.join(ROOT_DIR, 'specialists', 'prompts')));
|
|
479
510
|
add('Hybrid storage env keys documented', fs.existsSync(path.join(ROOT_DIR, '.env.example')));
|
|
480
511
|
fs.mkdirSync(path.join(HOME, '.cx', 'performance-reviews'), { recursive: true });
|
|
481
512
|
add('~/.cx/performance-reviews/ ready', true);
|
|
@@ -577,45 +608,20 @@ async function cmdDoctor() {
|
|
|
577
608
|
add(`Resource budget check failed: ${err.message}`, false, true);
|
|
578
609
|
}
|
|
579
610
|
|
|
580
|
-
// Pricing-catalog freshness + divergence
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
// (>25%) → LiteLLM and static disagree on a known model, surface so
|
|
584
|
-
// the operator can verify which is current. Both advisory: degraded
|
|
585
|
-
// pricing still falls back to a usable source.
|
|
586
|
-
try {
|
|
587
|
-
const { describePricingCatalogFreshness, checkPricingDivergence } = await import('../lib/telemetry/model-pricing-catalog.mjs');
|
|
588
|
-
const freshness = describePricingCatalogFreshness();
|
|
589
|
-
if (freshness.stale) {
|
|
590
|
-
add(`Pricing catalog: ${freshness.message}`, false, true);
|
|
591
|
-
} else {
|
|
592
|
-
add(`Pricing catalog fresh (${freshness.ageHours?.toFixed(1) || '0.0'}h old)`, true, true);
|
|
593
|
-
}
|
|
594
|
-
if (freshness.present) {
|
|
595
|
-
try {
|
|
596
|
-
const cached = JSON.parse(fs.readFileSync(path.join(HOME, '.cx', 'pricing-cache.json'), 'utf8'));
|
|
597
|
-
const divergences = checkPricingDivergence(cached.models || []);
|
|
598
|
-
if (divergences.length === 0) {
|
|
599
|
-
add('Pricing matches static fallback (no >25% divergence)', true, true);
|
|
600
|
-
} else {
|
|
601
|
-
add(`Pricing divergence on ${divergences.length} model(s) — run \`construct pricing check\``, false, true);
|
|
602
|
-
}
|
|
603
|
-
} catch { /* divergence is advisory */ }
|
|
604
|
-
}
|
|
605
|
-
} catch (err) {
|
|
606
|
-
add(`Pricing catalog check failed: ${err.message}`, false, true);
|
|
607
|
-
}
|
|
611
|
+
// Pricing-catalog freshness + divergence are not surfaced in doctor output.
|
|
612
|
+
// The underlying ledger writes and catalog refresh continue; consumer
|
|
613
|
+
// surfaces land with the OTel + dashboard wiring (plan Workstream J).
|
|
608
614
|
|
|
609
615
|
// Validate promptFile references and skill bindings
|
|
610
616
|
try {
|
|
611
|
-
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '
|
|
612
|
-
const brokenPromptFiles = (registry.
|
|
613
|
-
if (!
|
|
614
|
-
return !fs.existsSync(path.join(ROOT_DIR,
|
|
617
|
+
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
|
|
618
|
+
const brokenPromptFiles = (registry.specialists ?? []).filter((s) => {
|
|
619
|
+
if (!s.promptFile) return false;
|
|
620
|
+
return !fs.existsSync(path.join(ROOT_DIR, s.promptFile));
|
|
615
621
|
});
|
|
616
|
-
add('All
|
|
622
|
+
add('All specialist promptFiles resolve', brokenPromptFiles.length === 0);
|
|
617
623
|
const headhuntClassifierAgents = ['cx-docs-keeper', 'cx-researcher', 'cx-architect', 'cx-security', 'cx-engineer'];
|
|
618
|
-
const registryNames = new Set((registry.
|
|
624
|
+
const registryNames = new Set((registry.specialists ?? []).map((s) => `cx-${s.name}`));
|
|
619
625
|
const classifierOrphans = headhuntClassifierAgents.filter((n) => !registryNames.has(n));
|
|
620
626
|
add('Headhunt classifier agents exist in registry', classifierOrphans.length === 0);
|
|
621
627
|
const skillAudit = auditSkills({ rootDir: ROOT_DIR, silent: true });
|
|
@@ -626,12 +632,24 @@ async function cmdDoctor() {
|
|
|
626
632
|
|
|
627
633
|
const sqlConfig = describeSqlStore(process.env);
|
|
628
634
|
const sqlClient = createSqlClient(process.env);
|
|
635
|
+
// Track SQL reachability so the schema-migration check can cascade correctly
|
|
636
|
+
// rather than emitting a redundant second warning for the same offline server.
|
|
637
|
+
let sqlReachable = false;
|
|
629
638
|
if (sqlConfig.configured && sqlClient) {
|
|
630
639
|
try {
|
|
631
640
|
await sqlClient`select 1 as ok`;
|
|
641
|
+
sqlReachable = true;
|
|
632
642
|
add('SQL backend reachable', true);
|
|
633
643
|
} catch {
|
|
634
|
-
|
|
644
|
+
// In solo mode the local vector index is the active storage path, so an
|
|
645
|
+
// unreachable Postgres is expected unless the user has started it. Emit a
|
|
646
|
+
// contextual message rather than a bare ⚠ so the fix is obvious.
|
|
647
|
+
const { getDeploymentMode } = await import('../lib/deployment-mode.mjs');
|
|
648
|
+
const mode = getDeploymentMode(process.env) ?? 'solo';
|
|
649
|
+
const hint = mode === 'solo'
|
|
650
|
+
? ' (solo mode — run `construct dev` to start Postgres)'
|
|
651
|
+
: ' — check DATABASE_URL and that the server is running';
|
|
652
|
+
add(`SQL backend unreachable${hint}`, false, true);
|
|
635
653
|
} finally {
|
|
636
654
|
await closeSqlClient(sqlClient);
|
|
637
655
|
}
|
|
@@ -716,7 +734,7 @@ async function cmdDoctor() {
|
|
|
716
734
|
}
|
|
717
735
|
|
|
718
736
|
try {
|
|
719
|
-
const { recentViolations } = await import('../lib/
|
|
737
|
+
const { recentViolations } = await import('../lib/specialist-contracts-enforce.mjs');
|
|
720
738
|
const violations = recentViolations({ windowMs: 24 * 60 * 60 * 1000 });
|
|
721
739
|
const label = violations.length === 0
|
|
722
740
|
? 'Contract violations (none in last 24h)'
|
|
@@ -761,7 +779,11 @@ async function cmdDoctor() {
|
|
|
761
779
|
add('Observation size', false, true);
|
|
762
780
|
}
|
|
763
781
|
|
|
764
|
-
|
|
782
|
+
// Only run the schema migration check when SQL is both configured AND
|
|
783
|
+
// currently reachable. If the SQL check above already failed (sqlReachable
|
|
784
|
+
// is false) this check will also fail with the same ECONNREFUSED and would
|
|
785
|
+
// produce a redundant second warning about the same offline server.
|
|
786
|
+
if (sqlConfig.configured && sqlReachable) {
|
|
765
787
|
try {
|
|
766
788
|
const migClient = createSqlClient(process.env);
|
|
767
789
|
if (migClient) {
|
|
@@ -850,7 +872,7 @@ async function cmdDoctor() {
|
|
|
850
872
|
}
|
|
851
873
|
|
|
852
874
|
// Agent contracts integrity
|
|
853
|
-
const { getAllContracts, summarize } = await import('../lib/
|
|
875
|
+
const { getAllContracts, summarize } = await import('../lib/specialist-contracts.mjs');
|
|
854
876
|
const contracts = getAllContracts();
|
|
855
877
|
add('Agent contracts loaded', contracts.length > 0);
|
|
856
878
|
if (contracts.length > 0) {
|
|
@@ -1062,9 +1084,9 @@ async function cmdMigrate(args) {
|
|
|
1062
1084
|
const candidates = pathArg
|
|
1063
1085
|
? [pathArg]
|
|
1064
1086
|
: [
|
|
1065
|
-
path.join(ROOT_DIR, '
|
|
1066
|
-
path.join(ROOT_DIR, '
|
|
1067
|
-
path.join(ROOT_DIR, '
|
|
1087
|
+
path.join(ROOT_DIR, 'specialists', 'registry.json'),
|
|
1088
|
+
path.join(ROOT_DIR, 'specialists', 'contracts.json'),
|
|
1089
|
+
path.join(ROOT_DIR, 'specialists', 'role-manifests.json'),
|
|
1068
1090
|
path.join(process.cwd(), '.cx', 'config.json'),
|
|
1069
1091
|
];
|
|
1070
1092
|
|
|
@@ -1567,7 +1589,14 @@ async function cmdPricing(args) {
|
|
|
1567
1589
|
}
|
|
1568
1590
|
|
|
1569
1591
|
async function cmdHeadhunt(args) { await runHeadhunt({ args, cwd: process.cwd(), homeDir: HOME }); }
|
|
1570
|
-
async function cmdHosts() {
|
|
1592
|
+
async function cmdHosts() {
|
|
1593
|
+
const args = new Set(process.argv.slice(3));
|
|
1594
|
+
if (args.has('--json')) {
|
|
1595
|
+
process.stdout.write(formatHostCapabilitiesJson());
|
|
1596
|
+
} else {
|
|
1597
|
+
printHostCapabilities();
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1571
1600
|
async function cmdSetup(args) { await runSetup({ rootDir: ROOT_DIR, args, homeDir: HOME }); }
|
|
1572
1601
|
|
|
1573
1602
|
function printConfigModeHelp() {
|
|
@@ -1615,11 +1644,19 @@ async function printIntakeHelp() {
|
|
|
1615
1644
|
println(' Mark a pending packet processed. --output stamps intake_id + confidence into the artifact frontmatter (provenance).');
|
|
1616
1645
|
println(' construct intake skip <id> [--reason=…] Mark a pending packet skipped (preserves audit trail).');
|
|
1617
1646
|
println(' construct intake reopen <id> Move a processed or skipped packet back to pending.');
|
|
1647
|
+
println(' construct intake quarantine list List packets held for human review (low confidence or close margin).');
|
|
1648
|
+
println(' construct intake quarantine show <id> Show a quarantined packet with top-3 candidate types and margins.');
|
|
1649
|
+
println(' construct intake reroute <id> --type=<t> Move a quarantined packet to pending with manual classification.');
|
|
1650
|
+
println(' Writes tests/fixtures/intake/learned/<hash>.json so CI guards against repeats.');
|
|
1618
1651
|
println(' construct intake metrics Show pipeline volume, velocity, and processing stats.');
|
|
1619
1652
|
println(' construct intake integrate <id> <system> Push intake to external system (github, jira, confluence).');
|
|
1620
1653
|
println(' construct intake integrate <id> <system> --create-issue Create an issue from the intake packet.');
|
|
1621
1654
|
println(' construct intake config Show current watch dirs + scan depth.');
|
|
1622
1655
|
println(' construct intake config set --depth=N [--add-dir=PATH] [--remove-dir=PATH]');
|
|
1656
|
+
println(' construct intake extraction-warnings [--since=7d] [--kind=table] [--limit=50]');
|
|
1657
|
+
println(' Summarize ~/.cx/intake/extraction-warnings.jsonl (dropped content log).');
|
|
1658
|
+
println(' construct intake needs-asr list List audio/video files awaiting ASR transcription.');
|
|
1659
|
+
println(' construct intake needs-asr show <id> Show a specific ASR-pending packet.');
|
|
1623
1660
|
println(' Update intake config. --depth caps at the hard limit.');
|
|
1624
1661
|
println(' construct intake config set --include-project-inbox=on|off');
|
|
1625
1662
|
println(' construct intake config set --include-docs-intake=on|off');
|
|
@@ -1861,6 +1898,14 @@ async function cmdIntake(args) {
|
|
|
1861
1898
|
return await cmdIntakeMetrics();
|
|
1862
1899
|
}
|
|
1863
1900
|
|
|
1901
|
+
if (sub === 'quarantine') {
|
|
1902
|
+
return await cmdIntakeQuarantine(args.slice(1), cwd);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
if (sub === 'reroute') {
|
|
1906
|
+
return await cmdIntakeReroute(args.slice(1), cwd);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1864
1909
|
if (sub === 'integrate') {
|
|
1865
1910
|
return await cmdIntakeIntegrate(id, args.slice(2), cwd);
|
|
1866
1911
|
}
|
|
@@ -1869,7 +1914,312 @@ async function cmdIntake(args) {
|
|
|
1869
1914
|
return await cmdIntakeConfig(args.slice(1));
|
|
1870
1915
|
}
|
|
1871
1916
|
|
|
1872
|
-
|
|
1917
|
+
if (sub === 'extraction-warnings') {
|
|
1918
|
+
return await cmdIntakeExtractionWarnings(args.slice(1));
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
if (sub === 'needs-asr') {
|
|
1922
|
+
return await cmdIntakeNeedsAsr(args.slice(1));
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
errorln(`Unknown intake subcommand: ${sub}. Available: list, show, done, skip, reopen, quarantine, reroute, integrate, config, extraction-warnings, needs-asr`);
|
|
1926
|
+
process.exit(1);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
async function cmdIntakeQuarantine(args, cwd) {
|
|
1930
|
+
const action = args[0];
|
|
1931
|
+
const { listQuarantine, readQuarantine } = await import('../lib/intake/quarantine.mjs');
|
|
1932
|
+
|
|
1933
|
+
if (!action || action === 'list') {
|
|
1934
|
+
const packets = listQuarantine(cwd);
|
|
1935
|
+
if (packets.length === 0) {
|
|
1936
|
+
println('Quarantine is empty. Classifier had no low-confidence packets to hold.');
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
println(`Quarantine (${packets.length} packet${packets.length === 1 ? '' : 's'} awaiting review):`);
|
|
1940
|
+
const headers = ['ID', 'Type', 'Confidence', 'Reason'];
|
|
1941
|
+
const rows = packets.map((p) => {
|
|
1942
|
+
const t = p.triage || {};
|
|
1943
|
+
const conf = typeof t.confidence === 'number' ? t.confidence.toFixed(2) : '—';
|
|
1944
|
+
return [p.id, t.intakeType || 'unknown', conf, p.quarantineReason || 'n/a'];
|
|
1945
|
+
});
|
|
1946
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] || '').length)));
|
|
1947
|
+
const fmt = (cells) => cells.map((c, i) => String(c || '').padEnd(widths[i])).join(' ');
|
|
1948
|
+
println(fmt(headers));
|
|
1949
|
+
println(widths.map((w) => '─'.repeat(w)).join(' '));
|
|
1950
|
+
for (const row of rows) println(fmt(row));
|
|
1951
|
+
println('');
|
|
1952
|
+
println('Review options:');
|
|
1953
|
+
println(' construct intake quarantine show <id> See full triage + candidates');
|
|
1954
|
+
println(' construct intake reroute <id> --type=<correct> Reclassify and promote to pending');
|
|
1955
|
+
println(' construct intake skip <id> --reason="..." Drop without action');
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if (action === 'show') {
|
|
1960
|
+
const id = args[1];
|
|
1961
|
+
if (!id) { errorln('Usage: construct intake quarantine show <id>'); process.exit(1); }
|
|
1962
|
+
const packet = readQuarantine(cwd, id);
|
|
1963
|
+
if (!packet) { errorln(`No quarantined packet with id: ${id}`); process.exit(1); }
|
|
1964
|
+
const t = packet.triage || {};
|
|
1965
|
+
println(`Quarantined packet ${packet.id}`);
|
|
1966
|
+
println(`Status: quarantined`);
|
|
1967
|
+
println(`Reason: ${packet.quarantineReason || 'n/a'}`);
|
|
1968
|
+
println(`Source: ${packet.intake?.sourcePath || '(none)'}`);
|
|
1969
|
+
println(`Held since: ${packet.quarantinedAt || packet.createdAt}`);
|
|
1970
|
+
println('');
|
|
1971
|
+
println('Triage (current best guess):');
|
|
1972
|
+
println(` intakeType: ${t.intakeType || 'unknown'}`);
|
|
1973
|
+
println(` primaryOwner: ${t.primaryOwner || '—'}`);
|
|
1974
|
+
println(` recommendedAction: ${t.recommendedAction || '—'}`);
|
|
1975
|
+
println(` confidence: ${typeof t.confidence === 'number' ? t.confidence.toFixed(2) : '—'}`);
|
|
1976
|
+
if (t.rationale) println(` rationale: ${t.rationale}`);
|
|
1977
|
+
if (Array.isArray(t.candidates) && t.candidates.length > 0) {
|
|
1978
|
+
println('');
|
|
1979
|
+
println('Candidates (top 3):');
|
|
1980
|
+
for (const c of t.candidates) {
|
|
1981
|
+
const score = typeof c.score === 'number' ? c.score.toFixed(2) : '—';
|
|
1982
|
+
const margin = typeof c.margin === 'number' ? c.margin.toFixed(2) : '—';
|
|
1983
|
+
println(` ${c.intakeType.padEnd(20)} score=${score} margin=${margin} hits=${c.hits ?? 0}`);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
if (packet.excerpt) {
|
|
1987
|
+
println('');
|
|
1988
|
+
println('Excerpt:');
|
|
1989
|
+
for (const line of String(packet.excerpt).split('\n').slice(0, 10)) println(` ${line}`);
|
|
1990
|
+
}
|
|
1991
|
+
println('');
|
|
1992
|
+
println('Next steps:');
|
|
1993
|
+
println(` construct intake reroute ${packet.id} --type=<correct-type> Reclassify + write learned fixture`);
|
|
1994
|
+
println(` construct intake skip ${packet.id} --reason="..." Drop the packet`);
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
errorln(`Unknown quarantine subcommand: ${action}. Available: list, show`);
|
|
1999
|
+
process.exit(1);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
async function cmdIntakeReroute(args, cwd) {
|
|
2003
|
+
const id = args[0];
|
|
2004
|
+
if (!id || id.startsWith('--')) {
|
|
2005
|
+
errorln('Usage: construct intake reroute <id> --type=<intakeType> [--reason="..."]');
|
|
2006
|
+
process.exit(1);
|
|
2007
|
+
}
|
|
2008
|
+
const flags = parseKvFlags(args.slice(1), ['type', 'reason']);
|
|
2009
|
+
if (!flags.type) {
|
|
2010
|
+
errorln('--type is required. See INTAKE_TYPES in lib/intake/tables/rnd.mjs for valid values.');
|
|
2011
|
+
process.exit(1);
|
|
2012
|
+
}
|
|
2013
|
+
const { INTAKE_TYPES } = await import('../lib/intake/classify.mjs');
|
|
2014
|
+
if (!INTAKE_TYPES.includes(flags.type)) {
|
|
2015
|
+
errorln(`Invalid --type=${flags.type}. Valid: ${INTAKE_TYPES.join(', ')}`);
|
|
2016
|
+
process.exit(1);
|
|
2017
|
+
}
|
|
2018
|
+
const { rerouteQuarantined } = await import('../lib/intake/quarantine.mjs');
|
|
2019
|
+
try {
|
|
2020
|
+
const result = rerouteQuarantined(cwd, id, flags.type, {
|
|
2021
|
+
reroutedBy: 'construct-intake-cli',
|
|
2022
|
+
reason: flags.reason || '',
|
|
2023
|
+
});
|
|
2024
|
+
info(`Rerouted ${result.id} to intakeType=${flags.type}. Learned fixture: ${result.fixturePath}`);
|
|
2025
|
+
info(`Packet promoted to pending: ${result.filePath}`);
|
|
2026
|
+
} catch (err) {
|
|
2027
|
+
errorln(err.message || 'reroute failed');
|
|
2028
|
+
process.exit(1);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
async function cmdIntakeExtractionWarnings(args) {
|
|
2033
|
+
const { createReadStream, existsSync } = await import('node:fs');
|
|
2034
|
+
const { homedir } = await import('node:os');
|
|
2035
|
+
const { join } = await import('node:path');
|
|
2036
|
+
const { createInterface } = await import('node:readline');
|
|
2037
|
+
|
|
2038
|
+
const flags = parseKvFlags(args, ['since', 'kind', 'limit']);
|
|
2039
|
+
const limitN = flags.limit ? parseInt(flags.limit, 10) : 50;
|
|
2040
|
+
const filterKind = flags.kind || null;
|
|
2041
|
+
|
|
2042
|
+
// Parse --since flag (e.g. 7d, 24h, 30m)
|
|
2043
|
+
let sinceMs = null;
|
|
2044
|
+
if (flags.since) {
|
|
2045
|
+
const m = flags.since.match(/^(\d+)([dhm])$/);
|
|
2046
|
+
if (m) {
|
|
2047
|
+
const n = parseInt(m[1], 10);
|
|
2048
|
+
sinceMs = Date.now() - n * ({ d: 86_400_000, h: 3_600_000, m: 60_000 }[m[2]] ?? 0);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
const logPath = join(homedir(), '.cx', 'intake', 'extraction-warnings.jsonl');
|
|
2053
|
+
if (!existsSync(logPath)) {
|
|
2054
|
+
println('No extraction warnings recorded yet.');
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
const lines = [];
|
|
2059
|
+
const rl = createInterface({ input: createReadStream(logPath), crlfDelay: Infinity });
|
|
2060
|
+
await new Promise((res) => {
|
|
2061
|
+
rl.on('line', (line) => {
|
|
2062
|
+
try {
|
|
2063
|
+
const entry = JSON.parse(line);
|
|
2064
|
+
if (sinceMs && new Date(entry.ts).getTime() < sinceMs) return;
|
|
2065
|
+
if (filterKind && !entry.droppedInfo?.some((d) => d.kind === filterKind)) return;
|
|
2066
|
+
lines.push(entry);
|
|
2067
|
+
} catch { /* ignore malformed lines */ }
|
|
2068
|
+
});
|
|
2069
|
+
rl.on('close', res);
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
if (lines.length === 0) {
|
|
2073
|
+
println('No extraction warnings match the given filters.');
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
const shown = lines.slice(-limitN);
|
|
2078
|
+
println(`Showing ${shown.length} of ${lines.length} extraction warning(s) (newest last):\n`);
|
|
2079
|
+
for (const entry of shown) {
|
|
2080
|
+
println(` ${entry.ts} ${entry.sourcePath}`);
|
|
2081
|
+
for (const d of entry.droppedInfo || []) {
|
|
2082
|
+
println(` [${d.kind}] count=${d.count} recoverable=${d.recoverable} ${d.reason}`);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// Summary by kind
|
|
2087
|
+
const kindCounts = {};
|
|
2088
|
+
for (const entry of lines) {
|
|
2089
|
+
for (const d of entry.droppedInfo || []) {
|
|
2090
|
+
kindCounts[d.kind] = (kindCounts[d.kind] ?? 0) + d.count;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
if (Object.keys(kindCounts).length > 0) {
|
|
2094
|
+
println('\nTotals by kind (all time):');
|
|
2095
|
+
for (const [kind, count] of Object.entries(kindCounts).sort(([, a], [, b]) => b - a)) {
|
|
2096
|
+
println(` ${kind.padEnd(20)} ${count}`);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
async function cmdIntakeNeedsAsr(args) {
|
|
2102
|
+
const { existsSync, readdirSync, readFileSync } = await import('node:fs');
|
|
2103
|
+
const { homedir } = await import('node:os');
|
|
2104
|
+
const { join } = await import('node:path');
|
|
2105
|
+
|
|
2106
|
+
const sub = args[0] || 'list';
|
|
2107
|
+
const asrDir = join(homedir(), '.cx', 'intake', 'needs-asr');
|
|
2108
|
+
|
|
2109
|
+
if (sub === 'list') {
|
|
2110
|
+
if (!existsSync(asrDir)) { println('No audio/video files awaiting ASR.'); return; }
|
|
2111
|
+
const files = readdirSync(asrDir).filter((f) => f.endsWith('.json'));
|
|
2112
|
+
if (files.length === 0) { println('No audio/video files awaiting ASR.'); return; }
|
|
2113
|
+
println(`${files.length} file(s) awaiting ASR:\n`);
|
|
2114
|
+
for (const f of files) {
|
|
2115
|
+
try {
|
|
2116
|
+
const p = JSON.parse(readFileSync(join(asrDir, f), 'utf8'));
|
|
2117
|
+
println(` ${p.id} ${p.extension} ${p.sourcePath}`);
|
|
2118
|
+
} catch { println(` ${f} (malformed packet)`); }
|
|
2119
|
+
}
|
|
2120
|
+
println('\nTo process: set CONSTRUCT_ASR_BACKEND=whisper|assemblyai and re-run construct embed start.');
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
if (sub === 'show') {
|
|
2125
|
+
const id = args[1];
|
|
2126
|
+
if (!id) { errorln('Usage: construct intake needs-asr show <id>'); process.exit(1); }
|
|
2127
|
+
if (!existsSync(asrDir)) { errorln('needs-asr queue is empty.'); process.exit(1); }
|
|
2128
|
+
const files = readdirSync(asrDir).filter((f) => f.startsWith(id) && f.endsWith('.json'));
|
|
2129
|
+
if (files.length === 0) { errorln(`No packet found for id: ${id}`); process.exit(1); }
|
|
2130
|
+
const p = JSON.parse(readFileSync(join(asrDir, files[0]), 'utf8'));
|
|
2131
|
+
println(JSON.stringify(p, null, 2));
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
errorln(`Unknown needs-asr subcommand: ${sub}. Available: list, show`);
|
|
2136
|
+
process.exit(1);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
async function cmdSkillsUsage(sub, args) {
|
|
2140
|
+
const { summarizeSkillCalls, findOrphanSkills, hotSkills, DEFAULT_LOG_PATH } =
|
|
2141
|
+
await import('../lib/telemetry/skill-calls.mjs');
|
|
2142
|
+
const { existsSync, readdirSync, readFileSync } = await import('node:fs');
|
|
2143
|
+
const { join } = await import('node:path');
|
|
2144
|
+
|
|
2145
|
+
const flags = {};
|
|
2146
|
+
for (let i = 0; i < args.length; i++) {
|
|
2147
|
+
const m = args[i].match(/^--(\w[\w-]*)(?:=(.+))?$/);
|
|
2148
|
+
if (m) flags[m[1]] = m[2] ?? args[++i] ?? true;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (sub === 'usage') {
|
|
2152
|
+
const since = flags.since || '30d';
|
|
2153
|
+
const { skills, totalEvents } = summarizeSkillCalls({ since, agentId: flags.agent, source: flags.source });
|
|
2154
|
+
println(`Skill usage — last ${since} (${totalEvents} events)\n`);
|
|
2155
|
+
const rows = Object.entries(skills).sort((a, b) => b[1].calls - a[1].calls);
|
|
2156
|
+
if (rows.length === 0) { println('No load events found.'); return; }
|
|
2157
|
+
for (const [id, s] of rows) {
|
|
2158
|
+
const latency = s.p50LatencyMs != null ? ` p50=${s.p50LatencyMs}ms p95=${s.p95LatencyMs}ms` : '';
|
|
2159
|
+
println(` ${id.padEnd(50)} calls=${s.calls} sessions=${s.sessions} agents=${s.agents.length}${latency}`);
|
|
2160
|
+
}
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (sub === 'orphans') {
|
|
2165
|
+
const since = flags.since || '30d';
|
|
2166
|
+
const skillsRoot = join(ROOT_DIR, 'skills');
|
|
2167
|
+
if (!existsSync(skillsRoot)) { errorln('skills/ directory not found'); process.exit(1); }
|
|
2168
|
+
const allIds = [];
|
|
2169
|
+
function walk(dir, prefix = '') {
|
|
2170
|
+
for (const f of readdirSync(dir, { withFileTypes: true })) {
|
|
2171
|
+
const rel = prefix ? `${prefix}/${f.name}` : f.name;
|
|
2172
|
+
if (f.isDirectory()) {
|
|
2173
|
+
const skillMd = join(dir, f.name, 'SKILL.md');
|
|
2174
|
+
if (existsSync(skillMd)) allIds.push(rel);
|
|
2175
|
+
else walk(join(dir, f.name), rel);
|
|
2176
|
+
} else if (f.name.endsWith('.md') && f.name !== 'routing.md') {
|
|
2177
|
+
allIds.push(rel.replace(/\.md$/, ''));
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
walk(skillsRoot);
|
|
2182
|
+
const orphans = findOrphanSkills({ since, allSkillIds: allIds });
|
|
2183
|
+
if (orphans.length === 0) { println(`No orphan skills in the last ${since}.`); return; }
|
|
2184
|
+
println(`${orphans.length} skill(s) with no load events in the last ${since}:\n`);
|
|
2185
|
+
for (const id of orphans) println(` ${id}`);
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
if (sub === 'hot') {
|
|
2190
|
+
const since = flags.since || '30d';
|
|
2191
|
+
const limit = parseInt(flags.limit || '20', 10);
|
|
2192
|
+
const rows = hotSkills({ since, limit });
|
|
2193
|
+
if (rows.length === 0) { println('No skill load events found.'); return; }
|
|
2194
|
+
println(`Top ${limit} skills — last ${since}\n`);
|
|
2195
|
+
for (const s of rows) {
|
|
2196
|
+
const latency = s.p50LatencyMs != null ? ` p50=${s.p50LatencyMs}ms` : '';
|
|
2197
|
+
println(` ${s.skillId.padEnd(50)} calls=${s.calls}${latency}`);
|
|
2198
|
+
}
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
if (sub === 'correlate-quality') {
|
|
2203
|
+
println('Quality correlation requires Postgres (CONSTRUCT_DB_URL).');
|
|
2204
|
+
println('Local JSONL does not carry cx_score data.');
|
|
2205
|
+
println('Run with Postgres configured for full correlation output.');
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
if (sub === 'backfill-postgres') {
|
|
2210
|
+
if (!process.env.CONSTRUCT_DB_URL) {
|
|
2211
|
+
errorln('CONSTRUCT_DB_URL not set — Postgres backfill requires a database connection.');
|
|
2212
|
+
process.exit(1);
|
|
2213
|
+
}
|
|
2214
|
+
println('Backfilling skill invocations from JSONL into Postgres…');
|
|
2215
|
+
if (!existsSync(DEFAULT_LOG_PATH)) { println('No JSONL log found at ' + DEFAULT_LOG_PATH); return; }
|
|
2216
|
+
const lines = readFileSync(DEFAULT_LOG_PATH, 'utf8').split('\n').filter(Boolean);
|
|
2217
|
+
println(`Found ${lines.length} JSONL entries. Connect and insert via your Postgres client.`);
|
|
2218
|
+
println('(Full Postgres backfill client available in team/enterprise deployment mode.)');
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
errorln(`Unknown skills subcommand: ${sub}. Available: usage, orphans, hot, correlate-quality, backfill-postgres`);
|
|
1873
2223
|
process.exit(1);
|
|
1874
2224
|
}
|
|
1875
2225
|
|
|
@@ -2543,7 +2893,7 @@ async function cmdUpgrade(args) {
|
|
|
2543
2893
|
}
|
|
2544
2894
|
}
|
|
2545
2895
|
async function cmdValidate() {
|
|
2546
|
-
const reg = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '
|
|
2896
|
+
const reg = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
|
|
2547
2897
|
const result = validateRegistry(reg, { rootDir: ROOT_DIR });
|
|
2548
2898
|
if (result.valid) {
|
|
2549
2899
|
println(`✓ Registry valid (${result.summary})`);
|
|
@@ -2596,7 +2946,7 @@ async function cmdModels(args) {
|
|
|
2596
2946
|
if (args.includes('--apply')) {
|
|
2597
2947
|
const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
|
|
2598
2948
|
const models = await pollFreeModels(apiKey);
|
|
2599
|
-
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '
|
|
2949
|
+
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
|
|
2600
2950
|
const selections = {};
|
|
2601
2951
|
for (const currentTier of ['reasoning', 'standard', 'fast']) {
|
|
2602
2952
|
const fallbacks = registry.models?.[currentTier]?.fallback ?? [];
|
|
@@ -2646,7 +2996,7 @@ async function cmdModels(args) {
|
|
|
2646
2996
|
await cmdSync([]);
|
|
2647
2997
|
return;
|
|
2648
2998
|
}
|
|
2649
|
-
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '
|
|
2999
|
+
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
|
|
2650
3000
|
const result = readCurrentModels(envPath, registry.models ?? {});
|
|
2651
3001
|
const nothingSelected = ['reasoning', 'standard', 'fast'].every((t) => !result[t]);
|
|
2652
3002
|
println('Current model assignments:');
|
|
@@ -3004,6 +3354,113 @@ async function cmdEvals(args) {
|
|
|
3004
3354
|
println(' construct evals retrieval [--fixture=PATH] [--json]');
|
|
3005
3355
|
}
|
|
3006
3356
|
|
|
3357
|
+
async function cmdDocsReconcile(args) {
|
|
3358
|
+
const { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } = await import('node:fs');
|
|
3359
|
+
const { join, relative } = await import('node:path');
|
|
3360
|
+
const scopeArg = args.find((a) => a.startsWith('--scope='))?.replace('--scope=', '');
|
|
3361
|
+
const scopes = scopeArg ? scopeArg.split(',') : ['docs/prd', 'docs/adr', 'docs/rfc'];
|
|
3362
|
+
|
|
3363
|
+
const cwd = process.cwd();
|
|
3364
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
3365
|
+
const driftDir = join(cwd, '.cx', 'doc-drift', now);
|
|
3366
|
+
mkdirSync(driftDir, { recursive: true });
|
|
3367
|
+
|
|
3368
|
+
const candidates = [];
|
|
3369
|
+
for (const scope of scopes) {
|
|
3370
|
+
const dir = join(cwd, scope);
|
|
3371
|
+
if (!existsSync(dir)) continue;
|
|
3372
|
+
const walk = (d) => {
|
|
3373
|
+
try {
|
|
3374
|
+
for (const f of readdirSync(d, { withFileTypes: true })) {
|
|
3375
|
+
if (f.isDirectory()) walk(join(d, f.name));
|
|
3376
|
+
else if (f.name.endsWith('.md') || f.name.endsWith('.mdx')) {
|
|
3377
|
+
candidates.push(join(d, f.name));
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
} catch { /* skip */ }
|
|
3381
|
+
};
|
|
3382
|
+
walk(dir);
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
if (candidates.length === 0) {
|
|
3386
|
+
println(`No documents found in scopes: ${scopes.join(', ')}`);
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
println(`Reconciling ${candidates.length} document(s) across ${scopes.join(', ')}…\n`);
|
|
3391
|
+
println('This performs a structural drift check (frontmatter, section headings, cross-refs).');
|
|
3392
|
+
println('Full AI-assisted reconciliation requires a specialist dispatch via the MCP worker_run tool.\n');
|
|
3393
|
+
|
|
3394
|
+
const reports = [];
|
|
3395
|
+
const STALE_DAYS = 30;
|
|
3396
|
+
const now_ms = Date.now();
|
|
3397
|
+
|
|
3398
|
+
for (const p of candidates) {
|
|
3399
|
+
let text = '';
|
|
3400
|
+
try { text = readFileSync(p, 'utf8'); } catch { continue; }
|
|
3401
|
+
|
|
3402
|
+
// Extract last_verified_at from frontmatter.
|
|
3403
|
+
const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
|
|
3404
|
+
let lastVerified = null;
|
|
3405
|
+
if (fmMatch) {
|
|
3406
|
+
const lvaMatch = fmMatch[1].match(/last_verified_at:\s*(.+)/);
|
|
3407
|
+
if (lvaMatch) lastVerified = lvaMatch[1].trim();
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
const ageDays = lastVerified
|
|
3411
|
+
? Math.floor((now_ms - new Date(lastVerified).getTime()) / 86400000)
|
|
3412
|
+
: null;
|
|
3413
|
+
|
|
3414
|
+
const verdict = lastVerified === null ? 'unverified'
|
|
3415
|
+
: ageDays > STALE_DAYS ? 'stale'
|
|
3416
|
+
: 'current';
|
|
3417
|
+
|
|
3418
|
+
const rel = relative(cwd, p);
|
|
3419
|
+
reports.push({ path: rel, verdict, lastVerified, ageDays });
|
|
3420
|
+
|
|
3421
|
+
const reportPath = join(driftDir, rel.replace(/[/\\]/g, '-') + '.md');
|
|
3422
|
+
mkdirSync(driftDir, { recursive: true });
|
|
3423
|
+
writeFileSync(reportPath, [
|
|
3424
|
+
`# Drift report: ${rel}`,
|
|
3425
|
+
'',
|
|
3426
|
+
`**Verdict:** ${verdict}`,
|
|
3427
|
+
`**Last verified:** ${lastVerified || '(none)'}`,
|
|
3428
|
+
`**Age (days):** ${ageDays ?? '—'}`,
|
|
3429
|
+
'',
|
|
3430
|
+
'## Notes',
|
|
3431
|
+
'',
|
|
3432
|
+
verdict === 'stale'
|
|
3433
|
+
? `This document was last verified ${ageDays} days ago (threshold: ${STALE_DAYS} days). Review against current implementation.`
|
|
3434
|
+
: verdict === 'unverified'
|
|
3435
|
+
? 'This document has no last_verified_at frontmatter field. Add one after reviewing.'
|
|
3436
|
+
: 'This document appears current.',
|
|
3437
|
+
].join('\n'));
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
// Write summary.
|
|
3441
|
+
const stale = reports.filter((r) => r.verdict === 'stale');
|
|
3442
|
+
const unverified = reports.filter((r) => r.verdict === 'unverified');
|
|
3443
|
+
const current = reports.filter((r) => r.verdict === 'current');
|
|
3444
|
+
|
|
3445
|
+
const summaryPath = join(driftDir, 'summary.md');
|
|
3446
|
+
writeFileSync(summaryPath, [
|
|
3447
|
+
`# Doc drift summary — ${now}`,
|
|
3448
|
+
'',
|
|
3449
|
+
`Scopes: ${scopes.join(', ')}`,
|
|
3450
|
+
`Total: ${reports.length} | Current: ${current.length} | Stale: ${stale.length} | Unverified: ${unverified.length}`,
|
|
3451
|
+
'',
|
|
3452
|
+
stale.length ? `## Stale (>${STALE_DAYS} days)\n${stale.map((r) => `- ${r.path} (${r.ageDays}d)`).join('\n')}` : '',
|
|
3453
|
+
unverified.length ? `\n## Unverified\n${unverified.map((r) => `- ${r.path}`).join('\n')}` : '',
|
|
3454
|
+
].filter(Boolean).join('\n'));
|
|
3455
|
+
|
|
3456
|
+
println(`Results:`);
|
|
3457
|
+
println(` current: ${current.length}`);
|
|
3458
|
+
println(` stale: ${stale.length}`);
|
|
3459
|
+
println(` unverified: ${unverified.length}`);
|
|
3460
|
+
println(`\nDrift reports written to: .cx/doc-drift/${now}/`);
|
|
3461
|
+
println(`Summary: .cx/doc-drift/${now}/summary.md`);
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3007
3464
|
async function cmdDocsUpdate(args) {
|
|
3008
3465
|
const check = args.includes('--check');
|
|
3009
3466
|
const { regenerateDocs: regen } = await import('../lib/auto-docs.mjs');
|
|
@@ -3158,6 +3615,209 @@ async function cmdGatesAudit(args) {
|
|
|
3158
3615
|
if (!report.ok) process.exit(1);
|
|
3159
3616
|
}
|
|
3160
3617
|
|
|
3618
|
+
async function cmdCi(args) {
|
|
3619
|
+
const sub = args[0];
|
|
3620
|
+
|
|
3621
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
3622
|
+
println('Usage: construct ci <preview|status|list>');
|
|
3623
|
+
println(' preview [--job=<name>] [--list] [--full] Run CI jobs locally using the same commands CI uses');
|
|
3624
|
+
println(' status Show recent CI run results for the current branch');
|
|
3625
|
+
println(' list Alias for: ci preview --list');
|
|
3626
|
+
return;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
if (sub === 'list') return cmdCiPreview(['--list']);
|
|
3630
|
+
if (sub === 'preview') return cmdCiPreview(args.slice(1));
|
|
3631
|
+
if (sub === 'status') return cmdCiStatus(args.slice(1));
|
|
3632
|
+
|
|
3633
|
+
errorln(`Unknown ci subcommand: ${sub}. Available: preview, status, list`);
|
|
3634
|
+
process.exit(1);
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
async function cmdCiPreview(args) {
|
|
3638
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
3639
|
+
const { join } = await import('node:path');
|
|
3640
|
+
const { spawnSync: spSync } = await import('node:child_process');
|
|
3641
|
+
const YAML = await import('node:fs');
|
|
3642
|
+
|
|
3643
|
+
const listOnly = args.includes('--list');
|
|
3644
|
+
const fullMode = args.includes('--full');
|
|
3645
|
+
const jobFilter = args.find((a) => a.startsWith('--job='))?.split('=').slice(1).join('=');
|
|
3646
|
+
|
|
3647
|
+
const ciPath = join(ROOT_DIR, '.github', 'workflows', 'ci.yml');
|
|
3648
|
+
if (!existsSync(ciPath)) {
|
|
3649
|
+
errorln('No .github/workflows/ci.yml found');
|
|
3650
|
+
process.exit(1);
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
const yml = readFileSync(ciPath, 'utf8');
|
|
3654
|
+
|
|
3655
|
+
// Extract jobs under the `jobs:` top-level key only.
|
|
3656
|
+
// Split on the `jobs:` line, then slice job blocks between consecutive 2-space-indent keys.
|
|
3657
|
+
const jobsStart = yml.indexOf('\njobs:\n');
|
|
3658
|
+
const jobsSection = jobsStart >= 0 ? yml.slice(jobsStart + 1) : '';
|
|
3659
|
+
|
|
3660
|
+
// Find all top-level job keys (2-space indent) and slice blocks between them.
|
|
3661
|
+
const jobKeyPattern = /^ (\w[\w-]*):\s*$/gm;
|
|
3662
|
+
const jobPositions = [];
|
|
3663
|
+
let km;
|
|
3664
|
+
while ((km = jobKeyPattern.exec(jobsSection)) !== null) {
|
|
3665
|
+
if (km[1] === 'jobs') continue;
|
|
3666
|
+
jobPositions.push({ id: km[1], start: km.index });
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
const jobBlocks = [];
|
|
3670
|
+
for (let i = 0; i < jobPositions.length; i++) {
|
|
3671
|
+
const { id, start } = jobPositions[i];
|
|
3672
|
+
const end = jobPositions[i + 1]?.start ?? jobsSection.length;
|
|
3673
|
+
const block = jobsSection.slice(start, end);
|
|
3674
|
+
|
|
3675
|
+
const nameMatch = block.match(/^ name:\s*(.+)\s*$/m);
|
|
3676
|
+
const displayName = nameMatch
|
|
3677
|
+
? nameMatch[1].replace(/\$\{\{[^}]+\}\}/g, '<matrix>').trim()
|
|
3678
|
+
: id;
|
|
3679
|
+
|
|
3680
|
+
const runSteps = [];
|
|
3681
|
+
let sm;
|
|
3682
|
+
|
|
3683
|
+
// Named steps: `- name: ...\n (optional: if/env lines)\n run: cmd`
|
|
3684
|
+
const namedRunPattern = /- name:\s*([^\n]+)\n(?:[ \t]+[^\n]+\n)*?[ \t]+run:\s*([^\n]+)/g;
|
|
3685
|
+
while ((sm = namedRunPattern.exec(block)) !== null) {
|
|
3686
|
+
const cmd = sm[2].trim();
|
|
3687
|
+
if (cmd && !cmd.startsWith('#')) {
|
|
3688
|
+
runSteps.push({ name: sm[1].trim(), cmd });
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
// Unnamed run steps: `- run: cmd` — skip `npm ci` (CI setup, not needed locally).
|
|
3693
|
+
const unnamedRunPattern = /^[ \t]+-\s+run:\s*([^\n|]+)$/gm;
|
|
3694
|
+
while ((sm = unnamedRunPattern.exec(block)) !== null) {
|
|
3695
|
+
const cmd = sm[1].trim();
|
|
3696
|
+
if (!cmd || cmd.startsWith('#')) continue;
|
|
3697
|
+
if (/^npm ci\b/.test(cmd)) continue;
|
|
3698
|
+
if (runSteps.some((s) => s.cmd === cmd)) continue;
|
|
3699
|
+
runSteps.push({ name: cmd.slice(0, 50), cmd });
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
if (runSteps.length > 0 || listOnly) {
|
|
3703
|
+
jobBlocks.push({ id, name: displayName, steps: runSteps });
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
if (listOnly) {
|
|
3708
|
+
println('CI jobs from .github/workflows/ci.yml:');
|
|
3709
|
+
for (const job of jobBlocks) {
|
|
3710
|
+
println(` ${job.id.padEnd(20)} ${job.name}`);
|
|
3711
|
+
for (const step of job.steps) {
|
|
3712
|
+
println(` → ${step.name}`);
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
const targetJobs = jobFilter
|
|
3719
|
+
? jobBlocks.filter((j) => j.id === jobFilter || j.name.toLowerCase().includes(jobFilter.toLowerCase()))
|
|
3720
|
+
: jobBlocks.filter((j) => !['changes'].includes(j.id));
|
|
3721
|
+
|
|
3722
|
+
if (targetJobs.length === 0) {
|
|
3723
|
+
errorln(`No jobs matched: ${jobFilter}`);
|
|
3724
|
+
process.exit(1);
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
println(`Running ${targetJobs.length} CI job(s) locally...`);
|
|
3728
|
+
let failed = 0;
|
|
3729
|
+
|
|
3730
|
+
for (const job of targetJobs) {
|
|
3731
|
+
println(`\n▶ ${job.name} (${job.id})`);
|
|
3732
|
+
for (const step of job.steps) {
|
|
3733
|
+
const cmd = step.cmd;
|
|
3734
|
+
|
|
3735
|
+
// Docker/Trivy steps require --full.
|
|
3736
|
+
const isHeavy = /docker|trivy/i.test(cmd);
|
|
3737
|
+
if (isHeavy && !fullMode) {
|
|
3738
|
+
println(` ⊘ skipped (heavy — pass --full to run): ${step.name}`);
|
|
3739
|
+
println(` equivalent: ${cmd}`);
|
|
3740
|
+
continue;
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
// Parse the command into executable form.
|
|
3744
|
+
const parts = cmd.split(/\s+/);
|
|
3745
|
+
const executable = parts[0];
|
|
3746
|
+
const cmdArgs = parts.slice(1);
|
|
3747
|
+
|
|
3748
|
+
println(` ▷ ${step.name}`);
|
|
3749
|
+
|
|
3750
|
+
const result = spSync(executable, cmdArgs, {
|
|
3751
|
+
cwd: ROOT_DIR,
|
|
3752
|
+
stdio: 'inherit',
|
|
3753
|
+
env: { ...process.env, CI: '1' },
|
|
3754
|
+
});
|
|
3755
|
+
|
|
3756
|
+
if (result.status !== 0) {
|
|
3757
|
+
errorln(` ✗ ${step.name} failed (exit ${result.status})`);
|
|
3758
|
+
failed++;
|
|
3759
|
+
} else {
|
|
3760
|
+
println(` ✓ ${step.name}`);
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
if (failed > 0) {
|
|
3766
|
+
errorln(`\n${failed} step(s) failed.`);
|
|
3767
|
+
process.exit(1);
|
|
3768
|
+
} else {
|
|
3769
|
+
println('\nAll CI steps passed locally.');
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
async function cmdCiStatus(args) {
|
|
3774
|
+
const { execSync: exec } = await import('node:child_process');
|
|
3775
|
+
const jsonOutput = args.includes('--json');
|
|
3776
|
+
|
|
3777
|
+
let branch = '';
|
|
3778
|
+
try {
|
|
3779
|
+
branch = exec('git branch --show-current', { cwd: process.cwd(), stdio: 'pipe', encoding: 'utf8', timeout: 2000 }).trim();
|
|
3780
|
+
} catch { /* not in a git repo */ }
|
|
3781
|
+
|
|
3782
|
+
try {
|
|
3783
|
+
const limit = args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '5';
|
|
3784
|
+
const branchFlag = branch ? `--branch=${branch}` : '';
|
|
3785
|
+
const raw = exec(
|
|
3786
|
+
`gh run list ${branchFlag} --limit=${limit} --json conclusion,name,workflowName,createdAt,databaseId,url,status`,
|
|
3787
|
+
{ cwd: process.cwd(), stdio: 'pipe', encoding: 'utf8', timeout: 10_000 },
|
|
3788
|
+
);
|
|
3789
|
+
const runs = JSON.parse(raw);
|
|
3790
|
+
if (jsonOutput) {
|
|
3791
|
+
println(JSON.stringify(runs, null, 2));
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
if (runs.length === 0) {
|
|
3796
|
+
println(`No CI runs found${branch ? ` for branch '${branch}'` : ''}.`);
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
println(`CI runs${branch ? ` on '${branch}'` : ''} (${runs.length}):`);
|
|
3801
|
+
for (const run of runs) {
|
|
3802
|
+
const icon = run.conclusion === 'success' ? '✓'
|
|
3803
|
+
: run.conclusion === 'failure' ? '✗'
|
|
3804
|
+
: run.status === 'in_progress' ? '⏳'
|
|
3805
|
+
: '○';
|
|
3806
|
+
const age = run.createdAt ? new Date(run.createdAt).toLocaleString() : '';
|
|
3807
|
+
println(` ${icon} ${(run.workflowName || run.name).padEnd(35)} ${age}`);
|
|
3808
|
+
if (run.conclusion === 'failure') println(` ${run.url}`);
|
|
3809
|
+
}
|
|
3810
|
+
} catch (err) {
|
|
3811
|
+
const msg = err?.message || String(err);
|
|
3812
|
+
if (/gh/.test(msg) || /command not found/.test(msg)) {
|
|
3813
|
+
errorln('gh CLI not available. Install from https://cli.github.com/');
|
|
3814
|
+
} else {
|
|
3815
|
+
errorln(`ci status failed: ${msg.slice(0, 200)}`);
|
|
3816
|
+
}
|
|
3817
|
+
process.exit(1);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3161
3821
|
async function cmdLintTemplates(args) {
|
|
3162
3822
|
const bodyFileArg = args.find((a) => a.startsWith('--body-file='))?.split('=').slice(1).join('=');
|
|
3163
3823
|
const bodyArgIdx = args.indexOf('--body-file');
|
|
@@ -3183,14 +3843,14 @@ async function cmdLintResearch() {
|
|
|
3183
3843
|
}
|
|
3184
3844
|
|
|
3185
3845
|
async function cmdLintAgents() {
|
|
3186
|
-
const { validateRegistryFile } = await import('../lib/
|
|
3187
|
-
const registryPath = path.join(ROOT_DIR, '
|
|
3846
|
+
const { validateRegistryFile } = await import('../lib/specialists/schema.mjs');
|
|
3847
|
+
const registryPath = path.join(ROOT_DIR, 'specialists', 'registry.json');
|
|
3188
3848
|
const { errors, agentCount } = validateRegistryFile({ registryPath, rootDir: ROOT_DIR });
|
|
3189
3849
|
if (errors.length === 0) {
|
|
3190
|
-
console.log(`
|
|
3850
|
+
console.log(`specialists/registry.json: ${agentCount} agents, 0 errors`);
|
|
3191
3851
|
return;
|
|
3192
3852
|
}
|
|
3193
|
-
console.error(`
|
|
3853
|
+
console.error(`specialists/registry.json: ${errors.length} error(s) across ${agentCount} agents`);
|
|
3194
3854
|
for (const err of errors) console.error(` ${err}`);
|
|
3195
3855
|
process.exit(1);
|
|
3196
3856
|
}
|
|
@@ -3199,10 +3859,10 @@ async function cmdLintContracts() {
|
|
|
3199
3859
|
const { validateContractsFile } = await import('../lib/contracts/validate.mjs');
|
|
3200
3860
|
const result = validateContractsFile({ repoRoot: ROOT_DIR });
|
|
3201
3861
|
if (result.ok) {
|
|
3202
|
-
console.log('
|
|
3862
|
+
console.log('specialists/contracts.json: 0 errors');
|
|
3203
3863
|
return;
|
|
3204
3864
|
}
|
|
3205
|
-
console.error(`
|
|
3865
|
+
console.error(`specialists/contracts.json: ${result.errors.length} error(s)`);
|
|
3206
3866
|
for (const err of result.errors) console.error(` ${err}`);
|
|
3207
3867
|
process.exit(1);
|
|
3208
3868
|
}
|
|
@@ -3395,10 +4055,390 @@ async function cmdProvider(args) {
|
|
|
3395
4055
|
process.exit(1);
|
|
3396
4056
|
}
|
|
3397
4057
|
|
|
3398
|
-
|
|
4058
|
+
if (sub === 'new') {
|
|
4059
|
+
const name = args[1];
|
|
4060
|
+
if (!name) { errorln('Usage: construct provider new <name> [--capabilities=read,search,write]'); process.exit(1); }
|
|
4061
|
+
const { scaffoldProvider } = await import('../lib/providers/scaffold.mjs');
|
|
4062
|
+
const capFlag = args.find((a) => a.startsWith('--capabilities='));
|
|
4063
|
+
const capabilities = capFlag ? capFlag.replace('--capabilities=', '').split(',') : ['read', 'search'];
|
|
4064
|
+
const force = args.includes('--force');
|
|
4065
|
+
const result = await scaffoldProvider(name, { capabilities, force, rootDir: ROOT_DIR });
|
|
4066
|
+
if (result.skipped?.length) println(`Skipped (already exist): ${result.skipped.join(', ')}`);
|
|
4067
|
+
if (result.written?.length) println(`Created: ${result.written.join('\n ')}`);
|
|
4068
|
+
println(`\nProvider scaffold ready. Run: npm test tests/providers/${name}.test.mjs`);
|
|
4069
|
+
return;
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
errorln(`Unknown provider subcommand: ${sub}. Available: list, info, test, plugins, new`);
|
|
4073
|
+
process.exit(1);
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
async function cmdTags(args) {
|
|
4077
|
+
const sub = args[0];
|
|
4078
|
+
const { loadVocabulary } = await import('../lib/tags/vocabulary.mjs');
|
|
4079
|
+
const { proposeTag, addTag, deprecateTag, archiveTag, listProposed, auditTags } =
|
|
4080
|
+
await import('../lib/tags/lifecycle.mjs');
|
|
4081
|
+
|
|
4082
|
+
if (!sub || sub === 'audit') {
|
|
4083
|
+
const report = auditTags();
|
|
4084
|
+
println(`Tag audit\n`);
|
|
4085
|
+
println(`Active tags: ${Object.keys(report.active).length}`);
|
|
4086
|
+
if (report.overBudget.length) {
|
|
4087
|
+
println(`\nOver budget:`);
|
|
4088
|
+
for (const t of report.overBudget) println(` ${t.id} count=${t.count} budget=${t.budget}`);
|
|
4089
|
+
}
|
|
4090
|
+
if (report.deprecated.length) {
|
|
4091
|
+
println(`\nDeprecated (still in use):`);
|
|
4092
|
+
for (const t of report.deprecated) println(` ${t.id} docs=${t.docCount} replacedBy=${t.replacedBy || '—'}`);
|
|
4093
|
+
}
|
|
4094
|
+
if (report.unknown.length) {
|
|
4095
|
+
println(`\nUnknown (in docs, not in vocab):`);
|
|
4096
|
+
for (const t of report.unknown) println(` ${t.id} docs=${t.docCount}`);
|
|
4097
|
+
}
|
|
4098
|
+
if (report.orphan.length) {
|
|
4099
|
+
println(`\nOrphan (in vocab, never used):`);
|
|
4100
|
+
for (const t of report.orphan) println(` ${t.id}`);
|
|
4101
|
+
}
|
|
4102
|
+
return;
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
if (sub === 'propose') {
|
|
4106
|
+
const id = args[1];
|
|
4107
|
+
const facetArg = args.find((a) => a.startsWith('--facet='))?.replace('--facet=', '');
|
|
4108
|
+
const labelArg = args.find((a) => a.startsWith('--label='))?.replace('--label=', '');
|
|
4109
|
+
if (!id || !facetArg) { errorln('Usage: construct tags propose <id> --facet=<f> [--label="..."]'); process.exit(1); }
|
|
4110
|
+
proposeTag({ id, facet: facetArg, label: labelArg || id });
|
|
4111
|
+
println(`Proposed tag ${id} written to .cx/tags/proposed.jsonl`);
|
|
4112
|
+
return;
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
if (sub === 'add') {
|
|
4116
|
+
const id = args[1];
|
|
4117
|
+
const facetArg = args.find((a) => a.startsWith('--facet='))?.replace('--facet=', '');
|
|
4118
|
+
const labelArg = args.find((a) => a.startsWith('--label='))?.replace('--label=', '');
|
|
4119
|
+
const scope = args.includes('--scope=project') ? 'project' : 'repo';
|
|
4120
|
+
if (!id || !facetArg) { errorln('Usage: construct tags add <id> --facet=<f> [--label="..."] [--scope=project]'); process.exit(1); }
|
|
4121
|
+
addTag({ id, facet: facetArg, label: labelArg || id, scope, cwd: process.cwd(), rootDir: ROOT_DIR });
|
|
4122
|
+
println(`Tag ${id} added to ${scope === 'project' ? '.cx/tags/vocabulary-overrides.json' : 'config/tag-vocabulary.json'}`);
|
|
4123
|
+
return;
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
if (sub === 'deprecate') {
|
|
4127
|
+
const id = args[1];
|
|
4128
|
+
const reason = args.find((a) => a.startsWith('--reason='))?.replace('--reason=', '');
|
|
4129
|
+
const replacedBy = args.find((a) => a.startsWith('--replaced-by='))?.replace('--replaced-by=', '');
|
|
4130
|
+
if (!id) { errorln('Usage: construct tags deprecate <id> --reason="..." [--replaced-by=<other>]'); process.exit(1); }
|
|
4131
|
+
deprecateTag({ id, reason: reason || 'deprecated', replacedBy, cwd: process.cwd(), rootDir: ROOT_DIR });
|
|
4132
|
+
println(`Tag ${id} marked deprecated.`);
|
|
4133
|
+
return;
|
|
4134
|
+
}
|
|
4135
|
+
|
|
4136
|
+
if (sub === 'archive') {
|
|
4137
|
+
const id = args[1];
|
|
4138
|
+
if (!id) { errorln('Usage: construct tags archive <id>'); process.exit(1); }
|
|
4139
|
+
archiveTag({ id, cwd: process.cwd(), rootDir: ROOT_DIR });
|
|
4140
|
+
println(`Tag ${id} archived.`);
|
|
4141
|
+
return;
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
if (sub === 'list') {
|
|
4145
|
+
const vocab = loadVocabulary({ rootDir: ROOT_DIR, cwd: process.cwd() });
|
|
4146
|
+
println(`Tag vocabulary (${vocab.tags.length} entries)\n`);
|
|
4147
|
+
for (const t of vocab.tags) {
|
|
4148
|
+
if (t.status === 'archived') continue;
|
|
4149
|
+
const dep = t.status === 'deprecated' ? ' [deprecated]' : '';
|
|
4150
|
+
println(` ${t.id.padEnd(40)} facet=${t.facet} status=${t.status}${dep}`);
|
|
4151
|
+
}
|
|
4152
|
+
return;
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
if (sub === 'proposed') {
|
|
4156
|
+
const items = listProposed({ cwd: process.cwd() });
|
|
4157
|
+
if (!items.length) { println('No pending tag proposals.'); return; }
|
|
4158
|
+
println(`${items.length} pending proposal(s):\n`);
|
|
4159
|
+
for (const p of items) println(` ${p.id.padEnd(40)} facet=${p.facet} proposed=${p.proposedAt}`);
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4163
|
+
if (sub === 'backfill') {
|
|
4164
|
+
println('Tag backfill reads .cx/knowledge/**/*.md frontmatter and writes tags to construct_documents.');
|
|
4165
|
+
println('Requires DATABASE_URL. Use: node scripts/tag-candidate-mining.mjs for standalone mode.');
|
|
4166
|
+
return;
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
errorln(`Unknown tags subcommand: ${sub}. Available: audit, propose, add, deprecate, archive, list, proposed, backfill`);
|
|
3399
4170
|
process.exit(1);
|
|
3400
4171
|
}
|
|
3401
4172
|
|
|
4173
|
+
async function cmdScheduler(args) {
|
|
4174
|
+
const sub = args[0];
|
|
4175
|
+
const { registerJob, runJobOnce, listJobs } = await import('../lib/scheduler/index.mjs');
|
|
4176
|
+
|
|
4177
|
+
if (!sub || sub === 'list') {
|
|
4178
|
+
const jobs = listJobs();
|
|
4179
|
+
println(`Scheduled jobs (${jobs.length})\n`);
|
|
4180
|
+
for (const j of jobs) {
|
|
4181
|
+
println(` ${j.id.padEnd(30)} schedule=${j.schedule} mode=${j.mode}`);
|
|
4182
|
+
}
|
|
4183
|
+
return;
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
if (sub === 'run') {
|
|
4187
|
+
const id = args[1];
|
|
4188
|
+
if (!id) { errorln('Usage: construct scheduler run <job-id>'); process.exit(1); }
|
|
4189
|
+
println(`Running job '${id}' one-shot…`);
|
|
4190
|
+
try {
|
|
4191
|
+
await runJobOnce(id, { cwd: process.cwd(), env: process.env });
|
|
4192
|
+
println(`Job '${id}' completed.`);
|
|
4193
|
+
} catch (err) {
|
|
4194
|
+
errorln(`Job '${id}' failed: ${err.message}`);
|
|
4195
|
+
process.exit(1);
|
|
4196
|
+
}
|
|
4197
|
+
return;
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
if (sub === 'runner') {
|
|
4201
|
+
println('Starting scheduler runner (team/enterprise mode)…');
|
|
4202
|
+
println('Set DATABASE_URL for Postgres-backed leader-election runner.');
|
|
4203
|
+
println('In solo mode, use native triggers: construct scheduler list');
|
|
4204
|
+
return;
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
errorln(`Unknown scheduler subcommand: ${sub}. Available: list, run, runner`);
|
|
4208
|
+
process.exit(1);
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
async function cmdCreds(args) {
|
|
4212
|
+
const sub = args[0];
|
|
4213
|
+
const { readCreds, writeCreds, deleteCreds, listCreds, checkCredsFileMode, credsFilePath } =
|
|
4214
|
+
await import('../lib/providers/creds.mjs');
|
|
4215
|
+
const { createInterface } = await import('node:readline');
|
|
4216
|
+
|
|
4217
|
+
if (!sub || sub === 'list') {
|
|
4218
|
+
const entries = listCreds();
|
|
4219
|
+
if (entries.length === 0) { println('No provider credentials stored.'); return; }
|
|
4220
|
+
println('Provider credentials\n');
|
|
4221
|
+
println(' PROVIDER ACCOUNT ROTATED_AT NEXT_ROTATION');
|
|
4222
|
+
for (const e of entries) {
|
|
4223
|
+
println(` ${e.provider.padEnd(26)} ${(e.account || '—').padEnd(27)} ${(e.rotatedAt || '—').padEnd(23)} ${e.nextRotationDue || '—'}`);
|
|
4224
|
+
}
|
|
4225
|
+
return;
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
if (sub === 'test') {
|
|
4229
|
+
const provider = args[1];
|
|
4230
|
+
if (!provider) { errorln('Usage: construct creds test <provider>'); process.exit(1); }
|
|
4231
|
+
const { resolveProviders } = await import('../lib/providers/registry.mjs');
|
|
4232
|
+
const { providers } = await resolveProviders({ rootDir: ROOT_DIR, env: process.env });
|
|
4233
|
+
if (!providers[provider]) { errorln(`Unknown provider: ${provider}`); process.exit(1); }
|
|
4234
|
+
const health = await providers[provider].health({});
|
|
4235
|
+
println(`${provider}: ${health.ok ? 'ok' : 'unhealthy'}`);
|
|
4236
|
+
if (health.detail) println(` ${health.detail}`);
|
|
4237
|
+
process.exit(health.ok ? 0 : 1);
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
if (sub === 'revoke') {
|
|
4241
|
+
const provider = args[1];
|
|
4242
|
+
if (!provider) { errorln('Usage: construct creds revoke <provider>'); process.exit(1); }
|
|
4243
|
+
deleteCreds(provider);
|
|
4244
|
+
println(`Credentials for ${provider} removed from ${credsFilePath()}.`);
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
if (sub === 'set' || sub === 'rotate') {
|
|
4249
|
+
const provider = args[1];
|
|
4250
|
+
if (!provider) { errorln(`Usage: construct creds ${sub} <provider>`); process.exit(1); }
|
|
4251
|
+
println(`Enter credentials for ${provider} (input hidden):`);
|
|
4252
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
4253
|
+
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
4254
|
+
const key = await ask('API key / token: ');
|
|
4255
|
+
const account = await ask('Account / username (optional): ');
|
|
4256
|
+
rl.close();
|
|
4257
|
+
writeCreds(provider, { key: key.trim(), account: account.trim() || undefined });
|
|
4258
|
+
checkCredsFileMode();
|
|
4259
|
+
println(`\nCredentials stored at ${credsFilePath()} (mode 0600).`);
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
errorln(`Unknown creds subcommand: ${sub}. Available: list, set, rotate, revoke, test`);
|
|
4264
|
+
process.exit(1);
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
async function cmdProviders(args) {
|
|
4268
|
+
const sub = args[0];
|
|
4269
|
+
|
|
4270
|
+
if (!sub || sub === 'status') {
|
|
4271
|
+
const resetProvider = args.find((a) => a.startsWith('--reset='))?.replace('--reset=', '');
|
|
4272
|
+
if (resetProvider) {
|
|
4273
|
+
const { reset } = await import('../lib/providers/circuit-breaker.mjs');
|
|
4274
|
+
reset(resetProvider);
|
|
4275
|
+
println(`Circuit breaker for ${resetProvider} reset to CLOSED.`);
|
|
4276
|
+
return;
|
|
4277
|
+
}
|
|
4278
|
+
const { getState } = await import('../lib/providers/circuit-breaker.mjs');
|
|
4279
|
+
const { describeProviders } = await import('../lib/providers/registry.mjs');
|
|
4280
|
+
const desc = await describeProviders({ rootDir: ROOT_DIR, env: process.env });
|
|
4281
|
+
println('Provider status\n');
|
|
4282
|
+
for (const entry of desc.summary) {
|
|
4283
|
+
const breaker = getState(entry.id);
|
|
4284
|
+
const breakerStatus = breaker.state === 'CLOSED' ? 'closed' : `OPEN (${breaker.failures} failures)`;
|
|
4285
|
+
println(` ${entry.id.padEnd(26)} enabled=${entry.health.ok ? 'yes' : 'no'} breaker=${breakerStatus}`);
|
|
4286
|
+
}
|
|
4287
|
+
return;
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4290
|
+
if (sub === 'discover') {
|
|
4291
|
+
const providerName = args[1];
|
|
4292
|
+
if (!providerName) { errorln('Usage: construct providers discover <provider> [--org=<org>]'); process.exit(1); }
|
|
4293
|
+
const orgFlag = args.find((a) => a.startsWith('--org='))?.replace('--org=', '');
|
|
4294
|
+
println(`Discovering resources for ${providerName}${orgFlag ? ` (org: ${orgFlag})` : ''}…`);
|
|
4295
|
+
println('Discovery requires the provider to be configured with a valid API token.');
|
|
4296
|
+
println(`Run: construct creds set ${providerName}`);
|
|
4297
|
+
return;
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
errorln(`Unknown providers subcommand: ${sub}. Available: status, discover`);
|
|
4301
|
+
process.exit(1);
|
|
4302
|
+
}
|
|
4303
|
+
|
|
4304
|
+
async function cmdWorkflow(args) {
|
|
4305
|
+
const sub = args[0] || 'list';
|
|
4306
|
+
|
|
4307
|
+
if (sub === 'list') {
|
|
4308
|
+
const { existsSync, readdirSync } = await import('node:fs');
|
|
4309
|
+
const { join } = await import('node:path');
|
|
4310
|
+
const dir = join(ROOT_DIR, 'templates', 'workflows');
|
|
4311
|
+
if (!existsSync(dir)) { println('No workflow templates found.'); return; }
|
|
4312
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
4313
|
+
if (files.length === 0) { println('No workflow templates found.'); return; }
|
|
4314
|
+
println('Available workflow templates:\n');
|
|
4315
|
+
for (const f of files) {
|
|
4316
|
+
try {
|
|
4317
|
+
const { readFileSync } = await import('node:fs');
|
|
4318
|
+
const raw = readFileSync(join(dir, f), 'utf8');
|
|
4319
|
+
const titleMatch = raw.match(/^title:\s*(.+)$/m);
|
|
4320
|
+
const descMatch = raw.match(/^description:\s*(.+)$/m);
|
|
4321
|
+
const id = f.replace(/\.(yml|yaml)$/, '');
|
|
4322
|
+
println(` ${id.padEnd(30)} ${titleMatch?.[1] || ''}`);
|
|
4323
|
+
if (descMatch?.[1]) println(` ${''.padEnd(30)} ${descMatch[1]}`);
|
|
4324
|
+
} catch { println(` ${f}`); }
|
|
4325
|
+
}
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4329
|
+
if (sub === 'show') {
|
|
4330
|
+
const id = args[1];
|
|
4331
|
+
if (!id) { errorln('Usage: construct workflow show <id>'); process.exit(1); }
|
|
4332
|
+
const { existsSync, readFileSync } = await import('node:fs');
|
|
4333
|
+
const { join } = await import('node:path');
|
|
4334
|
+
const candidates = [
|
|
4335
|
+
join(ROOT_DIR, 'templates', 'workflows', `${id}.yml`),
|
|
4336
|
+
join(ROOT_DIR, 'templates', 'workflows', `${id}.yaml`),
|
|
4337
|
+
];
|
|
4338
|
+
const p = candidates.find(existsSync);
|
|
4339
|
+
if (!p) { errorln(`Workflow template not found: ${id}`); process.exit(1); }
|
|
4340
|
+
println(readFileSync(p, 'utf8'));
|
|
4341
|
+
return;
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4344
|
+
if (sub === 'new') {
|
|
4345
|
+
const id = args[1];
|
|
4346
|
+
if (!id) { errorln('Usage: construct workflow new <template-id> [--name=<name>] [--non-interactive]'); process.exit(1); }
|
|
4347
|
+
const { instantiateWorkflow } = await import('../lib/workflows/instantiate.mjs');
|
|
4348
|
+
const nameFlag = args.find((a) => a.startsWith('--name='))?.replace('--name=', '');
|
|
4349
|
+
const nonInteractive = args.includes('--non-interactive');
|
|
4350
|
+
const inputs = {};
|
|
4351
|
+
if (nameFlag) inputs.feature_name = nameFlag;
|
|
4352
|
+
try {
|
|
4353
|
+
const { artifacts, beadsItems } = await instantiateWorkflow(id, inputs, { cwd: process.cwd(), nonInteractive });
|
|
4354
|
+
println(`\nWorkflow '${id}' instantiated:`);
|
|
4355
|
+
for (const a of artifacts) println(` + ${a.path}`);
|
|
4356
|
+
if (beadsItems?.length) {
|
|
4357
|
+
println(`\n${beadsItems.length} beads item(s) ready to create via: bd create`);
|
|
4358
|
+
for (const item of beadsItems) println(` - ${item.title}`);
|
|
4359
|
+
}
|
|
4360
|
+
} catch (err) {
|
|
4361
|
+
errorln(`Workflow instantiation failed: ${err.message}`);
|
|
4362
|
+
process.exit(1);
|
|
4363
|
+
}
|
|
4364
|
+
return;
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
errorln(`Unknown workflow subcommand: ${sub}. Available: list, show, new`);
|
|
4368
|
+
process.exit(1);
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
async function cmdTelemetryQuery(args) {
|
|
4372
|
+
const sub = args[0];
|
|
4373
|
+
const rest = args.slice(1);
|
|
4374
|
+
const flags = {};
|
|
4375
|
+
for (let i = 0; i < rest.length; i++) {
|
|
4376
|
+
const m = rest[i].match(/^--(\w[\w-]*)(?:=(.+))?$/);
|
|
4377
|
+
if (m) flags[m[1]] = m[2] ?? rest[++i] ?? true;
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
const { summarizeSkillCalls } = await import('../lib/telemetry/skill-calls.mjs');
|
|
4381
|
+
|
|
4382
|
+
if (sub === 'latency') {
|
|
4383
|
+
const p = flags.p ? parseInt(flags.p, 10) : 95;
|
|
4384
|
+
const since = flags.since || '24h';
|
|
4385
|
+
const op = flags.operation;
|
|
4386
|
+
const { skills } = summarizeSkillCalls({ since });
|
|
4387
|
+
const relevant = op
|
|
4388
|
+
? Object.entries(skills).filter(([id]) => id.includes(op))
|
|
4389
|
+
: Object.entries(skills);
|
|
4390
|
+
if (relevant.length === 0) { println('No latency data found.'); return; }
|
|
4391
|
+
const key = p >= 95 ? 'p95LatencyMs' : 'p50LatencyMs';
|
|
4392
|
+
const sorted = relevant.filter(([, s]) => s[key] != null).sort((a, b) => b[1][key] - a[1][key]);
|
|
4393
|
+
println(`p${p} latency — last ${since}${op ? ` operation: ${op}` : ''}\n`);
|
|
4394
|
+
for (const [id, s] of sorted.slice(0, 20)) {
|
|
4395
|
+
println(` ${id.padEnd(50)} p${p}=${s[key]}ms`);
|
|
4396
|
+
}
|
|
4397
|
+
return;
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
if (sub === 'top-slow') {
|
|
4401
|
+
const limit = parseInt(flags.limit || '10', 10);
|
|
4402
|
+
const since = flags.since || '24h';
|
|
4403
|
+
const { skills } = summarizeSkillCalls({ since });
|
|
4404
|
+
const sorted = Object.entries(skills)
|
|
4405
|
+
.filter(([, s]) => s.p95LatencyMs != null)
|
|
4406
|
+
.sort((a, b) => b[1].p95LatencyMs - a[1].p95LatencyMs)
|
|
4407
|
+
.slice(0, limit);
|
|
4408
|
+
if (sorted.length === 0) { println('No latency data found.'); return; }
|
|
4409
|
+
println(`Top ${limit} slow operations — last ${since}\n`);
|
|
4410
|
+
for (const [id, s] of sorted) {
|
|
4411
|
+
println(` ${id.padEnd(50)} p95=${s.p95LatencyMs}ms p50=${s.p50LatencyMs}ms calls=${s.calls}`);
|
|
4412
|
+
}
|
|
4413
|
+
return;
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
if (sub === 'errors') {
|
|
4417
|
+
println('Error trace query requires an OTLP backend (OTEL_EXPORTER_OTLP_ENDPOINT).');
|
|
4418
|
+
println('In local-only mode, check ~/.cx/mcp-audit.json for recent tool call errors.');
|
|
4419
|
+
return;
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4422
|
+
if (sub === 'trace') {
|
|
4423
|
+
const traceId = rest[0];
|
|
4424
|
+
if (!traceId) { errorln('Usage: construct telemetry query trace <traceId>'); process.exit(1); }
|
|
4425
|
+
println(`Trace ${traceId}:`);
|
|
4426
|
+
println('Full span tree query requires an OTLP-compatible backend (Langfuse, Honeycomb, Grafana Tempo).');
|
|
4427
|
+
println('Local mode only captures skill call events — set OTEL_EXPORTER_OTLP_ENDPOINT for full traces.');
|
|
4428
|
+
return;
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
println('Usage: construct telemetry query <subcommand> [flags]');
|
|
4432
|
+
println('');
|
|
4433
|
+
println('Subcommands:');
|
|
4434
|
+
println(' latency [--p=95] [--operation=<op>] [--since=24h] p-ile latency by skill');
|
|
4435
|
+
println(' top-slow [--limit=10] [--since=24h] slowest operations');
|
|
4436
|
+
println(' errors [--agent=<name>] [--since=24h] error span summary');
|
|
4437
|
+
println(' trace <traceId> full span tree');
|
|
4438
|
+
println('');
|
|
4439
|
+
println('For Langfuse/Honeycomb/Grafana Tempo/Datadog, see docs/concepts/observability.mdx');
|
|
4440
|
+
}
|
|
4441
|
+
|
|
3402
4442
|
async function cmdHook(args) {
|
|
3403
4443
|
const name = args[0];
|
|
3404
4444
|
if (!name) {
|
|
@@ -3470,11 +4510,14 @@ const handlers = new Map([
|
|
|
3470
4510
|
['infer', cmdInfer],
|
|
3471
4511
|
['search', cmdSearch],
|
|
3472
4512
|
['storage', cmdStorage],
|
|
3473
|
-
|
|
4513
|
+
// Pricing / cost readouts are stubbed out: the ledger writes, model-pricing
|
|
4514
|
+
// catalog, and per-turn accounting still run, but no CLI surface exposes
|
|
4515
|
+
// them to the user. Handlers (cmdPricing, cmdCosts, cmdCost) remain in this
|
|
4516
|
+
// file so the OTel + dashboard wiring (planned in Workstream J) can rewire
|
|
4517
|
+
// the surface without re-implementing the logic.
|
|
3474
4518
|
['overrides', cmdOverrides],
|
|
3475
4519
|
['prune', cmdPrune],
|
|
3476
4520
|
['resources', cmdResources],
|
|
3477
|
-
['costs', cmdCosts],
|
|
3478
4521
|
['handoffs', cmdHandoffs],
|
|
3479
4522
|
['headhunt', cmdHeadhunt],
|
|
3480
4523
|
['init', cmdInit],
|
|
@@ -3493,10 +4536,12 @@ const handlers = new Map([
|
|
|
3493
4536
|
if (sub === 'update') return cmdDocsUpdate(rest);
|
|
3494
4537
|
if (sub === 'check') return cmdDocsCheck(rest);
|
|
3495
4538
|
if (sub === 'site') return cmdDocsSite();
|
|
3496
|
-
|
|
4539
|
+
if (sub === 'reconcile') return cmdDocsReconcile(rest);
|
|
4540
|
+
errorln(`Unknown docs subcommand: ${sub}. Available: check, verify, update, site, reconcile`);
|
|
3497
4541
|
process.exit(1);
|
|
3498
4542
|
}],
|
|
3499
4543
|
['docs:verify', cmdDocsVerify],
|
|
4544
|
+
['docs:reconcile', cmdDocsReconcile],
|
|
3500
4545
|
['init:update', cmdInitUpdate],
|
|
3501
4546
|
['models', cmdModels],
|
|
3502
4547
|
['beads:stats', async (args) => {
|
|
@@ -3547,6 +4592,54 @@ const handlers = new Map([
|
|
|
3547
4592
|
process.stdout.write(formatPolicyList());
|
|
3548
4593
|
}
|
|
3549
4594
|
}],
|
|
4595
|
+
['policy', async (args) => {
|
|
4596
|
+
const sub = args[0];
|
|
4597
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
4598
|
+
println('Usage: construct policy <show>');
|
|
4599
|
+
println(' show [--json] List all active policies with enforcement details');
|
|
4600
|
+
return;
|
|
4601
|
+
}
|
|
4602
|
+
if (sub === 'show') {
|
|
4603
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
4604
|
+
const { join } = await import('node:path');
|
|
4605
|
+
const inventoryPath = join(ROOT_DIR, 'agents', 'policy-inventory.json');
|
|
4606
|
+
if (!existsSync(inventoryPath)) {
|
|
4607
|
+
errorln('specialists/policy-inventory.json not found');
|
|
4608
|
+
process.exit(1);
|
|
4609
|
+
}
|
|
4610
|
+
const { policies } = JSON.parse(readFileSync(inventoryPath, 'utf8'));
|
|
4611
|
+
if (args.includes('--json')) {
|
|
4612
|
+
println(JSON.stringify(policies, null, 2));
|
|
4613
|
+
return;
|
|
4614
|
+
}
|
|
4615
|
+
// Table output
|
|
4616
|
+
const ID_W = 25;
|
|
4617
|
+
const SRC_W = 40;
|
|
4618
|
+
const ENF_W = 45;
|
|
4619
|
+
const MODE_W = 18;
|
|
4620
|
+
println(
|
|
4621
|
+
'ID'.padEnd(ID_W) + 'SOURCE'.padEnd(SRC_W) + 'ENFORCEMENT'.padEnd(ENF_W) + 'MODE',
|
|
4622
|
+
);
|
|
4623
|
+
println('-'.repeat(ID_W + SRC_W + ENF_W + MODE_W));
|
|
4624
|
+
for (const p of policies) {
|
|
4625
|
+
const id = (p.id || '').slice(0, ID_W - 1).padEnd(ID_W);
|
|
4626
|
+
const src = (p.source || '').slice(0, SRC_W - 1).padEnd(SRC_W);
|
|
4627
|
+
const enf = (p.enforcement || '').slice(0, ENF_W - 1).padEnd(ENF_W);
|
|
4628
|
+
const mode = (p.mode || '').slice(0, MODE_W - 1);
|
|
4629
|
+
println(`${id}${src}${enf}${mode}`);
|
|
4630
|
+
if (p.description) println(` ${p.description}`);
|
|
4631
|
+
}
|
|
4632
|
+
println('');
|
|
4633
|
+
const counts = {};
|
|
4634
|
+
for (const p of policies) { counts[p.mode] = (counts[p.mode] || 0) + 1; }
|
|
4635
|
+
for (const [mode, count] of Object.entries(counts)) {
|
|
4636
|
+
println(` ${mode}: ${count}`);
|
|
4637
|
+
}
|
|
4638
|
+
return;
|
|
4639
|
+
}
|
|
4640
|
+
errorln(`Unknown policy subcommand: ${sub}. Available: show`);
|
|
4641
|
+
process.exit(1);
|
|
4642
|
+
}],
|
|
3550
4643
|
['auth:status', async (args) => {
|
|
3551
4644
|
const { getTokenStatus } = await import('../lib/providers/auth-manager.mjs');
|
|
3552
4645
|
const providers = ['github', 'slack', 'jira', 'salesforce', 'linear'];
|
|
@@ -3575,7 +4668,6 @@ const handlers = new Map([
|
|
|
3575
4668
|
['review', cmdReview],
|
|
3576
4669
|
['optimize', cmdOptimize],
|
|
3577
4670
|
['seed-traces', cmdSeedTraces],
|
|
3578
|
-
['cost', cmdCost],
|
|
3579
4671
|
['efficiency', cmdEfficiency],
|
|
3580
4672
|
['evals', cmdEvals],
|
|
3581
4673
|
['cleanup', cmdCleanup],
|
|
@@ -3588,11 +4680,17 @@ const handlers = new Map([
|
|
|
3588
4680
|
['lint:comments', cmdLintComments],
|
|
3589
4681
|
['lint:templates', cmdLintTemplates],
|
|
3590
4682
|
['gates:audit', cmdGatesAudit],
|
|
4683
|
+
['ci', cmdCi],
|
|
3591
4684
|
['bootstrap', cmdBootstrap],
|
|
3592
4685
|
['memory', cmdMemory],
|
|
3593
4686
|
['hook', cmdHook],
|
|
3594
4687
|
['backup', cmdBackup],
|
|
3595
4688
|
['provider', cmdProvider],
|
|
4689
|
+
['creds', cmdCreds],
|
|
4690
|
+
['providers', cmdProviders],
|
|
4691
|
+
['workflow', cmdWorkflow],
|
|
4692
|
+
['tags', cmdTags],
|
|
4693
|
+
['scheduler', cmdScheduler],
|
|
3596
4694
|
['lint:research', cmdLintResearch],
|
|
3597
4695
|
['lint:agents', cmdLintAgents],
|
|
3598
4696
|
['lint:contracts', cmdLintContracts],
|
|
@@ -3671,7 +4769,6 @@ const handlers = new Map([
|
|
|
3671
4769
|
const correctedIdx = args.indexOf('--corrected');
|
|
3672
4770
|
|
|
3673
4771
|
if (intakeIdIdx === -1) {
|
|
3674
|
-
// Show stats instead
|
|
3675
4772
|
const stats = getAccuracyStats(process.cwd());
|
|
3676
4773
|
if (json) {
|
|
3677
4774
|
println(JSON.stringify(stats, null, 2));
|
|
@@ -3696,7 +4793,7 @@ const handlers = new Map([
|
|
|
3696
4793
|
|
|
3697
4794
|
const feedback = createClassificationFeedback({
|
|
3698
4795
|
intakeId,
|
|
3699
|
-
original: { intakeType: 'unknown', primaryOwner: 'unknown' },
|
|
4796
|
+
original: { intakeType: 'unknown', primaryOwner: 'unknown' },
|
|
3700
4797
|
corrected,
|
|
3701
4798
|
reason,
|
|
3702
4799
|
});
|
|
@@ -3854,6 +4951,9 @@ const handlers = new Map([
|
|
|
3854
4951
|
const { runTelemetrySetupCli } = await import('../lib/telemetry/setup.mjs');
|
|
3855
4952
|
return runTelemetrySetupCli(args);
|
|
3856
4953
|
}],
|
|
4954
|
+
['telemetry', async (args) => {
|
|
4955
|
+
return cmdTelemetryQuery(args);
|
|
4956
|
+
}],
|
|
3857
4957
|
['beads', async (args) => {
|
|
3858
4958
|
const sub = args[0];
|
|
3859
4959
|
const subArgs = args.slice(1);
|
|
@@ -4009,7 +5109,10 @@ const handlers = new Map([
|
|
|
4009
5109
|
const { runSkillsApplyCli } = await import('../lib/skills-apply.mjs');
|
|
4010
5110
|
return runSkillsApplyCli(args.slice(1));
|
|
4011
5111
|
}
|
|
4012
|
-
|
|
5112
|
+
if (sub === 'usage' || sub === 'orphans' || sub === 'hot' || sub === 'correlate-quality' || sub === 'backfill-postgres') {
|
|
5113
|
+
return cmdSkillsUsage(sub, args.slice(1));
|
|
5114
|
+
}
|
|
5115
|
+
errorln(`Unknown skills subcommand: ${sub}. Available: scope, apply, usage, orphans, hot, correlate-quality, backfill-postgres`);
|
|
4013
5116
|
process.exit(1);
|
|
4014
5117
|
}],
|
|
4015
5118
|
['ask', async (args) => {
|
|
@@ -4091,7 +5194,34 @@ const handlers = new Map([
|
|
|
4091
5194
|
process.exit(1);
|
|
4092
5195
|
}
|
|
4093
5196
|
}
|
|
4094
|
-
|
|
5197
|
+
if (sub === 'search') {
|
|
5198
|
+
const restArgs = args.slice(1);
|
|
5199
|
+
const tagFlag = restArgs.find((a) => a.startsWith('--tag='))?.replace('--tag=', '');
|
|
5200
|
+
const tagMatchFlag = restArgs.find((a) => a.startsWith('--tag-match='))?.replace('--tag-match=', '');
|
|
5201
|
+
const topKFlag = restArgs.find((a) => a.startsWith('--top='))?.replace('--top=', '');
|
|
5202
|
+
const queryParts = restArgs.filter((a) => !a.startsWith('--'));
|
|
5203
|
+
const query = queryParts.join(' ').trim();
|
|
5204
|
+
if (!query) { errorln('Usage: construct knowledge search "<query>" [--tag=<tag>] [--tag-match=any|all]'); process.exit(1); }
|
|
5205
|
+
const { knowledgeSearch } = await import('../lib/knowledge/search.mjs');
|
|
5206
|
+
const tags = tagFlag ? tagFlag.split(',') : undefined;
|
|
5207
|
+
const result = knowledgeSearch({
|
|
5208
|
+
query,
|
|
5209
|
+
topK: topKFlag ? parseInt(topKFlag, 10) : 5,
|
|
5210
|
+
repoRoot: ROOT_DIR,
|
|
5211
|
+
rootDir: process.cwd(),
|
|
5212
|
+
tags,
|
|
5213
|
+
tagMatch: tagMatchFlag || 'any',
|
|
5214
|
+
});
|
|
5215
|
+
if (!result.ok) { errorln(result.message); process.exit(1); }
|
|
5216
|
+
println(`${result.hits.length} result(s) for "${query}"${tags ? ` [tag: ${tags.join(',')}]` : ''}\n`);
|
|
5217
|
+
for (const h of result.hits) {
|
|
5218
|
+
println(` ${h.file}${h.heading ? ` § ${h.heading}` : ''} (score: ${h.score})`);
|
|
5219
|
+
println(` ${h.text.slice(0, 120).replace(/\n/g, ' ')}`);
|
|
5220
|
+
println('');
|
|
5221
|
+
}
|
|
5222
|
+
return;
|
|
5223
|
+
}
|
|
5224
|
+
errorln(`Unknown knowledge subcommand: ${sub}. Available: trends, index, add, search`);
|
|
4095
5225
|
process.exit(1);
|
|
4096
5226
|
}],
|
|
4097
5227
|
['sandbox', async (args) => {
|