@geraldmaron/construct 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +20 -17
  2. package/bin/construct +1199 -69
  3. package/commands/work/optimize-prompts.md +1 -1
  4. package/db/schema/007_tags.sql +30 -0
  5. package/db/schema/008_skill_usage.sql +24 -0
  6. package/db/schema/009_scheduler.sql +14 -0
  7. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  8. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  9. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  10. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  11. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  12. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  13. package/examples/seed-observations/decisions.md +1 -1
  14. package/lib/audit-skills.mjs +3 -3
  15. package/lib/auto-docs.mjs +2 -2
  16. package/lib/cli-commands.mjs +276 -122
  17. package/lib/comment-lint.mjs +5 -1
  18. package/lib/completions.mjs +1 -1
  19. package/lib/config/schema.mjs +15 -1
  20. package/lib/contracts/validate.mjs +65 -17
  21. package/lib/distill.mjs +22 -4
  22. package/lib/doc-stamp.mjs +48 -0
  23. package/lib/doctor/watchers/consistency.mjs +62 -12
  24. package/lib/document-extract.mjs +221 -28
  25. package/lib/document-ingest.mjs +2 -0
  26. package/lib/embed/inbox.mjs +48 -3
  27. package/lib/embed/role-framing.mjs +3 -3
  28. package/lib/env-config.mjs +9 -3
  29. package/lib/extractors/calendar.mjs +173 -0
  30. package/lib/extractors/shared/drop-info.mjs +22 -0
  31. package/lib/extractors/transcript.mjs +0 -0
  32. package/lib/gates-audit.mjs +8 -2
  33. package/lib/git-hooks/prepare-commit-msg +1 -1
  34. package/lib/headhunt.mjs +2 -2
  35. package/lib/hooks/agent-tracker.mjs +6 -11
  36. package/lib/hooks/guard-bash.mjs +35 -14
  37. package/lib/hooks/mcp-audit.mjs +12 -0
  38. package/lib/hooks/pre-compact.mjs +92 -5
  39. package/lib/hooks/pre-push-gate.mjs +41 -8
  40. package/lib/hooks/registry-sync.mjs +2 -2
  41. package/lib/hooks/stop-notify.mjs +7 -6
  42. package/lib/host-capabilities.mjs +10 -1
  43. package/lib/hygiene/scan.mjs +141 -0
  44. package/lib/init-unified.mjs +18 -0
  45. package/lib/install/stage-project.mjs +4 -4
  46. package/lib/intake/classify.mjs +222 -55
  47. package/lib/intake/filesystem-queue.mjs +25 -5
  48. package/lib/intake/postgres-queue.mjs +45 -3
  49. package/lib/intake/prepare.mjs +3 -0
  50. package/lib/intake/quarantine.mjs +205 -0
  51. package/lib/knowledge/postgres-search.mjs +132 -0
  52. package/lib/knowledge/search.mjs +14 -4
  53. package/lib/mcp/server.mjs +9 -3
  54. package/lib/mcp/tools/skills.mjs +32 -11
  55. package/lib/mcp/tools/workflow.mjs +1 -1
  56. package/lib/migrations/index.mjs +1 -1
  57. package/lib/model-registry.mjs +1 -1
  58. package/lib/opencode-runtime-plugin.mjs +1 -1
  59. package/lib/orchestration-policy.mjs +4 -4
  60. package/lib/overrides/resolver.mjs +3 -3
  61. package/lib/parity.mjs +4 -4
  62. package/lib/policy/engine.mjs +2 -2
  63. package/lib/profiles/lifecycle.mjs +1 -1
  64. package/lib/prompt-metadata.mjs +4 -4
  65. package/lib/providers/circuit-breaker.mjs +14 -0
  66. package/lib/providers/contract.mjs +67 -3
  67. package/lib/providers/creds.mjs +219 -0
  68. package/lib/providers/scaffold.mjs +107 -0
  69. package/lib/role-preload.mjs +5 -0
  70. package/lib/roles/catalog.mjs +1 -1
  71. package/lib/roles/manifest.mjs +2 -2
  72. package/lib/scheduler/index.mjs +112 -0
  73. package/lib/scheduler/solo.mjs +183 -0
  74. package/lib/server/index.mjs +7 -1
  75. package/lib/server/insights.mjs +14 -12
  76. package/lib/server/langfuse-login.mjs +58 -0
  77. package/lib/server/static/assets/index-ab25c707.js +1 -1
  78. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  79. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  80. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  81. package/lib/{agents → specialists}/schema.mjs +6 -6
  82. package/lib/status.mjs +13 -11
  83. package/lib/storage/backup.mjs +2 -2
  84. package/lib/tags/lifecycle.mjs +278 -0
  85. package/lib/tags/vocabulary.mjs +140 -0
  86. package/lib/telemetry/otel-tracer.mjs +184 -0
  87. package/lib/telemetry/skill-calls.mjs +73 -12
  88. package/lib/uninstall/uninstall.mjs +1 -1
  89. package/lib/update.mjs +1 -1
  90. package/lib/validator.mjs +57 -56
  91. package/lib/workflows/instantiate.mjs +320 -0
  92. package/package.json +13 -3
  93. package/personas/construct.md +2 -2
  94. package/platforms/claude/CLAUDE.md +1 -1
  95. package/rules/common/no-fabrication.md +1 -1
  96. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
  97. package/skills/ai/prompt-optimizer.md +3 -3
  98. package/skills/exploration/dependency-graph-reading.md +98 -0
  99. package/skills/exploration/tracer-bullet-method.md +71 -0
  100. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  101. package/skills/operating/change-management.md +91 -0
  102. package/skills/operating/incident-response.md +75 -0
  103. package/skills/operating/oncall-rotation.md +95 -0
  104. package/skills/operating/orchestration-reference.md +2 -2
  105. package/skills/strategy/competitive-landscape.md +75 -0
  106. package/skills/strategy/market-research-methods.md +87 -0
  107. package/skills/strategy/narrative-arc.md +77 -0
  108. package/skills/strategy/pricing-positioning.md +94 -0
  109. package/{agents → specialists}/contracts.schema.json +1 -1
  110. package/specialists/policy-inventory.json +160 -0
  111. package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
  112. package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
  113. package/{agents → specialists}/prompts/cx-architect.md +1 -1
  114. package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
  115. package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
  116. package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
  117. package/{agents → specialists}/prompts/cx-debugger.md +1 -1
  118. package/{agents → specialists}/prompts/cx-designer.md +1 -1
  119. package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
  120. package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
  121. package/{agents → specialists}/prompts/cx-engineer.md +2 -2
  122. package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
  123. package/{agents → specialists}/prompts/cx-explorer.md +1 -1
  124. package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
  125. package/{agents → specialists}/prompts/cx-operations.md +1 -1
  126. package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
  127. package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
  128. package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
  129. package/{agents → specialists}/prompts/cx-qa.md +1 -1
  130. package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
  131. package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
  132. package/{agents → specialists}/prompts/cx-researcher.md +1 -1
  133. package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
  134. package/{agents → specialists}/prompts/cx-security.md +1 -1
  135. package/{agents → specialists}/prompts/cx-sre.md +2 -2
  136. package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
  137. package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
  138. package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
  139. package/{agents → specialists}/registry.json +651 -423
  140. package/{agents → specialists}/role-manifests.json +8 -8
  141. package/templates/provider-scaffold/health.test.mjs +30 -0
  142. package/templates/provider-scaffold/index.mjs +48 -0
  143. package/templates/workflows/cross-team-handoff.yml +85 -0
  144. package/templates/workflows/engineering-onboarding.yml +77 -0
  145. package/templates/workflows/new-feature.yml +53 -0
  146. /package/{agents → specialists}/contracts.json +0 -0
  147. /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}, 'pending',
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(entry)}
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 } = {}) {
@@ -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
+ }
@@ -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: allChunks.length, hits: [], sources: [], message: 'Query contains no searchable terms after stop-word removal' };
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, allChunks);
297
+ const idf = buildIdf(queryTokens, filteredChunks);
288
298
 
289
- const scored = allChunks
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)
@@ -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 agents/role-manifests.json fence rules in team / enterprise mode.',
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 agents/role-manifests.json for team / enterprise mode.' },
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 agents/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
+ 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);
@@ -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
- results = results.concat(listDirRecursive(join(dir, entry.name), `${prefix}${entry.name}/`));
25
- } else if (entry.name.endsWith('.md') && entry.name !== 'SKILL.md') {
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
- const fullPath = join(ROOT_DIR, 'skills', `${skillPath}.md`);
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
- if (!existsSync(fullPath)) {
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({ skillId: skillPath, source: 'mcp' });
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('../../agent-contracts.mjs');
118
- const { describePostconditions } = await import('../../agents/postconditions.mjs');
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, 'agents', 'teams.json');
386
- if (!existsSync(teamsPath)) return { error: 'agents/teams.json not found' };
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, 'agents', 'teams.json');
404
- if (!existsSync(teamsPath)) return { error: 'agents/teams.json not found' };
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 agents/contracts.json.
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
@@ -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 (agents/registry.json, contracts.json,
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
@@ -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 (agents/registry.json): Project recommendations
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, "agents", "registry.json");
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 agents/contracts.json and loaded via
10
- * lib/agent-contracts.mjs. That file is the single source of truth for
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 './agent-contracts.mjs';
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 agents/role-manifests.json.
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',