@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.
- package/README.md +0 -2
- package/bin/construct +15 -215
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +3 -3
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- package/lib/orchestration/runtime.mjs +3 -2
- package/lib/reconcile/index.mjs +0 -2
- package/lib/service-manager.mjs +38 -256
- package/lib/setup.mjs +26 -426
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/platforms/claude/settings.template.json +0 -7
- package/scripts/sync-specialists.mjs +46 -12
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/registry.json +0 -8
- package/templates/docs/construct_guide.md +1 -1
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/reconcile/postgres-namespace.mjs +0 -102
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/unified-storage.mjs +0 -550
- package/lib/storage/vector-store.mjs +0 -100
package/lib/strategy-store.mjs
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
259
|
-
|
|
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
|
-
*
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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 {
|
|
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 ||
|
|
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,
|
|
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
|
-
}
|
|
239
|
-
|
|
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.
|
|
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
|
-
"
|
|
85
|
-
"
|
|
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": "^
|
|
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": "^
|
|
103
|
+
"eslint": "^10.4.1",
|
|
103
104
|
"globals": "^17.6.0"
|
|
104
105
|
}
|
|
105
106
|
}
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
package/db/schema/001_init.sql
DELETED
|
@@ -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
|
-
);
|