@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.
Files changed (106) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +189 -207
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/beads-client.mjs +9 -0
  7. package/lib/beads-optimistic.mjs +23 -71
  8. package/lib/bridges/copilot-proxy.mjs +116 -0
  9. package/lib/cli-commands.mjs +5 -1
  10. package/lib/comment-lint.mjs +1 -1
  11. package/lib/config/schema.mjs +1 -1
  12. package/lib/document-extract/docling-client.mjs +16 -6
  13. package/lib/document-extract/docling-sidecar.py +32 -2
  14. package/lib/document-extract.mjs +37 -10
  15. package/lib/document-ingest.mjs +90 -5
  16. package/lib/embed/inbox.mjs +6 -3
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/embed/roadmap.mjs +16 -1
  20. package/lib/engine/consolidate.mjs +160 -3
  21. package/lib/engine/contradiction-judge.mjs +71 -0
  22. package/lib/engine/contradiction.mjs +74 -0
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/session-reflect.mjs +5 -1
  25. package/lib/host-capabilities.mjs +30 -0
  26. package/lib/ingest/docling-remote.mjs +90 -0
  27. package/lib/ingest/strategy.mjs +1 -1
  28. package/lib/init-unified.mjs +9 -13
  29. package/lib/intake/git-queue.mjs +195 -0
  30. package/lib/intake/queue.mjs +9 -16
  31. package/lib/logging/rotate.mjs +18 -0
  32. package/lib/mcp/server.mjs +124 -12
  33. package/lib/mcp/tool-budget.mjs +53 -0
  34. package/lib/mcp/tools/storage.mjs +2 -3
  35. package/lib/mcp-catalog.json +4 -4
  36. package/lib/mcp-manager.mjs +59 -3
  37. package/lib/observation-store.mjs +38 -166
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration/runtime.mjs +3 -2
  43. package/lib/orchestration-policy.mjs +41 -6
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +12 -8
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reconcile/index.mjs +0 -2
  48. package/lib/reflect/extractor.mjs +14 -1
  49. package/lib/reflect/salience.mjs +65 -0
  50. package/lib/rules-delivery.mjs +122 -0
  51. package/lib/runtime/uv-bootstrap.mjs +32 -17
  52. package/lib/service-manager.mjs +79 -259
  53. package/lib/setup.mjs +44 -425
  54. package/lib/specialists/prompt-schema.mjs +162 -0
  55. package/lib/specialists/scaffold.mjs +109 -0
  56. package/lib/status.mjs +3 -6
  57. package/lib/storage/admin.mjs +48 -325
  58. package/lib/storage/backend.mjs +10 -57
  59. package/lib/storage/embeddings-engine.mjs +19 -5
  60. package/lib/storage/hybrid-query.mjs +15 -196
  61. package/lib/storage/sync.mjs +36 -177
  62. package/lib/storage/vector-client.mjs +256 -235
  63. package/lib/strategy-store.mjs +35 -286
  64. package/lib/telemetry/beads-fallback.mjs +40 -0
  65. package/lib/telemetry/hook-calls.mjs +138 -0
  66. package/lib/worker/entrypoint.mjs +6 -14
  67. package/package.json +6 -5
  68. package/personas/construct.md +1 -1
  69. package/platforms/capabilities.json +76 -0
  70. package/platforms/claude/settings.template.json +0 -7
  71. package/platforms/opencode/sync-config.mjs +121 -25
  72. package/rules/common/neurodivergent-output.md +1 -1
  73. package/rules/web/coding-style.md +8 -0
  74. package/rules/web/design-quality.md +8 -0
  75. package/rules/web/hooks.md +8 -0
  76. package/rules/web/patterns.md +8 -0
  77. package/rules/web/performance.md +8 -0
  78. package/rules/web/security.md +8 -0
  79. package/rules/web/testing.md +8 -0
  80. package/scripts/sync-specialists.mjs +174 -40
  81. package/specialists/prompts/cx-architect.md +20 -0
  82. package/specialists/prompts/cx-qa.md +1 -1
  83. package/specialists/prompts/cx-test-automation.md +12 -0
  84. package/specialists/registry.json +0 -8
  85. package/templates/docs/construct_guide.md +1 -1
  86. package/db/schema/001_init.sql +0 -40
  87. package/db/schema/002_pgvector.sql +0 -182
  88. package/db/schema/003_intake.sql +0 -47
  89. package/db/schema/003_observation_reconciliation.sql +0 -14
  90. package/db/schema/004_recommendations.sql +0 -46
  91. package/db/schema/005_strategy.sql +0 -21
  92. package/db/schema/006_graph.sql +0 -24
  93. package/db/schema/007_tags.sql +0 -30
  94. package/db/schema/008_skill_usage.sql +0 -24
  95. package/db/schema/009_scheduler.sql +0 -14
  96. package/db/schema/010_cx_scores.sql +0 -51
  97. package/lib/intake/postgres-queue.mjs +0 -240
  98. package/lib/reconcile/postgres-namespace.mjs +0 -102
  99. package/lib/services/local-postgres.mjs +0 -15
  100. package/lib/storage/backup.mjs +0 -347
  101. package/lib/storage/migrations.mjs +0 -187
  102. package/lib/storage/postgres-backup.mjs +0 -124
  103. package/lib/storage/sql-store.mjs +0 -45
  104. package/lib/storage/store-version.mjs +0 -115
  105. package/lib/storage/unified-storage.mjs +0 -550
  106. 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
  }
@@ -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 { 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.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
- "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
  }
@@ -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, scannable chunks, plain language, explicit next step; 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`.
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