@geraldmaron/construct 1.0.23 → 1.1.0
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 +189 -207
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +4 -4
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration/runtime.mjs +3 -2
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reconcile/index.mjs +0 -2
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/service-manager.mjs +79 -259
- package/lib/setup.mjs +44 -425
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/embeddings-engine.mjs +19 -5
- 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/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/personas/construct.md +1 -1
- package/platforms/capabilities.json +76 -0
- package/platforms/claude/settings.template.json +0 -7
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +174 -40
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/prompts/cx-test-automation.md +12 -0
- 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
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/telemetry/beads-fallback.mjs — count legacy-lock fallback firings.
|
|
3
|
+
*
|
|
4
|
+
* The beads write path is optimistic by default; the exclusive file-lock runs only
|
|
5
|
+
* as the PATH-3 fallback after optimistic retries exhaust. Whether that fallback
|
|
6
|
+
* ever fires in practice is the evidence that decides if the lock + wait-queue can
|
|
7
|
+
* be retired (bead construct-nhn5): one JSON line per firing to
|
|
8
|
+
* ~/.cx/beads-fallback.jsonl. Zero entries over a representative window = safe to
|
|
9
|
+
* remove; entries name the bd commands that actually contend.
|
|
10
|
+
*
|
|
11
|
+
* Errors here are non-fatal — telemetry must never add a failure mode to the
|
|
12
|
+
* fallback it observes.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { appendBounded } from '../logging/rotate.mjs';
|
|
17
|
+
import { cxDir } from '../paths.mjs';
|
|
18
|
+
|
|
19
|
+
export function defaultLogPath() {
|
|
20
|
+
return path.join(cxDir(), 'beads-fallback.jsonl');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function logBeadsFallback(event, opts = {}) {
|
|
24
|
+
const logPath = opts.logPath || defaultLogPath();
|
|
25
|
+
const entry = {
|
|
26
|
+
ts: new Date().toISOString(),
|
|
27
|
+
command: event?.command || 'unknown',
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
31
|
+
appendBounded('beads-fallback', logPath, JSON.stringify(entry) + '\n');
|
|
32
|
+
} catch { /* best-effort */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readBeadsFallbacks({ logPath = defaultLogPath() } = {}) {
|
|
36
|
+
if (!fs.existsSync(logPath)) return [];
|
|
37
|
+
return fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean).map((l) => {
|
|
38
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
39
|
+
}).filter(Boolean);
|
|
40
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/telemetry/hook-calls.mjs — measure which hooks fire and which actually block.
|
|
3
|
+
*
|
|
4
|
+
* Hooks carry @p95ms latency budgets but no record of how often each one fires or
|
|
5
|
+
* blocks, so a hook that gates real mistakes and one that has never fired once look
|
|
6
|
+
* identical. The central dispatcher (`construct hook <name>`) appends one JSON line
|
|
7
|
+
* per invocation to ~/.cx/hook-calls.jsonl with the hook id, the outcome derived
|
|
8
|
+
* from its exit code, and latency, so `construct hooks:usage` can answer which hooks
|
|
9
|
+
* earn their keep before any are consolidated.
|
|
10
|
+
*
|
|
11
|
+
* Outcome is what the dispatcher can observe centrally: exit 0 = ok, exit 2 = blocked
|
|
12
|
+
* (the Claude Code convention for a refused tool call — see block-no-verify), any
|
|
13
|
+
* other non-zero = error. A hook that mutates state without blocking reads as `ok`
|
|
14
|
+
* here; per-hook mutation reporting is a separate, opt-in signal.
|
|
15
|
+
*
|
|
16
|
+
* Disable with CONSTRUCT_HOOK_TELEMETRY=off. Errors here are non-fatal — telemetry
|
|
17
|
+
* must never change a hook's exit code or add a failure mode to the hot path.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { appendBounded } from '../logging/rotate.mjs';
|
|
23
|
+
import { resolveProjectScope } from '../project-root.mjs';
|
|
24
|
+
import { cxDir } from '../paths.mjs';
|
|
25
|
+
|
|
26
|
+
// cxDir() (not os.homedir()) so CX_HOME_OVERRIDE isolates the log in tests and the
|
|
27
|
+
// path stays consistent with the rest of ~/.cx. Resolved per-call so an override
|
|
28
|
+
// set after import still takes effect.
|
|
29
|
+
|
|
30
|
+
export function defaultLogPath() {
|
|
31
|
+
return path.join(cxDir(), 'hook-calls.jsonl');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Exit-code → outcome, using the Claude Code hook convention (2 = block a tool
|
|
35
|
+
// call). Anything else non-zero is a hook that errored rather than deliberately
|
|
36
|
+
// gated, which is itself a value signal (a hook erroring every fire is noise).
|
|
37
|
+
|
|
38
|
+
export function outcomeFromExit(code) {
|
|
39
|
+
if (code === 0 || code == null) return 'ok';
|
|
40
|
+
if (code === 2) return 'blocked';
|
|
41
|
+
return 'error';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fire-and-forget log of one hook invocation.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} event
|
|
48
|
+
* @param {string} event.hookId — the hook name, e.g. "block-no-verify"
|
|
49
|
+
* @param {number} [event.exitCode] — the hook's process exit code
|
|
50
|
+
* @param {'ok'|'blocked'|'error'} [event.outcome] — derived from exitCode if omitted
|
|
51
|
+
* @param {number} [event.latencyMs]
|
|
52
|
+
* @param {object} [opts]
|
|
53
|
+
* @param {string} [opts.logPath]
|
|
54
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
55
|
+
*/
|
|
56
|
+
export function logHookCall(event, opts = {}) {
|
|
57
|
+
const env = opts.env || process.env;
|
|
58
|
+
if (env.CONSTRUCT_HOOK_TELEMETRY === 'off') return;
|
|
59
|
+
if (!event || !event.hookId) return;
|
|
60
|
+
|
|
61
|
+
const logPath = opts.logPath || defaultLogPath();
|
|
62
|
+
const outcome = event.outcome || outcomeFromExit(event.exitCode);
|
|
63
|
+
const scope = resolveProjectScope();
|
|
64
|
+
const entry = {
|
|
65
|
+
ts: new Date().toISOString(),
|
|
66
|
+
hookId: event.hookId,
|
|
67
|
+
outcome,
|
|
68
|
+
...(event.exitCode != null ? { exitCode: event.exitCode } : {}),
|
|
69
|
+
...(scope?.projectId ? { projectId: scope.projectId } : {}),
|
|
70
|
+
...(event.latencyMs != null ? { latencyMs: event.latencyMs } : {}),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
75
|
+
appendBounded('hook-calls', logPath, JSON.stringify(entry) + '\n');
|
|
76
|
+
} catch {
|
|
77
|
+
// Append-only telemetry; an unwritable home dir is a silent skip rather than
|
|
78
|
+
// a new failure mode on the hook that triggered the call.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Roll a hook-calls log up into per-hook stats: fire count, blocked/error counts,
|
|
84
|
+
* most-recent timestamp, latency percentiles. A hook with calls but zero blocks/
|
|
85
|
+
* errors over a long window is a pure observer — a demotion candidate.
|
|
86
|
+
*/
|
|
87
|
+
export function summarizeHookCalls({ logPath = defaultLogPath(), since } = {}) {
|
|
88
|
+
if (!fs.existsSync(logPath)) return { totalEvents: 0, hooks: {} };
|
|
89
|
+
const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean);
|
|
90
|
+
const sinceTs = since ? _parseSince(since) : null;
|
|
91
|
+
const hooks = {};
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
let entry;
|
|
94
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
95
|
+
if (!entry?.hookId) continue;
|
|
96
|
+
if (sinceTs && entry.ts && entry.ts < sinceTs) continue;
|
|
97
|
+
const slot = hooks[entry.hookId] ||= { calls: 0, blocked: 0, errors: 0, latencies: [], lastCalledAt: null };
|
|
98
|
+
slot.calls += 1;
|
|
99
|
+
if (entry.outcome === 'blocked') slot.blocked += 1;
|
|
100
|
+
if (entry.outcome === 'error') slot.errors += 1;
|
|
101
|
+
if (entry.latencyMs != null) slot.latencies.push(entry.latencyMs);
|
|
102
|
+
if (entry.ts && (!slot.lastCalledAt || entry.ts > slot.lastCalledAt)) slot.lastCalledAt = entry.ts;
|
|
103
|
+
}
|
|
104
|
+
const result = {};
|
|
105
|
+
for (const [id, slot] of Object.entries(hooks)) {
|
|
106
|
+
result[id] = {
|
|
107
|
+
calls: slot.calls,
|
|
108
|
+
blocked: slot.blocked,
|
|
109
|
+
errors: slot.errors,
|
|
110
|
+
lastCalledAt: slot.lastCalledAt,
|
|
111
|
+
...(slot.latencies.length ? _latencyStats(slot.latencies) : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { totalEvents: lines.length, hooks: result };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Hooks with zero fire events in the window — they cost a settings entry and a
|
|
119
|
+
* spawn budget but have never run, so they are consolidation candidates.
|
|
120
|
+
*/
|
|
121
|
+
export function findIdleHooks({ logPath = defaultLogPath(), since = '30d', allHookIds = [] } = {}) {
|
|
122
|
+
const { hooks } = summarizeHookCalls({ logPath, since });
|
|
123
|
+
const active = new Set(Object.keys(hooks));
|
|
124
|
+
return allHookIds.filter((id) => !active.has(id));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _parseSince(since) {
|
|
128
|
+
const m = String(since).match(/^(\d+)(d|h|m)$/);
|
|
129
|
+
if (!m) return null;
|
|
130
|
+
const ms = parseInt(m[1], 10) * ({ d: 86400000, h: 3600000, m: 60000 }[m[2]]);
|
|
131
|
+
return new Date(Date.now() - ms).toISOString();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _latencyStats(arr) {
|
|
135
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
136
|
+
const p = (pct) => sorted[Math.floor((pct / 100) * (sorted.length - 1))];
|
|
137
|
+
return { p50LatencyMs: p(50), p95LatencyMs: p(95) };
|
|
138
|
+
}
|
|
@@ -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.1.0",
|
|
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
|
}
|
package/personas/construct.md
CHANGED
|
@@ -62,7 +62,7 @@ The active profile (`construct profile show`) sets the intake taxonomy. Session-
|
|
|
62
62
|
|
|
63
63
|
Lead with the answer. One question when blocked. Confirm what changed when done.
|
|
64
64
|
|
|
65
|
-
**Output style**: format human-facing output (terminal, prose, dashboard) for neurodivergent readers — answer first, clear hierarchy,
|
|
65
|
+
**Output style**: format human-facing output (terminal, prose, dashboard) for neurodivergent readers — answer first, clear hierarchy, plain language, explicit next step. Prose for reasoning; lists only for genuinely parallel items where scanning helps, never a wall of bullets. Never rely on color or motion alone; honor `NO_COLOR` and reduced-motion. Presentation only — never reshape machine-readable output (`--json`, parsed tokens, registries, contracts). See `rules/common/neurodivergent-output.md`.
|
|
66
66
|
|
|
67
67
|
**Tool invisibility**: deliverables are about the user's project, never Construct. Never name Construct, `cx-*` role ids, or internal orchestration mechanics in artifact content unless the subject project is Construct itself. Provenance goes in a comment, not the prose. See `rules/common/tool-invisibility.md`.
|
|
68
68
|
|