@geraldmaron/construct 1.0.23 → 1.0.24

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 (52) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +15 -215
  3. package/lib/embed/inbox.mjs +6 -3
  4. package/lib/embed/recommendation-store.mjs +7 -289
  5. package/lib/embed/reconcile.mjs +2 -2
  6. package/lib/hooks/config-protection.mjs +4 -4
  7. package/lib/hooks/session-reflect.mjs +5 -1
  8. package/lib/intake/git-queue.mjs +195 -0
  9. package/lib/intake/queue.mjs +9 -16
  10. package/lib/mcp/tools/storage.mjs +2 -3
  11. package/lib/mcp-catalog.json +3 -3
  12. package/lib/mcp-manager.mjs +59 -3
  13. package/lib/observation-store.mjs +38 -166
  14. package/lib/orchestration/runtime.mjs +3 -2
  15. package/lib/reconcile/index.mjs +0 -2
  16. package/lib/service-manager.mjs +38 -256
  17. package/lib/setup.mjs +26 -426
  18. package/lib/status.mjs +3 -6
  19. package/lib/storage/admin.mjs +48 -325
  20. package/lib/storage/backend.mjs +10 -57
  21. package/lib/storage/hybrid-query.mjs +15 -196
  22. package/lib/storage/sync.mjs +36 -177
  23. package/lib/storage/vector-client.mjs +256 -235
  24. package/lib/strategy-store.mjs +35 -286
  25. package/lib/worker/entrypoint.mjs +6 -14
  26. package/package.json +6 -5
  27. package/platforms/claude/settings.template.json +0 -7
  28. package/scripts/sync-specialists.mjs +46 -12
  29. package/specialists/prompts/cx-qa.md +1 -1
  30. package/specialists/registry.json +0 -8
  31. package/templates/docs/construct_guide.md +1 -1
  32. package/db/schema/001_init.sql +0 -40
  33. package/db/schema/002_pgvector.sql +0 -182
  34. package/db/schema/003_intake.sql +0 -47
  35. package/db/schema/003_observation_reconciliation.sql +0 -14
  36. package/db/schema/004_recommendations.sql +0 -46
  37. package/db/schema/005_strategy.sql +0 -21
  38. package/db/schema/006_graph.sql +0 -24
  39. package/db/schema/007_tags.sql +0 -30
  40. package/db/schema/008_skill_usage.sql +0 -24
  41. package/db/schema/009_scheduler.sql +0 -14
  42. package/db/schema/010_cx_scores.sql +0 -51
  43. package/lib/intake/postgres-queue.mjs +0 -240
  44. package/lib/reconcile/postgres-namespace.mjs +0 -102
  45. package/lib/services/local-postgres.mjs +0 -15
  46. package/lib/storage/backup.mjs +0 -347
  47. package/lib/storage/migrations.mjs +0 -187
  48. package/lib/storage/postgres-backup.mjs +0 -124
  49. package/lib/storage/sql-store.mjs +0 -45
  50. package/lib/storage/store-version.mjs +0 -115
  51. package/lib/storage/unified-storage.mjs +0 -550
  52. package/lib/storage/vector-store.mjs +0 -100
@@ -6,23 +6,19 @@
6
6
  * Strategy documents are NEVER auto-updated from ingested documents — they require
7
7
  * an explicit writeStrategy() call from a privileged caller.
8
8
  *
9
- * In team/enterprise mode Postgres is the secondary store (best-effort). Each scope
10
- * is stored as a separate row whose content is prefixed with `scope:{name}\n` so we
11
- * can store and retrieve scopes without adding a new column to the existing schema.
9
+ * Construct now uses embedded LanceDB for vectors only; strategy remains
10
+ * filesystem-primary for simplicity and Git-backed collaboration.
12
11
  *
13
12
  * getStrategyDigest() / getStrategyDigestSync() return a compact block (≤ 500 tokens)
14
13
  * suitable for injection into agent prompts. getStrategyDigestSync() is the file-only
15
14
  * synchronous path required where await is not available (e.g. prompt assembly).
16
15
  */
17
16
 
18
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
19
18
  import { join, basename } from 'node:path';
20
19
  import { cxDir } from './paths.mjs';
21
- import { hasSqlStore } from './storage/sql-store.mjs';
22
- import { createSqlClient } from './storage/backend.mjs';
23
20
 
24
21
  const VALID_SCOPES = ['product', 'technical', 'gtm', 'platform'];
25
- const SCOPE_PREFIX = 'scope:';
26
22
 
27
23
  // ── Path helpers ──────────────────────────────────────────────────────────────
28
24
 
@@ -38,9 +34,6 @@ export function strategyFilePath(scope = 'product', env = process.env) {
38
34
 
39
35
  /**
40
36
  * Return scope names that have corresponding files in the strategy directory.
41
- *
42
- * @param {object} [env]
43
- * @returns {string[]}
44
37
  */
45
38
  export function listStrategyScopes(env = process.env) {
46
39
  const dir = strategyDir(env);
@@ -57,315 +50,71 @@ export function listStrategyScopes(env = process.env) {
57
50
  // ── Read helpers ──────────────────────────────────────────────────────────────
58
51
 
59
52
  /**
60
- * Read one strategy scope from Postgres (primary) or file (fallback).
61
- *
62
- * @param {string} [scope]
63
- * @param {object} [env]
64
- * @returns {Promise<{ content: string, version: number, updatedAt: string, source: 'postgres'|'file'|'none', scope: string }>}
53
+ * Read one strategy scope from file.
65
54
  */
66
55
  export async function readStrategy(scope = 'product', env = process.env) {
67
- if (hasSqlStore(env)) {
68
- const client = createSqlClient(env);
69
- try {
70
- const project = env.CX_PROJECT || 'default';
71
- const prefix = `${SCOPE_PREFIX}${scope}\n`;
72
-
73
- // Rows stored with the scope prefix — match on content prefix
74
- const rows = await client`
75
- select content, version, updated_at
76
- from construct_strategy
77
- where project = ${project}
78
- and content like ${prefix + '%'}
79
- order by version desc
80
- limit 1
81
- `;
82
-
83
- if (rows.length > 0) {
84
- const row = rows[0];
85
- return {
86
- content: row.content.startsWith(prefix) ? row.content.slice(prefix.length) : row.content,
87
- version: row.version,
88
- updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
89
- source: 'postgres',
90
- scope,
91
- };
92
- }
93
- } catch {
94
- // Postgres unavailable — fall through to file
95
- } finally {
96
- if (client) await client.end({ timeout: 5 }).catch(() => {});
97
- }
98
- }
99
-
100
56
  const filePath = strategyFilePath(scope, env);
101
- if (existsSync(filePath)) {
57
+ if (!existsSync(filePath)) {
58
+ return { content: '', version: 0, updatedAt: null, source: 'none', scope };
59
+ }
60
+ try {
102
61
  const content = readFileSync(filePath, 'utf8');
103
- return { content, version: 1, updatedAt: new Date().toISOString(), source: 'file', scope };
62
+ const stat = statSync(filePath);
63
+ return {
64
+ content,
65
+ version: 1,
66
+ updatedAt: stat.mtime.toISOString(),
67
+ source: 'file',
68
+ scope,
69
+ };
70
+ } catch {
71
+ return { content: '', version: 0, updatedAt: null, source: 'none', scope };
104
72
  }
105
-
106
- return { content: '', version: 0, updatedAt: new Date().toISOString(), source: 'none', scope };
107
73
  }
108
74
 
109
75
  /**
110
- * Read all present strategy scopes.
111
- *
112
- * When Postgres is available it reads all rows for the project and parses scope
113
- * from the prefix. Files are used as the fallback or supplement for scopes that
114
- * have no Postgres row.
115
- *
116
- * @param {object} [env]
117
- * @returns {Promise<Map<string, { content: string, version: number, updatedAt: string, source: string }>>}
76
+ * Read all strategy scopes into a Map.
118
77
  */
119
78
  export async function readAllStrategies(env = process.env) {
120
79
  const result = new Map();
121
-
122
- if (hasSqlStore(env)) {
123
- const client = createSqlClient(env);
124
- try {
125
- const project = env.CX_PROJECT || 'default';
126
-
127
- // Fetch all rows — latest version per scope
128
- const rows = await client`
129
- select distinct on (
130
- substring(content from 1 for position(E'\n' in content))
131
- ) content, version, updated_at
132
- from construct_strategy
133
- where project = ${project}
134
- and content like ${SCOPE_PREFIX + '%'}
135
- order by
136
- substring(content from 1 for position(E'\n' in content)),
137
- version desc
138
- `;
139
-
140
- for (const row of rows) {
141
- const firstNewline = row.content.indexOf('\n');
142
- if (firstNewline === -1) continue;
143
- const header = row.content.slice(0, firstNewline);
144
- if (!header.startsWith(SCOPE_PREFIX)) continue;
145
- const rowScope = header.slice(SCOPE_PREFIX.length);
146
- const content = row.content.slice(firstNewline + 1);
147
- result.set(rowScope, {
148
- content,
149
- version: row.version,
150
- updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
151
- source: 'postgres',
152
- });
153
- }
154
- } catch {
155
- // Fall through to file-based reads
156
- } finally {
157
- if (client) await client.end({ timeout: 5 }).catch(() => {});
158
- }
159
- }
160
-
161
- // Supplement / replace with file-based reads for any scope present on disk
162
- const fileScopes = listStrategyScopes(env);
163
- for (const fileScope of fileScopes) {
164
- if (result.has(fileScope)) continue;
165
- const filePath = strategyFilePath(fileScope, env);
166
- try {
167
- const content = readFileSync(filePath, 'utf8');
168
- result.set(fileScope, {
169
- content,
170
- version: 1,
171
- updatedAt: new Date().toISOString(),
172
- source: 'file',
173
- });
174
- } catch {
175
- // Skip unreadable files
176
- }
80
+ const scopes = listStrategyScopes(env);
81
+ for (const scope of scopes) {
82
+ const data = await readStrategy(scope, env);
83
+ if (data.source !== 'none') result.set(scope, data);
177
84
  }
178
-
179
85
  return result;
180
86
  }
181
87
 
182
- // ── Digest helpers ────────────────────────────────────────────────────────────
183
-
184
- /**
185
- * Parse markdown into sections split on ## headers.
186
- *
187
- * @param {string} content
188
- * @returns {Array<{ header: string, body: string }>}
189
- */
190
- function parseSections(content) {
191
- const lines = content.split('\n');
192
- const sections = [];
193
- let current = null;
194
-
195
- for (const line of lines) {
196
- if (line.startsWith('## ')) {
197
- if (current) sections.push(current);
198
- current = { header: line.slice(3).trim(), body: '' };
199
- } else if (current) {
200
- current.body += line + '\n';
201
- }
202
- }
203
- if (current) sections.push(current);
204
- return sections;
205
- }
206
-
207
- /**
208
- * Truncate a body to the first N sentences.
209
- *
210
- * @param {string} body
211
- * @param {number} maxSentences
212
- * @returns {string}
213
- */
214
- function firstSentences(body, maxSentences = 2) {
215
- const trimmed = body.trim();
216
- if (!trimmed) return '';
217
- const sentences = trimmed.match(/[^.!?\n]+[.!?\n]+/g) || [trimmed];
218
- return sentences.slice(0, maxSentences).join(' ').trim();
219
- }
220
-
221
- /**
222
- * Build a compact markdown block from a single scope's content.
223
- *
224
- * @param {string} scopeName
225
- * @param {string} content
226
- * @param {number} charBudget — remaining character budget; mutated by reference via returned delta
227
- * @returns {{ text: string, charsUsed: number }}
228
- */
229
- function buildScopeBlock(scopeName, content, charBudget) {
230
- const sections = parseSections(content);
231
- if (!sections.length) return { text: '', charsUsed: 0 };
232
-
233
- const lines = [`### ${scopeName}`];
234
- let used = scopeName.length + 5;
235
-
236
- for (const { header, body } of sections) {
237
- if (used >= charBudget) break;
238
- const excerpt = firstSentences(body, 2);
239
- const line = excerpt ? `**${header}:** ${excerpt}` : `**${header}**`;
240
- lines.push(line);
241
- used += line.length + 1;
242
- }
243
-
244
- const text = lines.join('\n');
245
- return { text, charsUsed: used };
246
- }
247
-
248
88
  /**
249
- * Return a compact strategy digest (≤ 500 tokens) for agent prompt injection.
250
- *
251
- * Reads all present scopes asynchronously. Returns empty string when no scopes exist.
252
- *
253
- * @param {object} [env]
254
- * @returns {Promise<string>}
89
+ * Synchronous digest for prompt assembly.
255
90
  */
256
- export async function getStrategyDigest(env = process.env) {
91
+ export function getStrategyDigestSync(scope = 'product', env = process.env) {
92
+ const filePath = strategyFilePath(scope, env);
93
+ if (!existsSync(filePath)) return '';
257
94
  try {
258
- const all = await readAllStrategies(env);
259
- if (!all.size) return '';
260
-
261
- const TOKEN_LIMIT = 500;
262
- let charBudget = TOKEN_LIMIT * 4;
263
- const parts = ['## Active strategy'];
264
-
265
- for (const [scopeName, { content }] of all) {
266
- if (!content) continue;
267
- const { text, charsUsed } = buildScopeBlock(scopeName, content, charBudget);
268
- if (text) {
269
- parts.push(text);
270
- charBudget -= charsUsed;
271
- }
272
- if (charBudget <= 0) break;
273
- }
274
-
275
- return parts.length > 1 ? parts.join('\n') : '';
95
+ const content = readFileSync(filePath, 'utf8');
96
+ return content.trim().slice(0, 2000); // approx 500 tokens
276
97
  } catch {
277
98
  return '';
278
99
  }
279
100
  }
280
101
 
281
102
  /**
282
- * Synchronous file-only strategy digest for prompt-composer.js.
283
- *
284
- * Reads all .md files present in the strategy directory directly. Never reads
285
- * Postgres. Returns empty string when the directory is absent or empty.
286
- *
287
- * @param {object} [env]
288
- * @returns {string}
103
+ * Async digest for general use.
289
104
  */
290
- export function getStrategyDigestSync(env = process.env) {
291
- try {
292
- const dir = strategyDir(env);
293
- if (!existsSync(dir)) return '';
294
-
295
- const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
296
- if (!files.length) return '';
297
-
298
- const TOKEN_LIMIT = 500;
299
- let charBudget = TOKEN_LIMIT * 4;
300
- const parts = ['## Active strategy'];
301
-
302
- for (const file of files) {
303
- if (charBudget <= 0) break;
304
- const scopeName = basename(file, '.md');
305
- let content;
306
- try {
307
- content = readFileSync(join(dir, file), 'utf8');
308
- } catch {
309
- continue;
310
- }
311
- if (!content) continue;
312
- const { text, charsUsed } = buildScopeBlock(scopeName, content, charBudget);
313
- if (text) {
314
- parts.push(text);
315
- charBudget -= charsUsed;
316
- }
317
- }
318
-
319
- return parts.length > 1 ? parts.join('\n') : '';
320
- } catch {
321
- return '';
322
- }
105
+ export async function getStrategyDigest(scope = 'product', env = process.env) {
106
+ const data = await readStrategy(scope, env);
107
+ return data.content.trim().slice(0, 2000);
323
108
  }
324
109
 
325
- // ── Write ─────────────────────────────────────────────────────────────────────
110
+ // ── Write helpers ─────────────────────────────────────────────────────────────
326
111
 
327
112
  /**
328
- * Write a strategy scope to file (always) and Postgres (best-effort if available).
329
- *
330
- * The scope parameter defaults to 'product'. When writing to Postgres the content
331
- * is prefixed with `scope:{name}\n` to allow per-scope storage without a schema change.
332
- *
333
- * @param {string} content
334
- * @param {string} [scope]
335
- * @param {object} [opts]
336
- * @param {string} [opts.updatedBy]
337
- * @param {object} [opts.env]
113
+ * Write a strategy scope to file.
338
114
  */
339
115
  export async function writeStrategy(content, scope = 'product', { updatedBy, env = process.env } = {}) {
340
116
  const filePath = strategyFilePath(scope, env);
341
117
  const dir = strategyDir(env);
342
118
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
343
119
  writeFileSync(filePath, content, 'utf8');
344
-
345
- if (!hasSqlStore(env)) return;
346
-
347
- const client = createSqlClient(env);
348
- try {
349
- const project = env.CX_PROJECT || 'default';
350
- const scopedContent = `${SCOPE_PREFIX}${scope}\n${content}`;
351
- const prefix = `${SCOPE_PREFIX}${scope}\n`;
352
-
353
- const existing = await client`
354
- select version from construct_strategy
355
- where project = ${project}
356
- and content like ${prefix + '%'}
357
- order by version desc
358
- limit 1
359
- `;
360
- const nextVersion = existing.length > 0 ? existing[0].version + 1 : 1;
361
-
362
- await client`
363
- insert into construct_strategy (project, content, version, updated_by)
364
- values (${project}, ${scopedContent}, ${nextVersion}, ${updatedBy ?? null})
365
- `;
366
- } catch {
367
- // Best-effort Postgres write — file write already succeeded
368
- } finally {
369
- if (client) await client.end({ timeout: 5 }).catch(() => {});
370
- }
371
120
  }
@@ -24,8 +24,7 @@
24
24
 
25
25
  import path from 'node:path';
26
26
 
27
- import { createSqlClient, closeSqlClient } from '../storage/backend.mjs';
28
- import { PostgresIntakeQueue } from '../intake/postgres-queue.mjs';
27
+ import { createIntakeQueue } from '../intake/queue.mjs';
29
28
  import { FilesystemTaskGraphStore } from '../task-graph/store.mjs';
30
29
  import { runJob } from './run.mjs';
31
30
  import { evidenceFromJobResult, recordEvidence, blockedPacket } from './evidence.mjs';
@@ -167,7 +166,6 @@ export async function runWorkerLoop({
167
166
  rootDir,
168
167
  workspace,
169
168
  project,
170
- sql,
171
169
  queue: injectedQueue = null,
172
170
  store: injectedStore = null,
173
171
  pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
@@ -178,10 +176,9 @@ export async function runWorkerLoop({
178
176
  } = {}) {
179
177
  if (!project) throw new Error('runWorkerLoop: project is required');
180
178
  if (!workspace) throw new Error('runWorkerLoop: workspace is required');
181
- if (!injectedQueue && !sql) throw new Error('runWorkerLoop: sql client (or injected queue) is required');
182
179
 
183
180
  const workerName = readWorkerName();
184
- const queue = injectedQueue || new PostgresIntakeQueue({ sql, project });
181
+ const queue = injectedQueue || createIntakeQueue(rootDir, process.env, { project });
185
182
  const store = injectedStore || new FilesystemTaskGraphStore(rootDir);
186
183
 
187
184
  let processed = 0;
@@ -223,19 +220,14 @@ if (invokedDirectly) {
223
220
  const pollIntervalMs = Number(args['poll-interval-ms'] || DEFAULT_POLL_INTERVAL_MS);
224
221
  const idleTimeoutSeconds = Number(args['idle-timeout-seconds'] || DEFAULT_IDLE_TIMEOUT_SECONDS);
225
222
 
226
- const sql = createSqlClient(process.env);
227
- if (!sql) {
228
- process.stderr.write('[worker] DATABASE_URL is required for the worker entrypoint.\n');
229
- process.exit(78); // EX_CONFIG
230
- }
231
-
232
223
  try {
233
224
  const summary = await runWorkerLoop({
234
- rootDir, workspace, project, sql, pollIntervalMs, idleTimeoutSeconds,
225
+ rootDir, workspace, project, pollIntervalMs, idleTimeoutSeconds,
235
226
  });
236
227
  process.stdout.write(`[worker] exiting after idle timeout — processed=${summary.processed} skipped=${summary.skipped} worker=${summary.workerName}\n`);
237
228
  process.exit(0);
238
- } finally {
239
- await closeSqlClient(sql);
229
+ } catch (err) {
230
+ process.stderr.write(`[worker] failed: ${err.stack}\n`);
231
+ process.exit(1);
240
232
  }
241
233
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "type": "module",
5
5
  "packageManager": "npm@11.5.1",
6
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
@@ -80,16 +80,17 @@
80
80
  "postinstall": "node ./bin/construct-postinstall.mjs"
81
81
  },
82
82
  "dependencies": {
83
+ "@lancedb/lancedb": "^0.30.0",
83
84
  "@modelcontextprotocol/sdk": "^1.12.0",
84
- "js-yaml": "^4.2.0",
85
- "postgres": "^3.4.9"
85
+ "apache-arrow": "^18.1.0",
86
+ "js-yaml": "^4.2.0"
86
87
  },
87
88
  "optionalDependencies": {
88
89
  "@huggingface/transformers": "^4.2.0",
89
90
  "@opentelemetry/api": "^1.9.0",
90
91
  "@opentelemetry/core": "^2.7.1",
91
92
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
92
- "@opentelemetry/resources": "^1.25.0",
93
+ "@opentelemetry/resources": "^2.7.1",
93
94
  "@opentelemetry/sdk-trace-node": "^1.25.0",
94
95
  "@opentelemetry/semantic-conventions": "^1.25.0"
95
96
  },
@@ -99,7 +100,7 @@
99
100
  "devDependencies": {
100
101
  "@eslint/js": "^9.39.4",
101
102
  "c8": "^11.0.0",
102
- "eslint": "^9.39.4",
103
+ "eslint": "^10.4.1",
103
104
  "globals": "^17.6.0"
104
105
  }
105
106
  }
@@ -547,13 +547,6 @@
547
547
  "memory": {
548
548
  "type": "http",
549
549
  "url": "http://127.0.0.1:8765/"
550
- },
551
- "playwright": {
552
- "command": "npx",
553
- "args": [
554
- "-y",
555
- "@playwright/mcp@latest"
556
- ]
557
550
  }
558
551
  }
559
552
  }
@@ -800,6 +800,50 @@ function makeHooksPortable(hooksJson) {
800
800
  return JSON.stringify(walk(hooksJson));
801
801
  }
802
802
 
803
+ const GLOBAL_CLAUDE_HOOK_IDS = new Set([
804
+ 'pre:bash:block-no-verify',
805
+ 'pre:bash:guard-dangerous',
806
+ 'pre:edit:config-protection',
807
+ 'pre:edit-guard',
808
+ 'post:edit:json-validate',
809
+ 'post:edit:scan-secrets',
810
+ ]);
811
+
812
+ const GLOBAL_CLAUDE_MCP_IDS = new Set([
813
+ 'context7',
814
+ ]);
815
+
816
+ function filterGlobalClaudeHooks(hooksJson) {
817
+ const filtered = {};
818
+ for (const [event, groups] of Object.entries(hooksJson ?? {})) {
819
+ const kept = groups.filter((group) => GLOBAL_CLAUDE_HOOK_IDS.has(group.id));
820
+ if (kept.length > 0) filtered[event] = kept;
821
+ }
822
+ return filtered;
823
+ }
824
+
825
+ function syncGlobalClaudeMcpServers(settings, registryMcp) {
826
+ settings.mcpServers ??= {};
827
+ for (const id of Object.keys(settings.mcpServers)) {
828
+ if (id in registryMcp && !GLOBAL_CLAUDE_MCP_IDS.has(id)) {
829
+ delete settings.mcpServers[id];
830
+ }
831
+ }
832
+
833
+ for (const [id, mcpDef] of Object.entries(registryMcp)) {
834
+ if (!GLOBAL_CLAUDE_MCP_IDS.has(id)) continue;
835
+ const existingEntry = settings.mcpServers[id];
836
+ const existing = JSON.stringify(existingEntry ?? "");
837
+ const hasPlaceholder = existing.includes("__");
838
+ const hasFloatingVersion = existing.includes("@latest");
839
+ const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
840
+ const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
841
+ const transportMismatch = registryWantsCommand && existingIsRemote;
842
+ if (existingEntry && !hasPlaceholder && !hasFloatingVersion && !transportMismatch) continue;
843
+ settings.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
844
+ }
845
+ }
846
+
803
847
  /**
804
848
  * Materialise a project-local `.claude/settings.json` from the home template,
805
849
  * with hook commands rewritten to be path-relative to whatever Construct
@@ -908,23 +952,13 @@ ${personaList}
908
952
  const constructReal = (() => {
909
953
  try { return fs.realpathSync(path.join(home, ".construct")); } catch { return path.join(home, ".construct"); }
910
954
  })();
911
- const hookStr = JSON.stringify(template.hooks)
955
+ const hookStr = JSON.stringify(filterGlobalClaudeHooks(template.hooks))
912
956
  .replace(/\$HOME\/\.construct/g, constructReal.replace(/\\/g, "/"));
913
957
  settings.hooks = JSON.parse(hookStr);
914
958
  }
915
959
  }
916
- if (!settings.mcpServers) settings.mcpServers = {};
917
960
  const registryMcp = registry.mcpServers ?? {};
918
- for (const [id, mcpDef] of Object.entries(registryMcp)) {
919
- const existingEntry = settings.mcpServers[id];
920
- const existing = JSON.stringify(existingEntry ?? "");
921
- const hasPlaceholder = existing.includes("__");
922
- const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
923
- const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
924
- const transportMismatch = registryWantsCommand && existingIsRemote;
925
- if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
926
- settings.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
927
- }
961
+ syncGlobalClaudeMcpServers(settings, registryMcp);
928
962
  if (!DRY_RUN) fs.writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2) + "\n");
929
963
  }
930
964
  }
@@ -18,7 +18,7 @@ You have watched acceptance criteria pass tests that didn't actually test the ac
18
18
  **Role guidance**: call `get_skill("roles/qa")` before drafting.
19
19
 
20
20
  When the verification domain is clear, also load exactly one relevant overlay before drafting:
21
- - `roles/qa.web-ui` for UI flows, accessibility, responsive states, visual regression, keyboard behavior, and browser automation
21
+ - `roles/qa.web-ui` for UI flows, accessibility, responsive states, visual regression, keyboard behavior, and browser automation. Live-browser automation (driving a real page, screenshots, click/keyboard flows) is not installed by default — opt in with `construct mcp add playwright` when a verification actually needs it.
22
22
  - `roles/qa.api-contract` for APIs, SDKs, status codes, error bodies, compatibility, and consumer-driven contracts
23
23
  - `roles/qa.data-pipeline` for ETL/ELT, data contracts, freshness, uniqueness, replay, backfills, and data quality checks
24
24
  - `roles/qa.ai-eval` for agents, prompts, model changes, retrieval, eval rubrics, golden traces, and promotion gates
@@ -1467,14 +1467,6 @@
1467
1467
  ],
1468
1468
  "description": "Library and framework documentation lookup"
1469
1469
  },
1470
- "playwright": {
1471
- "command": "npx",
1472
- "args": [
1473
- "-y",
1474
- "@playwright/mcp@0.0.75"
1475
- ],
1476
- "description": "Browser automation and E2E testing"
1477
- },
1478
1470
  "github": {
1479
1471
  "type": "url",
1480
1472
  "url": "https://api.githubcopilot.com/mcp/",
@@ -67,7 +67,7 @@ The full list of personas lives in `.claude/agents/`. Run `construct list` to se
67
67
 
68
68
  Two scopes:
69
69
 
70
- **Per-project** (committed, shared with peers): edit `.claude/settings.json`. Construct owns the `hooks` block and a known set of `mcpServers` keys (memory, context7, playwright, github, sequential-thinking, construct-mcp); your additions are preserved on `npm install`. Other top-level keys are yours to control.
70
+ **Per-project** (committed, shared with peers): edit `.claude/settings.json`. Construct owns the `hooks` block and a known set of `mcpServers` keys (memory, context7, github, sequential-thinking, construct-mcp); your additions — including opt-in MCPs like `playwright` (`construct mcp add playwright`) — are preserved on `npm install`. Other top-level keys are yours to control.
71
71
 
72
72
  **Per-machine** (private to you): edit `~/.construct/config.env`. This is where your API keys, consent flags, and local-service credentials live. Standard `KEY=value` format.
73
73
 
@@ -1,40 +0,0 @@
1
- create extension if not exists vector;
2
-
3
- create table if not exists construct_documents (
4
- id text primary key,
5
- project text not null,
6
- kind text not null,
7
- title text not null,
8
- summary text,
9
- body text not null,
10
- source_path text,
11
- tags jsonb not null default '[]'::jsonb,
12
- content_hash text not null,
13
- updated_at timestamptz not null default now(),
14
- created_at timestamptz not null default now()
15
- );
16
-
17
- create index if not exists construct_documents_project_kind_idx on construct_documents (project, kind);
18
- create index if not exists construct_documents_content_hash_idx on construct_documents (content_hash);
19
-
20
- create table if not exists construct_embeddings (
21
- document_id text primary key references construct_documents(id) on delete cascade,
22
- model text not null,
23
- embedding double precision[] not null,
24
- content_hash text not null,
25
- updated_at timestamptz not null default now(),
26
- created_at timestamptz not null default now()
27
- );
28
-
29
- create index if not exists construct_embeddings_model_idx on construct_embeddings (model);
30
-
31
- create table if not exists construct_sync_runs (
32
- id bigserial primary key,
33
- project text not null,
34
- source text not null,
35
- documents_synced integer not null default 0,
36
- embeddings_synced integer not null default 0,
37
- status text not null,
38
- note text,
39
- created_at timestamptz not null default now()
40
- );