@ludi-uni/ludi-agent-kit 0.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 (172) hide show
  1. package/AGENTS.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/adapters/codex/README.md +24 -0
  5. package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
  6. package/adapters/pi/README.md +88 -0
  7. package/adapters/pi/browser/agent-browser.mjs +193 -0
  8. package/adapters/pi/lib/invoke.mjs +55 -0
  9. package/adapters/pi/lib/list-models.mjs +29 -0
  10. package/adapters/pi/lib/settings-proposal.mjs +34 -0
  11. package/adapters/pi/lib/subagent.mjs +175 -0
  12. package/adapters/pi/loop-guard/index.js +51 -0
  13. package/adapters/pi/maintenance-policy.json +36 -0
  14. package/adapters/pi/mcp.template.json +4 -0
  15. package/adapters/pi/model-catalog.json +97 -0
  16. package/adapters/pi/models.json +13 -0
  17. package/adapters/pi/models.local.example.json +14 -0
  18. package/adapters/pi/orchestrator-ext/command.mjs +14 -0
  19. package/adapters/pi/orchestrator-ext/index.js +150 -0
  20. package/adapters/pi/settings.template.json +7 -0
  21. package/adapters/pi/shell-gate/index.js +70 -0
  22. package/adapters/pi/sync-pi.ps1 +137 -0
  23. package/agents/README.md +26 -0
  24. package/agents/browser.md +64 -0
  25. package/agents/coder.md +31 -0
  26. package/agents/orchestrator.md +37 -0
  27. package/agents/reviewer.md +32 -0
  28. package/agents/scout.md +35 -0
  29. package/agents/tester.md +28 -0
  30. package/agents/visual.md +28 -0
  31. package/context-pack/SPEC.md +101 -0
  32. package/context-pack/context-pack.schema.json +79 -0
  33. package/context-pack/examples/example-fix.md +44 -0
  34. package/docs/architecture.md +55 -0
  35. package/docs/migration-from-codex-setting.md +44 -0
  36. package/docs/model-maintenance.md +401 -0
  37. package/docs/orchestrator.md +155 -0
  38. package/docs/phase2-report.md +39 -0
  39. package/docs/roadmap.md +27 -0
  40. package/docs/third-party.md +15 -0
  41. package/lib/agents.mjs +79 -0
  42. package/lib/context-pack.mjs +215 -0
  43. package/lib/job.mjs +312 -0
  44. package/lib/language-policy.mjs +27 -0
  45. package/lib/maintenance-exec.mjs +377 -0
  46. package/lib/maintenance-runner.mjs +266 -0
  47. package/lib/maintenance.mjs +422 -0
  48. package/lib/normalize.mjs +101 -0
  49. package/lib/observe/differ.mjs +185 -0
  50. package/lib/observe/observation.mjs +147 -0
  51. package/lib/observe/observers.mjs +134 -0
  52. package/lib/observe/sources.mjs +154 -0
  53. package/lib/orchestrator/activity.mjs +249 -0
  54. package/lib/orchestrator/api.mjs +151 -0
  55. package/lib/orchestrator/contract.mjs +68 -0
  56. package/lib/orchestrator/escalation.mjs +84 -0
  57. package/lib/orchestrator/evaluator.mjs +92 -0
  58. package/lib/orchestrator/failures.mjs +88 -0
  59. package/lib/orchestrator/health.mjs +53 -0
  60. package/lib/orchestrator/orchestrator.mjs +483 -0
  61. package/lib/orchestrator/permissions.mjs +64 -0
  62. package/lib/orchestrator/planner.mjs +194 -0
  63. package/lib/orchestrator/policy.mjs +134 -0
  64. package/lib/orchestrator/router.mjs +45 -0
  65. package/lib/orchestrator/runner.mjs +278 -0
  66. package/lib/orchestrator/shell-policy.mjs +52 -0
  67. package/lib/orchestrator/store.mjs +581 -0
  68. package/lib/orchestrator/task-store.mjs +79 -0
  69. package/lib/orchestrator/turn-budget.mjs +63 -0
  70. package/lib/orchestrator/worktree.mjs +72 -0
  71. package/lib/pipeline.mjs +279 -0
  72. package/lib/registry.mjs +63 -0
  73. package/lib/resolve.mjs +35 -0
  74. package/lib/routing.mjs +137 -0
  75. package/lib/telemetry.mjs +222 -0
  76. package/mcp/README.md +11 -0
  77. package/mcp/servers.json +13 -0
  78. package/orchestration/decision-policy.json +66 -0
  79. package/package.json +56 -0
  80. package/routing/README.md +24 -0
  81. package/routing/routing.json +81 -0
  82. package/routing/routing.schema.json +66 -0
  83. package/rules/README.md +10 -0
  84. package/rules/common.md +52 -0
  85. package/rules/loop-prevention.md +15 -0
  86. package/rules/repo-local.md +6 -0
  87. package/scripts/check-environment.ps1 +22 -0
  88. package/scripts/context-pack.mjs +17 -0
  89. package/scripts/e2e-investigate-repro.mjs +66 -0
  90. package/scripts/model-maintenance-job.mjs +59 -0
  91. package/scripts/observe-models.mjs +97 -0
  92. package/scripts/orchestrate.mjs +137 -0
  93. package/scripts/reevaluate-models.mjs +95 -0
  94. package/scripts/report-model-maintenance.mjs +70 -0
  95. package/scripts/resolve-capabilities.mjs +39 -0
  96. package/scripts/run-pipeline.mjs +56 -0
  97. package/scripts/sync-agents-md.ps1 +10 -0
  98. package/scripts/validate.mjs +71 -0
  99. package/skills/README.md +14 -0
  100. package/skills/pi-workflow/SKILL.md +26 -0
  101. package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
  102. package/skills/pi-workflow/references/research.md +14 -0
  103. package/skills/pi-workflow/references/review.md +11 -0
  104. package/skills/pi-workflow/references/visual-work.md +14 -0
  105. package/skills/project-management/SKILL.md +106 -0
  106. package/skills/project-management/references/operations.md +52 -0
  107. package/skills/visual-verification/SKILL.md +88 -0
  108. package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
  109. package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
  110. package/skills/visual-verification/scripts/common.ps1 +387 -0
  111. package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
  112. package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
  113. package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
  114. package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
  115. package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
  116. package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
  117. package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
  118. package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
  119. package/skills/visual-verification/scripts/record-av.ps1 +102 -0
  120. package/skills/visual-verification/scripts/record.ps1 +72 -0
  121. package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
  122. package/skills/visual-verification/scripts/waveform.ps1 +450 -0
  123. package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
  124. package/tests/activity.test.mjs +252 -0
  125. package/tests/attempt-budget.test.mjs +102 -0
  126. package/tests/browser.test.mjs +121 -0
  127. package/tests/context-pack.test.mjs +98 -0
  128. package/tests/dirty-gate.test.mjs +211 -0
  129. package/tests/e2e-browser.mjs +66 -0
  130. package/tests/e2e-real-orchestrator-resume.mjs +101 -0
  131. package/tests/e2e-real-orchestrator.mjs +41 -0
  132. package/tests/e2e-real-pi.mjs +27 -0
  133. package/tests/e2e-real-tool-orchestrator.mjs +66 -0
  134. package/tests/fixtures/browser-page/index.html +20 -0
  135. package/tests/fixtures/maintenance/availability.txt +5 -0
  136. package/tests/fixtures/maintenance/catalog.json +74 -0
  137. package/tests/fixtures/maintenance/events.json +13 -0
  138. package/tests/fixtures/math-repo/README.md +3 -0
  139. package/tests/fixtures/math-repo/package.json +7 -0
  140. package/tests/fixtures/math-repo/src/math.js +11 -0
  141. package/tests/fixtures/math-repo/test/math.test.js +7 -0
  142. package/tests/fixtures/observe/announcements.json +8 -0
  143. package/tests/fixtures/orch-concurrent-child.mjs +44 -0
  144. package/tests/fixtures/orch-persist-child.mjs +61 -0
  145. package/tests/job.test.mjs +230 -0
  146. package/tests/kit.test.mjs +79 -0
  147. package/tests/language-policy.test.mjs +93 -0
  148. package/tests/loop-guard.test.mjs +60 -0
  149. package/tests/maintenance-exec.test.mjs +218 -0
  150. package/tests/maintenance-runner.test.mjs +222 -0
  151. package/tests/maintenance.test.mjs +195 -0
  152. package/tests/observe.test.mjs +283 -0
  153. package/tests/observer-registry.test.mjs +157 -0
  154. package/tests/orchestrator-cleanup.test.mjs +358 -0
  155. package/tests/orchestrator-command.test.mjs +14 -0
  156. package/tests/orchestrator-persist.test.mjs +375 -0
  157. package/tests/orchestrator-tools.test.mjs +215 -0
  158. package/tests/orchestrator.test.mjs +396 -0
  159. package/tests/package.test.mjs +37 -0
  160. package/tests/pipeline.test.mjs +239 -0
  161. package/tests/planner-classification.test.mjs +81 -0
  162. package/tests/planner-split.test.mjs +67 -0
  163. package/tests/qoder-observer.test.mjs +266 -0
  164. package/tests/reassign-progression.test.mjs +104 -0
  165. package/tests/retry-escalation.test.mjs +120 -0
  166. package/tests/routing.test.mjs +110 -0
  167. package/tests/sqlite-concurrency.test.mjs +178 -0
  168. package/tests/task-global-e2e.test.mjs +63 -0
  169. package/tests/task-global-failed.test.mjs +134 -0
  170. package/tests/telemetry.test.mjs +173 -0
  171. package/tests/test-sync-pi.ps1 +56 -0
  172. package/tests/turn-budget.test.mjs +106 -0
@@ -0,0 +1,581 @@
1
+ // Persistent orchestration state. SQLite (node:sqlite) is the only I/O here; the orchestrator never
2
+ // sees SQL. One file holds runs, tasks, pending decisions, decision memory, backend health and trace.
3
+ // Default location is repo-local (.orchestration/state.db). Callers pass the path; nothing writes to a user profile.
4
+ import { randomBytes } from 'node:crypto';
5
+ import { mkdirSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { createMemoryTaskStore } from './task-store.mjs';
9
+
10
+ export const RUN_STATUSES = ['running', 'waiting_for_user', 'completed', 'failed', 'cancelled'];
11
+ /** Runs in these states are history; anything else is active or resumable. */
12
+ export const TERMINAL_RUN_STATUSES = ['completed', 'failed', 'cancelled'];
13
+ const TERMINAL_RUN = new Set(TERMINAL_RUN_STATUSES);
14
+ export const BUSY_TIMEOUT_MS = 5000;
15
+ const SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS runs (
17
+ id TEXT PRIMARY KEY,
18
+ request TEXT NOT NULL,
19
+ status TEXT NOT NULL,
20
+ created_at TEXT NOT NULL,
21
+ updated_at TEXT NOT NULL,
22
+ round INTEGER NOT NULL DEFAULT 0,
23
+ rework_cycles INTEGER NOT NULL DEFAULT 0,
24
+ seq INTEGER NOT NULL DEFAULT 0,
25
+ planner TEXT,
26
+ policy_snapshot TEXT NOT NULL,
27
+ counters TEXT NOT NULL,
28
+ repo_root TEXT,
29
+ scope_key TEXT NOT NULL
30
+ );
31
+ CREATE TABLE IF NOT EXISTS run_owners (
32
+ run_id TEXT PRIMARY KEY,
33
+ pid INTEGER NOT NULL,
34
+ token TEXT NOT NULL
35
+ );
36
+ CREATE TABLE IF NOT EXISTS tasks (
37
+ run_id TEXT NOT NULL,
38
+ id TEXT NOT NULL,
39
+ status TEXT NOT NULL,
40
+ updated_at TEXT NOT NULL,
41
+ payload TEXT NOT NULL,
42
+ PRIMARY KEY (run_id, id)
43
+ );
44
+ CREATE TABLE IF NOT EXISTS decisions (
45
+ id TEXT PRIMARY KEY,
46
+ run_id TEXT NOT NULL,
47
+ task_id TEXT NOT NULL,
48
+ question TEXT NOT NULL,
49
+ options_json TEXT,
50
+ reason TEXT,
51
+ escalation_type TEXT,
52
+ flags_json TEXT,
53
+ status TEXT NOT NULL,
54
+ answer TEXT,
55
+ decision_key TEXT,
56
+ recommended TEXT,
57
+ created_at TEXT NOT NULL,
58
+ answered_at TEXT
59
+ );
60
+ CREATE TABLE IF NOT EXISTS decision_memory (
61
+ id TEXT PRIMARY KEY,
62
+ scope TEXT NOT NULL,
63
+ scope_key TEXT,
64
+ decision_key TEXT NOT NULL,
65
+ decision_json TEXT NOT NULL,
66
+ rationale TEXT,
67
+ confidence REAL,
68
+ created_at TEXT NOT NULL,
69
+ updated_at TEXT NOT NULL,
70
+ expires_at TEXT
71
+ );
72
+ CREATE UNIQUE INDEX IF NOT EXISTS decision_memory_lookup
73
+ ON decision_memory(scope, ifnull(scope_key, ''), decision_key);
74
+ CREATE TABLE IF NOT EXISTS backend_health (
75
+ provider TEXT NOT NULL,
76
+ model TEXT NOT NULL,
77
+ run_id TEXT NOT NULL,
78
+ state TEXT NOT NULL,
79
+ reason TEXT,
80
+ detected_at TEXT NOT NULL,
81
+ retry_after TEXT,
82
+ expires_at TEXT NOT NULL,
83
+ PRIMARY KEY (provider, model, run_id)
84
+ );
85
+ CREATE TABLE IF NOT EXISTS trace (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ run_id TEXT NOT NULL,
88
+ at TEXT NOT NULL,
89
+ round INTEGER,
90
+ type TEXT NOT NULL,
91
+ payload TEXT NOT NULL
92
+ );
93
+ CREATE TABLE IF NOT EXISTS protocol_stats (
94
+ provider TEXT NOT NULL,
95
+ model TEXT NOT NULL,
96
+ malformed INTEGER NOT NULL DEFAULT 0,
97
+ empty INTEGER NOT NULL DEFAULT 0,
98
+ turn_limit INTEGER NOT NULL DEFAULT 0,
99
+ structured_ok INTEGER NOT NULL DEFAULT 0,
100
+ updated_at TEXT NOT NULL,
101
+ PRIMARY KEY (provider, model)
102
+ );
103
+ `;
104
+
105
+ export function newId(prefix) {
106
+ return `${prefix}-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
107
+ }
108
+
109
+ const parse = (text, fallback) => { try { return JSON.parse(text); } catch { return fallback; } };
110
+
111
+ function mapRun(row) {
112
+ if (!row) return null;
113
+ return {
114
+ id: row.id, request: row.request, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at,
115
+ round: row.round, reworkCycles: row.rework_cycles, seq: row.seq, planner: row.planner,
116
+ policySnapshot: parse(row.policy_snapshot, {}), counters: parse(row.counters, {}),
117
+ repoRoot: row.repo_root, scopeKey: row.scope_key,
118
+ };
119
+ }
120
+
121
+ function mapDecision(row) {
122
+ return {
123
+ id: row.id, runId: row.run_id, taskId: row.task_id, question: row.question,
124
+ options: parse(row.options_json, []), reason: row.reason, escalationType: row.escalation_type,
125
+ flags: parse(row.flags_json, []), status: row.status, answer: row.answer == null ? undefined : parse(row.answer, row.answer),
126
+ key: row.decision_key, recommended: row.recommended, createdAt: row.created_at, answeredAt: row.answered_at ?? undefined,
127
+ };
128
+ }
129
+
130
+ function mapMemory(row) {
131
+ return {
132
+ id: row.id, scope: row.scope, scopeKey: row.scope_key ?? undefined, key: row.decision_key,
133
+ decision: parse(row.decision_json, {}), rationale: row.rationale ?? undefined,
134
+ confidence: row.confidence ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at,
135
+ expiresAt: row.expires_at ?? undefined,
136
+ };
137
+ }
138
+
139
+ function mapHealth(row) {
140
+ return {
141
+ provider: row.provider, model: row.model || undefined, state: row.state, reason: row.reason ?? undefined,
142
+ detectedAt: row.detected_at, retryAfter: row.retry_after ?? undefined, expiresAt: row.expires_at, runId: row.run_id || undefined,
143
+ };
144
+ }
145
+
146
+ export function openStore(storePath, { now = () => new Date().toISOString() } = {}) {
147
+ mkdirSync(dirname(storePath), { recursive: true });
148
+ const db = new DatabaseSync(storePath);
149
+ db.exec('PRAGMA journal_mode = WAL');
150
+ db.exec('PRAGMA synchronous = FULL');
151
+ // Bounded wait on SQLITE_BUSY so a second writer (another orchestrate process,
152
+ // pi-web) does not make this one throw immediately and leave a zombie run.
153
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
154
+ db.exec(SCHEMA);
155
+ let depth = 0;
156
+ const transaction = fn => {
157
+ if (depth > 0) return fn();
158
+ depth++;
159
+ db.exec('BEGIN IMMEDIATE');
160
+ try {
161
+ const result = fn();
162
+ db.exec('COMMIT');
163
+ depth--;
164
+ return result;
165
+ } catch (e) {
166
+ depth--;
167
+ try { db.exec('ROLLBACK'); } catch { /* already closed */ }
168
+ throw e;
169
+ }
170
+ };
171
+
172
+ function saveTask(runId, task) {
173
+ const updatedAt = task.updatedAt ?? now();
174
+ transaction(() => {
175
+ db.prepare(`INSERT INTO tasks (run_id, id, status, updated_at, payload) VALUES (?, ?, ?, ?, ?)
176
+ ON CONFLICT(run_id, id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at, payload = excluded.payload`)
177
+ .run(runId, task.id, task.status, updatedAt, JSON.stringify(task));
178
+ });
179
+ }
180
+
181
+ function loadTasks(runId) {
182
+ return db.prepare('SELECT payload FROM tasks WHERE run_id = ? ORDER BY rowid').all(runId).map(r => parse(r.payload, null)).filter(Boolean);
183
+ }
184
+
185
+ /**
186
+ * Classification for history cleanup. A run is deletable only when it is terminal
187
+ * (completed/failed/cancelled) AND has no pending decisions — pending decisions make
188
+ * it resumable even if the status column was somehow forced to a terminal value.
189
+ */
190
+ function cleanupInfo(row) {
191
+ const run = mapRun(row);
192
+ const pending = db.prepare(`SELECT count(*) AS n FROM decisions WHERE run_id = ? AND status = 'pending'`).get(run.id).n;
193
+ const runningTasks = db.prepare(`SELECT count(*) AS n FROM tasks WHERE run_id = ? AND status = 'running'`).get(run.id).n;
194
+ const terminal = TERMINAL_RUN.has(run.status);
195
+ const deletable = terminal && pending === 0 && runningTasks === 0;
196
+ return {
197
+ id: run.id, request: run.request, status: run.status, createdAt: run.createdAt, updatedAt: run.updatedAt,
198
+ counts: {
199
+ tasks: db.prepare('SELECT count(*) AS n FROM tasks WHERE run_id = ?').get(run.id).n,
200
+ decisions: db.prepare('SELECT count(*) AS n FROM decisions WHERE run_id = ?').get(run.id).n,
201
+ trace: db.prepare('SELECT count(*) AS n FROM trace WHERE run_id = ?').get(run.id).n,
202
+ health: db.prepare('SELECT count(*) AS n FROM backend_health WHERE run_id = ?').get(run.id).n,
203
+ pendingDecisions: pending, runningTasks,
204
+ },
205
+ deletable,
206
+ reason: deletable ? null : !terminal ? `active/resumable (${run.status})` : runningTasks ? `active (${runningTasks} running task${runningTasks === 1 ? '' : 's'})` : `resumable (${pending} pending decision${pending === 1 ? '' : 's'})`,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Every row owned by one run: tasks, decisions, trace and RUN-LOCAL backend health.
212
+ * decision_memory and protocol_stats are global and are never touched; backend_health
213
+ * rows with run_id = '' (e.g. usage_exhausted) are global too and survive.
214
+ */
215
+ function deleteRunRows(id) {
216
+ db.prepare('DELETE FROM run_owners WHERE run_id = ?').run(id);
217
+ db.prepare('DELETE FROM trace WHERE run_id = ?').run(id);
218
+ db.prepare('DELETE FROM decisions WHERE run_id = ?').run(id);
219
+ db.prepare('DELETE FROM tasks WHERE run_id = ?').run(id);
220
+ db.prepare('DELETE FROM backend_health WHERE run_id = ?').run(id);
221
+ db.prepare('DELETE FROM runs WHERE id = ?').run(id);
222
+ }
223
+
224
+ const session = {
225
+ path: storePath,
226
+ now,
227
+ close() { db.close(); },
228
+ transaction,
229
+ /** Current SQLite pragmas relevant to concurrency (for tests / diagnostics). */
230
+ pragmas() {
231
+ return {
232
+ journalMode: db.prepare('PRAGMA journal_mode').get().journal_mode,
233
+ busyTimeoutMs: db.prepare('PRAGMA busy_timeout').get().timeout,
234
+ };
235
+ },
236
+
237
+ createRun({ request, policy, repoRoot = null, planner = 'rules', scopeKey = null }) {
238
+ const id = newId('run');
239
+ const at = now();
240
+ const key = scopeKey ?? repoRoot ?? 'default';
241
+ db.prepare(`INSERT INTO runs (id, request, status, created_at, updated_at, round, rework_cycles, seq, planner, policy_snapshot, counters, repo_root, scope_key)
242
+ VALUES (?, ?, 'running', ?, ?, 0, 0, 0, ?, ?, ?, ?, ?)`)
243
+ .run(id, request, at, at, planner, JSON.stringify(policy), JSON.stringify({}), repoRoot, key);
244
+ return id;
245
+ },
246
+
247
+ getRun(id) { return mapRun(db.prepare('SELECT * FROM runs WHERE id = ?').get(id)); },
248
+
249
+ /** Claim an active run before recovery. A live process always wins; a dead owner is reclaimable. */
250
+ claimRun(id) {
251
+ const token = randomBytes(16).toString('hex');
252
+ transaction(() => {
253
+ if (!session.getRun(id)) throw new Error(`store: run not found: ${id}`);
254
+ const owner = db.prepare('SELECT pid FROM run_owners WHERE run_id = ?').get(id);
255
+ if (owner) {
256
+ let alive = true;
257
+ try { process.kill(owner.pid, 0); } catch (e) { alive = e.code !== 'ESRCH'; }
258
+ if (alive) throw new Error(`store: run ${id} is already active (pid ${owner.pid})`);
259
+ }
260
+ db.prepare('INSERT INTO run_owners (run_id, pid, token) VALUES (?, ?, ?) ON CONFLICT(run_id) DO UPDATE SET pid = excluded.pid, token = excluded.token')
261
+ .run(id, process.pid, token);
262
+ });
263
+ return token;
264
+ },
265
+
266
+ releaseRun(id, token) {
267
+ db.prepare('DELETE FROM run_owners WHERE run_id = ? AND token = ?').run(id, token);
268
+ },
269
+
270
+ updateRun(id, patch) {
271
+ const cur = session.getRun(id);
272
+ if (!cur) throw new Error(`store: run not found: ${id}`);
273
+ if (patch.status && !RUN_STATUSES.includes(patch.status)) throw new Error(`store: invalid run status "${patch.status}"`);
274
+ const next = {
275
+ status: patch.status ?? cur.status,
276
+ round: patch.round ?? cur.round,
277
+ reworkCycles: patch.reworkCycles ?? cur.reworkCycles,
278
+ seq: patch.seq ?? cur.seq,
279
+ planner: patch.planner ?? cur.planner,
280
+ counters: patch.counters ?? cur.counters,
281
+ updatedAt: now(),
282
+ };
283
+ db.prepare(`UPDATE runs SET status = ?, updated_at = ?, round = ?, rework_cycles = ?, seq = ?, planner = ?, counters = ? WHERE id = ?`)
284
+ .run(next.status, next.updatedAt, next.round, next.reworkCycles, next.seq, next.planner, JSON.stringify(next.counters), id);
285
+ return session.getRun(id);
286
+ },
287
+
288
+ listRuns({ status = null } = {}) {
289
+ const rows = status
290
+ ? db.prepare('SELECT * FROM runs WHERE status = ? ORDER BY updated_at DESC').all(status)
291
+ : db.prepare('SELECT * FROM runs ORDER BY updated_at DESC').all();
292
+ return rows.map(row => {
293
+ const run = mapRun(row);
294
+ const tasks = loadTasks(run.id);
295
+ const pending = db.prepare(`SELECT count(*) AS n FROM decisions WHERE run_id = ? AND status = 'pending'`).get(run.id).n;
296
+ return {
297
+ id: run.id, request: run.request, status: run.status, updatedAt: run.updatedAt, createdAt: run.createdAt,
298
+ completed: tasks.filter(t => t.status === 'completed').length, total: tasks.length, pendingDecisions: pending,
299
+ };
300
+ });
301
+ },
302
+
303
+ loadTasks,
304
+ saveTask,
305
+
306
+ /** Memory facade that writes through on add/update. Completed tasks stay completed across restarts. */
307
+ openTaskStore(runId) {
308
+ const mem = createMemoryTaskStore();
309
+ for (const t of loadTasks(runId)) mem.add(t);
310
+ return {
311
+ add(task) {
312
+ const stamped = { ...task, runId, createdAt: task.createdAt ?? now(), updatedAt: now() };
313
+ const t = mem.add(stamped);
314
+ saveTask(runId, t);
315
+ return t;
316
+ },
317
+ update(id, patch) {
318
+ const t = mem.update(id, { ...patch, updatedAt: now() });
319
+ saveTask(runId, t);
320
+ return t;
321
+ },
322
+ get: mem.get, has: mem.has, list: mem.list, size: mem.size, snapshot: mem.snapshot,
323
+ };
324
+ },
325
+
326
+ /** In-flight tasks go back to pending. Attempts are kept. Nothing is marked completed. */
327
+ recoverStale(runId) {
328
+ const recovered = [];
329
+ transaction(() => {
330
+ for (const t of loadTasks(runId)) {
331
+ if (t.status !== 'running') continue;
332
+ t.status = 'pending';
333
+ t.blockedReason = 'recovered after interruption; the in-flight attempt did not finish';
334
+ t.updatedAt = now();
335
+ saveTask(runId, t);
336
+ recovered.push(t.id);
337
+ }
338
+ });
339
+ return recovered;
340
+ },
341
+
342
+ /**
343
+ * Write pending decisions and the waiting task in one transaction so a crash cannot
344
+ * leave a decision without its task, or a waiting task without its decision.
345
+ */
346
+ persistWaiting(runId, task, drafts) {
347
+ return transaction(() => {
348
+ const ids = drafts.map(d => session.insertDecision({ ...d, runId }));
349
+ saveTask(runId, task);
350
+ return ids;
351
+ });
352
+ },
353
+
354
+ insertDecision(d) {
355
+ const id = newId('dec');
356
+ const at = now();
357
+ db.prepare(`INSERT INTO decisions (id, run_id, task_id, question, options_json, reason, escalation_type, flags_json, status, decision_key, recommended, created_at)
358
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`)
359
+ .run(id, d.runId, d.taskId, d.question, JSON.stringify(d.options ?? []), d.reason ?? '', d.escalationType ?? '', JSON.stringify(d.flags ?? []), d.key ?? '', d.recommended ?? null, at);
360
+ return id;
361
+ },
362
+
363
+ getDecision(id) {
364
+ const row = db.prepare('SELECT * FROM decisions WHERE id = ?').get(id);
365
+ return row ? mapDecision(row) : null;
366
+ },
367
+
368
+ listDecisions(runId, status = null) {
369
+ const rows = status
370
+ ? db.prepare('SELECT * FROM decisions WHERE run_id = ? AND status = ? ORDER BY created_at').all(runId, status)
371
+ : db.prepare('SELECT * FROM decisions WHERE run_id = ? ORDER BY created_at').all(runId);
372
+ return rows.map(mapDecision);
373
+ },
374
+
375
+ /**
376
+ * Record a user answer. The same answer twice is a no-op. A different answer keeps the first.
377
+ * The blocked task becomes pending when it has no other pending decisions.
378
+ */
379
+ answerDecision({ runId, decisionId, answer, scopeKey }) {
380
+ return transaction(() => {
381
+ const d = session.getDecision(decisionId);
382
+ if (!d || d.runId !== runId) throw new Error(`store: decision not found: ${decisionId}`);
383
+ const text = String(answer ?? '').trim();
384
+ if (d.status === 'answered') {
385
+ const same = String(d.answer ?? '').trim() === text;
386
+ return { idempotent: true, same, decision: d };
387
+ }
388
+ if (d.status !== 'pending') return { idempotent: true, same: false, decision: d };
389
+ const optionId = matchOption(d.options, text);
390
+ if (d.options.length && !optionId) throw new Error(`store: answer must select an option id (${d.options.map(o => o.id).join(', ')})`);
391
+ if (!text) throw new Error('store: answer cannot be empty');
392
+ const at = now();
393
+ db.prepare(`UPDATE decisions SET status = 'answered', answer = ?, answered_at = ? WHERE id = ?`)
394
+ .run(JSON.stringify(text), at, decisionId);
395
+ const run = session.getRun(runId);
396
+ session.saveMemory({
397
+ scope: 'repository', scopeKey: scopeKey ?? run.scopeKey, key: d.key || d.question.trim().toLowerCase(),
398
+ decision: { optionId, answer: text }, rationale: text, confidence: 1,
399
+ });
400
+ const tasks = loadTasks(runId);
401
+ const task = tasks.find(t => t.id === d.taskId);
402
+ if (task) {
403
+ const choice = optionId ? `${optionId}: ${text}` : text;
404
+ // key + optionId let a runner-level gate (e.g. worktree-dirty) recognise
405
+ // its own answered decision without parsing the question text.
406
+ task.decisions = [...(task.decisions ?? []), { key: d.key || undefined, optionId: optionId ?? undefined, question: d.question, choice, reason: `user answer (${decisionId})`, decisionId }];
407
+ const still = session.listDecisions(runId, 'pending').some(x => x.taskId === task.id);
408
+ if (!still && (task.status === 'waiting_for_user' || task.status === 'blocked')) {
409
+ task.status = 'pending';
410
+ task.blockedReason = undefined;
411
+ }
412
+ task.updatedAt = at;
413
+ saveTask(runId, task);
414
+ }
415
+ return { idempotent: false, same: true, decision: session.getDecision(decisionId), optionId };
416
+ });
417
+ },
418
+
419
+ saveMemory({ scope, scopeKey = null, key, decision, rationale = null, confidence = null, expiresAt = null }) {
420
+ if (!['global', 'project', 'repository'].includes(scope)) throw new Error(`store: invalid memory scope "${scope}"`);
421
+ const at = now();
422
+ const existing = db.prepare(`SELECT id, created_at FROM decision_memory WHERE scope = ? AND ifnull(scope_key, '') = ? AND decision_key = ?`)
423
+ .get(scope, scopeKey ?? '', key);
424
+ if (existing) {
425
+ db.prepare(`UPDATE decision_memory SET decision_json = ?, rationale = ?, confidence = ?, updated_at = ?, expires_at = ? WHERE id = ?`)
426
+ .run(JSON.stringify(decision), rationale, confidence, at, expiresAt, existing.id);
427
+ return existing.id;
428
+ }
429
+ const id = newId('mem');
430
+ db.prepare(`INSERT INTO decision_memory (id, scope, scope_key, decision_key, decision_json, rationale, confidence, created_at, updated_at, expires_at)
431
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
432
+ .run(id, scope, scopeKey, key, JSON.stringify(decision), rationale, confidence, at, at, expiresAt);
433
+ return id;
434
+ },
435
+
436
+ lookupMemory({ key, scopeKey, now: at = now() }) {
437
+ const rows = db.prepare(`SELECT * FROM decision_memory WHERE decision_key = ? AND (expires_at IS NULL OR expires_at > ?)`).all(key, at);
438
+ const rank = m => {
439
+ if (m.scope === 'repository' && m.scopeKey === scopeKey) return 0;
440
+ if (m.scope === 'project' && m.scopeKey === scopeKey) return 1;
441
+ if (m.scope === 'global') return 2;
442
+ return 9;
443
+ };
444
+ return rows.map(mapMemory).filter(m => rank(m) < 9).sort((a, b) => rank(a) - rank(b) || String(b.updatedAt).localeCompare(String(a.updatedAt)));
445
+ },
446
+
447
+ listMemory() { return db.prepare('SELECT * FROM decision_memory ORDER BY updated_at').all().map(mapMemory); },
448
+
449
+ recordHealth({ provider, model = '', state, reason, runId, ttlMs, now: at = now() }) {
450
+ const detectedAt = at;
451
+ const expiresAt = new Date(Date.parse(detectedAt) + ttlMs).toISOString();
452
+ const rowRun = state === 'usage_exhausted' ? '' : (runId ?? '');
453
+ db.prepare(`INSERT INTO backend_health (provider, model, run_id, state, reason, detected_at, retry_after, expires_at)
454
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
455
+ ON CONFLICT(provider, model, run_id) DO UPDATE SET
456
+ state = excluded.state, reason = excluded.reason, detected_at = excluded.detected_at,
457
+ retry_after = excluded.retry_after, expires_at = excluded.expires_at`)
458
+ .run(provider, model ?? '', rowRun, state, String(reason ?? '').slice(0, 500), detectedAt, expiresAt, expiresAt);
459
+ return { provider, model: model || undefined, state, reason, detectedAt, retryAfter: expiresAt, expiresAt, runId: rowRun || undefined };
460
+ },
461
+
462
+ /**
463
+ * Protocol-reliability telemetry per provider/model. Audit only — NOT fed back
464
+ * into routing scores. kind: 'malformed'|'empty'|'turn_limit'|'structured_ok'.
465
+ */
466
+ recordProtocol({ provider, model = '', kind, now: at = now() }) {
467
+ const col = { malformed: 'malformed', empty: 'empty', turn_limit: 'turn_limit', structured_ok: 'structured_ok' }[kind];
468
+ if (!col) return null;
469
+ db.prepare(`INSERT INTO protocol_stats (provider, model, ${col}, updated_at) VALUES (?, ?, 1, ?)
470
+ ON CONFLICT(provider, model) DO UPDATE SET ${col} = ${col} + 1, updated_at = excluded.updated_at`)
471
+ .run(provider, model ?? '', at);
472
+ },
473
+
474
+ protocolStats() {
475
+ return db.prepare('SELECT provider, model, malformed, empty, turn_limit, structured_ok, updated_at FROM protocol_stats ORDER BY provider, model').all();
476
+ },
477
+
478
+ activeHealth({ provider, model = '', runId, now: at = now() }) {
479
+ const row = db.prepare(`SELECT * FROM backend_health
480
+ WHERE provider = ? AND model = ? AND expires_at > ? AND (run_id = ? OR run_id = '')
481
+ ORDER BY CASE WHEN run_id = '' THEN 1 ELSE 0 END, detected_at DESC LIMIT 1`)
482
+ .get(provider, model ?? '', at, runId ?? '');
483
+ return row ? mapHealth(row) : null;
484
+ },
485
+
486
+ appendTrace(runId, event) {
487
+ db.prepare('INSERT INTO trace (run_id, at, round, type, payload) VALUES (?, ?, ?, ?, ?)')
488
+ .run(runId, event.at, event.round ?? null, event.type, JSON.stringify(event));
489
+ },
490
+
491
+ loadTrace(runId) {
492
+ return db.prepare('SELECT payload FROM trace WHERE run_id = ? ORDER BY id').all(runId).map(r => parse(r.payload, null)).filter(Boolean);
493
+ },
494
+
495
+ /**
496
+ * Read-only history-cleanup preview. `olderThan` is an ISO timestamp cutoff on
497
+ * runs.updated_at; `includeActive` reports non-terminal runs too (they are still
498
+ * never deletable while pending decisions exist). Nothing is written.
499
+ */
500
+ previewRuns({ olderThan = null, includeActive = false } = {}) {
501
+ const rows = db.prepare('SELECT * FROM runs ORDER BY updated_at DESC').all();
502
+ const runs = rows.map(row => {
503
+ const info = cleanupInfo(row);
504
+ const ageMatches = !olderThan || String(row.updated_at) < olderThan;
505
+ return { ...info, deletable: ageMatches && (includeActive || info.deletable),
506
+ reason: ageMatches ? (includeActive ? null : info.reason) : 'newer than cutoff' };
507
+ });
508
+ const deletable = runs.filter(r => r.deletable);
509
+ const totals = deletable.reduce((acc, r) => {
510
+ for (const k of ['tasks', 'decisions', 'trace', 'health']) acc[k] += r.counts[k];
511
+ return acc;
512
+ }, { runs: deletable.length, tasks: 0, decisions: 0, trace: 0, health: 0 });
513
+ return {
514
+ scope: { olderThan, includeActive }, executed: false, runs,
515
+ deletable: deletable.map(r => r.id),
516
+ skipped: runs.filter(r => !r.deletable).map(r => ({ id: r.id, reason: r.reason })),
517
+ totals: { ...totals, matched: runs.length, terminal: runs.filter(r => TERMINAL_RUN.has(r.status)).length,
518
+ active: runs.filter(r => !TERMINAL_RUN.has(r.status) || r.counts.pendingDecisions > 0 || r.counts.runningTasks > 0).length,
519
+ oldest: runs.at(-1)?.updatedAt ?? null, newest: runs[0]?.updatedAt ?? null },
520
+ };
521
+ },
522
+
523
+ /** Explicit force is required for an active/resumable run. */
524
+ deleteRun(id, { force = false } = {}) {
525
+ return transaction(() => {
526
+ const row = db.prepare('SELECT * FROM runs WHERE id = ?').get(id);
527
+ if (!row) throw new Error(`store: run not found: ${id}`);
528
+ const info = cleanupInfo(row);
529
+ if (!info.deletable && !force) throw new Error(`store: run is active/resumable; use --force (${id})`);
530
+ deleteRunRows(id);
531
+ return { id, status: info.status, deleted: info.counts };
532
+ });
533
+ },
534
+
535
+ /**
536
+ * Delete every deletable run matching the preview scope in ONE transaction.
537
+ * If any delete fails the whole batch rolls back — no partial cleanup.
538
+ */
539
+ pruneRuns({ olderThan = null } = {}) {
540
+ return transaction(() => {
541
+ const preview = session.previewRuns({ olderThan });
542
+ for (const id of preview.deletable) deleteRunRows(id);
543
+ return { ...preview, executed: true };
544
+ });
545
+ },
546
+
547
+ /** Preview (force=false) or delete (force=true) all run history. Terminal-only unless includeActive. */
548
+ clearRuns({ force = false, includeActive = false } = {}) {
549
+ if (!force) return { ...session.previewRuns({ includeActive }), executed: false };
550
+ // Preview INSIDE the transaction: a run that gained a pending decision or went
551
+ // active since the caller looked is reclassified and skipped, never deleted.
552
+ return transaction(() => {
553
+ const preview = session.previewRuns({ includeActive });
554
+ for (const id of preview.deletable) deleteRunRows(id);
555
+ return { ...preview, executed: true };
556
+ });
557
+ },
558
+ };
559
+ return session;
560
+ }
561
+
562
+ export function matchOption(options, answer) {
563
+ const a = String(answer ?? '').trim().toLowerCase();
564
+ if (!a) return null;
565
+ for (const o of options ?? []) {
566
+ if (String(o.id).toLowerCase() === a) return o.id;
567
+ if (o.summary && (a === String(o.summary).toLowerCase() || a.includes(String(o.summary).toLowerCase()))) return o.id;
568
+ }
569
+ // Permit an unambiguous explicit option reference or a whole-word id (not a
570
+ // substring such as `no` inside `know`). Ambiguous prose must be clarified.
571
+ const matches = (options ?? []).filter(o => {
572
+ const id = String(o.id).toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
573
+ return new RegExp(`\\b(?:option\\s+)?${id}\\b`, 'i').test(a) && (id.length > 1 || new RegExp(`\\boption\\s+${id}\\b`, 'i').test(a));
574
+ });
575
+ return matches.length === 1 ? matches[0].id : null;
576
+ }
577
+
578
+ /** Task store bound to one run. Exported for tests that open a store directly. */
579
+ export function createPersistentTaskStore(session, runId) {
580
+ return session.openTaskStore(runId);
581
+ }
@@ -0,0 +1,79 @@
1
+ // Run-local task graph. The TaskStore holds orchestration state for one run only; an ExternalProjectStore
2
+ // (e.g. a future Asana adapter) receives lifecycle notifications and is never required for execution.
3
+ export const TASK_STATUSES = ['pending', 'running', 'completed', 'failed', 'blocked', 'waiting_for_user'];
4
+ const TERMINAL = new Set(['completed', 'failed', 'blocked']);
5
+ const DEPENDENCY_STOP = new Set(['failed', 'blocked', 'waiting_for_user']);
6
+
7
+ /**
8
+ * @typedef {{ id: string, title: string, goal: string, capability: string, dependencies: string[],
9
+ * status: 'pending'|'running'|'completed'|'failed'|'blocked', assignedAgent?: string, result?: unknown,
10
+ * attempts: number, kind?: string, acceptance: string[], outputs: string[], origin?: string,
11
+ * feedback?: string[], decisions?: object[], blockedReason?: string }} OrchestratedTask
12
+ */
13
+ export function newTask({ id, title, goal, capability, dependencies = [], assignedAgent, acceptance = [], outputs = [], kind, origin = 'plan' }) {
14
+ if (!id || !title || !goal) throw new Error('task: id, title and goal are required');
15
+ return { id, title, goal, capability, dependencies: [...dependencies], status: 'pending', assignedAgent, result: undefined, attempts: 0, kind, acceptance: [...acceptance], outputs: [...outputs], origin, feedback: [], decisions: [] };
16
+ }
17
+
18
+ /** In-memory store for one process. Persistent runs use createPersistentTaskStore in store.mjs. */
19
+ export function createMemoryTaskStore() {
20
+ const tasks = new Map();
21
+ return {
22
+ add(task) {
23
+ if (tasks.has(task.id)) throw new Error(`task: duplicate id "${task.id}"`);
24
+ tasks.set(task.id, task);
25
+ return task;
26
+ },
27
+ get: id => tasks.get(id),
28
+ has: id => tasks.has(id),
29
+ list: () => [...tasks.values()],
30
+ size: () => tasks.size,
31
+ update(id, patch) {
32
+ const t = tasks.get(id);
33
+ if (!t) throw new Error(`task: unknown id "${id}"`);
34
+ if (patch.status && !TASK_STATUSES.includes(patch.status)) throw new Error(`task: invalid status "${patch.status}"`);
35
+ Object.assign(t, patch);
36
+ return t;
37
+ },
38
+ snapshot: () => structuredClone([...tasks.values()]),
39
+ };
40
+ }
41
+
42
+ export const createTaskStore = createMemoryTaskStore;
43
+
44
+ /** Pending tasks whose dependencies are all completed, in insertion order. */
45
+ export function runnableTasks(store) {
46
+ return store.list().filter(t => t.status === 'pending' && t.dependencies.every(d => store.get(d)?.status === 'completed'));
47
+ }
48
+
49
+ /** Pending tasks that can never run because a dependency ended failed, blocked, or waiting on the user. */
50
+ export function strandedTasks(store) {
51
+ return store.list().filter(t => t.status === 'pending' && t.dependencies.some(d => {
52
+ const dep = store.get(d);
53
+ return !dep || DEPENDENCY_STOP.has(dep.status);
54
+ }));
55
+ }
56
+
57
+ export const isTerminal = t => TERMINAL.has(t.status);
58
+
59
+ /** Returns the ids forming a cycle, or null. */
60
+ export function findCycle(tasks) {
61
+ const byId = new Map(tasks.map(t => [t.id, t]));
62
+ const state = new Map();
63
+ const stack = [];
64
+ const visit = id => {
65
+ if (state.get(id) === 2) return null;
66
+ if (state.get(id) === 1) return stack.slice(stack.indexOf(id)).concat(id);
67
+ state.set(id, 1); stack.push(id);
68
+ for (const d of byId.get(id)?.dependencies ?? []) { const c = byId.has(d) ? visit(d) : null; if (c) return c; }
69
+ stack.pop(); state.set(id, 2);
70
+ return null;
71
+ };
72
+ for (const t of tasks) { const c = visit(t.id); if (c) return c; }
73
+ return null;
74
+ }
75
+
76
+ /** No-op external project store. A future tracker (Asana) plugs in here; it is not the run store. */
77
+ export function createNullProjectStore() {
78
+ return { name: 'none', async onPlan() {}, async onTaskUpdate() {}, async onFinal() {} };
79
+ }