@voybio/ace-swarm 2.4.4 → 2.4.7

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 (120) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +72 -532
  3. package/assets/eval-battery/battery.json +85 -0
  4. package/assets/eval-battery/fixtures/edit-target.js +3 -0
  5. package/assets/scripts/render-mcp-configs.sh +12 -0
  6. package/dist/ace-context.d.ts +18 -0
  7. package/dist/ace-context.js +161 -16
  8. package/dist/ace-server-instructions.js +41 -12
  9. package/dist/bridge-trace.d.ts +17 -0
  10. package/dist/bridge-trace.js +23 -0
  11. package/dist/cli.d.ts +9 -1
  12. package/dist/cli.js +623 -11
  13. package/dist/discovery-runtime-wrappers.d.ts +16 -1
  14. package/dist/discovery-runtime-wrappers.js +65 -9
  15. package/dist/eval-harness.d.ts +110 -0
  16. package/dist/eval-harness.js +254 -0
  17. package/dist/experience-reflector.d.ts +13 -0
  18. package/dist/experience-reflector.js +62 -0
  19. package/dist/goal-engine.d.ts +155 -0
  20. package/dist/goal-engine.js +795 -0
  21. package/dist/helpers/store-resolution.js +40 -39
  22. package/dist/hermes/session-manager.d.ts +10 -0
  23. package/dist/hermes/session-manager.js +130 -27
  24. package/dist/job-scheduler.d.ts +58 -0
  25. package/dist/job-scheduler.js +202 -2
  26. package/dist/job-worker.d.ts +87 -0
  27. package/dist/job-worker.js +182 -0
  28. package/dist/local-model-policy.d.ts +8 -2
  29. package/dist/local-model-policy.js +31 -17
  30. package/dist/local-model-runtime.d.ts +6 -0
  31. package/dist/local-model-runtime.js +130 -2
  32. package/dist/measured-classing.d.ts +66 -0
  33. package/dist/measured-classing.js +165 -0
  34. package/dist/model-bridge.d.ts +45 -0
  35. package/dist/model-bridge.js +502 -44
  36. package/dist/orchestrator-supervisor.js +1 -1
  37. package/dist/plan-proposal.d.ts +7 -0
  38. package/dist/plan-proposal.js +74 -4
  39. package/dist/playbook-adaptation.d.ts +37 -0
  40. package/dist/playbook-adaptation.js +159 -0
  41. package/dist/playbook-curator.d.ts +16 -0
  42. package/dist/playbook-curator.js +106 -0
  43. package/dist/playbook-llm-reflector.d.ts +26 -0
  44. package/dist/playbook-llm-reflector.js +157 -0
  45. package/dist/public-surface.d.ts +2 -0
  46. package/dist/public-surface.js +26 -14
  47. package/dist/safe-edit.d.ts +20 -0
  48. package/dist/safe-edit.js +355 -78
  49. package/dist/server.d.ts +11 -2
  50. package/dist/server.js +55 -5
  51. package/dist/shared.d.ts +12 -0
  52. package/dist/shared.js +13 -1
  53. package/dist/source-mutation-coordinator.d.ts +54 -0
  54. package/dist/source-mutation-coordinator.js +259 -0
  55. package/dist/status-events.d.ts +4 -1
  56. package/dist/status-events.js +41 -6
  57. package/dist/store/ace-packed-store.d.ts +28 -9
  58. package/dist/store/ace-packed-store.js +324 -57
  59. package/dist/store/bgzf.d.ts +4 -0
  60. package/dist/store/bgzf.js +94 -0
  61. package/dist/store/bootstrap-store.d.ts +4 -1
  62. package/dist/store/bootstrap-store.js +57 -3
  63. package/dist/store/cache-workspace.d.ts +8 -0
  64. package/dist/store/cache-workspace.js +67 -1
  65. package/dist/store/crc32.d.ts +2 -0
  66. package/dist/store/crc32.js +16 -0
  67. package/dist/store/fsck.d.ts +16 -0
  68. package/dist/store/fsck.js +240 -0
  69. package/dist/store/materializers/context-snapshot-materializer.d.ts +1 -0
  70. package/dist/store/materializers/context-snapshot-materializer.js +4 -1
  71. package/dist/store/materializers/projection-manager.d.ts +7 -0
  72. package/dist/store/materializers/projection-manager.js +37 -0
  73. package/dist/store/materializers/scheduler-projection-materializer.js +10 -3
  74. package/dist/store/repositories/goal-repository.d.ts +112 -0
  75. package/dist/store/repositories/goal-repository.js +157 -0
  76. package/dist/store/repositories/playbook-repository.d.ts +107 -0
  77. package/dist/store/repositories/playbook-repository.js +314 -0
  78. package/dist/store/store-artifacts.js +44 -2
  79. package/dist/store/store-authority-audit.js +1 -0
  80. package/dist/store/store-cli.d.ts +30 -0
  81. package/dist/store/store-cli.js +242 -0
  82. package/dist/store/store-snapshot.d.ts +12 -0
  83. package/dist/store/store-snapshot.js +74 -11
  84. package/dist/store/types.d.ts +1 -0
  85. package/dist/store/write-coordinator.d.ts +7 -0
  86. package/dist/store/write-coordinator.js +11 -0
  87. package/dist/todo-state.d.ts +21 -0
  88. package/dist/todo-state.js +150 -53
  89. package/dist/tools-agent.js +86 -22
  90. package/dist/tools-discovery.js +5 -3
  91. package/dist/tools-files.js +39 -18
  92. package/dist/tools-framework.js +31 -3
  93. package/dist/tools-goal.d.ts +11 -0
  94. package/dist/tools-goal.js +108 -0
  95. package/dist/tools-handoff.js +7 -11
  96. package/dist/tools-lifecycle.js +2 -0
  97. package/dist/tools-scheduler.js +44 -1
  98. package/dist/tools-skills.js +3 -2
  99. package/dist/tools-todo.js +64 -3
  100. package/dist/tools.js +2 -0
  101. package/dist/tui/chat.js +4 -0
  102. package/dist/tui/index.js +4 -1
  103. package/dist/tui/ollama.d.ts +2 -0
  104. package/dist/tui/openai-compatible.d.ts +14 -0
  105. package/dist/tui/openai-compatible.js +15 -2
  106. package/dist/tui/provider-discovery.d.ts +1 -0
  107. package/dist/tui/provider-discovery.js +2 -2
  108. package/docs/cli-reference.md +127 -0
  109. package/docs/development.md +46 -0
  110. package/docs/evaluation-safety.md +27 -0
  111. package/docs/experience-playbook.md +35 -0
  112. package/docs/hermes.md +129 -0
  113. package/docs/library-api.md +31 -0
  114. package/docs/mcp-tool-reference.md +290 -0
  115. package/docs/provider-security.md +85 -0
  116. package/docs/state-model.md +113 -0
  117. package/docs/troubleshooting.md +95 -0
  118. package/docs/tui-operator-guide.md +75 -0
  119. package/package.json +3 -1
  120. package/scripts/dev/run_hermes_crispr_live.mjs +212 -0
@@ -0,0 +1,795 @@
1
+ /**
2
+ * ACE Goal Engine (WS-F).
3
+ *
4
+ * `startGoal("<objective>")` compiles a plan with ACE's own planner, then a
5
+ * self-paced driver loop (`runGoal`) executes it step-by-step under evidence-
6
+ * gated completion. A goal can leave `executing` only through one of four
7
+ * control-flow paths — evidence-gated success, budget exhaustion, operator
8
+ * stop, or an escalated blocker — so "declaring success by prose" is
9
+ * structurally impossible (there is no code path from a model message to
10
+ * `status: "done"`).
11
+ *
12
+ * That guarantee rests on the success contract carrying at least one
13
+ * machine-checkable obligation. A contract with none is refused at `startGoal`
14
+ * and again at verify time with `contract_uncheckable`, never satisfied
15
+ * vacuously — see {@link enforceableObligationCount}.
16
+ *
17
+ * This composes existing organs rather than forking a second orchestrator:
18
+ * - planning: proposePlan → validatePlan (blocking) → createTaskPlan
19
+ * - execution: getReadyTaskSteps / applyBridgeResultToStep / amendTaskPlan
20
+ * (the supervisor's own step primitives), one step per tick
21
+ * - completion: the same artifact-existence check the ModelBridge false-
22
+ * completion gate uses, run against the compiled success contract
23
+ *
24
+ * The single new mechanism is the goal record + tick/budget policy. All side
25
+ * effects (spawning a step, running a verification command, checking an
26
+ * artifact, sleeping, posting) are injectable so the engine is driven
27
+ * hermetically in tests with a stub bridge.
28
+ */
29
+ import { existsSync } from "node:fs";
30
+ import { isAbsolute, join } from "node:path";
31
+ import { amendTaskPlan, applyBridgeResultToStep, createTaskPlan, deriveTaskPlanStatus, getReadyTaskSteps, markTaskStepStatus, } from "./orchestrator-supervisor.js";
32
+ import { proposePlan, validatePlan } from "./plan-proposal.js";
33
+ import { resolveWorkspaceRoot } from "./helpers.js";
34
+ import { appendVericifyProcessPostSafe } from "./vericify-bridge.js";
35
+ import { appendStatusEventSafe } from "./status-events.js";
36
+ import { GoalRepository, defaultGoalBudgets, newGoalId, } from "./store/repositories/goal-repository.js";
37
+ import { PlaybookRepository } from "./store/repositories/playbook-repository.js";
38
+ // ── Defaults for the injectable seams ────────────────────────────────────────
39
+ function defaultArtifactExists(path, workspaceRoot) {
40
+ if (!path || path.trim().length === 0)
41
+ return false;
42
+ const clean = path.split("#")[0].trim();
43
+ const abs = isAbsolute(clean) ? clean : join(workspaceRoot, clean);
44
+ return existsSync(abs);
45
+ }
46
+ async function defaultRunVerification(command, workspaceRoot) {
47
+ const { exec } = await import("node:child_process");
48
+ return await new Promise((resolve) => {
49
+ exec(command, { cwd: workspaceRoot, timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
50
+ resolve({
51
+ ok: !error,
52
+ output: `${stdout ?? ""}${stderr ? `\n${stderr}` : ""}`.trim(),
53
+ });
54
+ });
55
+ });
56
+ }
57
+ async function defaultSpawnStep(step, _plan, _goal) {
58
+ const { runLocalModelTask } = await import("./local-model-runtime.js");
59
+ const outcome = await runLocalModelTask({
60
+ task: step.task,
61
+ role: step.role,
62
+ toolScope: step.tool_scope,
63
+ });
64
+ return outcome.result;
65
+ }
66
+ function defaultSleep(ms) {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+ function resolveDeps(deps = {}) {
70
+ const workspaceRoot = deps.workspaceRoot ?? resolveWorkspaceRoot();
71
+ const repo = new GoalRepository(workspaceRoot);
72
+ return {
73
+ workspaceRoot,
74
+ repo,
75
+ now: deps.now ?? (() => Date.now()),
76
+ spawnStep: deps.spawnStep ?? defaultSpawnStep,
77
+ compilePlan: deps.compilePlan ??
78
+ (async (objective) => {
79
+ const proposal = await proposePlan(objective);
80
+ const verdict = await validatePlan({ proposal });
81
+ return { proposal, verdict };
82
+ }),
83
+ runVerification: deps.runVerification ?? defaultRunVerification,
84
+ artifactExists: deps.artifactExists ?? defaultArtifactExists,
85
+ sleep: deps.sleep ?? defaultSleep,
86
+ emitStatusEvent: deps.emitStatusEvent ??
87
+ (async (input) => {
88
+ await appendStatusEventSafe({
89
+ source_module: "goal-engine",
90
+ event_type: `GOAL_${input.status.toUpperCase()}`,
91
+ status: "in_progress",
92
+ summary: input.summary,
93
+ payload: input.payload,
94
+ });
95
+ }),
96
+ appendProcessPost: deps.appendProcessPost ??
97
+ (async (input) => {
98
+ const kind = input.kind === "progress" || input.kind === "completion" || input.kind === "blocker"
99
+ ? input.kind
100
+ : "blocker";
101
+ await appendVericifyProcessPostSafe({
102
+ run_id: "workspace:current",
103
+ agent_id: "ace-goal-engine",
104
+ kind: kind === "completion" ? "completion" : kind === "progress" ? "progress" : "blocker",
105
+ summary: input.summary,
106
+ blocker_category: kind === "blocker" ? input.reason_code : undefined,
107
+ evidence_refs: input.evidence_refs,
108
+ });
109
+ }),
110
+ };
111
+ }
112
+ // ── Success-contract compilation ─────────────────────────────────────────────
113
+ const CRITERION_VERIFICATION_HINT = /^\s*verify\s*:\s*(.+)$/i;
114
+ const OBJECTIVE_VERIFICATION_HINT = /(?:^|[.!?]\s+)verify\s*:\s*(.+)$/i;
115
+ /**
116
+ * Extract explicitly delimited shell directives. Planner-authored criteria must
117
+ * devote the whole line to `verify:`. Objectives additionally support the
118
+ * documented trailing sentence form, `Ship it. verify: npm test`. In particular,
119
+ * ordinary prose containing `verification:` is never executable.
120
+ */
121
+ function extractVerificationCommands(text, mode) {
122
+ const found = [];
123
+ const hint = mode === "criterion" ? CRITERION_VERIFICATION_HINT : OBJECTIVE_VERIFICATION_HINT;
124
+ for (const line of text.split(/\r?\n/)) {
125
+ const match = hint.exec(line);
126
+ const command = match?.[1]?.trim().replace(/\.$/, "").trim();
127
+ if (command)
128
+ found.push(command);
129
+ }
130
+ return found;
131
+ }
132
+ /**
133
+ * An evidence ref is only machine-checkable when it names a filesystem path, and
134
+ * only a path separator proves that. A bare `foo.patch` or `v1.2` matches an
135
+ * "ends in an extension" test but names a hash or a version, so requiring it to
136
+ * exist yields a contract that `startGoal` accepts and `checkSuccessContract` can
137
+ * never satisfy. Those route to `uncompilable_criteria` for the qa step instead.
138
+ *
139
+ * Note the rest of the codebase treats `required_evidence_refs` as *substrings*
140
+ * matched against emitted evidence (tools-agent.ts `missingEvidence`); this engine
141
+ * is the only consumer that resolves them against the filesystem.
142
+ */
143
+ function isPathLikeEvidenceRef(ref) {
144
+ const clean = (ref.split("#")[0] ?? "").trim();
145
+ return clean.length > 0 && clean.includes("/");
146
+ }
147
+ /**
148
+ * The number of machine-checkable obligations in a contract. Zero means the
149
+ * only thing standing between the model and `status: "done"` is the model's own
150
+ * assertion — which is exactly what this engine exists to prevent.
151
+ *
152
+ * `gate_ids` is excluded: nothing populates it yet (see goal-repository.ts).
153
+ */
154
+ export function enforceableObligationCount(contract) {
155
+ return (contract.expected_artifacts.filter((a) => a.required !== false).length +
156
+ contract.required_evidence_refs.length +
157
+ contract.verification_commands.length);
158
+ }
159
+ function compileSuccessContract(proposal, input = {}) {
160
+ const expected = new Map();
161
+ const evidence = new Set();
162
+ const commands = new Set();
163
+ const uncompilable = [];
164
+ for (const step of proposal.steps) {
165
+ for (const artifact of step.expected_artifacts ?? []) {
166
+ if (!artifact.path)
167
+ continue;
168
+ // Scaffolded paths were invented by the deterministic planner to satisfy
169
+ // validatePlan, not promised by anyone. `agent-state/SPEC_CONTRACT.json` is
170
+ // shared across every goal in a workspace, so admitting it as an obligation
171
+ // lets one goal's leftover file certify the next goal's prose. Keep it in
172
+ // the plan, keep it out of the contract.
173
+ if (artifact.scaffolded)
174
+ continue;
175
+ expected.set(artifact.path, {
176
+ path: artifact.path,
177
+ required: artifact.required !== false,
178
+ });
179
+ }
180
+ for (const ref of step.required_evidence_refs ?? []) {
181
+ if (!ref)
182
+ continue;
183
+ // A ref like "reviewer sign-off" is not a path; enforcing it via
184
+ // artifactExists would fail forever. Adjudicate it in the qa step instead.
185
+ if (isPathLikeEvidenceRef(ref))
186
+ evidence.add(ref);
187
+ else
188
+ uncompilable.push(`evidence: ${ref}`);
189
+ }
190
+ }
191
+ // Success criteria and the objective both carry `verify: <cmd>` directives;
192
+ // everything else is uncompilable and routed to a qa step. The objective is the
193
+ // only channel a CLI user has, so it must be read: `ace goal start "Ship it.
194
+ // verify: npm test"`.
195
+ for (const criterion of proposal.success_criteria ?? []) {
196
+ const found = extractVerificationCommands(criterion, "criterion");
197
+ if (found.length > 0)
198
+ for (const command of found)
199
+ commands.add(command);
200
+ else
201
+ uncompilable.push(criterion);
202
+ }
203
+ for (const command of extractVerificationCommands(input.objective ?? "", "objective")) {
204
+ commands.add(command);
205
+ }
206
+ for (const command of input.verify_commands ?? []) {
207
+ const trimmed = command.trim();
208
+ if (trimmed)
209
+ commands.add(trimmed);
210
+ }
211
+ return {
212
+ expected_artifacts: Array.from(expected.values()),
213
+ required_evidence_refs: Array.from(evidence),
214
+ verification_commands: Array.from(commands),
215
+ gate_ids: [],
216
+ uncompilable_criteria: uncompilable,
217
+ };
218
+ }
219
+ function proposalToPlanSteps(proposal) {
220
+ return proposal.steps.map((s) => ({
221
+ role: s.role,
222
+ task: s.task,
223
+ depends_on: s.depends_on && s.depends_on.length > 0 ? s.depends_on : undefined,
224
+ tool_scope: s.tool_scope && s.tool_scope.length > 0 ? s.tool_scope : undefined,
225
+ expected_output_class: s.expected_output_class,
226
+ expected_artifacts: s.expected_artifacts,
227
+ allowed_tools: s.allowed_tools,
228
+ forbidden_patterns: s.forbidden_patterns,
229
+ required_evidence_refs: s.required_evidence_refs,
230
+ structural_edit_plan_required: s.structural_edit_plan_required,
231
+ structural_edit_waiver: s.structural_edit_waiver,
232
+ }));
233
+ }
234
+ // ── Escalation helper ────────────────────────────────────────────────────────
235
+ async function escalate(d, goal, kind, reasonCode, summary) {
236
+ const escalation = {
237
+ at: new Date(d.now()).toISOString(),
238
+ kind,
239
+ reason_code: reasonCode,
240
+ summary,
241
+ };
242
+ goal.escalations.push(escalation);
243
+ await d.emitStatusEvent({
244
+ status: kind === "run_complete" ? "done" : "blocked",
245
+ summary: `[${goal.goal_id}] ${summary}`,
246
+ payload: { goal_id: goal.goal_id, kind, reason_code: reasonCode },
247
+ });
248
+ await d.appendProcessPost({
249
+ kind: kind === "run_complete" ? "completion" : "blocker",
250
+ summary: `Goal ${goal.goal_id}: ${summary}`,
251
+ reason_code: reasonCode,
252
+ });
253
+ }
254
+ export async function startGoal(objective, options = {}, deps = {}) {
255
+ const d = resolveDeps(deps);
256
+ const trimmed = objective.trim();
257
+ // `typeof NaN === "number"`, and NaN silently disables a budget in memory
258
+ // (`elapsed >= NaN` is false) while serialising to `null`, which makes the same
259
+ // budget fire immediately after the record is reloaded. Neither is a budget.
260
+ const budgets = {
261
+ ...defaultGoalBudgets(),
262
+ ...(Number.isFinite(options.budget_hours)
263
+ ? { max_wall_clock_ms: Math.max(1, options.budget_hours) * 60 * 60 * 1000 }
264
+ : {}),
265
+ ...(Number.isFinite(options.max_ticks) ? { max_ticks: options.max_ticks } : {}),
266
+ ...(Number.isFinite(options.max_replans) ? { max_replans: options.max_replans } : {}),
267
+ };
268
+ const iso = new Date(d.now()).toISOString();
269
+ let goal = {
270
+ goal_id: options.goal_id ?? newGoalId(),
271
+ objective: trimmed,
272
+ status: "planning",
273
+ success_contract: {
274
+ expected_artifacts: [],
275
+ required_evidence_refs: [],
276
+ verification_commands: [],
277
+ gate_ids: [],
278
+ uncompilable_criteria: [],
279
+ },
280
+ budgets,
281
+ created_at: iso,
282
+ updated_at: iso,
283
+ tick_count: 0,
284
+ replan_count: 0,
285
+ stop_requested: false,
286
+ escalations: [],
287
+ };
288
+ if (trimmed.length === 0) {
289
+ goal.status = "failed";
290
+ goal.reason_code = "empty_objective";
291
+ goal = d.repo.saveGoal(goal);
292
+ return { ok: false, goal, reason_code: "empty_objective" };
293
+ }
294
+ goal = d.repo.saveGoal(goal);
295
+ // Compile + validate the plan. A plan that fails the quality gate never starts.
296
+ const { proposal, verdict } = await d.compilePlan(trimmed);
297
+ if (!verdict.ok) {
298
+ goal.status = "blocked";
299
+ goal.reason_code = "plan_rejected";
300
+ goal = d.repo.saveGoal(goal);
301
+ await escalate(d, goal, "approval_required", "plan_rejected", `plan rejected by quality gate: ${verdict.blocking_findings.join("; ")}`);
302
+ d.repo.saveGoal(goal);
303
+ return {
304
+ ok: false,
305
+ goal,
306
+ reason_code: "plan_rejected",
307
+ blocking_findings: verdict.blocking_findings,
308
+ };
309
+ }
310
+ const contract = compileSuccessContract(proposal, {
311
+ objective: trimmed,
312
+ verify_commands: options.verify_commands,
313
+ });
314
+ // Fail fast: a goal that could only ever be completed by model assertion must
315
+ // never reach `executing`. Blocking here beats burning ten steps of model
316
+ // budget first.
317
+ if (enforceableObligationCount(contract) === 0) {
318
+ goal.status = "blocked";
319
+ goal.reason_code = "contract_uncheckable";
320
+ goal.success_contract = contract;
321
+ goal = d.repo.saveGoal(goal);
322
+ await escalate(d, goal, "approval_required", "contract_uncheckable", "compiled plan declares no required artifacts, no path-like evidence refs, and no " +
323
+ "`verify: <command>` success criteria — the goal could only ever be completed by " +
324
+ "model assertion. Append `verify: <shell command>` to the objective, or pass " +
325
+ "`--verify <shell command>`.");
326
+ d.repo.saveGoal(goal);
327
+ return { ok: false, goal, reason_code: "contract_uncheckable" };
328
+ }
329
+ let plan = createTaskPlan({
330
+ task: trimmed,
331
+ steps: proposalToPlanSteps(proposal),
332
+ execution_mode: "sequential",
333
+ });
334
+ // L3: uncompilable success criteria are never silently dropped — append a
335
+ // terminal qa review step that adjudicates them, gated after all other work.
336
+ if (contract.uncompilable_criteria.length > 0) {
337
+ const priorIds = plan.steps.map((s) => s.step_id);
338
+ plan = amendTaskPlan(plan, {
339
+ append_steps: [
340
+ {
341
+ role: "qa",
342
+ task: "Review and confirm the following success criteria that could not be " +
343
+ `machine-verified:\n- ${contract.uncompilable_criteria.join("\n- ")}`,
344
+ depends_on: priorIds,
345
+ expected_output_class: "qa_verdict",
346
+ },
347
+ ],
348
+ });
349
+ }
350
+ goal.plan_id = plan.plan_id;
351
+ goal.success_contract = contract;
352
+ d.repo.savePlan(goal.goal_id, plan);
353
+ if (options.plan_only) {
354
+ goal.status = "planning";
355
+ goal.result_summary = `Plan compiled (${plan.steps.length} steps); execution deferred (plan_only).`;
356
+ goal = d.repo.saveGoal(goal);
357
+ return { ok: true, goal };
358
+ }
359
+ goal.status = "executing";
360
+ goal.started_at = iso;
361
+ goal.last_heartbeat_at = iso;
362
+ goal = d.repo.saveGoal(goal);
363
+ return { ok: true, goal };
364
+ }
365
+ export async function tickGoal(goalId, deps = {}) {
366
+ const d = resolveDeps(deps);
367
+ const goal = requireGoal(d, goalId);
368
+ const plan = requirePlan(d, goal);
369
+ const ready = getReadyTaskSteps(plan);
370
+ if (ready.length === 0) {
371
+ return { goal, plan, progressed: false };
372
+ }
373
+ const step = ready[0];
374
+ // Mark running and persist *before* the spawn so a crash mid-step leaves a
375
+ // `running` step that resumeGoal can detect and reset.
376
+ let nextPlan = markTaskStepStatus(plan, step.step_id, "running");
377
+ goal.current_step_id = step.step_id;
378
+ goal.tick_count += 1;
379
+ goal.last_heartbeat_at = new Date(d.now()).toISOString();
380
+ d.repo.savePlan(goalId, nextPlan);
381
+ d.repo.saveGoal(goal);
382
+ await d.emitStatusEvent({
383
+ status: "step_started",
384
+ summary: `tick ${goal.tick_count}: running ${step.step_id} (${step.role})`,
385
+ payload: { goal_id: goalId, step_id: step.step_id, role: step.role },
386
+ });
387
+ let result;
388
+ try {
389
+ result = await d.spawnStep(step, nextPlan, goal);
390
+ }
391
+ catch (err) {
392
+ result = {
393
+ bridge_id: `goal-${goalId}`,
394
+ role: step.role,
395
+ status: "failed",
396
+ summary: err instanceof Error ? err.message : String(err),
397
+ turns: 0,
398
+ tool_calls: [],
399
+ child_results: [],
400
+ reason_code: "spawn_threw",
401
+ };
402
+ }
403
+ nextPlan = applyBridgeResultToStep(nextPlan, step.step_id, result);
404
+ const finalStep = nextPlan.steps.find((s) => s.step_id === step.step_id);
405
+ // Re-hydrate so any field mutated *during* the spawn (e.g. an operator
406
+ // stop_requested via stopGoal) is preserved rather than clobbered by this
407
+ // tick's stale in-memory snapshot.
408
+ const latestGoal = d.repo.getGoal(goalId) ?? goal;
409
+ latestGoal.current_step_id = step.step_id;
410
+ latestGoal.last_heartbeat_at = new Date(d.now()).toISOString();
411
+ d.repo.savePlan(goalId, nextPlan);
412
+ d.repo.saveGoal(latestGoal);
413
+ await d.appendProcessPost({
414
+ kind: "progress",
415
+ summary: `Goal ${goalId} tick ${latestGoal.tick_count}: ${step.step_id} → ${finalStep?.status ?? "unknown"} (${result.summary})`,
416
+ evidence_refs: finalStep?.evidence_refs,
417
+ });
418
+ return {
419
+ goal: latestGoal,
420
+ plan: nextPlan,
421
+ progressed: true,
422
+ step_id: step.step_id,
423
+ step_status: finalStep?.status,
424
+ };
425
+ }
426
+ export async function checkSuccessContract(d, goal) {
427
+ const contract = goal.success_contract;
428
+ // A contract with nothing to check cannot certify anything. Refuse rather
429
+ // than pass vacuously — the whole point of the engine (see module docstring).
430
+ if (enforceableObligationCount(contract) === 0) {
431
+ return {
432
+ ok: false,
433
+ missing_artifacts: [],
434
+ missing_evidence_refs: [],
435
+ failed_commands: [],
436
+ uncheckable: true,
437
+ };
438
+ }
439
+ const missing = [];
440
+ for (const artifact of contract.expected_artifacts) {
441
+ if (artifact.required === false)
442
+ continue;
443
+ if (!d.artifactExists(artifact.path, d.workspaceRoot)) {
444
+ missing.push(artifact.path);
445
+ }
446
+ }
447
+ const missingEvidence = [];
448
+ for (const ref of contract.required_evidence_refs) {
449
+ if (!d.artifactExists(ref, d.workspaceRoot))
450
+ missingEvidence.push(ref);
451
+ }
452
+ const failed = [];
453
+ for (const command of contract.verification_commands) {
454
+ const outcome = await d.runVerification(command, d.workspaceRoot);
455
+ if (!outcome.ok)
456
+ failed.push(command);
457
+ }
458
+ return {
459
+ ok: missing.length === 0 && missingEvidence.length === 0 && failed.length === 0,
460
+ missing_artifacts: missing,
461
+ missing_evidence_refs: missingEvidence,
462
+ failed_commands: failed,
463
+ uncheckable: false,
464
+ };
465
+ }
466
+ const BACKOFF_START_MS = 5_000;
467
+ const BACKOFF_CAP_MS = 60_000;
468
+ function playbookEnabled() {
469
+ const raw = String(process.env.ACE_PLAYBOOK ?? "").trim().toLowerCase();
470
+ return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
471
+ }
472
+ function playbookRetryNote(workspaceRoot, reasonCode) {
473
+ if (!playbookEnabled() || !reasonCode)
474
+ return undefined;
475
+ try {
476
+ const count = new PlaybookRepository(workspaceRoot)
477
+ .listBullets({ status: "active" })
478
+ .filter((bullet) => bullet.scope.reason_code === reasonCode).length;
479
+ if (count === 0)
480
+ return undefined;
481
+ return `Playbook: ${count} lessons for reason ${reasonCode} are active in your context`;
482
+ }
483
+ catch {
484
+ return undefined;
485
+ }
486
+ }
487
+ export async function runGoal(goalId, deps = {}) {
488
+ const d = resolveDeps(deps);
489
+ let goal = requireGoal(d, goalId);
490
+ if (goal.status !== "executing" && goal.status !== "verifying") {
491
+ return { goal, plan: d.repo.getPlan(goalId) };
492
+ }
493
+ let backoff = BACKOFF_START_MS;
494
+ // Bounded by max_ticks; the loop can only exit through an explicit terminal
495
+ // transition below, never by a model asserting completion.
496
+ for (;;) {
497
+ goal = requireGoal(d, goalId);
498
+ // 1. Operator stop.
499
+ if (goal.stop_requested) {
500
+ goal.status = "stopped";
501
+ goal.result_summary = "Stopped by operator.";
502
+ goal = d.repo.saveGoal(goal);
503
+ await escalate(d, goal, "run_error", "operator_stop", "stopped by operator");
504
+ d.repo.saveGoal(goal);
505
+ break;
506
+ }
507
+ // 2. Budget enforcement.
508
+ const budgetReason = budgetExhausted(d, goal);
509
+ if (budgetReason) {
510
+ const budgetCode = `goal_budget_exhausted:${budgetReason}`;
511
+ goal.status = "blocked";
512
+ goal.reason_code = budgetCode;
513
+ goal = d.repo.saveGoal(goal);
514
+ await escalate(d, goal, "budget_blocked", budgetCode, `budget exhausted (${budgetReason})`);
515
+ d.repo.saveGoal(goal);
516
+ break;
517
+ }
518
+ const plan = requirePlan(d, goal);
519
+ const status = deriveTaskPlanStatus(plan);
520
+ // 3. Plan finished all steps → verify against the success contract.
521
+ if (status === "done") {
522
+ goal.status = "verifying";
523
+ goal = d.repo.saveGoal(goal);
524
+ const check = await checkSuccessContract(d, goal);
525
+ // Defense in depth: a hand-edited or pre-fix goal record can carry a
526
+ // vacuous contract past startGoal. Never certify it on prose alone.
527
+ if (check.uncheckable) {
528
+ goal.status = "blocked";
529
+ goal.reason_code = "contract_uncheckable";
530
+ goal = d.repo.saveGoal(goal);
531
+ await escalate(d, goal, "approval_required", "contract_uncheckable", `all plan steps completed but the success contract has zero enforceable obligations ` +
532
+ `(${goal.success_contract.uncompilable_criteria.length} uncompilable criteria); ` +
533
+ `refusing to mark done on model assertion alone`);
534
+ d.repo.saveGoal(goal);
535
+ break;
536
+ }
537
+ if (check.ok) {
538
+ const doneSummary = `Success contract satisfied (${goal.success_contract.expected_artifacts.length} artifacts, ${goal.success_contract.verification_commands.length} commands).`;
539
+ goal.status = "done";
540
+ goal.result_summary = doneSummary;
541
+ goal = d.repo.saveGoal(goal);
542
+ await escalate(d, goal, "run_complete", "success", doneSummary);
543
+ d.repo.saveGoal(goal);
544
+ break;
545
+ }
546
+ // Missing evidence → synthesize a remediation step (counts as a replan).
547
+ if (goal.replan_count >= goal.budgets.max_replans) {
548
+ const replanCode = "goal_budget_exhausted:max_replans";
549
+ goal.status = "blocked";
550
+ goal.reason_code = replanCode;
551
+ goal = d.repo.saveGoal(goal);
552
+ await escalate(d, goal, "gate_failed", replanCode, `success contract unmet and replans exhausted (missing: ${[
553
+ ...check.missing_artifacts,
554
+ ...check.missing_evidence_refs,
555
+ ...check.failed_commands,
556
+ ].join(", ")})`);
557
+ d.repo.saveGoal(goal);
558
+ break;
559
+ }
560
+ const remediated = amendTaskPlan(plan, {
561
+ append_steps: [
562
+ {
563
+ role: "coders",
564
+ task: "The goal success contract is unmet. Produce the missing evidence.\n" +
565
+ (check.missing_artifacts.length > 0
566
+ ? `Missing artifacts: ${check.missing_artifacts.join(", ")}\n`
567
+ : "") +
568
+ (check.missing_evidence_refs.length > 0
569
+ ? `Missing evidence refs: ${check.missing_evidence_refs.join(", ")}\n`
570
+ : "") +
571
+ (check.failed_commands.length > 0
572
+ ? `Failing verification commands: ${check.failed_commands.join("; ")}`
573
+ : ""),
574
+ expected_artifacts: [
575
+ ...check.missing_artifacts,
576
+ ...check.missing_evidence_refs,
577
+ ].map((path) => ({ path, required: true })),
578
+ },
579
+ ],
580
+ });
581
+ goal.replan_count += 1;
582
+ goal.status = "executing";
583
+ d.repo.savePlan(goalId, remediated);
584
+ goal = d.repo.saveGoal(goal);
585
+ await d.appendProcessPost({
586
+ kind: "progress",
587
+ summary: `Goal ${goalId}: success contract unmet, synthesized remediation step (replan ${goal.replan_count}/${goal.budgets.max_replans}).`,
588
+ });
589
+ backoff = BACKOFF_START_MS;
590
+ continue;
591
+ }
592
+ // 4. Plan blocked/failed → classify and either replan or park.
593
+ if (status === "blocked" || status === "failed") {
594
+ if (goal.replan_count >= goal.budgets.max_replans) {
595
+ goal.status = "blocked";
596
+ goal.reason_code = "plan_blocked";
597
+ goal = d.repo.saveGoal(goal);
598
+ await escalate(d, goal, "blocker", "plan_blocked", `plan ${status}; replans exhausted (${blockedReasons(plan)})`);
599
+ d.repo.saveGoal(goal);
600
+ break;
601
+ }
602
+ // Reset blocked/failed steps to planned for one more attempt (a replan).
603
+ const retryNoteByStep = new Map(plan.steps
604
+ .filter((s) => s.status === "blocked" || s.status === "failed")
605
+ .map((s) => [
606
+ s.step_id,
607
+ playbookRetryNote(d.workspaceRoot, s.blocked_reason ?? s.result_summary ?? undefined),
608
+ ]));
609
+ const retried = amendTaskPlan(plan, {
610
+ update_steps: plan.steps
611
+ .filter((s) => s.status === "blocked" || s.status === "failed")
612
+ .map((s) => ({
613
+ step_id: s.step_id,
614
+ patch: {
615
+ status: "planned",
616
+ blocked_reason: undefined,
617
+ upstream_outputs: [
618
+ ...(s.upstream_outputs ?? []),
619
+ {
620
+ step_id: s.step_id,
621
+ result_summary: `retry after ${s.status}: ${s.blocked_reason ?? s.result_summary ?? ""}`,
622
+ evidence_refs: [],
623
+ },
624
+ ...(retryNoteByStep.get(s.step_id)
625
+ ? [{
626
+ step_id: s.step_id,
627
+ result_summary: retryNoteByStep.get(s.step_id),
628
+ evidence_refs: [],
629
+ }]
630
+ : []),
631
+ ],
632
+ },
633
+ })),
634
+ });
635
+ goal.replan_count += 1;
636
+ goal = d.repo.saveGoal(goal);
637
+ d.repo.savePlan(goalId, retried);
638
+ await d.appendProcessPost({
639
+ kind: "progress",
640
+ summary: `Goal ${goalId}: plan ${status}, retrying blocked steps (replan ${goal.replan_count}/${goal.budgets.max_replans}).`,
641
+ });
642
+ backoff = BACKOFF_START_MS;
643
+ continue;
644
+ }
645
+ // 5. Ready work exists → execute one step.
646
+ const tick = await tickGoal(goalId, deps);
647
+ if (tick.progressed) {
648
+ backoff = BACKOFF_START_MS;
649
+ continue;
650
+ }
651
+ // 6. No ready step and not terminal. If nothing is in flight either, no future
652
+ // event can make a step ready — the plan's dependency graph is unsatisfiable
653
+ // and backing off would spin until the wall-clock budget expires (or forever,
654
+ // since `tickGoal` does not advance `tick_count` when it cannot progress).
655
+ if (!plan.steps.some((step) => step.status === "running")) {
656
+ goal.status = "blocked";
657
+ goal.reason_code = "plan_deadlocked";
658
+ goal = d.repo.saveGoal(goal);
659
+ await escalate(d, goal, "blocker", "plan_deadlocked", `no step is ready and none is running; the plan cannot progress ` +
660
+ `(unresolved: ${plan.steps.filter((s) => s.status === "planned").map((s) => s.step_id).join(", ") || "none"})`);
661
+ d.repo.saveGoal(goal);
662
+ break;
663
+ }
664
+ // A step is still running (an engine restarted mid-step). Back off, but charge
665
+ // the wait against max_ticks so an in-flight step cannot stall the goal forever.
666
+ goal.tick_count += 1;
667
+ goal = d.repo.saveGoal(goal);
668
+ await d.sleep(backoff);
669
+ backoff = Math.min(backoff * 2, BACKOFF_CAP_MS);
670
+ }
671
+ return { goal, plan: d.repo.getPlan(goalId) };
672
+ }
673
+ // ── resumeGoal / stopGoal / status ───────────────────────────────────────────
674
+ export async function resumeGoal(goalId, deps = {}) {
675
+ const d = resolveDeps(deps);
676
+ const goal = requireGoal(d, goalId);
677
+ const plan = d.repo.getPlan(goalId);
678
+ if (plan) {
679
+ // A step left `running` means the engine died mid-step — reset it to planned
680
+ // (the supervisor's own revisit pattern) so it re-executes on resume.
681
+ const stuck = plan.steps.filter((s) => s.status === "running");
682
+ if (stuck.length > 0) {
683
+ const reset = amendTaskPlan(plan, {
684
+ update_steps: stuck.map((s) => ({
685
+ step_id: s.step_id,
686
+ patch: {
687
+ status: "planned",
688
+ upstream_outputs: [
689
+ ...(s.upstream_outputs ?? []),
690
+ { step_id: s.step_id, result_summary: "reset after engine restart mid-step", evidence_refs: [] },
691
+ ],
692
+ },
693
+ })),
694
+ });
695
+ d.repo.savePlan(goalId, reset);
696
+ }
697
+ }
698
+ if (goal.status === "blocked" || goal.status === "stopped") {
699
+ // A `contract_uncheckable` block is not a transient failure — nothing about
700
+ // re-running changes the contract. Force the operator to restart the goal.
701
+ if (goal.reason_code === "contract_uncheckable") {
702
+ throw new GoalNotResumableError(goalId, `Goal ${goalId} is blocked on an uncheckable success contract and cannot be resumed. ` +
703
+ `Start a new goal whose objective declares an artifact or a \`verify: <command>\` criterion.`);
704
+ }
705
+ goal.status = "executing";
706
+ goal.stop_requested = false;
707
+ goal.reason_code = undefined;
708
+ d.repo.saveGoal(goal);
709
+ }
710
+ // `--plan-only` parks at `planning` with a compiled plan. Resuming is the
711
+ // documented way to execute it later; without this, the plan is unreachable.
712
+ if (goal.status === "planning") {
713
+ if (!plan)
714
+ throw new Error(`Goal ${goalId} has no compiled plan to resume.`);
715
+ const iso = new Date(d.now()).toISOString();
716
+ goal.status = "executing";
717
+ goal.stop_requested = false;
718
+ goal.started_at = goal.started_at ?? iso;
719
+ goal.last_heartbeat_at = iso;
720
+ d.repo.saveGoal(goal);
721
+ }
722
+ return runGoal(goalId, deps);
723
+ }
724
+ export function stopGoal(goalId, deps = {}) {
725
+ const d = resolveDeps(deps);
726
+ const goal = requireGoal(d, goalId);
727
+ goal.stop_requested = true;
728
+ return d.repo.saveGoal(goal);
729
+ }
730
+ export function getGoalStatus(goalId, deps = {}) {
731
+ const d = resolveDeps(deps);
732
+ const goal = d.repo.getGoal(goalId);
733
+ if (!goal)
734
+ return undefined;
735
+ const plan = d.repo.getPlan(goalId);
736
+ return {
737
+ goal,
738
+ plan,
739
+ ready_steps: plan ? getReadyTaskSteps(plan).map((s) => s.step_id) : [],
740
+ plan_status: plan ? deriveTaskPlanStatus(plan) : undefined,
741
+ };
742
+ }
743
+ export function listGoals(filter = {}, deps = {}) {
744
+ const d = resolveDeps(deps);
745
+ return d.repo.listGoals(filter);
746
+ }
747
+ // ── internal helpers ─────────────────────────────────────────────────────────
748
+ export class UnknownGoalError extends Error {
749
+ goal_id;
750
+ name = "UnknownGoalError";
751
+ constructor(goal_id) {
752
+ super(`Unknown goal_id: ${goal_id}`);
753
+ this.goal_id = goal_id;
754
+ }
755
+ }
756
+ /** A goal whose state cannot be revived by re-running it. Reported, not thrown, at the CLI. */
757
+ export class GoalNotResumableError extends Error {
758
+ goal_id;
759
+ message;
760
+ name = "GoalNotResumableError";
761
+ constructor(goal_id, message) {
762
+ super(message);
763
+ this.goal_id = goal_id;
764
+ this.message = message;
765
+ }
766
+ }
767
+ function requireGoal(d, goalId) {
768
+ const goal = d.repo.getGoal(goalId);
769
+ if (!goal)
770
+ throw new UnknownGoalError(goalId);
771
+ return goal;
772
+ }
773
+ function requirePlan(d, goal) {
774
+ const plan = goal.plan_id ? d.repo.getPlan(goal.goal_id) : undefined;
775
+ if (!plan)
776
+ throw new Error(`Goal ${goal.goal_id} has no persisted plan.`);
777
+ return plan;
778
+ }
779
+ function budgetExhausted(d, goal) {
780
+ if (goal.tick_count >= goal.budgets.max_ticks)
781
+ return "max_ticks";
782
+ if (goal.started_at) {
783
+ const elapsed = d.now() - Date.parse(goal.started_at);
784
+ if (elapsed >= goal.budgets.max_wall_clock_ms)
785
+ return "max_wall_clock";
786
+ }
787
+ return undefined;
788
+ }
789
+ function blockedReasons(plan) {
790
+ return plan.steps
791
+ .filter((s) => s.status === "blocked" || s.status === "failed")
792
+ .map((s) => `${s.step_id}:${s.blocked_reason ?? s.result_summary ?? s.status}`)
793
+ .join(", ");
794
+ }
795
+ //# sourceMappingURL=goal-engine.js.map