@geraldmaron/construct 1.0.6 → 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 +39 -34
- package/bin/construct +1403 -96
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +2 -2
- package/commands/work/parallel-review.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 +8 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +139 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +371 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +360 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +420 -17
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +50 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/env-config.mjs +9 -3
- package/lib/evaluator-optimizer.mjs +2 -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/comment-lint.mjs +16 -0
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +14 -1
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- 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 +73 -65
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +309 -58
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +31 -6
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -1
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +11 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/model-registry.mjs +1 -1
- package/lib/observation-store.mjs +9 -4
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/outcomes/record.mjs +2 -0
- 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/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- 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/reflect.mjs +49 -12
- 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 +29 -29
- 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/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- 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/client.mjs +5 -1
- 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/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +16 -3
- package/personas/construct.md +5 -3
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
- 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/specialists/contracts.json +1002 -0
- package/specialists/contracts.schema.json +83 -0
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-architect.md +3 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-debugger.md +3 -1
- package/{agents → specialists}/prompts/cx-designer.md +3 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
- package/{agents → specialists}/prompts/cx-engineer.md +4 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
- package/{agents → specialists}/prompts/cx-explorer.md +3 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
- package/{agents → specialists}/prompts/cx-operations.md +3 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-qa.md +3 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-researcher.md +3 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
- package/{agents → specialists}/prompts/cx-security.md +3 -1
- package/{agents → specialists}/prompts/cx-sre.md +4 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
- package/{agents → specialists}/registry.json +651 -418
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/docs/construct_guide.md +6 -6
- package/templates/docs/skill-artifact.md +1 -1
- 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/contracts.json +0 -387
- /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 ?? []) {
|
|
@@ -292,7 +292,7 @@ async function cmdList() {
|
|
|
292
292
|
async function cmdDoctor() {
|
|
293
293
|
const args = process.argv.slice(3);
|
|
294
294
|
|
|
295
|
-
const DAEMON_SUBS = new Set(['status', 'watch', 'stop', 'logs', 'tick', 'report']);
|
|
295
|
+
const DAEMON_SUBS = new Set(['status', 'watch', 'stop', 'logs', 'tick', 'report', 'consistency']);
|
|
296
296
|
if (args.length > 0 && DAEMON_SUBS.has(args[0])) {
|
|
297
297
|
const { runCli } = await import('../lib/doctor/cli.mjs');
|
|
298
298
|
const code = await runCli(args);
|
|
@@ -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) {
|
|
@@ -1053,6 +1075,59 @@ async function cmdVersion() {
|
|
|
1053
1075
|
println(`construct v${pkg.version}`);
|
|
1054
1076
|
}
|
|
1055
1077
|
|
|
1078
|
+
async function cmdMigrate(args) {
|
|
1079
|
+
const dryRun = args.includes('--dry-run');
|
|
1080
|
+
const pathArg = args.find((a) => a.startsWith('--path='))?.split('=')[1];
|
|
1081
|
+
const fromArg = args.find((a) => a.startsWith('--from='))?.split('=')[1];
|
|
1082
|
+
const { runMigrations, planMigrations, CURRENT_SCHEMA_VERSION } = await import('../lib/migrations/index.mjs');
|
|
1083
|
+
|
|
1084
|
+
const candidates = pathArg
|
|
1085
|
+
? [pathArg]
|
|
1086
|
+
: [
|
|
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'),
|
|
1090
|
+
path.join(process.cwd(), '.cx', 'config.json'),
|
|
1091
|
+
];
|
|
1092
|
+
|
|
1093
|
+
let anyFailed = false;
|
|
1094
|
+
for (const artifactPath of candidates) {
|
|
1095
|
+
if (!fs.existsSync(artifactPath)) continue;
|
|
1096
|
+
let from;
|
|
1097
|
+
try {
|
|
1098
|
+
const data = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
|
|
1099
|
+
from = typeof data?.version === 'number' ? data.version : Number(fromArg) || 0;
|
|
1100
|
+
} catch {
|
|
1101
|
+
println(` ⚠ ${artifactPath}: parse error, skipping`);
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const plan = planMigrations(from, CURRENT_SCHEMA_VERSION);
|
|
1106
|
+
if (plan === null) {
|
|
1107
|
+
println(` ✗ ${artifactPath}: no migration path from v${from} to v${CURRENT_SCHEMA_VERSION}`);
|
|
1108
|
+
anyFailed = true;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (plan.length === 0) {
|
|
1112
|
+
println(` ✓ ${artifactPath}: already at v${CURRENT_SCHEMA_VERSION}`);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
println(` ${dryRun ? '·' : '→'} ${artifactPath}: ${plan.length} step(s) (v${from} → v${CURRENT_SCHEMA_VERSION})`);
|
|
1117
|
+
const result = await runMigrations({ artifactPath, fromVersion: from, dryRun });
|
|
1118
|
+
for (const step of result.applied) {
|
|
1119
|
+
println(` v${step.from}→v${step.to}: ${step.summary}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (!result.ok) {
|
|
1122
|
+
println(` ✗ ${result.error}`);
|
|
1123
|
+
anyFailed = true;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (dryRun) println(`\n(dry-run — no files were written)`);
|
|
1128
|
+
if (anyFailed) process.exit(1);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1056
1131
|
async function cmdReview(args) {
|
|
1057
1132
|
fs.mkdirSync(path.join(HOME, '.cx', 'performance-reviews'), { recursive: true });
|
|
1058
1133
|
const sub = args[0];
|
|
@@ -1514,7 +1589,14 @@ async function cmdPricing(args) {
|
|
|
1514
1589
|
}
|
|
1515
1590
|
|
|
1516
1591
|
async function cmdHeadhunt(args) { await runHeadhunt({ args, cwd: process.cwd(), homeDir: HOME }); }
|
|
1517
|
-
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
|
+
}
|
|
1518
1600
|
async function cmdSetup(args) { await runSetup({ rootDir: ROOT_DIR, args, homeDir: HOME }); }
|
|
1519
1601
|
|
|
1520
1602
|
function printConfigModeHelp() {
|
|
@@ -1550,20 +1632,31 @@ function printActiveConfig() {
|
|
|
1550
1632
|
println(`Persisted at: ${getUserEnvPath(HOME)} (${DEPLOYMENT_MODE_ENV_KEY})`);
|
|
1551
1633
|
}
|
|
1552
1634
|
|
|
1553
|
-
function printIntakeHelp() {
|
|
1554
|
-
|
|
1635
|
+
async function printIntakeHelp() {
|
|
1636
|
+
const { getRebrand } = await import('../lib/profiles/rebrand.mjs');
|
|
1637
|
+
const { intakeQueueLabel, signalNoun } = getRebrand(process.cwd());
|
|
1638
|
+
println(`Construct intake — inspect and process the ${intakeQueueLabel}.`);
|
|
1555
1639
|
println('');
|
|
1556
1640
|
println('Usage:');
|
|
1557
|
-
println(
|
|
1641
|
+
println(` construct intake list List pending ${signalNoun}s (ID, type, stage, owner, action).`);
|
|
1558
1642
|
println(' construct intake show <id> Show a single packet: triage, source, excerpt, related docs.');
|
|
1559
|
-
println(' construct intake done <id> [--notes=…]
|
|
1643
|
+
println(' construct intake done <id> [--notes=…] [--output=<path>] [--no-output=<rejected|deferred|merged>]');
|
|
1644
|
+
println(' Mark a pending packet processed. --output stamps intake_id + confidence into the artifact frontmatter (provenance).');
|
|
1560
1645
|
println(' construct intake skip <id> [--reason=…] Mark a pending packet skipped (preserves audit trail).');
|
|
1561
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.');
|
|
1562
1651
|
println(' construct intake metrics Show pipeline volume, velocity, and processing stats.');
|
|
1563
1652
|
println(' construct intake integrate <id> <system> Push intake to external system (github, jira, confluence).');
|
|
1564
1653
|
println(' construct intake integrate <id> <system> --create-issue Create an issue from the intake packet.');
|
|
1565
1654
|
println(' construct intake config Show current watch dirs + scan depth.');
|
|
1566
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.');
|
|
1567
1660
|
println(' Update intake config. --depth caps at the hard limit.');
|
|
1568
1661
|
println(' construct intake config set --include-project-inbox=on|off');
|
|
1569
1662
|
println(' construct intake config set --include-docs-intake=on|off');
|
|
@@ -1580,11 +1673,12 @@ function parseKvFlags(args, keys) {
|
|
|
1580
1673
|
return out;
|
|
1581
1674
|
}
|
|
1582
1675
|
|
|
1583
|
-
function formatIntakeTable(rows) {
|
|
1676
|
+
function formatIntakeTable(rows, { intakeQueueLabel, signalNoun } = { intakeQueueLabel: 'Intake queue', signalNoun: 'signal' }) {
|
|
1584
1677
|
if (rows.length === 0) {
|
|
1585
|
-
println(
|
|
1678
|
+
println(`No pending ${signalNoun}s.`);
|
|
1586
1679
|
return;
|
|
1587
1680
|
}
|
|
1681
|
+
println(`${intakeQueueLabel} (${rows.length} pending):`);
|
|
1588
1682
|
const headers = ['ID', 'Type', 'Stage', 'Owner', 'Action'];
|
|
1589
1683
|
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] || '').length)));
|
|
1590
1684
|
const fmt = (cells) => cells.map((c, i) => String(c || '').padEnd(widths[i])).join(' ');
|
|
@@ -1597,12 +1691,14 @@ async function cmdIntake(args) {
|
|
|
1597
1691
|
const sub = args[0];
|
|
1598
1692
|
|
|
1599
1693
|
if (!sub || sub === '--help' || sub === '-h') {
|
|
1600
|
-
printIntakeHelp();
|
|
1694
|
+
await printIntakeHelp();
|
|
1601
1695
|
return;
|
|
1602
1696
|
}
|
|
1603
1697
|
|
|
1604
1698
|
const { createIntakeQueue } = await import('../lib/intake/queue.mjs');
|
|
1699
|
+
const { getRebrand } = await import('../lib/profiles/rebrand.mjs');
|
|
1605
1700
|
const cwd = process.cwd();
|
|
1701
|
+
const rebrand = getRebrand(cwd);
|
|
1606
1702
|
const queue = createIntakeQueue(cwd, process.env);
|
|
1607
1703
|
|
|
1608
1704
|
if (sub === 'list') {
|
|
@@ -1611,7 +1707,7 @@ async function cmdIntake(args) {
|
|
|
1611
1707
|
const t = p.triage || {};
|
|
1612
1708
|
return [p.id, t.intakeType || 'unknown', t.rdStage || 'unknown', t.primaryOwner || '—', t.recommendedAction || '—'];
|
|
1613
1709
|
});
|
|
1614
|
-
formatIntakeTable(rows);
|
|
1710
|
+
formatIntakeTable(rows, rebrand);
|
|
1615
1711
|
return;
|
|
1616
1712
|
}
|
|
1617
1713
|
|
|
@@ -1619,8 +1715,9 @@ async function cmdIntake(args) {
|
|
|
1619
1715
|
if (sub === 'show') {
|
|
1620
1716
|
if (!id) { errorln('Usage: construct intake show <id>'); process.exit(1); }
|
|
1621
1717
|
const entry = queue.read(id);
|
|
1622
|
-
if (!entry) { errorln(`No
|
|
1718
|
+
if (!entry) { errorln(`No ${rebrand.signalNoun} with id: ${id}`); process.exit(1); }
|
|
1623
1719
|
const t = entry.triage || {};
|
|
1720
|
+
println(`${rebrand.signalNoun.charAt(0).toUpperCase() + rebrand.signalNoun.slice(1)} ${entry.id}`);
|
|
1624
1721
|
println(`ID: ${entry.id}`);
|
|
1625
1722
|
println(`Status: ${entry.status}`);
|
|
1626
1723
|
println(`Source: ${entry.intake?.sourcePath || '(none)'}`);
|
|
@@ -1712,11 +1809,59 @@ async function cmdIntake(args) {
|
|
|
1712
1809
|
}
|
|
1713
1810
|
|
|
1714
1811
|
if (sub === 'done') {
|
|
1715
|
-
if (!id) {
|
|
1716
|
-
|
|
1812
|
+
if (!id) {
|
|
1813
|
+
errorln('Usage: construct intake done <id> [--notes=…] [--output=<path>] [--no-output=<rejected|deferred|merged>]');
|
|
1814
|
+
process.exit(1);
|
|
1815
|
+
}
|
|
1816
|
+
const flags = parseKvFlags(args.slice(2), ['notes', 'output', 'no-output']);
|
|
1817
|
+
const outputPath = flags.output || null;
|
|
1818
|
+
const noOutputOutcome = flags['no-output'] || null;
|
|
1819
|
+
|
|
1820
|
+
if (outputPath && noOutputOutcome) {
|
|
1821
|
+
errorln('--output and --no-output are mutually exclusive');
|
|
1822
|
+
process.exit(1);
|
|
1823
|
+
}
|
|
1824
|
+
if (noOutputOutcome && !['rejected', 'deferred', 'merged'].includes(noOutputOutcome)) {
|
|
1825
|
+
errorln(`--no-output value must be one of: rejected, deferred, merged (got "${noOutputOutcome}")`);
|
|
1826
|
+
process.exit(1);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
let stamped = null;
|
|
1830
|
+
if (outputPath) {
|
|
1831
|
+
const packet = queue.read(id);
|
|
1832
|
+
if (!packet) {
|
|
1833
|
+
errorln(`No intake packet found: ${id}`);
|
|
1834
|
+
process.exit(1);
|
|
1835
|
+
}
|
|
1836
|
+
const triage = packet.triage || {};
|
|
1837
|
+
try {
|
|
1838
|
+
const { stampIntakeProvenance } = await import('../lib/intake/traceability.mjs');
|
|
1839
|
+
const path = (await import('node:path')).default;
|
|
1840
|
+
const absPath = path.isAbsolute(outputPath) ? outputPath : path.join(cwd, outputPath);
|
|
1841
|
+
stamped = stampIntakeProvenance(absPath, {
|
|
1842
|
+
intakeId: id,
|
|
1843
|
+
confidence: triage.confidence,
|
|
1844
|
+
rationale: triage.rationale,
|
|
1845
|
+
});
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
errorln(err.message || 'intake stamping failed');
|
|
1848
|
+
process.exit(1);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
const notes = flags.notes
|
|
1853
|
+
|| (noOutputOutcome ? `intake_outcome: ${noOutputOutcome}` : '')
|
|
1854
|
+
|| (outputPath ? `output: ${outputPath}` : '');
|
|
1855
|
+
|
|
1717
1856
|
try {
|
|
1718
|
-
const r = queue.markProcessed(id, { processedBy: 'construct-intake-cli', notes
|
|
1719
|
-
|
|
1857
|
+
const r = queue.markProcessed(id, { processedBy: 'construct-intake-cli', notes });
|
|
1858
|
+
if (stamped) {
|
|
1859
|
+
info(`Marked ${r.id} processed. Stamped ${outputPath} with intake_id=${id} (confidence=${stamped.intake_confidence ?? 'n/a'}).`);
|
|
1860
|
+
} else if (noOutputOutcome) {
|
|
1861
|
+
info(`Marked ${r.id} processed (intake_outcome: ${noOutputOutcome}).`);
|
|
1862
|
+
} else {
|
|
1863
|
+
info(`Marked ${r.id} processed.`);
|
|
1864
|
+
}
|
|
1720
1865
|
} catch (err) {
|
|
1721
1866
|
errorln(err.message || 'mark processed failed');
|
|
1722
1867
|
process.exit(1);
|
|
@@ -1753,6 +1898,14 @@ async function cmdIntake(args) {
|
|
|
1753
1898
|
return await cmdIntakeMetrics();
|
|
1754
1899
|
}
|
|
1755
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
|
+
|
|
1756
1909
|
if (sub === 'integrate') {
|
|
1757
1910
|
return await cmdIntakeIntegrate(id, args.slice(2), cwd);
|
|
1758
1911
|
}
|
|
@@ -1761,7 +1914,312 @@ async function cmdIntake(args) {
|
|
|
1761
1914
|
return await cmdIntakeConfig(args.slice(1));
|
|
1762
1915
|
}
|
|
1763
1916
|
|
|
1764
|
-
|
|
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`);
|
|
1765
2223
|
process.exit(1);
|
|
1766
2224
|
}
|
|
1767
2225
|
|
|
@@ -2435,7 +2893,7 @@ async function cmdUpgrade(args) {
|
|
|
2435
2893
|
}
|
|
2436
2894
|
}
|
|
2437
2895
|
async function cmdValidate() {
|
|
2438
|
-
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'));
|
|
2439
2897
|
const result = validateRegistry(reg, { rootDir: ROOT_DIR });
|
|
2440
2898
|
if (result.valid) {
|
|
2441
2899
|
println(`✓ Registry valid (${result.summary})`);
|
|
@@ -2488,7 +2946,7 @@ async function cmdModels(args) {
|
|
|
2488
2946
|
if (args.includes('--apply')) {
|
|
2489
2947
|
const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
|
|
2490
2948
|
const models = await pollFreeModels(apiKey);
|
|
2491
|
-
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'));
|
|
2492
2950
|
const selections = {};
|
|
2493
2951
|
for (const currentTier of ['reasoning', 'standard', 'fast']) {
|
|
2494
2952
|
const fallbacks = registry.models?.[currentTier]?.fallback ?? [];
|
|
@@ -2538,7 +2996,7 @@ async function cmdModels(args) {
|
|
|
2538
2996
|
await cmdSync([]);
|
|
2539
2997
|
return;
|
|
2540
2998
|
}
|
|
2541
|
-
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'));
|
|
2542
3000
|
const result = readCurrentModels(envPath, registry.models ?? {});
|
|
2543
3001
|
const nothingSelected = ['reasoning', 'standard', 'fast'].every((t) => !result[t]);
|
|
2544
3002
|
println('Current model assignments:');
|
|
@@ -2741,27 +3199,55 @@ async function cmdSeedTraces(args) {
|
|
|
2741
3199
|
async function cmdCleanup() {
|
|
2742
3200
|
const force = restArgsCache.includes('--pressure-release');
|
|
2743
3201
|
const quiet = restArgsCache.includes('--quiet');
|
|
2744
|
-
const
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
const
|
|
2753
|
-
|
|
3202
|
+
const dryRun = restArgsCache.includes('--dry-run');
|
|
3203
|
+
const pressureOnly = restArgsCache.includes('--pressure-only');
|
|
3204
|
+
const diskOnly = restArgsCache.includes('--disk-only');
|
|
3205
|
+
|
|
3206
|
+
// Process pressure release: kill stale dev-agent processes.
|
|
3207
|
+
if (!diskOnly) {
|
|
3208
|
+
const report = runPressureRelease({ env: process.env, force });
|
|
3209
|
+
if (!quiet) {
|
|
3210
|
+
const swap = report.swapUsage
|
|
3211
|
+
? `${(report.swapUsage.usedBytes / (1024 ** 3)).toFixed(1)} GiB used`
|
|
3212
|
+
: 'swap unavailable';
|
|
3213
|
+
println(`Pressure guard: ${report.pressureTriggered ? 'triggered' : 'standby'} · ${swap}`);
|
|
3214
|
+
for (const entry of report.warnings ?? []) {
|
|
3215
|
+
const grace = entry.graceMinutes ? `; will terminate after ${entry.graceMinutes}m if still matched` : '';
|
|
3216
|
+
println(`Warning pid ${entry.pid} (${entry.reason})${grace}`);
|
|
3217
|
+
}
|
|
3218
|
+
if (report.killed.length) {
|
|
3219
|
+
for (const entry of report.killed) {
|
|
3220
|
+
println(`Stopped pid ${entry.pid} (${entry.reason})`);
|
|
3221
|
+
}
|
|
3222
|
+
} else {
|
|
3223
|
+
println('No stale dev-agent processes needed cleanup.');
|
|
3224
|
+
}
|
|
2754
3225
|
}
|
|
2755
3226
|
}
|
|
2756
3227
|
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
3228
|
+
// Disk maintenance: rotate oversized logs, prune stale cache entries.
|
|
3229
|
+
if (!pressureOnly) {
|
|
3230
|
+
const { runFullCleanup, formatBytes } = await import('../lib/maintenance/cleanup.mjs');
|
|
3231
|
+
const summary = runFullCleanup({ homeDir: HOME, env: process.env, dryRun });
|
|
3232
|
+
if (!quiet) {
|
|
3233
|
+
const verb = dryRun ? 'Would free' : 'Freed';
|
|
3234
|
+
println(`Disk maintenance${dryRun ? ' (dry-run)' : ''}: ${verb} ${formatBytes(summary.freedBytes)} in ${summary.durationMs}ms`);
|
|
3235
|
+
const log = summary.embedLog;
|
|
3236
|
+
if (log?.truncated?.length) {
|
|
3237
|
+
for (const t of log.truncated) println(` truncated ${path.relative(HOME, t.path)} (was ${formatBytes(t.sizeWas)})`);
|
|
3238
|
+
}
|
|
3239
|
+
if (log?.removed?.length) {
|
|
3240
|
+
for (const r of log.removed) println(` removed ${path.relative(HOME, r.path)} (${formatBytes(r.size)})`);
|
|
3241
|
+
}
|
|
3242
|
+
if (summary.jsonlLogs?.truncated?.length) {
|
|
3243
|
+
for (const t of summary.jsonlLogs.truncated) println(` truncated ${path.relative(HOME, t.path)} (was ${formatBytes(t.sizeWas)} → ${formatBytes(t.sizeNow)})`);
|
|
3244
|
+
}
|
|
3245
|
+
if (summary.cacheDir?.removed?.length) {
|
|
3246
|
+
println(` pruned ${summary.cacheDir.removed.length} stale cache file(s)`);
|
|
3247
|
+
}
|
|
3248
|
+
for (const err of summary.errors ?? []) {
|
|
3249
|
+
println(` ! ${err.step}: ${err.error}`);
|
|
3250
|
+
}
|
|
2765
3251
|
}
|
|
2766
3252
|
}
|
|
2767
3253
|
}
|
|
@@ -2868,6 +3354,113 @@ async function cmdEvals(args) {
|
|
|
2868
3354
|
println(' construct evals retrieval [--fixture=PATH] [--json]');
|
|
2869
3355
|
}
|
|
2870
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
|
+
|
|
2871
3464
|
async function cmdDocsUpdate(args) {
|
|
2872
3465
|
const check = args.includes('--check');
|
|
2873
3466
|
const { regenerateDocs: regen } = await import('../lib/auto-docs.mjs');
|
|
@@ -2970,6 +3563,12 @@ async function cmdMemory(args) {
|
|
|
2970
3563
|
async function cmdLintComments(args) {
|
|
2971
3564
|
const fix = args.includes('--fix');
|
|
2972
3565
|
const staged = args.includes('--staged');
|
|
3566
|
+
// CLI invocation enforces block severity for artifact-prose lint (see
|
|
3567
|
+
// lib/comment-lint.mjs#checkArtifactBanned). Operators may opt back into
|
|
3568
|
+
// warn-only by exporting CONSTRUCT_ARTIFACT_LINT_MODE=warn explicitly.
|
|
3569
|
+
if (!process.env.CONSTRUCT_ARTIFACT_LINT_MODE) {
|
|
3570
|
+
process.env.CONSTRUCT_ARTIFACT_LINT_MODE = 'block';
|
|
3571
|
+
}
|
|
2973
3572
|
const { lintRepo: lint, lintFile, formatResults: fmt } = await import('../lib/comment-lint.mjs');
|
|
2974
3573
|
|
|
2975
3574
|
let results;
|
|
@@ -3016,6 +3615,209 @@ async function cmdGatesAudit(args) {
|
|
|
3016
3615
|
if (!report.ok) process.exit(1);
|
|
3017
3616
|
}
|
|
3018
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
|
+
|
|
3019
3821
|
async function cmdLintTemplates(args) {
|
|
3020
3822
|
const bodyFileArg = args.find((a) => a.startsWith('--body-file='))?.split('=').slice(1).join('=');
|
|
3021
3823
|
const bodyArgIdx = args.indexOf('--body-file');
|
|
@@ -3041,18 +3843,30 @@ async function cmdLintResearch() {
|
|
|
3041
3843
|
}
|
|
3042
3844
|
|
|
3043
3845
|
async function cmdLintAgents() {
|
|
3044
|
-
const { validateRegistryFile } = await import('../lib/
|
|
3045
|
-
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');
|
|
3046
3848
|
const { errors, agentCount } = validateRegistryFile({ registryPath, rootDir: ROOT_DIR });
|
|
3047
3849
|
if (errors.length === 0) {
|
|
3048
|
-
console.log(`
|
|
3850
|
+
console.log(`specialists/registry.json: ${agentCount} agents, 0 errors`);
|
|
3049
3851
|
return;
|
|
3050
3852
|
}
|
|
3051
|
-
console.error(`
|
|
3853
|
+
console.error(`specialists/registry.json: ${errors.length} error(s) across ${agentCount} agents`);
|
|
3052
3854
|
for (const err of errors) console.error(` ${err}`);
|
|
3053
3855
|
process.exit(1);
|
|
3054
3856
|
}
|
|
3055
3857
|
|
|
3858
|
+
async function cmdLintContracts() {
|
|
3859
|
+
const { validateContractsFile } = await import('../lib/contracts/validate.mjs');
|
|
3860
|
+
const result = validateContractsFile({ repoRoot: ROOT_DIR });
|
|
3861
|
+
if (result.ok) {
|
|
3862
|
+
console.log('specialists/contracts.json: 0 errors');
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
console.error(`specialists/contracts.json: ${result.errors.length} error(s)`);
|
|
3866
|
+
for (const err of result.errors) console.error(` ${err}`);
|
|
3867
|
+
process.exit(1);
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3056
3870
|
async function cmdBackup(args) {
|
|
3057
3871
|
const sub = args[0];
|
|
3058
3872
|
|
|
@@ -3241,10 +4055,390 @@ async function cmdProvider(args) {
|
|
|
3241
4055
|
process.exit(1);
|
|
3242
4056
|
}
|
|
3243
4057
|
|
|
3244
|
-
|
|
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`);
|
|
3245
4170
|
process.exit(1);
|
|
3246
4171
|
}
|
|
3247
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
|
+
|
|
3248
4442
|
async function cmdHook(args) {
|
|
3249
4443
|
const name = args[0];
|
|
3250
4444
|
if (!name) {
|
|
@@ -3316,11 +4510,14 @@ const handlers = new Map([
|
|
|
3316
4510
|
['infer', cmdInfer],
|
|
3317
4511
|
['search', cmdSearch],
|
|
3318
4512
|
['storage', cmdStorage],
|
|
3319
|
-
|
|
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.
|
|
3320
4518
|
['overrides', cmdOverrides],
|
|
3321
4519
|
['prune', cmdPrune],
|
|
3322
4520
|
['resources', cmdResources],
|
|
3323
|
-
['costs', cmdCosts],
|
|
3324
4521
|
['handoffs', cmdHandoffs],
|
|
3325
4522
|
['headhunt', cmdHeadhunt],
|
|
3326
4523
|
['init', cmdInit],
|
|
@@ -3339,10 +4536,12 @@ const handlers = new Map([
|
|
|
3339
4536
|
if (sub === 'update') return cmdDocsUpdate(rest);
|
|
3340
4537
|
if (sub === 'check') return cmdDocsCheck(rest);
|
|
3341
4538
|
if (sub === 'site') return cmdDocsSite();
|
|
3342
|
-
|
|
4539
|
+
if (sub === 'reconcile') return cmdDocsReconcile(rest);
|
|
4540
|
+
errorln(`Unknown docs subcommand: ${sub}. Available: check, verify, update, site, reconcile`);
|
|
3343
4541
|
process.exit(1);
|
|
3344
4542
|
}],
|
|
3345
4543
|
['docs:verify', cmdDocsVerify],
|
|
4544
|
+
['docs:reconcile', cmdDocsReconcile],
|
|
3346
4545
|
['init:update', cmdInitUpdate],
|
|
3347
4546
|
['models', cmdModels],
|
|
3348
4547
|
['beads:stats', async (args) => {
|
|
@@ -3393,6 +4592,54 @@ const handlers = new Map([
|
|
|
3393
4592
|
process.stdout.write(formatPolicyList());
|
|
3394
4593
|
}
|
|
3395
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
|
+
}],
|
|
3396
4643
|
['auth:status', async (args) => {
|
|
3397
4644
|
const { getTokenStatus } = await import('../lib/providers/auth-manager.mjs');
|
|
3398
4645
|
const providers = ['github', 'slack', 'jira', 'salesforce', 'linear'];
|
|
@@ -3421,11 +4668,11 @@ const handlers = new Map([
|
|
|
3421
4668
|
['review', cmdReview],
|
|
3422
4669
|
['optimize', cmdOptimize],
|
|
3423
4670
|
['seed-traces', cmdSeedTraces],
|
|
3424
|
-
['cost', cmdCost],
|
|
3425
4671
|
['efficiency', cmdEfficiency],
|
|
3426
4672
|
['evals', cmdEvals],
|
|
3427
4673
|
['cleanup', cmdCleanup],
|
|
3428
4674
|
['version', cmdVersion],
|
|
4675
|
+
['migrate', cmdMigrate],
|
|
3429
4676
|
['docs:update', cmdDocsUpdate],
|
|
3430
4677
|
['docs:check', cmdDocsCheck],
|
|
3431
4678
|
['docs:site', cmdDocsSite],
|
|
@@ -3433,13 +4680,20 @@ const handlers = new Map([
|
|
|
3433
4680
|
['lint:comments', cmdLintComments],
|
|
3434
4681
|
['lint:templates', cmdLintTemplates],
|
|
3435
4682
|
['gates:audit', cmdGatesAudit],
|
|
4683
|
+
['ci', cmdCi],
|
|
3436
4684
|
['bootstrap', cmdBootstrap],
|
|
3437
4685
|
['memory', cmdMemory],
|
|
3438
4686
|
['hook', cmdHook],
|
|
3439
4687
|
['backup', cmdBackup],
|
|
3440
4688
|
['provider', cmdProvider],
|
|
4689
|
+
['creds', cmdCreds],
|
|
4690
|
+
['providers', cmdProviders],
|
|
4691
|
+
['workflow', cmdWorkflow],
|
|
4692
|
+
['tags', cmdTags],
|
|
4693
|
+
['scheduler', cmdScheduler],
|
|
3441
4694
|
['lint:research', cmdLintResearch],
|
|
3442
4695
|
['lint:agents', cmdLintAgents],
|
|
4696
|
+
['lint:contracts', cmdLintContracts],
|
|
3443
4697
|
['audit', async (args) => {
|
|
3444
4698
|
const sub = args[0];
|
|
3445
4699
|
if (sub === 'skills') return runAuditSkillsCli(args.slice(1));
|
|
@@ -3515,7 +4769,6 @@ const handlers = new Map([
|
|
|
3515
4769
|
const correctedIdx = args.indexOf('--corrected');
|
|
3516
4770
|
|
|
3517
4771
|
if (intakeIdIdx === -1) {
|
|
3518
|
-
// Show stats instead
|
|
3519
4772
|
const stats = getAccuracyStats(process.cwd());
|
|
3520
4773
|
if (json) {
|
|
3521
4774
|
println(JSON.stringify(stats, null, 2));
|
|
@@ -3540,7 +4793,7 @@ const handlers = new Map([
|
|
|
3540
4793
|
|
|
3541
4794
|
const feedback = createClassificationFeedback({
|
|
3542
4795
|
intakeId,
|
|
3543
|
-
original: { intakeType: 'unknown', primaryOwner: 'unknown' },
|
|
4796
|
+
original: { intakeType: 'unknown', primaryOwner: 'unknown' },
|
|
3544
4797
|
corrected,
|
|
3545
4798
|
reason,
|
|
3546
4799
|
});
|
|
@@ -3698,6 +4951,9 @@ const handlers = new Map([
|
|
|
3698
4951
|
const { runTelemetrySetupCli } = await import('../lib/telemetry/setup.mjs');
|
|
3699
4952
|
return runTelemetrySetupCli(args);
|
|
3700
4953
|
}],
|
|
4954
|
+
['telemetry', async (args) => {
|
|
4955
|
+
return cmdTelemetryQuery(args);
|
|
4956
|
+
}],
|
|
3701
4957
|
['beads', async (args) => {
|
|
3702
4958
|
const sub = args[0];
|
|
3703
4959
|
const subArgs = args.slice(1);
|
|
@@ -3853,7 +5109,10 @@ const handlers = new Map([
|
|
|
3853
5109
|
const { runSkillsApplyCli } = await import('../lib/skills-apply.mjs');
|
|
3854
5110
|
return runSkillsApplyCli(args.slice(1));
|
|
3855
5111
|
}
|
|
3856
|
-
|
|
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`);
|
|
3857
5116
|
process.exit(1);
|
|
3858
5117
|
}],
|
|
3859
5118
|
['ask', async (args) => {
|
|
@@ -3935,7 +5194,34 @@ const handlers = new Map([
|
|
|
3935
5194
|
process.exit(1);
|
|
3936
5195
|
}
|
|
3937
5196
|
}
|
|
3938
|
-
|
|
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`);
|
|
3939
5225
|
process.exit(1);
|
|
3940
5226
|
}],
|
|
3941
5227
|
['sandbox', async (args) => {
|
|
@@ -4331,6 +5617,27 @@ if (!handler) {
|
|
|
4331
5617
|
process.exit(1);
|
|
4332
5618
|
}
|
|
4333
5619
|
|
|
5620
|
+
// Self-maintenance on version change. Skipped for high-frequency hook calls
|
|
5621
|
+
// (the harness invokes `construct hook <name>` many times per session and
|
|
5622
|
+
// must stay fast). When the installed version differs from the stamp at
|
|
5623
|
+
// ~/.construct/.cleanup-stamp, we run a single bounded cleanup pass and
|
|
5624
|
+
// update the stamp. Subsequent invocations are a single file read.
|
|
5625
|
+
if (command !== 'hook') {
|
|
5626
|
+
try {
|
|
5627
|
+
const { maybeRunCleanupOnUpgrade, formatBytes } = await import('../lib/maintenance/cleanup.mjs');
|
|
5628
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8'));
|
|
5629
|
+
const result = maybeRunCleanupOnUpgrade({ homeDir: HOME, env: process.env, currentVersion: pkg.version });
|
|
5630
|
+
if (result.ran && result.summary?.freedBytes > 1024 * 1024) {
|
|
5631
|
+
const previous = result.previousVersion ? ` from v${result.previousVersion}` : '';
|
|
5632
|
+
process.stderr.write(`[construct] Maintenance after upgrade${previous} to v${pkg.version}: freed ${formatBytes(result.summary.freedBytes)}.\n`);
|
|
5633
|
+
}
|
|
5634
|
+
} catch (err) {
|
|
5635
|
+
// Maintenance must never block the actual command. Surface the failure
|
|
5636
|
+
// but continue. The next invocation will retry.
|
|
5637
|
+
process.stderr.write(`[construct] startup maintenance skipped: ${err.message}\n`);
|
|
5638
|
+
}
|
|
5639
|
+
}
|
|
5640
|
+
|
|
4334
5641
|
// First-invocation resource probe — skipped for hooks, setup, uninstall,
|
|
4335
5642
|
// doctor, and once BOOTSTRAP_CHECKED=1 is cached in ~/.construct/config.env.
|
|
4336
5643
|
// On TTY: if anything's missing, prints the status table and offers to run
|