@geraldmaron/construct 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -17
- package/bin/construct +1199 -69
- package/commands/work/optimize-prompts.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +2 -2
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +5 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +65 -17
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/doctor/watchers/consistency.mjs +62 -12
- package/lib/document-extract.mjs +221 -28
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/inbox.mjs +48 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/env-config.mjs +9 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +12 -0
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +18 -0
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +222 -55
- package/lib/intake/filesystem-queue.mjs +25 -5
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -0
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/mcp/server.mjs +9 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +1 -1
- package/lib/migrations/index.mjs +1 -1
- package/lib/model-registry.mjs +1 -1
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +7 -1
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +13 -3
- package/personas/construct.md +2 -2
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/no-fabrication.md +1 -1
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +130 -58
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/{agents → specialists}/contracts.schema.json +1 -1
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-architect.md +1 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-debugger.md +1 -1
- package/{agents → specialists}/prompts/cx-designer.md +1 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
- package/{agents → specialists}/prompts/cx-engineer.md +2 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
- package/{agents → specialists}/prompts/cx-explorer.md +1 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
- package/{agents → specialists}/prompts/cx-operations.md +1 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-qa.md +1 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-researcher.md +1 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
- package/{agents → specialists}/prompts/cx-security.md +1 -1
- package/{agents → specialists}/prompts/cx-sre.md +2 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
- package/{agents → specialists}/registry.json +651 -423
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- /package/{agents → specialists}/contracts.json +0 -0
- /package/{agents → specialists}/teams.json +0 -0
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { shouldQuarantine } from './quarantine.mjs';
|
|
18
19
|
|
|
19
20
|
function slugify(value) {
|
|
20
21
|
return String(value || 'untitled')
|
|
@@ -69,21 +70,62 @@ export class PostgresIntakeQueue {
|
|
|
69
70
|
const id = `${ts}-${slug}`;
|
|
70
71
|
const triage = entry.triage || {};
|
|
71
72
|
|
|
73
|
+
// Quarantine routing: low-confidence packets land in status='quarantined'
|
|
74
|
+
// instead of 'pending' so worker claim() never picks them up. Human
|
|
75
|
+
// reroute via rerouteQuarantined() promotes to 'pending'.
|
|
76
|
+
const quarantineDecision = shouldQuarantine(triage);
|
|
77
|
+
const status = quarantineDecision.quarantine ? 'quarantined' : 'pending';
|
|
78
|
+
const augmentedEntry = quarantineDecision.quarantine
|
|
79
|
+
? { ...entry, quarantineReason: quarantineDecision.reason }
|
|
80
|
+
: entry;
|
|
81
|
+
|
|
72
82
|
await this.sql`
|
|
73
83
|
INSERT INTO construct_intake_items (
|
|
74
84
|
id, project, tenant_id, status,
|
|
75
85
|
intake_type, rd_stage, primary_owner, recommended_action,
|
|
76
86
|
risk, requires_approval, confidence, payload
|
|
77
87
|
) VALUES (
|
|
78
|
-
${id}, ${this.project}, ${this.tenantId},
|
|
88
|
+
${id}, ${this.project}, ${this.tenantId}, ${status},
|
|
79
89
|
${triage.intakeType || null}, ${triage.rdStage || null},
|
|
80
90
|
${triage.primaryOwner || null}, ${triage.recommendedAction || null},
|
|
81
91
|
${triage.risk || null}, ${triage.requiresApproval || false},
|
|
82
92
|
${typeof triage.confidence === 'number' ? triage.confidence : null},
|
|
83
|
-
${this.sql.json(
|
|
93
|
+
${this.sql.json(augmentedEntry)}
|
|
84
94
|
)
|
|
85
95
|
`;
|
|
86
|
-
return { id };
|
|
96
|
+
return { id, route: quarantineDecision.quarantine ? 'quarantine' : 'pending', reason: quarantineDecision.reason };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async listQuarantine({ limit = 100 } = {}) {
|
|
100
|
+
const rows = await this.sql`
|
|
101
|
+
SELECT * FROM construct_intake_items
|
|
102
|
+
WHERE project = ${this.project}
|
|
103
|
+
AND (${this.tenantId}::text IS NULL OR tenant_id IS NOT DISTINCT FROM ${this.tenantId})
|
|
104
|
+
AND status = 'quarantined'
|
|
105
|
+
ORDER BY created_at ASC
|
|
106
|
+
LIMIT ${limit}
|
|
107
|
+
`;
|
|
108
|
+
return rows.map(rowToEntry);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async rerouteQuarantined(id, newType, { reroutedBy = 'unknown', reason = '' } = {}) {
|
|
112
|
+
const result = await this.sql`
|
|
113
|
+
UPDATE construct_intake_items
|
|
114
|
+
SET status = 'pending',
|
|
115
|
+
intake_type = ${newType},
|
|
116
|
+
payload = jsonb_set(
|
|
117
|
+
jsonb_set(payload, '{triage,originalIntakeType}', to_jsonb(intake_type::text)),
|
|
118
|
+
'{triage,intakeType}', to_jsonb(${newType}::text)
|
|
119
|
+
),
|
|
120
|
+
updated_at = now(),
|
|
121
|
+
notes = ${`rerouted by ${reroutedBy}${reason ? `: ${reason}` : ''}`}
|
|
122
|
+
WHERE id = ${id}
|
|
123
|
+
AND project = ${this.project}
|
|
124
|
+
AND status = 'quarantined'
|
|
125
|
+
RETURNING id
|
|
126
|
+
`;
|
|
127
|
+
if (result.count === 0) throw new Error(`rerouteQuarantined: no quarantined entry ${id}`);
|
|
128
|
+
return { id, route: 'pending' };
|
|
87
129
|
}
|
|
88
130
|
|
|
89
131
|
async listPending({ limit = 100 } = {}) {
|
package/lib/intake/prepare.mjs
CHANGED
|
@@ -83,12 +83,15 @@ export async function prepareIntakeForIngestedFile({
|
|
|
83
83
|
const customerMentions = detectCustomerMentions(extracted.slice(0, 2000));
|
|
84
84
|
const customers = customerMentions.map((c) => c.customerId);
|
|
85
85
|
|
|
86
|
+
const droppedInfo = ingestedFile.droppedInfo ?? [];
|
|
87
|
+
|
|
86
88
|
const entry = {
|
|
87
89
|
intake: {
|
|
88
90
|
sourcePath: ingestedFile.sourcePath,
|
|
89
91
|
outputPath: ingestedFile.outputPath,
|
|
90
92
|
characters: ingestedFile.characters,
|
|
91
93
|
knowledgeSubdir: ingestedFile.knowledgeSubdir,
|
|
94
|
+
extractor: droppedInfo.length > 0 ? { droppedInfo } : undefined,
|
|
92
95
|
},
|
|
93
96
|
triage,
|
|
94
97
|
suggestion: lane ? { lane, source: 'docs-routing.suggestDocsLaneForFile' } : null,
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/intake/quarantine.mjs — low-confidence intake quarantine + learned-fixture loop.
|
|
3
|
+
*
|
|
4
|
+
* When the classifier is uncertain (low confidence or close margin between
|
|
5
|
+
* top-2 candidates), the packet should not auto-route to pending where a
|
|
6
|
+
* downstream specialist might act on a confident-looking-but-wrong label.
|
|
7
|
+
* Instead it lands in `.cx/intake/quarantine/` and waits for human review
|
|
8
|
+
* via `construct intake quarantine show <id>` + `construct intake reroute`.
|
|
9
|
+
*
|
|
10
|
+
* The reroute path writes a fixture into tests/fixtures/intake/learned/
|
|
11
|
+
* keyed by content hash. CI loads these fixtures alongside the golden
|
|
12
|
+
* corpus and asserts the classifier handles them correctly. The override
|
|
13
|
+
* thus becomes durable — the same wrong call cannot happen twice without
|
|
14
|
+
* also failing CI.
|
|
15
|
+
*
|
|
16
|
+
* Determinism contract preserved: the daemon never reads the learned
|
|
17
|
+
* fixtures at runtime; only tests do. Classification stays a pure function
|
|
18
|
+
* of (sourcePath, extractedText, related, profile).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const QUEUE_SUBDIR = '.cx/intake';
|
|
26
|
+
|
|
27
|
+
// Quarantine thresholds. A packet with a winning candidate still lands in
|
|
28
|
+
// quarantine when either bar fails. Conservative defaults: 0.60 confidence
|
|
29
|
+
// and 0.20 margin cover the demonstrated failure cases without flooding
|
|
30
|
+
// quarantine for borderline-but-defensible classifications.
|
|
31
|
+
export const QUARANTINE_CONFIDENCE_THRESHOLD = 0.60;
|
|
32
|
+
export const QUARANTINE_MARGIN_THRESHOLD = 0.20;
|
|
33
|
+
|
|
34
|
+
export function quarantineDir(rootDir) {
|
|
35
|
+
return path.join(rootDir, QUEUE_SUBDIR, 'quarantine');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether a triage result should be routed to quarantine.
|
|
40
|
+
* Returns { quarantine: boolean, reason?: string }.
|
|
41
|
+
*/
|
|
42
|
+
export function shouldQuarantine(triage) {
|
|
43
|
+
if (!triage || triage.intakeType === 'unknown') {
|
|
44
|
+
return { quarantine: false }; // unknown is its own bucket; not quarantine
|
|
45
|
+
}
|
|
46
|
+
const confidence = typeof triage.confidence === 'number' ? triage.confidence : 1;
|
|
47
|
+
if (confidence < QUARANTINE_CONFIDENCE_THRESHOLD) {
|
|
48
|
+
return { quarantine: true, reason: `confidence ${confidence.toFixed(2)} < ${QUARANTINE_CONFIDENCE_THRESHOLD}` };
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(triage.candidates) && triage.candidates.length >= 2) {
|
|
51
|
+
const margin = triage.candidates[0].score - triage.candidates[1].score;
|
|
52
|
+
if (margin < QUARANTINE_MARGIN_THRESHOLD) {
|
|
53
|
+
return { quarantine: true, reason: `margin ${margin.toFixed(2)} < ${QUARANTINE_MARGIN_THRESHOLD}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { quarantine: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hash content for fixture deduplication. Uses the same input the classifier
|
|
61
|
+
* sees so two packets with identical signal produce one fixture.
|
|
62
|
+
*/
|
|
63
|
+
export function fixtureHash(sourcePath, extractedText) {
|
|
64
|
+
const h = createHash('sha256');
|
|
65
|
+
h.update(String(sourcePath || ''));
|
|
66
|
+
h.update('\n--\n');
|
|
67
|
+
h.update(String(extractedText || '').slice(0, 8000));
|
|
68
|
+
return h.digest('hex').slice(0, 16);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write a learned-fixture file recording the human-chosen classification.
|
|
73
|
+
* Called by `construct intake reroute`. Idempotent — overwrites with the
|
|
74
|
+
* latest reroute decision (the most recent human call is authoritative).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} rootDir - repo root where tests/fixtures/intake/learned lives
|
|
77
|
+
* @param {object} packet - the quarantined packet being rerouted
|
|
78
|
+
* @param {string} expectedType - intakeType the human picked
|
|
79
|
+
* @returns {{ filePath: string, hash: string }}
|
|
80
|
+
*/
|
|
81
|
+
export function writeLearnedFixture(rootDir, packet, expectedType) {
|
|
82
|
+
const sourcePath = packet?.intake?.sourcePath || packet?.sourcePath || '';
|
|
83
|
+
const text = packet?.excerpt || '';
|
|
84
|
+
const hash = fixtureHash(sourcePath, text);
|
|
85
|
+
const dir = path.join(rootDir, 'tests', 'fixtures', 'intake', 'learned');
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
const filePath = path.join(dir, `${hash}.json`);
|
|
88
|
+
const fixture = {
|
|
89
|
+
content_hash: `sha256:${hash}`,
|
|
90
|
+
source_path: sourcePath,
|
|
91
|
+
text_snippet: text.slice(0, 500),
|
|
92
|
+
expected: { intakeType: expectedType },
|
|
93
|
+
origin: 'user-reroute',
|
|
94
|
+
created_at: new Date().toISOString(),
|
|
95
|
+
packet_id: packet?.id || null,
|
|
96
|
+
};
|
|
97
|
+
writeFileSync(filePath, JSON.stringify(fixture, null, 2) + '\n', 'utf8');
|
|
98
|
+
return { filePath, hash };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List all learned fixtures (for the calibration test). Returns an array
|
|
103
|
+
* of { content_hash, source_path, text_snippet, expected, ... } objects.
|
|
104
|
+
*/
|
|
105
|
+
export function loadLearnedFixtures(rootDir) {
|
|
106
|
+
const dir = path.join(rootDir, 'tests', 'fixtures', 'intake', 'learned');
|
|
107
|
+
if (!existsSync(dir)) return [];
|
|
108
|
+
return readdirSync(dir)
|
|
109
|
+
.filter((name) => name.endsWith('.json'))
|
|
110
|
+
.map((name) => {
|
|
111
|
+
try { return JSON.parse(readFileSync(path.join(dir, name), 'utf8')); }
|
|
112
|
+
catch { return null; }
|
|
113
|
+
})
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write a quarantined packet to disk. Mirrors the structure of pending/
|
|
119
|
+
* packets so `intake show` / `intake reroute` can read either location.
|
|
120
|
+
*/
|
|
121
|
+
export function writeQuarantinePacket(rootDir, packet, quarantineReason) {
|
|
122
|
+
const dir = quarantineDir(rootDir);
|
|
123
|
+
mkdirSync(dir, { recursive: true });
|
|
124
|
+
const id = packet.id;
|
|
125
|
+
if (!id) throw new Error('writeQuarantinePacket: packet.id required');
|
|
126
|
+
const filePath = path.join(dir, `${id}.json`);
|
|
127
|
+
const payload = {
|
|
128
|
+
...packet,
|
|
129
|
+
status: 'quarantined',
|
|
130
|
+
quarantinedAt: new Date().toISOString(),
|
|
131
|
+
quarantineReason: quarantineReason || null,
|
|
132
|
+
};
|
|
133
|
+
writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
134
|
+
return { id, filePath };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* List packets currently in quarantine. Same shape as listPending.
|
|
139
|
+
*/
|
|
140
|
+
export function listQuarantine(rootDir) {
|
|
141
|
+
const dir = quarantineDir(rootDir);
|
|
142
|
+
if (!existsSync(dir)) return [];
|
|
143
|
+
return readdirSync(dir)
|
|
144
|
+
.filter((name) => name.endsWith('.json'))
|
|
145
|
+
.map((name) => {
|
|
146
|
+
const filePath = path.join(dir, name);
|
|
147
|
+
try {
|
|
148
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
149
|
+
return { ...data, filePath };
|
|
150
|
+
} catch { return null; }
|
|
151
|
+
})
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.sort((a, b) => (a.quarantinedAt || '').localeCompare(b.quarantinedAt || ''));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read a single quarantined packet by id. Returns null if not found.
|
|
158
|
+
*/
|
|
159
|
+
export function readQuarantine(rootDir, id) {
|
|
160
|
+
const filePath = path.join(quarantineDir(rootDir), `${id}.json`);
|
|
161
|
+
if (!existsSync(filePath)) return null;
|
|
162
|
+
try {
|
|
163
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
164
|
+
return { ...data, filePath };
|
|
165
|
+
} catch { return null; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Move a quarantined packet to pending after a human reroute. Updates the
|
|
170
|
+
* triage.intakeType + records the reroute history. Writes the learned
|
|
171
|
+
* fixture as a side effect.
|
|
172
|
+
*
|
|
173
|
+
* Returns { id, filePath, fixturePath, fixtureHash } on success.
|
|
174
|
+
*/
|
|
175
|
+
export function rerouteQuarantined(rootDir, id, newType, { reroutedBy = 'unknown', reason = '' } = {}) {
|
|
176
|
+
const packet = readQuarantine(rootDir, id);
|
|
177
|
+
if (!packet) throw new Error(`reroute: no quarantined packet ${id}`);
|
|
178
|
+
|
|
179
|
+
// Write learned fixture BEFORE moving — fixture creation must succeed for
|
|
180
|
+
// the reroute to commit. If fixture write fails, the packet stays put so
|
|
181
|
+
// the human can retry.
|
|
182
|
+
const { filePath: fixturePath, hash } = writeLearnedFixture(rootDir, packet, newType);
|
|
183
|
+
|
|
184
|
+
const updated = {
|
|
185
|
+
...packet,
|
|
186
|
+
status: 'pending',
|
|
187
|
+
triage: {
|
|
188
|
+
...(packet.triage || {}),
|
|
189
|
+
intakeType: newType,
|
|
190
|
+
originalIntakeType: packet.triage?.intakeType,
|
|
191
|
+
reroutedAt: new Date().toISOString(),
|
|
192
|
+
reroutedBy,
|
|
193
|
+
rerouteReason: reason || null,
|
|
194
|
+
rerouteFixtureHash: hash,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
delete updated.quarantinedAt;
|
|
198
|
+
delete updated.quarantineReason;
|
|
199
|
+
|
|
200
|
+
const pendingPath = path.join(rootDir, QUEUE_SUBDIR, 'pending', `${id}.json`);
|
|
201
|
+
mkdirSync(path.dirname(pendingPath), { recursive: true });
|
|
202
|
+
writeFileSync(pendingPath, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
203
|
+
rmSync(packet.filePath);
|
|
204
|
+
return { id, filePath: pendingPath, fixturePath, fixtureHash: hash };
|
|
205
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/knowledge/postgres-search.mjs — Tag-aware vector search via pgvector + GIN.
|
|
3
|
+
*
|
|
4
|
+
* Runs when DATABASE_URL is configured. Chooses between two execution paths
|
|
5
|
+
* based on tag selectivity:
|
|
6
|
+
*
|
|
7
|
+
* - High selectivity (tag matches <5% of corpus): GIN prefilter first, then
|
|
8
|
+
* HNSW ANN on the filtered set.
|
|
9
|
+
* - Low selectivity (tag matches ≥5% of corpus): HNSW ANN first with
|
|
10
|
+
* hnsw.iterative_scan=relaxed_order, postfilter by tag.
|
|
11
|
+
*
|
|
12
|
+
* Falls back gracefully when DATABASE_URL is absent or the pgvector extension
|
|
13
|
+
* is not installed; callers should use knowledgeSearch() from search.mjs in
|
|
14
|
+
* that case.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Selectivity threshold: if the tagged fraction exceeds this, use postfilter.
|
|
18
|
+
const HIGH_SELECTIVITY_THRESHOLD = 0.05;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Execute a tag-filtered vector search against Postgres + pgvector.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {Float32Array|number[]} opts.queryEmbedding — query vector
|
|
25
|
+
* @param {string} opts.project — project id (construct_documents.project)
|
|
26
|
+
* @param {string[]} [opts.tags] — tag filter
|
|
27
|
+
* @param {'any'|'all'} [opts.tagMatch] — 'any' (default) or 'all'
|
|
28
|
+
* @param {number} [opts.topK] — result limit (default 20)
|
|
29
|
+
* @param {object} opts.db — postgres client (from lib/db.mjs or postgres pkg)
|
|
30
|
+
* @returns {Promise<Array<{id, title, score}>>}
|
|
31
|
+
*/
|
|
32
|
+
export async function postgresTagSearch({ queryEmbedding, project, tags = [], tagMatch = 'any', topK = 20, db }) {
|
|
33
|
+
if (!db) throw new Error('postgres-search: db client required');
|
|
34
|
+
if (!queryEmbedding?.length) throw new Error('postgres-search: queryEmbedding required');
|
|
35
|
+
|
|
36
|
+
const vecLiteral = `[${Array.from(queryEmbedding).join(',')}]`;
|
|
37
|
+
|
|
38
|
+
if (!tags.length) {
|
|
39
|
+
// No tag filter — plain ANN.
|
|
40
|
+
const rows = await db`
|
|
41
|
+
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
42
|
+
from construct_embeddings e
|
|
43
|
+
join construct_documents d on d.id = e.document_id
|
|
44
|
+
where d.project = ${project}
|
|
45
|
+
order by e.embedding <=> ${vecLiteral}::vector
|
|
46
|
+
limit ${topK}
|
|
47
|
+
`;
|
|
48
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Estimate selectivity for this tag set.
|
|
52
|
+
const tagJsonb = JSON.stringify(tags);
|
|
53
|
+
const [{ total }] = await db`select count(*) as total from construct_documents where project = ${project}`;
|
|
54
|
+
const totalCount = parseInt(total, 10) || 1;
|
|
55
|
+
|
|
56
|
+
let taggedCount;
|
|
57
|
+
if (tagMatch === 'all') {
|
|
58
|
+
const [{ cnt }] = await db`
|
|
59
|
+
select count(*) as cnt from construct_documents
|
|
60
|
+
where project = ${project} and tags @> ${tagJsonb}::jsonb
|
|
61
|
+
`;
|
|
62
|
+
taggedCount = parseInt(cnt, 10);
|
|
63
|
+
} else {
|
|
64
|
+
// 'any' — count docs with at least one matching tag.
|
|
65
|
+
const [{ cnt }] = await db`
|
|
66
|
+
select count(*) as cnt from construct_documents
|
|
67
|
+
where project = ${project}
|
|
68
|
+
and (${db.unsafe(tags.map((_, i) => `tags ? $${i + 2}`).join(' or '))})
|
|
69
|
+
`;
|
|
70
|
+
taggedCount = parseInt(cnt, 10);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const selectivity = taggedCount / totalCount;
|
|
74
|
+
|
|
75
|
+
if (selectivity < HIGH_SELECTIVITY_THRESHOLD) {
|
|
76
|
+
// High selectivity: GIN prefilter → ANN on filtered set.
|
|
77
|
+
if (tagMatch === 'all') {
|
|
78
|
+
const rows = await db`
|
|
79
|
+
with filtered as (
|
|
80
|
+
select id from construct_documents
|
|
81
|
+
where project = ${project} and tags @> ${tagJsonb}::jsonb
|
|
82
|
+
)
|
|
83
|
+
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
84
|
+
from construct_embeddings e
|
|
85
|
+
join filtered f on f.id = e.document_id
|
|
86
|
+
join construct_documents d on d.id = e.document_id
|
|
87
|
+
order by e.embedding <=> ${vecLiteral}::vector
|
|
88
|
+
limit ${topK}
|
|
89
|
+
`;
|
|
90
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
91
|
+
}
|
|
92
|
+
// 'any' — OR-expand tag list.
|
|
93
|
+
const tagConditions = tags.map((t) => `tags ? '${t.replace(/'/g, "''")}'`).join(' or ');
|
|
94
|
+
const rows = await db.unsafe(`
|
|
95
|
+
with filtered as (
|
|
96
|
+
select id from construct_documents
|
|
97
|
+
where project = $1 and (${tagConditions})
|
|
98
|
+
)
|
|
99
|
+
select d.id, d.title, e.embedding <=> $2::vector as distance
|
|
100
|
+
from construct_embeddings e
|
|
101
|
+
join filtered f on f.id = e.document_id
|
|
102
|
+
join construct_documents d on d.id = e.document_id
|
|
103
|
+
order by e.embedding <=> $2::vector
|
|
104
|
+
limit $3
|
|
105
|
+
`, [project, vecLiteral, topK]);
|
|
106
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Low selectivity: HNSW iterative scan → postfilter.
|
|
110
|
+
await db`set hnsw.iterative_scan = relaxed_order`;
|
|
111
|
+
if (tagMatch === 'all') {
|
|
112
|
+
const rows = await db`
|
|
113
|
+
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
114
|
+
from construct_embeddings e
|
|
115
|
+
join construct_documents d on d.id = e.document_id
|
|
116
|
+
where d.project = ${project} and d.tags @> ${tagJsonb}::jsonb
|
|
117
|
+
order by e.embedding <=> ${vecLiteral}::vector
|
|
118
|
+
limit ${topK}
|
|
119
|
+
`;
|
|
120
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
121
|
+
}
|
|
122
|
+
const tagConditions = tags.map((t) => `d.tags ? '${t.replace(/'/g, "''")}'`).join(' or ');
|
|
123
|
+
const rows = await db.unsafe(`
|
|
124
|
+
select d.id, d.title, e.embedding <=> $1::vector as distance
|
|
125
|
+
from construct_embeddings e
|
|
126
|
+
join construct_documents d on d.id = e.document_id
|
|
127
|
+
where d.project = $2 and (${tagConditions})
|
|
128
|
+
order by e.embedding <=> $1::vector
|
|
129
|
+
limit $3
|
|
130
|
+
`, [vecLiteral, project, topK]);
|
|
131
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
132
|
+
}
|
package/lib/knowledge/search.mjs
CHANGED
|
@@ -253,7 +253,7 @@ function buildObservationChunks(rootDir) {
|
|
|
253
253
|
* @param {string} [opts.rootDir] — data dir where .cx/observations/ lives (default: homedir())
|
|
254
254
|
* @returns {KnowledgeSearchResult}
|
|
255
255
|
*/
|
|
256
|
-
export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, rootDir } = {}) {
|
|
256
|
+
export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, rootDir, tags, tagMatch = 'any' } = {}) {
|
|
257
257
|
if (!query || typeof query !== 'string') {
|
|
258
258
|
return { ok: false, query: query ?? '', totalChunks: 0, hits: [], sources: [], message: 'query is required' };
|
|
259
259
|
}
|
|
@@ -279,14 +279,24 @@ export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, roo
|
|
|
279
279
|
return { ok: false, query, totalChunks: 0, hits: [], sources: [], message: 'No documentation or observation sources found' };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
// Tag filter (optional): applied before scoring to restrict the candidate set.
|
|
283
|
+
const filteredChunks = tags?.length
|
|
284
|
+
? allChunks.filter((c) => {
|
|
285
|
+
const chunkTags = new Set(Array.isArray(c.tags) ? c.tags : []);
|
|
286
|
+
const required = tags;
|
|
287
|
+
if (tagMatch === 'all') return required.every((t) => chunkTags.has(t));
|
|
288
|
+
return required.some((t) => chunkTags.has(t));
|
|
289
|
+
})
|
|
290
|
+
: allChunks;
|
|
291
|
+
|
|
282
292
|
const queryTokens = tokenise(query);
|
|
283
293
|
if (!queryTokens.length) {
|
|
284
|
-
return { ok: false, query, totalChunks:
|
|
294
|
+
return { ok: false, query, totalChunks: filteredChunks.length, hits: [], sources: [], message: 'Query contains no searchable terms after stop-word removal' };
|
|
285
295
|
}
|
|
286
296
|
|
|
287
|
-
const idf = buildIdf(queryTokens,
|
|
297
|
+
const idf = buildIdf(queryTokens, filteredChunks);
|
|
288
298
|
|
|
289
|
-
const scored =
|
|
299
|
+
const scored = filteredChunks
|
|
290
300
|
.map(chunk => ({ chunk, score: scoreChunk(chunk, queryTokens, idf) }))
|
|
291
301
|
.filter(({ score }) => score >= minScore)
|
|
292
302
|
.sort((a, b) => b.score - a.score)
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
14
14
|
import { realpathSync } from 'node:fs';
|
|
15
15
|
import { loadToolkitEnv } from '../toolkit-env.mjs';
|
|
16
16
|
import { loadConstructEnv } from '../env-config.mjs';
|
|
17
|
+
import { withGenAiSpan, GenAiAttrs, extractTraceContext, injectTraceContext } from '../telemetry/otel-tracer.mjs';
|
|
17
18
|
|
|
18
19
|
// Apply config.env values to process.env, letting config.env win over shell env
|
|
19
20
|
// so telemetry/OpenRouter credentials are always correct regardless of host env.
|
|
@@ -415,11 +416,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
415
416
|
},
|
|
416
417
|
{
|
|
417
418
|
name: 'broker_check',
|
|
418
|
-
description: 'Queries the MCP broker policy gate for a pending action without executing it. Agents call this BEFORE attempting a high-risk action so the response (allowed / approvalRequired / reason / source) can be surfaced to the user in the agent\'s voice rather than triggering a denial after the fact. In solo mode the broker is off by default — returns `brokerActive: false` with `allowed: true` so the call is cheap and agents don\'t waste tokens on an inactive gate. Always emits a `tool.called` trace event for audit-trail parity. Reads
|
|
419
|
+
description: 'Queries the MCP broker policy gate for a pending action without executing it. Agents call this BEFORE attempting a high-risk action so the response (allowed / approvalRequired / reason / source) can be surfaced to the user in the agent\'s voice rather than triggering a denial after the fact. In solo mode the broker is off by default — returns `brokerActive: false` with `allowed: true` so the call is cheap and agents don\'t waste tokens on an inactive gate. Always emits a `tool.called` trace event for audit-trail parity. Reads specialists/role-manifests.json fence rules in team / enterprise mode.',
|
|
419
420
|
inputSchema: {
|
|
420
421
|
type: 'object',
|
|
421
422
|
properties: {
|
|
422
|
-
role: { type: 'string', description: 'Persona name (e.g. `engineer`, `security`). Must match a key in
|
|
423
|
+
role: { type: 'string', description: 'Persona name (e.g. `engineer`, `security`). Must match a key in specialists/role-manifests.json for team / enterprise mode.' },
|
|
423
424
|
project: { type: 'string', description: 'Optional project scope for the decision.' },
|
|
424
425
|
tool: { type: 'string', description: 'The tool the agent wants to invoke (e.g. `github`, `fs`).' },
|
|
425
426
|
action: { type: 'string', description: 'The action on that tool (e.g. `create_pr`, `edit:lib/foo.mjs`).' },
|
|
@@ -431,7 +432,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
431
432
|
},
|
|
432
433
|
{
|
|
433
434
|
name: 'agent_contract',
|
|
434
|
-
description: 'Looks up explicit agent-to-agent service contracts (from
|
|
435
|
+
description: 'Looks up explicit agent-to-agent service contracts (from specialists/contracts.json). Specialists should call this at the start of a handoff to see the expected input shape, preconditions, and what postconditions they must satisfy. Use without args to get all contracts; pass producer/consumer to narrow; pass id for a specific contract.',
|
|
435
436
|
inputSchema: {
|
|
436
437
|
type: 'object',
|
|
437
438
|
properties: {
|
|
@@ -875,6 +876,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
875
876
|
cwd: { type: 'string', description: 'Project root (default: server cwd).' },
|
|
876
877
|
top_k: { type: 'number', description: 'Max communities to return (default 5).' },
|
|
877
878
|
min_size: { type: 'number', description: 'Skip communities smaller than this (default 2).' },
|
|
879
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Restrict entity extraction to observations carrying these tags.' },
|
|
880
|
+
tag_match: { type: 'string', enum: ['any', 'all'], description: 'Tag match mode: any (default) or all.' },
|
|
878
881
|
},
|
|
879
882
|
},
|
|
880
883
|
},
|
|
@@ -906,6 +909,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
906
909
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
907
910
|
const { name, arguments: args = {} } = request.params;
|
|
908
911
|
|
|
912
|
+
// Extract W3C traceparent from params._meta (SEP-414 propagation).
|
|
913
|
+
const parentCtx = await extractTraceContext(request.params?._meta || {});
|
|
914
|
+
|
|
909
915
|
let result;
|
|
910
916
|
try {
|
|
911
917
|
if (name === 'agent_health') result = agentHealth(args);
|
package/lib/mcp/tools/skills.mjs
CHANGED
|
@@ -21,8 +21,14 @@ export function listSkills({ ROOT_DIR }) {
|
|
|
21
21
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
22
22
|
for (const entry of entries) {
|
|
23
23
|
if (entry.isDirectory()) {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Directory form: <name>/SKILL.md — emit the directory name as the skill id.
|
|
25
|
+
const skillMd = join(dir, entry.name, 'SKILL.md');
|
|
26
|
+
if (existsSync(skillMd)) {
|
|
27
|
+
results.push(`${prefix}${entry.name}`);
|
|
28
|
+
} else {
|
|
29
|
+
results = results.concat(listDirRecursive(join(dir, entry.name), `${prefix}${entry.name}/`));
|
|
30
|
+
}
|
|
31
|
+
} else if (entry.name.endsWith('.md') && entry.name !== 'SKILL.md' && entry.name !== 'routing.md') {
|
|
26
32
|
results.push(`${prefix}${entry.name.replace('.md', '')}`);
|
|
27
33
|
}
|
|
28
34
|
}
|
|
@@ -37,13 +43,28 @@ export function getSkill(args, { ROOT_DIR }) {
|
|
|
37
43
|
const { path: skillPath } = args;
|
|
38
44
|
if (!skillPath) return { error: 'Missing path argument' };
|
|
39
45
|
|
|
40
|
-
|
|
46
|
+
// Primary: flat file form skills/<path>.md
|
|
47
|
+
// Fallback: directory form skills/<path>/SKILL.md (one minor-release grace period)
|
|
48
|
+
const candidates = [
|
|
49
|
+
join(ROOT_DIR, 'skills', `${skillPath}.md`),
|
|
50
|
+
join(ROOT_DIR, 'skills', skillPath, 'SKILL.md'),
|
|
51
|
+
];
|
|
41
52
|
|
|
42
|
-
|
|
53
|
+
const fullPath = candidates.find(existsSync);
|
|
54
|
+
if (!fullPath) {
|
|
43
55
|
return { error: `Skill not found: ${skillPath}` };
|
|
44
56
|
}
|
|
57
|
+
const t0 = Date.now();
|
|
45
58
|
const content = readFileSync(fullPath, 'utf8');
|
|
46
|
-
logSkillCall({
|
|
59
|
+
logSkillCall({
|
|
60
|
+
skillId: skillPath,
|
|
61
|
+
source: 'mcp',
|
|
62
|
+
latencyMs: Date.now() - t0,
|
|
63
|
+
callerContext: args.callerContext,
|
|
64
|
+
agentId: args.agentId,
|
|
65
|
+
sessionId: args.sessionId,
|
|
66
|
+
tokensReturned: Math.ceil(content.length / 4),
|
|
67
|
+
});
|
|
47
68
|
return { content };
|
|
48
69
|
}
|
|
49
70
|
|
|
@@ -114,8 +135,8 @@ export async function agentContract(args = {}) {
|
|
|
114
135
|
getOutgoingContracts,
|
|
115
136
|
getIncomingContracts,
|
|
116
137
|
summarize,
|
|
117
|
-
} = await import('../../
|
|
118
|
-
const { describePostconditions } = await import('../../
|
|
138
|
+
} = await import('../../specialist-contracts.mjs');
|
|
139
|
+
const { describePostconditions } = await import('../../specialists/postconditions.mjs');
|
|
119
140
|
const { id, producer, consumer } = args;
|
|
120
141
|
// Attach binary postconditions to every returned contract so a specialist
|
|
121
142
|
// calling this tool at handoff time sees the hard checks their output
|
|
@@ -382,8 +403,8 @@ export async function workerRun(args = {}) {
|
|
|
382
403
|
}
|
|
383
404
|
|
|
384
405
|
export function listTeams({ ROOT_DIR }) {
|
|
385
|
-
const teamsPath = join(ROOT_DIR, '
|
|
386
|
-
if (!existsSync(teamsPath)) return { error: '
|
|
406
|
+
const teamsPath = join(ROOT_DIR, 'specialists', 'teams.json');
|
|
407
|
+
if (!existsSync(teamsPath)) return { error: 'specialists/teams.json not found' };
|
|
387
408
|
const { templates } = JSON.parse(readFileSync(teamsPath, 'utf8'));
|
|
388
409
|
return {
|
|
389
410
|
teams: Object.entries(templates).map(([name, t]) => ({
|
|
@@ -400,8 +421,8 @@ export function listTeams({ ROOT_DIR }) {
|
|
|
400
421
|
export function getTeam(args, { ROOT_DIR }) {
|
|
401
422
|
const { name } = args;
|
|
402
423
|
if (!name) return { error: 'Missing name argument' };
|
|
403
|
-
const teamsPath = join(ROOT_DIR, '
|
|
404
|
-
if (!existsSync(teamsPath)) return { error: '
|
|
424
|
+
const teamsPath = join(ROOT_DIR, 'specialists', 'teams.json');
|
|
425
|
+
if (!existsSync(teamsPath)) return { error: 'specialists/teams.json not found' };
|
|
405
426
|
const { templates } = JSON.parse(readFileSync(teamsPath, 'utf8'));
|
|
406
427
|
const team = templates[name];
|
|
407
428
|
if (!team) return { error: `Team not found: ${name}`, available: Object.keys(templates) };
|
|
@@ -97,7 +97,7 @@ export function workflowValidate(args) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* Validate a producer→consumer handoff against
|
|
100
|
+
* Validate a producer→consumer handoff against specialists/contracts.json.
|
|
101
101
|
*
|
|
102
102
|
* Args:
|
|
103
103
|
* producer — agent or persona name producing the artifact
|
package/lib/migrations/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/migrations/index.mjs — Schema migration registry and runner.
|
|
3
3
|
*
|
|
4
|
-
* Construct's machine-readable artifacts (
|
|
4
|
+
* Construct's machine-readable artifacts (specialists/registry.json, contracts.json,
|
|
5
5
|
* role-manifests.json, .cx/config.json, profile JSON) carry an integer
|
|
6
6
|
* `version` field. When the installed binary's expected schema version is
|
|
7
7
|
* higher than the on-disk version, migrations apply in order. When the on-disk
|
package/lib/model-registry.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
* RESOLUTION ORDER (highest to lowest):
|
|
20
20
|
* 1. Environment variables (CX_MODEL_*): Emergency overrides
|
|
21
21
|
* 2. User config (~/.construct/config.env): Personal defaults
|
|
22
|
-
* 3. Registry (
|
|
22
|
+
* 3. Registry (specialists/registry.json): Project recommendations
|
|
23
23
|
* 4. Built-in defaults: Last resort fallbacks
|
|
24
24
|
*
|
|
25
25
|
* Primary resolution path. All code must use this function.
|
|
@@ -415,7 +415,7 @@ function buildHostConstraints() {
|
|
|
415
415
|
|
|
416
416
|
function readRegistryModels(rootDir) {
|
|
417
417
|
try {
|
|
418
|
-
const registryPath = join(rootDir, "
|
|
418
|
+
const registryPath = join(rootDir, "specialists", "registry.json");
|
|
419
419
|
if (!existsSync(registryPath)) return {};
|
|
420
420
|
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
421
421
|
return registry?.models ?? {};
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
* 2. framing/research/doc-ownership gates (what must be true before work starts)
|
|
7
7
|
* 3. contract chain (resolveContractChain) (what the typed handoffs are)
|
|
8
8
|
*
|
|
9
|
-
* Agent-to-agent contracts are defined in
|
|
10
|
-
* lib/
|
|
9
|
+
* Agent-to-agent contracts are defined in specialists/contracts.json and loaded via
|
|
10
|
+
* lib/specialist-contracts.mjs. That file is the single source of truth for
|
|
11
11
|
* producer→consumer expectations, replacing the scattered DOC_OWNERSHIP map,
|
|
12
12
|
* SPECIALIST_MAP, and informal "collaborators" lists for anything contract-
|
|
13
13
|
* shaped. The older maps remain for quick lookups but defer to contracts.json
|
|
14
14
|
* for authoritative semantics.
|
|
15
15
|
*/
|
|
16
|
-
import { resolveContractChain } from './
|
|
16
|
+
import { resolveContractChain } from './specialist-contracts.mjs';
|
|
17
17
|
import { verifyRoute } from './intent-classifier.mjs';
|
|
18
18
|
|
|
19
19
|
export const EXECUTION_TRACKS = {
|
|
@@ -80,7 +80,7 @@ export const DOC_OWNERSHIP = {
|
|
|
80
80
|
// Event ownership for the role framework (lib/roles/**). When a hook emits
|
|
81
81
|
// an event of one of these types, the gateway routes the invocation to the
|
|
82
82
|
// listed persona. Empty owners mean the event is recorded but no persona
|
|
83
|
-
// fires until onboarded. Keep in sync with
|
|
83
|
+
// fires until onboarded. Keep in sync with specialists/role-manifests.json.
|
|
84
84
|
|
|
85
85
|
export const EVENT_OWNERSHIP = {
|
|
86
86
|
'push_gate.fail': 'cx-sre',
|