@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.
Files changed (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /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 } = {}) {
@@ -77,19 +77,21 @@ export async function prepareIntakeForIngestedFile({
77
77
  extractedText: extracted,
78
78
  related,
79
79
  profile: activeProfile?.id,
80
- cwd: rootDir,
81
80
  });
82
81
 
83
82
  // Detect customer mentions and update profiles
84
83
  const customerMentions = detectCustomerMentions(extracted.slice(0, 2000));
85
84
  const customers = customerMentions.map((c) => c.customerId);
86
85
 
86
+ const droppedInfo = ingestedFile.droppedInfo ?? [];
87
+
87
88
  const entry = {
88
89
  intake: {
89
90
  sourcePath: ingestedFile.sourcePath,
90
91
  outputPath: ingestedFile.outputPath,
91
92
  characters: ingestedFile.characters,
92
93
  knowledgeSubdir: ingestedFile.knowledgeSubdir,
94
+ extractor: droppedInfo.length > 0 ? { droppedInfo } : undefined,
93
95
  },
94
96
  triage,
95
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
+ }
@@ -13,6 +13,7 @@
13
13
  import { createIntakeQueue } from './queue.mjs';
14
14
  import { formatTriageLine } from './classify.mjs';
15
15
  import { isBrokered } from '../mcp/broker.mjs';
16
+ import { getRebrand } from '../profiles/rebrand.mjs';
16
17
 
17
18
  export function buildIntakePrelude({ cwd, env = process.env } = {}) {
18
19
  if (!cwd) return '';
@@ -20,11 +21,16 @@ export function buildIntakePrelude({ cwd, env = process.env } = {}) {
20
21
  const queue = createIntakeQueue(cwd, env);
21
22
  const pending = queue.listPending();
22
23
  if (!pending.length) return '';
24
+ const { intakeQueueLabel, signalNoun } = getRebrand(cwd);
23
25
  const recent = pending.slice(-3).map((p) => {
24
26
  const src = p.intake?.sourcePath || p.id;
25
27
  return `- ${formatTriageLine(src, p.triage)}`;
26
28
  });
27
- return `\n## Pending R&D intake (${pending.length})\n${recent.join('\n')}\nEach packet at \`.cx/intake/pending/<id>.json\` carries the new signal, a triage block (intakeType, rdStage, primaryOwner, recommendedChain, recommendedAction, risk), related existing docs, and an excerpt. Process via the recommended chain, then close via \`construct intake done <id>\`.\n`;
29
+ // Strip a trailing " queue" / " intake" so the heading reads
30
+ // "## Pending R&D intake (N)" rather than the redundant
31
+ // "## Pending R&D intake queue queue (N)".
32
+ const heading = intakeQueueLabel.replace(/\s+queue$/i, '');
33
+ return `\n## Pending ${heading} (${pending.length})\n${recent.join('\n')}\nEach packet at \`.cx/intake/pending/<id>.json\` carries the new ${signalNoun}, a triage block (intakeType, rdStage, primaryOwner, recommendedChain, recommendedAction, risk), related existing docs, and an excerpt. Process via the recommended chain, then close via \`construct intake done <id>\`.\n`;
28
34
  } catch {
29
35
  return '';
30
36
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * lib/intake/traceability.mjs — stamps intake provenance into artifact frontmatter.
3
+ *
4
+ * Backs `construct intake done <id> --output=<path>`: reads the intake packet,
5
+ * extracts (id, confidence, rationale), and writes them into the artifact's
6
+ * YAML frontmatter. Refuses to overwrite a different intake_id (an artifact
7
+ * can be linked to exactly one intake source). Idempotent on re-stamping.
8
+ *
9
+ * Read by docs:verify checkIntakeTraceability to surface artifacts in
10
+ * intake-fed locations that lack a reference.
11
+ *
12
+ * Field schema (in artifact YAML frontmatter):
13
+ * intake_id: construct-xxx
14
+ * intake_confidence: 0.7
15
+ * intake_rationale: "Matched 3 keywords: dashboard, latency, p95"
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
19
+
20
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
21
+
22
+ export function parseArtifactFrontmatter(content) {
23
+ const m = content.match(FRONTMATTER_RE);
24
+ if (!m) return { frontmatter: {}, body: content, hasBlock: false };
25
+ const out = {};
26
+ for (const line of m[1].split('\n')) {
27
+ if (!line.trim() || line.trim().startsWith('#')) continue;
28
+ const idx = line.indexOf(':');
29
+ if (idx === -1) continue;
30
+ const key = line.slice(0, idx).trim();
31
+ const rawValue = line.slice(idx + 1).trim();
32
+ if (!key) continue;
33
+ const unquoted = rawValue.replace(/^["']|["']$/g, '');
34
+ if (/^-?\d+(\.\d+)?$/.test(unquoted)) out[key] = Number(unquoted);
35
+ else out[key] = unquoted;
36
+ }
37
+ return { frontmatter: out, body: content.slice(m[0].length), hasBlock: true };
38
+ }
39
+
40
+ function serializeFrontmatter(fm) {
41
+ const lines = ['---'];
42
+ for (const [k, v] of Object.entries(fm)) {
43
+ if (v === null || v === undefined) continue;
44
+ if (typeof v === 'number' || typeof v === 'boolean') {
45
+ lines.push(`${k}: ${v}`);
46
+ } else {
47
+ const s = String(v);
48
+ const needsQuoting = /[:#"'\n]|^\s|\s$/.test(s);
49
+ lines.push(`${k}: ${needsQuoting ? JSON.stringify(s) : s}`);
50
+ }
51
+ }
52
+ lines.push('---');
53
+ return lines.join('\n');
54
+ }
55
+
56
+ export function stampIntakeProvenance(artifactPath, { intakeId, confidence, rationale }) {
57
+ if (!existsSync(artifactPath)) {
58
+ throw new Error(`Output artifact not found: ${artifactPath}`);
59
+ }
60
+ if (!intakeId) throw new Error('intakeId required for stamping');
61
+ const content = readFileSync(artifactPath, 'utf8');
62
+ const { frontmatter, body, hasBlock } = parseArtifactFrontmatter(content);
63
+
64
+ const existing = frontmatter.intake_id;
65
+ if (existing && existing !== intakeId) {
66
+ throw new Error(
67
+ `Refusing to overwrite intake_id: artifact already references ${existing}, not ${intakeId}. Resolve manually or supersede.`,
68
+ );
69
+ }
70
+
71
+ const next = {
72
+ ...frontmatter,
73
+ intake_id: intakeId,
74
+ ...(confidence !== undefined && confidence !== null ? { intake_confidence: confidence } : {}),
75
+ ...(rationale ? { intake_rationale: rationale } : {}),
76
+ };
77
+
78
+ const fmText = serializeFrontmatter(next);
79
+ const out = hasBlock ? `${fmText}\n${body}` : `${fmText}\n\n${content}`;
80
+ writeFileSync(artifactPath, out, 'utf8');
81
+ return { intake_id: intakeId, intake_confidence: next.intake_confidence, intake_rationale: next.intake_rationale };
82
+ }
83
+
84
+ export function hasIntakeReference(artifactPath) {
85
+ if (!existsSync(artifactPath)) return false;
86
+ const { frontmatter } = parseArtifactFrontmatter(readFileSync(artifactPath, 'utf8'));
87
+ if (frontmatter.intake_id) return true;
88
+ if (frontmatter.intake === 'none') return true;
89
+ return false;
90
+ }
@@ -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
+ }
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { syncFileStateToSql } from '../storage/sync.mjs';
21
21
  import { resolveActiveProfile } from '../profiles/loader.mjs';
22
+ import { ensureCxDir } from '../project-init-shared.mjs';
22
23
 
23
24
  const ROOT = '.cx/knowledge/external/research';
24
25
  const MAX_BYTES = 50 * 1024;
@@ -68,6 +69,7 @@ export async function addResearchFinding({
68
69
  const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
69
70
 
70
71
  const outDir = join(cwd, ROOT);
72
+ ensureCxDir(cwd);
71
73
  mkdirSync(outDir, { recursive: true });
72
74
  const outPath = join(outDir, `${slug}.md`);
73
75
 
@@ -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)