@geraldmaron/construct 1.0.20 → 1.0.23

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 (124) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +194 -16
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +44 -16
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +22 -0
  11. package/lib/decisions/enforced-baseline.json +3 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/document-ingest.mjs +13 -4
  16. package/lib/embed/daemon.mjs +1 -1
  17. package/lib/embed/inbox.mjs +25 -7
  18. package/lib/embedded-contract/triage.mjs +24 -2
  19. package/lib/embedded-contract/workflow-invoke.mjs +21 -0
  20. package/lib/features.mjs +11 -11
  21. package/lib/git-hooks-path.mjs +61 -0
  22. package/lib/home-namespace.mjs +60 -0
  23. package/lib/hooks/ci-status-check.mjs +62 -40
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/stop-notify.mjs +32 -17
  27. package/lib/hooks/stop-typecheck.mjs +7 -2
  28. package/lib/host-capabilities.mjs +24 -8
  29. package/lib/host-disposition.mjs +76 -0
  30. package/lib/ingest/provider-extract.mjs +1 -1
  31. package/lib/ingest/strategy.mjs +35 -3
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intent-classifier.mjs +1 -1
  38. package/lib/knowledge/search.mjs +52 -3
  39. package/lib/mcp/server.mjs +57 -14
  40. package/lib/mcp/tools/memory.mjs +2 -2
  41. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  42. package/lib/model-registry.mjs +40 -33
  43. package/lib/observation-store.mjs +6 -2
  44. package/lib/opencode-config.mjs +1 -1
  45. package/lib/orchestration/events.mjs +66 -0
  46. package/lib/orchestration/run-store-postgres.mjs +85 -0
  47. package/lib/orchestration/run-store-sqlite.mjs +122 -0
  48. package/lib/orchestration/runtime.mjs +188 -50
  49. package/lib/orchestration/store.mjs +102 -0
  50. package/lib/orchestration/worker.mjs +215 -0
  51. package/lib/orchestration-policy.mjs +27 -3
  52. package/lib/parity.mjs +80 -26
  53. package/lib/policy/unified-gates.mjs +96 -0
  54. package/lib/project-init-shared.mjs +0 -173
  55. package/lib/reconcile/adapter-prune.mjs +105 -0
  56. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  57. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  58. package/lib/reconcile/index.mjs +171 -0
  59. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  60. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  61. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  62. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  63. package/lib/reconcile/postgres-namespace.mjs +102 -0
  64. package/lib/runtime/uv-bootstrap.mjs +27 -3
  65. package/lib/schema-infer.mjs +16 -2
  66. package/lib/server/csrf.mjs +14 -2
  67. package/lib/server/index.mjs +95 -0
  68. package/lib/service-manager.mjs +39 -14
  69. package/lib/setup-prompts.mjs +2 -1
  70. package/lib/setup.mjs +165 -141
  71. package/lib/storage/backend.mjs +14 -0
  72. package/lib/storage/unified-storage.mjs +550 -0
  73. package/lib/template-registry.mjs +73 -0
  74. package/lib/term-format.mjs +75 -0
  75. package/lib/uninstall/uninstall.mjs +180 -7
  76. package/package.json +2 -2
  77. package/personas/construct.md +7 -8
  78. package/platforms/claude/settings.template.json +30 -4
  79. package/platforms/opencode/config.template.json +2 -2
  80. package/rules/common/neurodivergent-output.md +66 -0
  81. package/rules/common/tool-invisibility.md +37 -0
  82. package/scripts/sync-specialists.mjs +381 -95
  83. package/skills/operating/orchestration-reference.md +2 -16
  84. package/specialists/policy-inventory.json +14 -0
  85. package/specialists/prompts/cx-accessibility.md +2 -6
  86. package/specialists/prompts/cx-ai-engineer.md +0 -4
  87. package/specialists/prompts/cx-architect.md +3 -5
  88. package/specialists/prompts/cx-business-strategist.md +0 -5
  89. package/specialists/prompts/cx-data-analyst.md +0 -4
  90. package/specialists/prompts/cx-data-engineer.md +0 -4
  91. package/specialists/prompts/cx-debugger.md +2 -6
  92. package/specialists/prompts/cx-designer.md +0 -8
  93. package/specialists/prompts/cx-devil-advocate.md +2 -2
  94. package/specialists/prompts/cx-docs-keeper.md +0 -13
  95. package/specialists/prompts/cx-engineer.md +0 -13
  96. package/specialists/prompts/cx-evaluator.md +2 -2
  97. package/specialists/prompts/cx-explorer.md +4 -5
  98. package/specialists/prompts/cx-legal-compliance.md +4 -5
  99. package/specialists/prompts/cx-operations.md +0 -5
  100. package/specialists/prompts/cx-orchestrator.md +0 -4
  101. package/specialists/prompts/cx-platform-engineer.md +0 -8
  102. package/specialists/prompts/cx-product-manager.md +0 -8
  103. package/specialists/prompts/cx-qa.md +2 -11
  104. package/specialists/prompts/cx-rd-lead.md +0 -5
  105. package/specialists/prompts/cx-release-manager.md +0 -8
  106. package/specialists/prompts/cx-researcher.md +5 -29
  107. package/specialists/prompts/cx-reviewer.md +2 -6
  108. package/specialists/prompts/cx-security.md +2 -11
  109. package/specialists/prompts/cx-sre.md +0 -15
  110. package/specialists/prompts/cx-test-automation.md +0 -4
  111. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  112. package/specialists/prompts/cx-ux-researcher.md +0 -4
  113. package/specialists/registry.json +29 -29
  114. package/templates/distribution/run.mjs +36 -7
  115. package/templates/docs/accessibility-audit.md +56 -0
  116. package/templates/docs/architecture-review.md +59 -0
  117. package/templates/docs/code-review-report.md +46 -0
  118. package/templates/docs/construct_guide.md +14 -14
  119. package/templates/docs/debug-investigation.md +53 -0
  120. package/templates/docs/qa-report.md +48 -0
  121. package/templates/docs/security-audit-report.md +48 -0
  122. package/templates/docs/task-packet.md +49 -0
  123. package/templates/docs/verdict.md +40 -0
  124. package/lib/server/static/index.html +0 -1
@@ -20,17 +20,14 @@ import path from 'node:path';
20
20
  * 1. Environment variables (CX_MODEL_*): Emergency overrides
21
21
  * 2. User config (~/.construct/config.env): Personal defaults
22
22
  * 3. Registry (specialists/registry.json): Project recommendations
23
- * 4. Built-in defaults: Last resort fallbacks
24
- *
25
- * Primary resolution path. All code must use this function.
23
+ *
24
+ * Construct ships with NO implicit model defaults (ADR-0027,
25
+ * tests/model-router-no-defaults.test.mjs). When none of the three sources
26
+ * resolves a tier, that tier returns null with source 'not configured'.
27
+ * Callers must surface a clear remediation hint rather than silently
28
+ * substituting a vendor-specific default.
26
29
  */
27
30
 
28
- const BUILTIN_DEFAULTS = {
29
- reasoning: 'anthropic/claude-3-5-sonnet',
30
- standard: 'anthropic/claude-3-haiku',
31
- fast: 'anthropic/claude-3-haiku',
32
- };
33
-
34
31
  const ENV_PREFIX = 'CX_MODEL';
35
32
 
36
33
  // ---------------------------------------------------------------------------
@@ -86,28 +83,30 @@ export function resolveModelTiers(options = {}) {
86
83
  }
87
84
  }
88
85
 
89
- // Built-in default
90
- result[tier] = BUILTIN_DEFAULTS[tier];
91
- sources[tier] = 'default';
86
+ // No source resolved this tier — leave null + 'not configured' so callers
87
+ // can surface a clear remediation hint. Aligns with the wired path
88
+ // (lib/model-router.mjs:resolveTierAssignments) and the no-defaults
89
+ // contract locked in by tests/model-router-no-defaults.test.mjs.
90
+
91
+ result[tier] = null;
92
+ sources[tier] = 'not configured';
92
93
  }
93
-
94
- // Validation
94
+
95
95
  const configured = Object.values(result).filter(Boolean).length;
96
-
96
+
97
97
  if (strict && configured < 3) {
98
98
  const missing = Object.entries(result)
99
99
  .filter(([, v]) => !v)
100
100
  .map(([k]) => k);
101
101
  errors.push(`Missing configuration for tiers: ${missing.join(', ')}`);
102
102
  }
103
-
103
+
104
104
  return {
105
105
  models: result,
106
106
  sources,
107
107
  configured,
108
108
  complete: configured === 3,
109
109
  errors: errors.length > 0 ? errors : null,
110
- defaultsUsed: Object.values(sources).filter(s => s === 'default').length,
111
110
  };
112
111
  }
113
112
 
@@ -117,7 +116,7 @@ export function resolveModelTiers(options = {}) {
117
116
 
118
117
  export function getModelForTier(tier, options = {}) {
119
118
  const resolved = resolveModelTiers(options);
120
- return resolved.models[tier] || BUILTIN_DEFAULTS[tier];
119
+ return resolved.models[tier];
121
120
  }
122
121
 
123
122
  export function getModelSource(tier, options = {}) {
@@ -131,12 +130,15 @@ export function getModelSource(tier, options = {}) {
131
130
 
132
131
  export function validateModelTiers(options = {}) {
133
132
  const resolved = resolveModelTiers({ ...options, strict: true });
134
-
133
+ const unconfigured = Object.entries(resolved.sources)
134
+ .filter(([, source]) => source === 'not configured')
135
+ .map(([tier]) => tier);
136
+
135
137
  return {
136
138
  valid: resolved.complete && !resolved.errors,
137
139
  errors: resolved.errors,
138
- warnings: resolved.defaultsUsed > 0
139
- ? [`Using ${resolved.defaultsUsed} built-in default(s). Consider configuring custom models.`]
140
+ warnings: unconfigured.length > 0
141
+ ? [`Unconfigured tier${unconfigured.length === 1 ? '' : 's'}: ${unconfigured.join(', ')}. Run 'construct models --apply' or set CX_MODEL_<TIER>.`]
140
142
  : [],
141
143
  resolution: resolved,
142
144
  };
@@ -144,25 +146,30 @@ export function validateModelTiers(options = {}) {
144
146
 
145
147
  export function formatModelStatus(options = {}) {
146
148
  const resolved = resolveModelTiers(options);
147
-
149
+
148
150
  let output = 'Model Configuration:\n\n';
149
-
151
+
150
152
  for (const tier of ['reasoning', 'standard', 'fast']) {
151
153
  const model = resolved.models[tier];
152
154
  const source = resolved.sources[tier];
153
- const icon = source === 'default' ? '⚠' : '✓';
154
-
155
- output += `${icon} ${tier.padEnd(10)} ${model}\n`;
155
+ const icon = source === 'not configured' ? '⚠' : '✓';
156
+ const display = model ?? '(not configured)';
157
+
158
+ output += `${icon} ${tier.padEnd(10)} ${display}\n`;
156
159
  output += ` Source: ${source}\n\n`;
157
160
  }
158
-
159
- if (resolved.defaultsUsed > 0) {
160
- output += '\nNote: Using built-in defaults. To customize:\n';
161
- output += ' export CX_MODEL_REASONING=your-model\n';
162
- output += ' export CX_MODEL_STANDARD=your-model\n';
163
- output += ' export CX_MODEL_FAST=your-model\n';
161
+
162
+ const unconfigured = Object.entries(resolved.sources)
163
+ .filter(([, source]) => source === 'not configured')
164
+ .map(([tier]) => tier);
165
+ if (unconfigured.length > 0) {
166
+ output += `\nUnconfigured: ${unconfigured.join(', ')}. To configure:\n`;
167
+ output += ' construct models --apply (interactive, picks from available providers)\n';
168
+ output += ' export CX_MODEL_REASONING=... (env override)\n';
169
+ output += ' export CX_MODEL_STANDARD=...\n';
170
+ output += ' export CX_MODEL_FAST=...\n';
164
171
  }
165
-
172
+
166
173
  return output;
167
174
  }
168
175
 
@@ -238,8 +238,12 @@ export async function addObservation(rootDir, {
238
238
  return record;
239
239
  }
240
240
  await client.close();
241
- } catch {
242
- // Fall through to local vector storage
241
+ } catch (err) {
242
+ // A reachable backend that still fails to store (dimension drift, auth, transient
243
+ // network) must not look identical to "no backend configured" — surface it, then
244
+ // preserve the observation in the local index so the write never silently vanishes.
245
+
246
+ process.stderr.write(`[observation-store] SQL store failed; kept observation in local index: ${err?.message || err}\n`);
243
247
  }
244
248
 
245
249
  // Fallback: local JSON vector index with hashing-bow-v1.
@@ -48,7 +48,7 @@ export function sanitizeOpenCodeConfig(config) {
48
48
 
49
49
  export function writeOpenCodeConfig(config, file = findOpenCodeConfigPath()) {
50
50
  // Treat the canonical home config as the default target; any explicit path
51
- // outside the canonical home dir (e.g. project-scoped .opencode/config.json)
51
+ // outside the canonical home dir (e.g. project-scoped .opencode/opencode.json)
52
52
  // is honored as-is so per-project sync writes to the right tree.
53
53
 
54
54
  const canonical = getCanonicalOpenCodeConfigPath();
@@ -0,0 +1,66 @@
1
+ /**
2
+ * lib/orchestration/events.mjs — in-process run lifecycle event bus.
3
+ *
4
+ * The orchestration runtime persists runs to a store and appends durable JSONL
5
+ * traces, but has no live pub/sub a server can stream from. This is that
6
+ * primitive: a process-local EventEmitter keyed by runId so an HTTP SSE handler
7
+ * can subscribe to a single run's transitions (planned → running → per-task →
8
+ * completed) and forward them to a thin client. Events are lightweight status
9
+ * deltas — never task output or any credential — so the stream stays cheap and
10
+ * secret-free; clients fetch full run records (envelope-guarded) for outputs.
11
+ *
12
+ * Process-local by design: it streams runs executing in THIS process. Durable,
13
+ * cross-process history lives in the run store and the trace log.
14
+ */
15
+
16
+ import { EventEmitter } from 'node:events';
17
+
18
+ const bus = new EventEmitter();
19
+ bus.setMaxListeners(0);
20
+
21
+ function channel(runId) {
22
+ return `run:${runId}`;
23
+ }
24
+
25
+ /**
26
+ * Emit a run lifecycle event. Stamps runId + an ISO timestamp; callers pass a
27
+ * small status delta (type, status, taskId, role, executor, error).
28
+ *
29
+ * @param {string} runId
30
+ * @param {object} event
31
+ */
32
+ export function emitRunEvent(runId, event = {}) {
33
+ if (!runId) return;
34
+ bus.emit(channel(runId), { runId, at: new Date().toISOString(), ...event });
35
+ }
36
+
37
+ /**
38
+ * Subscribe to one run's events.
39
+ *
40
+ * @param {string} runId
41
+ * @param {(event:object)=>void} handler
42
+ * @returns {() => void} unsubscribe
43
+ */
44
+ export function onRunEvent(runId, handler) {
45
+ const ch = channel(runId);
46
+ bus.on(ch, handler);
47
+ return () => bus.off(ch, handler);
48
+ }
49
+
50
+ // Cooperative, between-task cancellation. executeRun checks this before each
51
+ // task and stops cleanly if set — an honest soft cancel (it cannot abort a
52
+ // model call already in flight), not a forced kill.
53
+
54
+ const cancelled = new Set();
55
+
56
+ export function requestCancel(runId) {
57
+ if (runId) cancelled.add(runId);
58
+ }
59
+
60
+ export function isCancelRequested(runId) {
61
+ return cancelled.has(runId);
62
+ }
63
+
64
+ export function clearCancel(runId) {
65
+ cancelled.delete(runId);
66
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * lib/orchestration/run-store-postgres.mjs — Postgres-backed run store (Mode-C).
3
+ *
4
+ * Mirrors lib/intake/postgres-queue.mjs: a class taking the tagged-template `sql`
5
+ * client from createSqlClient(env) (porsager/postgres style) and a project key.
6
+ * Orchestration runs persist to `construct_orchestration_runs`; triage-like
7
+ * columns (status, execution_mode) are flattened for filtering while the full run
8
+ * round-trips in `payload jsonb`. Multi-project isolation comes from the
9
+ * (run_id, project) composite primary key.
10
+ *
11
+ * Scope: the STORE is the deliverable. Container-worker spawning (Mode-C's
12
+ * optional worker topology) is out of scope (ADR-0021); a Postgres run store lets
13
+ * a team/enterprise deployment share durable runs across processes today.
14
+ */
15
+
16
+ export class PostgresRunStore {
17
+ constructor({ sql, project } = {}) {
18
+ if (!sql) throw new Error('PostgresRunStore: sql client is required');
19
+ if (!project) throw new Error('PostgresRunStore: project is required');
20
+ this.sql = sql;
21
+ this.project = project;
22
+ }
23
+
24
+ async ensureSchema() {
25
+ await this.sql`
26
+ CREATE TABLE IF NOT EXISTS construct_orchestration_runs (
27
+ run_id TEXT,
28
+ project TEXT,
29
+ created_at TEXT,
30
+ status TEXT,
31
+ execution_mode TEXT,
32
+ payload JSONB,
33
+ PRIMARY KEY (run_id, project)
34
+ )
35
+ `;
36
+ }
37
+
38
+ async saveRun(run) {
39
+ if (!run?.runId) throw new Error('saveRun: run.runId is required');
40
+ await this.sql`
41
+ INSERT INTO construct_orchestration_runs (
42
+ run_id, project, created_at, status, execution_mode, payload
43
+ ) VALUES (
44
+ ${run.runId}, ${this.project}, ${run.createdAt || null},
45
+ ${run.status || null}, ${run.execution?.executionMode || null},
46
+ ${this.sql.json(run)}
47
+ )
48
+ ON CONFLICT (run_id, project) DO UPDATE SET
49
+ created_at = EXCLUDED.created_at,
50
+ status = EXCLUDED.status,
51
+ execution_mode = EXCLUDED.execution_mode,
52
+ payload = EXCLUDED.payload
53
+ `;
54
+ return run;
55
+ }
56
+
57
+ async loadRun(runId) {
58
+ if (!runId) return null;
59
+ const rows = await this.sql`
60
+ SELECT payload FROM construct_orchestration_runs
61
+ WHERE run_id = ${runId} AND project = ${this.project}
62
+ LIMIT 1
63
+ `;
64
+ return rows[0]?.payload || null;
65
+ }
66
+
67
+ async listRuns({ limit = 20 } = {}) {
68
+ const rows = await this.sql`
69
+ SELECT payload FROM construct_orchestration_runs
70
+ WHERE project = ${this.project}
71
+ ORDER BY created_at DESC
72
+ LIMIT ${limit}
73
+ `;
74
+ return rows.map((row) => {
75
+ const run = row.payload || {};
76
+ return {
77
+ runId: run.runId,
78
+ status: run.status,
79
+ executionMode: run.execution?.executionMode || null,
80
+ createdAt: run.createdAt,
81
+ request: run.request?.summary || null,
82
+ };
83
+ });
84
+ }
85
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * lib/orchestration/run-store-sqlite.mjs — SQLite-backed run store (Mode-B).
3
+ *
4
+ * Same saveRun/loadRun/listRuns surface as the filesystem store (run-store.mjs),
5
+ * backed by a single `node:sqlite` database under
6
+ * `.cx/runtime/orchestration/runs.db`. A daemon-grade local store: durable,
7
+ * single-file, queryable, with no Postgres/Docker dependency.
8
+ *
9
+ * Node compatibility boundary: `node:sqlite` exists only on Node >=22.5, but the
10
+ * CI matrix also runs Node 20. So the import is LAZY (inside a function, never at
11
+ * module top level) and `sqliteAvailable()` reports false when it throws. The
12
+ * store factory throws a structured SQLITE_UNAVAILABLE error (with remediation)
13
+ * rather than crashing, so the resolver can fall back to filesystem. Nothing on
14
+ * the default test path statically imports node:sqlite.
15
+ */
16
+
17
+ import { mkdirSync } from 'node:fs';
18
+ import { isAbsolute, join, resolve } from 'node:path';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const RUNTIME_REL = join('.cx', 'runtime', 'orchestration');
23
+
24
+ let cachedDatabaseSync;
25
+
26
+ // The node:sqlite probe is cached and synchronous. createRequire above is a
27
+ // plain static import of node:module (always present); only node:sqlite itself
28
+ // is loaded lazily here, so Node 20 never touches the unavailable builtin.
29
+
30
+ function loadDatabaseSync() {
31
+ if (cachedDatabaseSync !== undefined) return cachedDatabaseSync;
32
+ try {
33
+ cachedDatabaseSync = require('node:sqlite').DatabaseSync;
34
+ } catch {
35
+ cachedDatabaseSync = null;
36
+ }
37
+ return cachedDatabaseSync;
38
+ }
39
+
40
+ export function sqliteAvailable() {
41
+ return loadDatabaseSync() != null;
42
+ }
43
+
44
+ function projectRoot(cwd) {
45
+ return isAbsolute(cwd) ? cwd : resolve(cwd);
46
+ }
47
+
48
+ function dbPath(cwd) {
49
+ const dir = join(projectRoot(cwd), RUNTIME_REL);
50
+ mkdirSync(dir, { recursive: true });
51
+ return join(dir, 'runs.db');
52
+ }
53
+
54
+ function openDb(cwd) {
55
+ const DatabaseSync = loadDatabaseSync();
56
+ if (!DatabaseSync) {
57
+ const err = new Error('SQLite run store requires Node >=22.5 (node:sqlite is unavailable on this runtime).');
58
+ err.code = 'SQLITE_UNAVAILABLE';
59
+ err.remediation = 'Upgrade to Node >=22.5, or set orchestration.store to "filesystem" / "postgres".';
60
+ throw err;
61
+ }
62
+ const db = new DatabaseSync(dbPath(cwd));
63
+ db.exec(`CREATE TABLE IF NOT EXISTS runs (
64
+ run_id TEXT PRIMARY KEY,
65
+ created_at TEXT,
66
+ status TEXT,
67
+ execution_mode TEXT,
68
+ json TEXT
69
+ )`);
70
+ return db;
71
+ }
72
+
73
+ /**
74
+ * Build a SQLite-backed run store bound to a project directory.
75
+ *
76
+ * @param {object} opts
77
+ * @param {string} opts.cwd
78
+ * @returns {{ saveRun(run):object, loadRun(runId):object|null, listRuns(opts):Array }}
79
+ */
80
+ export function createSqliteRunStore({ cwd = process.cwd() } = {}) {
81
+ const db = openDb(cwd);
82
+ return {
83
+ saveRun(run) {
84
+ const stmt = db.prepare(`INSERT INTO runs (run_id, created_at, status, execution_mode, json)
85
+ VALUES (?, ?, ?, ?, ?)
86
+ ON CONFLICT(run_id) DO UPDATE SET
87
+ created_at = excluded.created_at,
88
+ status = excluded.status,
89
+ execution_mode = excluded.execution_mode,
90
+ json = excluded.json`);
91
+ stmt.run(run.runId, run.createdAt || null, run.status || null, run.execution?.executionMode || null, JSON.stringify(run));
92
+ return run;
93
+ },
94
+ loadRun(runId) {
95
+ if (!runId) return null;
96
+ const row = db.prepare('SELECT json FROM runs WHERE run_id = ?').get(runId);
97
+ if (!row || !row.json) return null;
98
+ try {
99
+ return JSON.parse(row.json);
100
+ } catch {
101
+ return null;
102
+ }
103
+ },
104
+ listRuns({ limit = 20 } = {}) {
105
+ const rows = db.prepare('SELECT json FROM runs ORDER BY created_at DESC LIMIT ?').all(limit);
106
+ const out = [];
107
+ for (const row of rows) {
108
+ try {
109
+ const run = JSON.parse(row.json);
110
+ out.push({
111
+ runId: run.runId,
112
+ status: run.status,
113
+ executionMode: run.execution?.executionMode || null,
114
+ createdAt: run.createdAt,
115
+ request: run.request?.summary || null,
116
+ });
117
+ } catch { /* a corrupt row is skipped, not fatal to listing */ }
118
+ }
119
+ return out;
120
+ },
121
+ };
122
+ }