@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,249 @@
1
+ // Public run-activity snapshots for external clients (pi-web, future UIs). The tracker keeps a
2
+ // live in-memory view of one client's runs and writes an ATOMIC public JSON snapshot per client
3
+ // under <kit>/.orchestration/activity/clients/<kind>-<sessionId>.json. The store remains the
4
+ // source of truth; this file is a projection that other processes can tail without SQLite.
5
+ //
6
+ // Event policy: orchestration-level events (plan/round/result/retry/escalation/…), invocation
7
+ // lifecycle (start/end), tool calls, turn-cap extensions and fallbacks are SIGNIFICANT — they are
8
+ // also the events the orchestrator persists to the run trace. High-frequency per-turn progress
9
+ // updates only refresh the in-memory snapshot + written file, never the trace.
10
+ import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync, rmSync } from 'node:fs';
11
+ import { join, basename, dirname } from 'node:path';
12
+ import { randomBytes } from 'node:crypto';
13
+
14
+ export const ACTIVITY_VERSION = 1;
15
+
16
+ /** Events that matter for history: persisted to the run trace by the orchestrator. */
17
+ export const SIGNIFICANT_EVENT_TYPES = new Set([
18
+ 'invocation-start', 'invocation-end', 'invocation-tool', 'invocation-extension', 'candidate-changed',
19
+ ]);
20
+
21
+ /** High-frequency progress events: live snapshot only, never the trace. */
22
+ export const HIGH_FREQUENCY_EVENT_TYPES = new Set(['invocation-turn', 'invocation-tool-completed']);
23
+
24
+ const safeSegment = s => String(s ?? '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
25
+ const invocationKey = (runId, taskId, invocationId) => JSON.stringify([runId, taskId, invocationId]);
26
+
27
+ /** clientContext -> canonical snapshot file name, e.g. pi-web-<canonicalPiUUID>.json */
28
+ export function activityFileName(clientContext) {
29
+ return `${safeSegment(clientContext?.kind)}-${safeSegment(clientContext?.sessionId)}.json`;
30
+ }
31
+
32
+ export function clientKeyOf(clientContext) {
33
+ return JSON.stringify([clientContext?.kind ?? '', clientContext?.sessionId ?? '']);
34
+ }
35
+
36
+ /** Publish a complete document without exposing partial JSON to readers. On Windows a
37
+ * reader may briefly hold the destination without FILE_SHARE_DELETE, causing EPERM.
38
+ * Retry only those sharing violations; never remove the old snapshot first. */
39
+ export function publishActivitySnapshot(file, doc, {
40
+ rename = renameSync,
41
+ wait = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
42
+ windows = process.platform === 'win32',
43
+ } = {}) {
44
+ const dir = dirname(file);
45
+ mkdirSync(dir, { recursive: true });
46
+ const tmp = join(dir, `.${randomBytes(4).toString('hex')}.tmp`);
47
+ try {
48
+ writeFileSync(tmp, JSON.stringify(doc, null, 2));
49
+ for (let attempt = 0; ; attempt++) {
50
+ try { rename(tmp, file); return; }
51
+ catch (error) {
52
+ if (!windows || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt >= 5) throw error;
53
+ wait([10, 20, 40, 80, 160][attempt]);
54
+ }
55
+ }
56
+ } finally {
57
+ // A failed rename must not accumulate temporary files on every turn.
58
+ try { rmSync(tmp, { force: true }); } catch { /* temporary file may itself be locked */ }
59
+ }
60
+ }
61
+
62
+ /** Drop raw payloads: only bounded, non-secret fields may leave the process. */
63
+ export function sanitizeInvocationEvent(type, data = {}) {
64
+ const out = { type };
65
+ const copy = k => { if (data[k] != null) out[k] = typeof data[k] === 'string' ? data[k].slice(0, 500) : data[k]; };
66
+ for (const k of ['runId', 'taskId', 'invocationId', 'agent', 'capability', 'modelId', 'backend', 'provider', 'turn', 'turnCap', 'turnsUsed', 'turnLimit', 'toolCalls', 'extensionsGranted', 'oldLimit', 'newLimit', 'fromModel', 'toModel', 'from', 'to', 'reason', 'status', 'at']) copy(k);
67
+ if (data.tool) {
68
+ const file = data.tool.file ? basename(String(data.tool.file).replace(/\\/g, '/')).slice(0, 100) : '';
69
+ out.tool = { name: String(data.tool.name ?? 'tool').slice(0, 80), ...(file ? { file } : {}) };
70
+ }
71
+ if (data.childSessionId) out.childSessionId = String(data.childSessionId);
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * @param {object} o
77
+ * @param {string} o.kit kit root; snapshots go to <kit>/.orchestration/activity/clients/
78
+ * @param {object} [o.clientContext] { kind, sessionId } — generic; the extension injects the
79
+ * authoritative pi session id for kind 'pi-web'.
80
+ */
81
+ export function createActivityTracker({ kit, clientContext = null, now = () => new Date().toISOString() } = {}) {
82
+ const dir = kit ? join(kit, '.orchestration', 'activity', 'clients') : null;
83
+ const file = dir && clientContext?.sessionId ? join(dir, activityFileName(clientContext)) : null;
84
+ const runs = new Map(); // runId -> live activity object
85
+ const bindings = new Map(); // clientKey -> runId (a client sees the runs it started)
86
+ const listeners = new Map(); // runId -> Set<listener>
87
+ const invocations = new Map();// invocationKey -> invocation record
88
+ const startedAt = new Map();
89
+ let snapshotWarningShown = false;
90
+
91
+ const notify = runId => {
92
+ const activity = runs.get(runId);
93
+ for (const fn of listeners.get(runId) ?? []) {
94
+ try { fn(structuredClone(activity)); } catch { /* listener must not break the run */ }
95
+ }
96
+ };
97
+
98
+ const writeSnapshot = () => {
99
+ if (!file) return;
100
+ const runList = [...bindings.values()].map(id => runs.get(id)).filter(Boolean);
101
+ const latest = runList.at(-1) ?? null;
102
+ const doc = {
103
+ version: ACTIVITY_VERSION,
104
+ clientContext: clientContext ? { kind: clientContext.kind, sessionId: clientContext.sessionId } : null,
105
+ runId: latest?.runId ?? null,
106
+ repoRoot: latest?.repoRoot ?? null,
107
+ startedAt: latest ? startedAt.get(latest.runId) : null,
108
+ activity: latest ? { ...structuredClone(latest), updatedAt: now() } : null,
109
+ };
110
+ try {
111
+ publishActivitySnapshot(file, doc);
112
+ snapshotWarningShown = false;
113
+ } catch (error) {
114
+ // The public projection is optional: a sharing violation must not abort
115
+ // the authoritative SQLite run or turn a successful task into a failure.
116
+ if (!snapshotWarningShown) console.warn(`activity snapshot unavailable (${error.code ?? 'write error'}); the run remains persisted`);
117
+ snapshotWarningShown = true;
118
+ }
119
+ };
120
+
121
+ const ensureRun = runId => {
122
+ if (!runs.has(runId)) {
123
+ runs.set(runId, { runId, state: 'running', completedTasks: 0, totalTasks: 0, activeAgents: 0, tasks: [], activeInvocations: [], updatedAt: now() });
124
+ }
125
+ return runs.get(runId);
126
+ };
127
+
128
+ const tracker = {
129
+ file,
130
+ clientContext,
131
+
132
+ /** Bind this client to a run the moment the run exists. */
133
+ bindRun({ runId, repoRoot = null }) {
134
+ if (!runId) return;
135
+ const a = ensureRun(runId);
136
+ if (!startedAt.has(runId)) startedAt.set(runId, now());
137
+ a.repoRoot = repoRoot ?? a.repoRoot;
138
+ if (clientContext?.sessionId) bindings.set(clientKeyOf(clientContext), runId);
139
+ notify(runId);
140
+ writeSnapshot();
141
+ },
142
+
143
+ /**
144
+ * Feed one event. `persist` tells the orchestrator whether this type belongs in the
145
+ * durable trace (significant) or only in the live snapshot (high-frequency turns).
146
+ */
147
+ emit(type, data = {}) {
148
+ const runId = data.runId;
149
+ const persist = SIGNIFICANT_EVENT_TYPES.has(type);
150
+ if (!runId) return { persist };
151
+ const a = ensureRun(runId);
152
+ const e = sanitizeInvocationEvent(type, data);
153
+ if (type === 'candidate-changed' || type === 'fallback') {
154
+ const task = a.tasks.find(t => t.taskId === data.taskId);
155
+ if (task) {
156
+ task.fallbackFrom = e.fromModel ?? e.from;
157
+ task.fallbackTo = e.toModel ?? e.to;
158
+ if (e.toModel) task.modelId = e.toModel;
159
+ if (e.to) task.capability = e.to;
160
+ }
161
+ a.recentEvent = type;
162
+ }
163
+ if (type === 'invocation-start') {
164
+ invocations.set(invocationKey(runId, data.taskId, data.invocationId), e);
165
+ } else if (type === 'invocation-end') {
166
+ invocations.delete(invocationKey(runId, data.taskId, data.invocationId));
167
+ } else if ((HIGH_FREQUENCY_EVENT_TYPES.has(type) || SIGNIFICANT_EVENT_TYPES.has(type)) && data.invocationId) {
168
+ const key = invocationKey(runId, data.taskId, data.invocationId);
169
+ const previous = invocations.get(key);
170
+ if (previous) {
171
+ const next = { ...previous, ...e };
172
+ if (e.turn !== undefined) next.turnsUsed = e.turn;
173
+ if (e.turnCap !== undefined) next.turnLimit = e.turnCap;
174
+ if (e.newLimit !== undefined) next.turnLimit = e.newLimit;
175
+ if (type === 'invocation-tool' && e.tool) next.recentTools = [...(previous.recentTools ?? []), { tool: e.tool.name, ...(e.tool.file ? { summary: e.tool.file } : {}) }].slice(-5);
176
+ invocations.set(key, next);
177
+ }
178
+ }
179
+ a.activeInvocations = [...invocations.values()].filter(i => i.runId === runId);
180
+ a.activeAgents = a.activeInvocations.length || a.tasks.filter(t => t.state === 'running').length;
181
+ a.updatedAt = now();
182
+ notify(runId);
183
+ writeSnapshot();
184
+ return { persist };
185
+ },
186
+
187
+ /** Sync the task-level projection after a store update. */
188
+ syncTasks({ runId, tasks = [], state = null }) {
189
+ const a = ensureRun(runId);
190
+ a.tasks = tasks.map(t => {
191
+ const live = a.activeInvocations.find(i => i.taskId === t.id);
192
+ return { taskId: t.id, title: t.title, agent: t.assignedAgent, capability: t.capability,
193
+ modelId: live?.modelId ?? t.modelId, state: t.status, attempts: t.attempts ?? 0,
194
+ ...(a.tasks.find(x => x.taskId === t.id)?.fallbackFrom ? {
195
+ fallbackFrom: a.tasks.find(x => x.taskId === t.id).fallbackFrom,
196
+ fallbackTo: a.tasks.find(x => x.taskId === t.id).fallbackTo,
197
+ } : {}),
198
+ ...(live?.turnsUsed != null ? { turnsUsed: live.turnsUsed } : {}),
199
+ ...(live?.turnLimit != null ? { turnLimit: live.turnLimit } : {}),
200
+ ...(live?.toolCalls != null ? { toolCalls: live.toolCalls } : {}),
201
+ ...(live?.recentTools?.length ? { recentTools: live.recentTools } : {}) };
202
+ });
203
+ a.totalTasks = a.tasks.length;
204
+ a.completedTasks = a.tasks.filter(t => t.state === 'completed').length;
205
+ a.activeAgents = a.activeInvocations.length || a.tasks.filter(t => t.state === 'running').length;
206
+ if (state) a.state = state;
207
+ a.updatedAt = now();
208
+ notify(runId);
209
+ writeSnapshot();
210
+ },
211
+
212
+ getRunActivity(runId) {
213
+ const a = runs.get(runId);
214
+ if (a) return structuredClone(a);
215
+ const snapshot = file ? readActivitySnapshot(file) : null;
216
+ return snapshot?.runId === runId ? snapshot.activity ?? null : null;
217
+ },
218
+
219
+ getRunActivityByClient(kind, sessionId) {
220
+ if (clientKeyOf({ kind, sessionId }) !== clientKeyOf(clientContext)) return null;
221
+ const runId = bindings.get(clientKeyOf({ kind, sessionId }));
222
+ if (runId) return tracker.getRunActivity(runId);
223
+ const snapshot = file ? readActivitySnapshot(file) : null;
224
+ return snapshot?.clientContext?.kind === kind && snapshot?.clientContext?.sessionId === sessionId ? snapshot.activity ?? null : null;
225
+ },
226
+
227
+ /** Stop live subscriptions when a run terminates; keep its final snapshot. */
228
+ finishRun(runId) {
229
+ for (const key of invocations.keys()) if (JSON.parse(key)[0] === runId) invocations.delete(key);
230
+ const a = runs.get(runId);
231
+ if (a) { a.activeInvocations = []; a.activeAgents = 0; a.updatedAt = now(); notify(runId); writeSnapshot(); }
232
+ listeners.delete(runId);
233
+ },
234
+
235
+ /** listener(activity) on every significant change. Returns an unsubscribe fn. */
236
+ subscribeRunActivity(runId, listener) {
237
+ if (!listeners.has(runId)) listeners.set(runId, new Set());
238
+ listeners.get(runId).add(listener);
239
+ return () => listeners.get(runId)?.delete(listener);
240
+ },
241
+ };
242
+ return tracker;
243
+ }
244
+
245
+ /** Read a published client snapshot (for tools / tests). Returns null when absent. */
246
+ export function readActivitySnapshot(path) {
247
+ if (!existsSync(path)) return null;
248
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
249
+ }
@@ -0,0 +1,151 @@
1
+ // Programmatic boundary for the CLI and the pi extension. No SQL and no provider-specific imports:
2
+ // the caller supplies `invoke` / `runner`. Natural-language UI can call these functions later.
3
+ import { join } from 'node:path';
4
+ import { loadRouting } from '../routing.mjs';
5
+ import { loadRegistry } from '../registry.mjs';
6
+ import { loadAgents } from '../agents.mjs';
7
+ import { loadPolicy } from './policy.mjs';
8
+ import { openStore } from './store.mjs';
9
+ import { createHealthMonitor } from './health.mjs';
10
+ import { createAgentRunner } from './runner.mjs';
11
+ import { orchestrate, formatReport, formatRunList, toEscalation } from './orchestrator.mjs';
12
+ import { createActivityTracker } from './activity.mjs';
13
+
14
+ export function defaultStorePath(kit, env = process.env) {
15
+ return env.LUDI_ORCHESTRATION_STORE || join(kit, '.orchestration', 'state.db');
16
+ }
17
+
18
+ export function loadOrchestrationContext({ kit, storePath, policyPath = null, localPolicyPath = null, clientContext = null }) {
19
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
20
+ const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
21
+ const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
22
+ const base = join(kit, 'orchestration/decision-policy.json');
23
+ const { policy } = loadPolicy(base, policyPath ?? localPolicyPath);
24
+ const session = openStore(storePath);
25
+ const activity = createActivityTracker({ kit, clientContext });
26
+ return { kit, routing, registry, agents, errors, policy, session, storePath, clientContext, activity };
27
+ }
28
+
29
+ /** Public live activity for one run (in-memory projection; null when unknown). */
30
+ export function getRunActivity(ctx, runId) {
31
+ return ctx.activity?.getRunActivity(runId) ?? null;
32
+ }
33
+
34
+ /** Public live activity addressed by owning client ({ kind, sessionId }). */
35
+ export function getRunActivityByClient(ctx, kind, sessionId) {
36
+ return ctx.activity?.getRunActivityByClient(kind, sessionId) ?? null;
37
+ }
38
+
39
+ /** Subscribe to activity updates for one run. Returns an unsubscribe function. */
40
+ export function subscribeRunActivity(ctx, runId, listener) {
41
+ return ctx.activity?.subscribeRunActivity(runId, listener) ?? (() => {});
42
+ }
43
+
44
+ export function listOrchestrationRuns(ctx, { status = null } = {}) {
45
+ return ctx.session.listRuns(status ? { status } : {});
46
+ }
47
+
48
+ export function pendingDecisions(ctx, { runId = null } = {}) {
49
+ const runs = runId ? [ctx.session.getRun(runId)].filter(Boolean) : ctx.session.listRuns({ status: 'waiting_for_user' });
50
+ const out = [];
51
+ for (const run of runs) {
52
+ const id = run.id;
53
+ for (const d of ctx.session.listDecisions(id, 'pending')) out.push(d);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ export function showRun(ctx, runId) {
59
+ const run = ctx.session.getRun(runId);
60
+ if (!run) throw new Error(`run not found: ${runId}`);
61
+ const tasks = ctx.session.loadTasks(runId);
62
+ const pending = ctx.session.listDecisions(runId, 'pending');
63
+ const status = run.status === 'waiting_for_user' ? 'needs-user' : run.status === 'completed' ? 'completed' : 'incomplete';
64
+ return {
65
+ status, runStatus: run.status, runId, request: run.request, planner: run.planner, rounds: run.round,
66
+ errors: [], tasks, autoDecisions: run.counters.autoDecisions ?? [], escalations: pending.map(toEscalation),
67
+ unresolved: run.counters.unresolved ?? [], limitsHit: run.counters.limitsHit ?? [], trace: ctx.session.loadTrace(runId),
68
+ };
69
+ }
70
+
71
+ export function answerOrchestration(ctx, { runId, decisionId, answer }) {
72
+ return ctx.session.answerDecision({ runId, decisionId, answer, scopeKey: ctx.session.getRun(runId)?.scopeKey });
73
+ }
74
+
75
+ const DURATION_UNITS = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
76
+
77
+ /** Parse a duration like "30m", "12h", "7d", "2w" into milliseconds. Throws on bad input. */
78
+ export function parseOlderThan(text) {
79
+ const m = /^(\d+)\s*([mhdw])$/i.exec(String(text ?? '').trim());
80
+ if (!m) throw new Error(`invalid --older-than "${text}" (examples: 30m, 12h, 7d, 2w)`);
81
+ const ms = Number(m[1]) * DURATION_UNITS[m[2].toLowerCase()];
82
+ if (!Number.isSafeInteger(ms) || ms <= 0) throw new Error(`invalid --older-than "${text}"`);
83
+ return ms;
84
+ }
85
+
86
+ /** Read-only preview of what a cleanup would delete. Never writes. */
87
+ export function previewRunCleanup(ctx, { olderThan = null, includeActive = false } = {}) {
88
+ return ctx.session.previewRuns({ olderThan, includeActive });
89
+ }
90
+
91
+ /** Delete terminal runs older than `olderThanMs` (null = all terminal). One transaction. */
92
+ export function pruneOrchestrationRuns(ctx, { olderThanMs = 7 * 86_400_000 } = {}) {
93
+ const cutoff = new Date(Date.now() - (olderThanMs ?? 7 * 86_400_000)).toISOString();
94
+ return ctx.session.pruneRuns({ olderThan: cutoff });
95
+ }
96
+
97
+ /** Delete one run's history. The store refuses active/resumable runs. */
98
+ export function deleteOrchestrationRun(ctx, runId, { force = false } = {}) {
99
+ return ctx.session.deleteRun(runId, { force });
100
+ }
101
+
102
+ /** force=false previews; force=true deletes all terminal runs (includeActive additionally removes active ones). */
103
+ export function clearOrchestrationRuns(ctx, { force = false, includeActive = false } = {}) {
104
+ return ctx.session.clearRuns({ force, includeActive });
105
+ }
106
+
107
+ const cleanupLine = r => `${r.id} ${r.status} tasks:${r.counts.tasks} decisions:${r.counts.decisions} trace:${r.counts.trace} ${r.updatedAt} ${String(r.request).replace(/\s+/g, ' ').slice(0, 60)}`;
108
+
109
+ /** Concise Japanese summary of a cleanup preview or executed cleanup. */
110
+ export function formatCleanup(result, { verb = '削除', hint = '--force' } = {}) {
111
+ const lines = [];
112
+ if (!result.runs.length || (result.totals.runs === 0 && !result.skipped.length)) return '削除対象のランはありません';
113
+ const head = result.executed ? `${verb}しました` : `削除対象(プレビュー)`;
114
+ lines.push(`${head}: ${result.totals.runs} ラン (terminal:${result.totals.terminal} active/resumable:${result.totals.active}; tasks:${result.totals.tasks} decisions:${result.totals.decisions} trace:${result.totals.trace} health:${result.totals.health})`);
115
+ lines.push(`内訳: completed ${result.runs.filter(r => r.deletable && r.status === 'completed').length}, failed ${result.runs.filter(r => r.deletable && r.status === 'failed').length}, cancelled ${result.runs.filter(r => r.deletable && r.status === 'cancelled').length}, active保持 ${result.skipped.filter(s => /active|resumable/.test(s.reason)).length}`);
116
+ lines.push(`更新日時: ${result.totals.oldest ?? 'なし'} ~ ${result.totals.newest ?? 'なし'}`);
117
+ for (const r of result.runs.filter(x => x.deletable)) lines.push(` ${cleanupLine(r)}`);
118
+ if (result.skipped.length) {
119
+ lines.push(`スキップ: ${result.skipped.length} ラン`);
120
+ for (const s of result.skipped) lines.push(` ${s.id} ${s.reason}`);
121
+ }
122
+ if (!result.executed) lines.push(`実行するには ${hint} を指定してください`);
123
+ return lines.join('\n');
124
+ }
125
+
126
+ export function createRunHealth(ctx) {
127
+ return createHealthMonitor({ session: ctx.session, policy: ctx.policy });
128
+ }
129
+
130
+ export function createRunRunner(ctx, { invoke, runSubagent = null, repoRoot, outDir, apply = false, health }) {
131
+ return createAgentRunner({
132
+ invoke, runSubagent, agents: ctx.agents, routing: ctx.routing, registry: ctx.registry, repoRoot, outDir, health, policy: ctx.policy, session: ctx.session,
133
+ maxModelAttempts: ctx.policy.limits.model_attempts_per_task, pipelineAgents: apply ? ['coder'] : [],
134
+ });
135
+ }
136
+
137
+ export async function startOrchestration(ctx, { request, repoRoot = null, planner = 'rules', plan = null, runner, invoke = null, health = null, projectStore }) {
138
+ return orchestrate({
139
+ request, planner, plan, agents: ctx.agents, routing: ctx.routing, registry: ctx.registry, policy: ctx.policy,
140
+ runner, invoke, repoRoot, session: ctx.session, health, projectStore, activity: ctx.activity,
141
+ });
142
+ }
143
+
144
+ export async function resumeOrchestration(ctx, { runId, answers = [], repoRoot = null, runner, invoke = null, health = null, projectStore }) {
145
+ return orchestrate({
146
+ request: '', resumeRunId: runId, answers, agents: ctx.agents, routing: ctx.routing, registry: ctx.registry,
147
+ policy: ctx.policy, runner, invoke, repoRoot, session: ctx.session, health, projectStore, activity: ctx.activity,
148
+ });
149
+ }
150
+
151
+ export { formatReport, formatRunList };
@@ -0,0 +1,68 @@
1
+ // Task contract handed to a worker. The result block stays compatible with the Phase 1 evaluator.
2
+ import { accessOf } from './permissions.mjs';
3
+
4
+ export const RESULT_STATUSES = ['completed', 'failed', 'blocked', 'needs_decision'];
5
+
6
+ export function forbiddenActions() {
7
+ return [
8
+ 'git push', 'npm publish or any package publish', 'deployment or production mutation',
9
+ 'git reset, git checkout --, git clean, or git stash', 'deleting files outside dist/build/coverage/node_modules',
10
+ 'changing credentials or user configuration', 'network calls',
11
+ ];
12
+ }
13
+
14
+ export function buildTaskContract(task, { dependencyResults = [], survey = null, access = null, workspace = null } = {}) {
15
+ const rights = access ?? { filesystem: 'read', shell: 'false', git: 'none', network: false };
16
+ const lines = [
17
+ 'TASK', task.title, '',
18
+ 'GOAL', task.goal, '',
19
+ 'CONTEXT',
20
+ ];
21
+ if (dependencyResults.length) {
22
+ for (const d of dependencyResults) {
23
+ lines.push(`- ${d.id} (${d.agent}): ${d.title}`, String(d.summary ?? '').slice(0, 2000));
24
+ if (d.filesChanged?.length) lines.push(` files: ${d.filesChanged.join(', ')}`);
25
+ }
26
+ } else lines.push('No completed dependencies.');
27
+ if (task.attemptsLog?.length) {
28
+ lines.push('', 'PREVIOUS_ATTEMPTS');
29
+ for (const a of task.attemptsLog) {
30
+ lines.push(`- attempt ${a.attempt} [${a.failureClass}]: ${(a.reasons ?? []).join('; ') || a.summary || ''}`);
31
+ if (a.filesChanged?.length) lines.push(` changed: ${a.filesChanged.join(', ')}`);
32
+ if (a.verification?.length) lines.push(` verification: ${JSON.stringify(a.verification).slice(0, 800)}`);
33
+ }
34
+ }
35
+ lines.push('', 'WORKSPACE', `path: ${workspace?.path ?? '(unspecified)'}`, `repository: ${workspace?.repository ?? '(unspecified)'}`, 'Change files only inside this workspace. Do not reset, checkout, or stash existing changes.');
36
+ lines.push('', 'ACCEPTANCE_CRITERIA');
37
+ (task.acceptance?.length ? task.acceptance : ['the goal is achieved and verified']).forEach((a, i) => lines.push(`- A${i + 1}: ${a}`));
38
+ if (task.outputs?.length) lines.push('', 'REQUIRED_OUTPUTS', ...task.outputs.map(o => `- ${o}`));
39
+ lines.push('', 'ALLOWED_ACTIONS', `filesystem: ${rights.filesystem}`, `shell: ${rights.shell}`, `git: ${rights.git}`, `network: ${rights.network}`);
40
+ if (rights.shell !== 'false') lines.push('Run commands only through ludi_exec. Tests, lint, build, git status, and git diff are allowed when your shell access says so.');
41
+ lines.push('', 'FORBIDDEN_ACTIONS', ...forbiddenActions().map(f => `- ${f}`));
42
+ if (task.decisions?.length) lines.push('', 'DECISIONS_ALREADY_MADE', ...task.decisions.map(d => `- ${d.question} -> ${d.choice} (${d.reason})`));
43
+ if (task.feedback?.length) lines.push('', 'RETRY_FEEDBACK', ...task.feedback.map(f => `- ${f}`));
44
+ if (survey) {
45
+ lines.push('', 'REPOSITORY_FILES', ...survey.files.map(f => `- ${f.path} (${f.size} B)`));
46
+ lines.push('', 'FILE_CONTENTS', ...survey.inline.map(f => `### \`${f.path}\`\n\`\`\`\n${f.content}\n\`\`\``));
47
+ }
48
+ lines.push('', 'EXPECTED_RESULT_FORMAT',
49
+ 'Your reply MUST end with exactly one fenced ```json result block. A reply without it is rejected as malformed regardless of how good the prose is.',
50
+ 'Do not ask the user anything. If you need a choice, return status "needs_decision" or "blocked" and a decisions array.',
51
+ 'Required block (the LAST thing in your reply):',
52
+ '```json',
53
+ '{"status":"completed|failed|blocked|needs_decision","summary":"...","filesChanged":[],"commandsRun":[],',
54
+ ' "verification":[{"command":"...","result":"pass|fail|skipped"}],',
55
+ ' "acceptance":[{"id":"A1","met":true,"evidence":"..."}],',
56
+ ' "remainingIssues":[{"summary":"...","blocking":false}],"decisions":[],"newTasks":[]}',
57
+ '```',
58
+ 'status "completed" requires evidence for every acceptance id. A claim without a command, diff, or file reference is not evidence.',
59
+ '',
60
+ 'LANGUAGE',
61
+ 'Write natural-language values (summary, evidence, remainingIssues, decisions, newTasks) in Japanese unless the user requested another language.',
62
+ 'Keep all JSON keys, enum values (completed/failed/blocked/needs_decision/pass/fail/skipped), file paths, commands and identifiers in English/exact form — never translate them.');
63
+ return lines.join('\n');
64
+ }
65
+
66
+ export function accessForPrompt(agent) {
67
+ return accessOf(agent);
68
+ }
@@ -0,0 +1,84 @@
1
+ // Escalation gate: resolve a sub-agent's decision request without the user whenever policy allows.
2
+ // Order: hard gate -> current-run decision -> persistent decision memory -> single option ->
3
+ // reversible -> low risk -> project policy -> small experiment -> escalate to user.
4
+ // Hard gate is always first, so a remembered answer never authorizes an irreversible action.
5
+ import { ESCALATION_FLAGS } from './policy.mjs';
6
+
7
+ const LEVEL = { none: 0, low: 0, medium: 1, high: 2 };
8
+ const level = v => LEVEL[v] ?? 1;
9
+
10
+ export const decisionKey = d => String(d.key ?? d.question ?? '').trim().toLowerCase();
11
+
12
+ /** Flags that force user escalation: declared by the agent, derived from numbers, or matched by policy keywords. */
13
+ export function escalationFlags(decision, policy) {
14
+ const dp = policy.decision_policy;
15
+ const found = new Set((decision.flags ?? []).filter(f => ESCALATION_FLAGS.includes(f)));
16
+ for (const o of decision.options ?? []) {
17
+ for (const f of o.flags ?? []) if (ESCALATION_FLAGS.includes(f)) found.add(f);
18
+ if (typeof o.costUsd === 'number' && o.costUsd >= dp.high_cost_threshold_usd) found.add('high_cost');
19
+ if (typeof o.estimatedWeeks === 'number' && o.estimatedWeeks >= dp.major_direction_change_weeks) found.add('major_direction_change');
20
+ }
21
+ const text = [decision.question, ...(decision.options ?? []).map(o => o.summary)].join('\n').toLowerCase();
22
+ for (const [flag, words] of Object.entries(dp.escalation_keywords ?? {})) if (words.some(w => text.includes(w.toLowerCase()))) found.add(flag);
23
+ return [...found].filter(f => dp.escalation[f] !== false);
24
+ }
25
+
26
+ function policyScore(o, dp) {
27
+ let s = 0;
28
+ if (dp.prefer_existing_assets && o.usesExistingAssets === true) s += 1;
29
+ if (dp.prefer_maintainability && o.maintainability) s += { high: 1, medium: 0, low: -1 }[o.maintainability] ?? 0;
30
+ if (dp.prefer_reversible_actions && o.reversible === true) s += 1;
31
+ return s;
32
+ }
33
+
34
+ function best(options, score) {
35
+ let top = null, topScore = -Infinity, tie = false;
36
+ for (const o of options) {
37
+ const s = score(o);
38
+ if (s > topScore) { top = o; topScore = s; tie = false; } else if (s === topScore) tie = true;
39
+ }
40
+ return { option: top, tie };
41
+ }
42
+
43
+ /**
44
+ * @returns {{ action: 'decide'|'experiment'|'escalate', step: string, optionId?: string, reason: string, flags: string[], experiment?: object }}
45
+ */
46
+ export function evaluateDecision(decision, { policy, decisionLog = [], memory = [] }) {
47
+ const dp = policy.decision_policy;
48
+ const options = Array.isArray(decision.options) ? decision.options.filter(o => o && o.id) : [];
49
+ const key = decisionKey(decision);
50
+ const flags = escalationFlags(decision, policy);
51
+ const out = (action, step, reason, extra = {}) => ({ action, step, reason, flags, key, question: decision.question, ...extra });
52
+
53
+ if (flags.length) return out('escalate', 'hard-gate', `requires user: ${flags.join(', ')}`);
54
+
55
+ const prior = decisionLog.find(e => e.key === key && e.action === 'decide');
56
+ if (prior && options.some(o => o.id === prior.optionId)) return out('decide', 'context', `already decided earlier in this run (${prior.step})`, { optionId: prior.optionId });
57
+ const remembered = memory.find(m => m.key === key && options.some(o => o.id === m.decision?.optionId));
58
+ if (remembered) return out('decide', 'memory', `persistent decision memory (${remembered.scope})`, { optionId: remembered.decision.optionId, memoryId: remembered.id, scope: remembered.scope });
59
+ if (options.length === 1) return out('decide', 'context', 'only one viable option', { optionId: options[0].id });
60
+ if (!options.length) return out('escalate', 'no-options', 'decision request has no options to choose from');
61
+
62
+ const full = o => (o.id === decision.recommended ? 2 : 0) + policyScore(o, dp) - level(o.risk) - level(o.cost);
63
+ const reversible = decision.reversible === true || options.every(o => o.reversible === true);
64
+ if (reversible && dp.default_behavior.reversible_decision === 'auto') {
65
+ return out('decide', 'reversible', 'reversible decision; chose the best-scoring option', { optionId: best(options, full).option.id });
66
+ }
67
+ const lowRisk = options.filter(o => o.risk === 'low' && level(o.cost ?? 'low') === 0);
68
+ if (lowRisk.length && dp.default_behavior.low_risk_decision === 'auto') {
69
+ return out('decide', 'low-risk', 'low cost and low risk option available', { optionId: best(lowRisk, full).option.id });
70
+ }
71
+ const byPolicy = best(options, o => policyScore(o, dp));
72
+ if (!byPolicy.tie && policyScore(byPolicy.option, dp) > 0) {
73
+ return out('decide', 'policy', 'project policy (existing assets / maintainability / reversibility) prefers one option', { optionId: byPolicy.option.id });
74
+ }
75
+ const limit = dp.poc.prefer_if_estimated_hours_lte;
76
+ const triedExperiment = decisionLog.some(e => e.key === key && e.action === 'experiment');
77
+ const hours = decision.experiment?.estimatedHours ?? Math.max(...options.map(o => o.estimatedHours ?? Infinity));
78
+ if (!triedExperiment && Number.isFinite(hours) && hours <= limit) {
79
+ return out('experiment', 'experiment', `a small experiment (~${hours}h <= ${limit}h) can settle it`, {
80
+ experiment: { goal: decision.experiment?.goal ?? `Compare options for "${decision.question}": ${options.map(o => `${o.id} (${o.summary ?? ''})`).join('; ')}. Recommend one with evidence.`, agent: decision.experiment?.agent, estimatedHours: hours },
81
+ });
82
+ }
83
+ return out('escalate', 'unresolved', triedExperiment ? 'experiment already ran and the choice is still open' : 'not reversible, not low risk, and policy does not discriminate');
84
+ }
@@ -0,0 +1,92 @@
1
+ // ResultEvaluator: a task succeeds only with a structured result whose status is "completed",
2
+ // a non-empty summary, every required output present, and every acceptance criterion met with evidence.
3
+ import { existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { dangerousCommands } from './shell-policy.mjs';
6
+
7
+ const isBlocking = i => i?.blocking === true || ['high', 'critical', 'blocking'].includes(String(i?.severity ?? '').toLowerCase());
8
+
9
+ // Issues that mean "the work itself cannot proceed safely" — these still fail
10
+ // or gate even on investigation. Advisory findings (open questions, multiple
11
+ // valid directions, repo observations) are the deliverable of an investigation,
12
+ // not a failure.
13
+ const SAFETY_RE = /destructive|overwrite|uncommitted|conflict|mutually exclusive|不可逆|破壊|上書き|衝突|排他|未コミット.*(上書き|破壊|衝突)|overwrite.*uncommitted/i;
14
+ const DECISION_RE = /choose|select|which|pick|decide|ambiguous|unclear|不明|未定|未指定|選択|判断|どちら|いずれ|候補|option/i;
15
+
16
+ /** Classify a blocking issue: 'safety' gates the run, 'decision' needs user input, 'advisory' is a note. */
17
+ export function classifyBlockingIssue(issue) {
18
+ const text = `${issue?.summary ?? ''} ${issue?.detail ?? ''}`;
19
+ if (SAFETY_RE.test(text)) return 'safety';
20
+ if (DECISION_RE.test(text)) return 'decision';
21
+ return 'advisory';
22
+ }
23
+
24
+ /** @returns {{ verdict: 'success'|'failure'|'blocked', reasons: string[], decisions: object[], newTasks: object[], blockingIssues: object[] }} */
25
+ export function evaluateResult(task, run, { repoRoot = null } = {}) {
26
+ const out = (verdict, reasons, extra = {}) => ({ verdict, reasons, decisions: [], newTasks: [], blockingIssues: [], ...extra });
27
+ if (!run?.ok) {
28
+ // A runner-level decision request (e.g. dirty-worktree safety gate) bypasses
29
+ // the structured-result check — it never reached the model.
30
+ if (run?.decision) return out('blocked', [run.error ?? 'decision required'], { decisions: [run.decision] });
31
+ return out('failure', [`agent run failed: ${run?.error ?? 'unknown error'}`]);
32
+ }
33
+ if (!run.structured) return out('failure', ['no structured result block; a bare completion claim is not accepted']);
34
+ const r = run.result;
35
+ const newTasks = r.newTasks ?? [];
36
+ if (r.status === 'blocked') {
37
+ return r.decisions?.length ? out('blocked', ['agent needs a decision'], { decisions: r.decisions, newTasks }) : out('failure', ['agent reported blocked without a decision request']);
38
+ }
39
+ const reasons = [];
40
+ if (r.status === 'failed') reasons.push(`agent reported failure: ${r.summary || 'no summary'}`);
41
+ if (!r.summary?.trim()) reasons.push('summary is empty');
42
+ if (repoRoot) for (const o of task.outputs ?? []) if (!existsSync(resolve(repoRoot, o))) reasons.push(`required output missing: ${o}`);
43
+ const criteria = task.acceptance?.length ? task.acceptance : ['the goal is achieved and verified'];
44
+ criteria.forEach((c, i) => {
45
+ const id = `A${i + 1}`;
46
+ const entry = (r.acceptance ?? []).find(a => a?.id === id);
47
+ if (!entry) reasons.push(`acceptance ${id} not reported (${c})`);
48
+ else if (entry.met !== true) reasons.push(`acceptance ${id} not met (${c})${entry.evidence ? `: ${entry.evidence}` : ''}`);
49
+ else if (!String(entry.evidence ?? '').trim()) reasons.push(`acceptance ${id} claimed without evidence`);
50
+ });
51
+ const denied = dangerousCommands(r.commandsRun, { shell: 'true', network: false });
52
+ if (denied.length) {
53
+ return out('blocked', denied.map(d => `command refused: ${d.command} (${d.reason})`), {
54
+ decisions: denied.map(d => ({ key: `shell:${d.command}`, question: `Run \`${d.command}\`?`, flags: d.flags ?? ['destructive_action'], options: [{ id: 'no', summary: 'do not run it', reversible: true }, { id: 'yes', summary: d.command, reversible: false, flags: d.flags ?? ['destructive_action'] }] })),
55
+ newTasks,
56
+ });
57
+ }
58
+ const changes = run.worktree?.agentChanges ?? [];
59
+ if (task.kind === 'implement' && run.worktree?.before?.source && !changes.length && r.status === 'completed') reasons.push('no file changes detected against the workspace baseline');
60
+ if (changes.some(c => String(c.path).includes('..'))) reasons.push('change escaped the workspace');
61
+ const blockingIssues = (r.remainingIssues ?? []).filter(isBlocking);
62
+ // A review's job is to report blocking issues; for other tasks they mean the work is not done —
63
+ // EXCEPT investigations, where reporting open questions / multiple valid directions IS the work.
64
+ if (blockingIssues.length && task.kind !== 'review') {
65
+ if (task.kind === 'investigate') {
66
+ // Safety issues still gate; decision-shaped issues become a user decision;
67
+ // advisory issues are the deliverable and do not fail the task.
68
+ const safety = blockingIssues.filter(i => classifyBlockingIssue(i) === 'safety');
69
+ const decisionIssues = blockingIssues.filter(i => classifyBlockingIssue(i) === 'decision');
70
+ if (safety.length) {
71
+ reasons.push(...safety.map(i => `blocking issue: ${i.summary}`));
72
+ } else if (decisionIssues.length && r.status === 'completed') {
73
+ // Investigation surfaced a genuine either/or choice -> needs_decision, not failure.
74
+ return out('blocked', decisionIssues.map(i => `blocking issue: ${i.summary}`), {
75
+ decisions: decisionIssues.map(i => ({
76
+ key: `investigate:${i.summary}`,
77
+ question: i.summary,
78
+ flags: i.flags ?? [],
79
+ options: i.options ?? [{ id: 'proceed', summary: 'proceed with the recommended interpretation', reversible: true }, { id: 'clarify', summary: 'ask the user to clarify', reversible: true }],
80
+ recommended: i.recommended,
81
+ })),
82
+ newTasks,
83
+ });
84
+ }
85
+ // advisory blockingIssues fall through to success
86
+ } else {
87
+ reasons.push(...blockingIssues.map(i => `blocking issue: ${i.summary}`));
88
+ }
89
+ }
90
+ if (reasons.length) return out('failure', reasons, { newTasks });
91
+ return out('success', [], { newTasks, blockingIssues });
92
+ }