atom-agent 1.2.0 → 1.3.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 (54) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/palette.js +1 -1
  33. package/dist/ui/status-bar.js +80 -5
  34. package/dist/zen.js +305 -75
  35. package/documentation/architecture.md +114 -0
  36. package/documentation/cli.md +82 -0
  37. package/documentation/compaction.md +50 -0
  38. package/documentation/configuration.md +111 -0
  39. package/documentation/development.md +62 -0
  40. package/documentation/extensions.md +160 -0
  41. package/documentation/getting-started.md +63 -0
  42. package/documentation/goals.md +41 -0
  43. package/documentation/index.md +41 -0
  44. package/documentation/observability.md +70 -0
  45. package/documentation/permissions.md +66 -0
  46. package/documentation/providers.md +78 -0
  47. package/documentation/sessions.md +92 -0
  48. package/documentation/skills.md +57 -0
  49. package/documentation/tools.md +94 -0
  50. package/documentation/troubleshooting.md +54 -0
  51. package/examples/extensions/01-audit-gate.js +24 -0
  52. package/examples/extensions/02-notes-tool.js +32 -0
  53. package/examples/extensions/03-custom-command.js +32 -0
  54. package/package.json +6 -2
@@ -21,11 +21,13 @@
21
21
  // agent/normalize, agent/types} and NOT zen (transports stay in
22
22
  // zen.ts; runAgenticLoopForProvider wraps this loop from there).
23
23
  import { loadAtomConfig } from "../config.js";
24
- import { createContextManager, historyCharBudget, historyChars, historyMessageBudget, truncateHistoryWithCaps, } from "../context-manager.js";
24
+ import { historyChars } from "../context-manager.js";
25
25
  import { planBatches } from "../scheduler.js";
26
26
  import { describeToolCall, executeTool, invalidCall, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
27
+ import { afterToolInterceptors, applyAfterInterceptors, applyBeforeInterceptors, beforeToolInterceptors, blockedToolResult, } from "../tools/intercept.js";
27
28
  import { getReadCacheStats } from "../tools/read-cache.js";
28
- import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, openTodoNeedles, } from "./gates.js";
29
+ import { emptyGoalProgress, GOAL_STALL_REPEATS, goalFollowUp, goalPausedNotice, goalReportAck, goalReportOutsideError, goalReportRejectedNotice, goalStallNudge, goalStallReached, goalVerdictNotice, noteGoalProgress, recentTurnsForJudge, resetGoalStall, sameGoalDisposition, updateGoalDisposition, validateUpdateGoalArgs, } from "../goal.js";
30
+ import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, todoCompletionGate, verificationGate, } from "./gates.js";
29
31
  import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
30
32
  import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
31
33
  // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
@@ -72,13 +74,6 @@ export function toolStepBudget() {
72
74
  }
73
75
  return loadAtomConfig().config.maxToolSteps ?? Number.POSITIVE_INFINITY;
74
76
  }
75
- // Legacy trim entry: byte-identical contract (legacy env/config/default caps
76
- // + live todo pinning, same notice, same in-place splice). New code should
77
- // use a ContextManager (derived, window-aware caps); the loop core does when
78
- // it knows the model (see AgenticOpts.context).
79
- export function truncateHistory(history, notify, reserve) {
80
- return truncateHistoryWithCaps(history, { maxMessages: historyMessageBudget(), maxChars: historyCharBudget() }, { notify, reserve, todoNeedles: openTodoNeedles() });
81
- }
82
77
  // Empty-response recovery (live-proven on free-tier gateways: a 200-OK
83
78
  // stream can carry only queue comments and reasoning with zero answer text
84
79
  // and zero tool calls, which the transport reports as an `Empty reply`
@@ -186,27 +181,79 @@ async function resolveApproval(name, parsed, opts) {
186
181
  return "no";
187
182
  }
188
183
  }
189
- // Execute one parsed tool call through validation + permission +
190
- // ask_question gates. Model mistakes (unknown name, invalid args) return
191
- // repairs-oriented results WITHOUT executing; cancellations propagate as
192
- // LoopCancelledError (never a result, never retried). Everything else
193
- // returns a result string fed back to the model:
184
+ // Extension tool-call interception (ticket 03): global pre/post hooks
185
+ // registered via ExtensionAPI.onBeforeToolCall/onAfterToolCall. Both
186
+ // helpers snapshot the live handler list and never throw — a throwing
187
+ // before handler fails closed (blocked outcome), a throwing after handler
188
+ // fails open (original content). Cancellation checks stay where they are;
189
+ // hooks are in-process policy, not executions, so they run even when the
190
+ // signal is armed and the existing boundaries still stop the turn.
191
+ async function runBeforeIntercept(name, parsed) {
192
+ try {
193
+ return await applyBeforeInterceptors(beforeToolInterceptors(), name, parsed);
194
+ }
195
+ catch {
196
+ return { args: parsed, blocked: blockedToolResult(name, "(unknown)", "interception failed") };
197
+ }
198
+ }
199
+ async function runAfterIntercept(name, parsed, result, isError) {
200
+ try {
201
+ return await applyAfterInterceptors(afterToolInterceptors(), { name, args: parsed, result, isError });
202
+ }
203
+ catch {
204
+ return { content: result, isError };
205
+ }
206
+ }
207
+ // Execute one parsed tool call through interception + validation +
208
+ // permission + ask_question gates. Model mistakes (unknown name, invalid
209
+ // args) return repairs-oriented results WITHOUT executing; cancellations
210
+ // propagate as LoopCancelledError (never a result, never retried).
211
+ // Everything else returns the result string plus the effective (post-
212
+ // rewrite) args the commit funnel must use for gates, labels, and pairing:
213
+ // - Hook-vs-approval ordering: pre-hooks run BEFORE validation and
214
+ // approval. The hook sees the call pre-approval and may rewrite args
215
+ // before the approval prompt shows them; a block short-circuits approval
216
+ // entirely (no prompt for a call that never runs). Rewrites always
217
+ // re-validate before execution, so a hook can never smuggle unvalidated
218
+ // args into an executor.
219
+ // - Unknown names never reach hooks (model mistake — nothing would run).
220
+ // update_goal is the one exemption (ticket 03): a loop-intercepted tool
221
+ // like ask_question, resolved without an executor via onUpdateGoal.
194
222
  // - ask_question never needs approval; without an askUser hook it resolves
195
223
  // to "Error: ask_question has no UI hook".
224
+ // - update_goal never needs approval either; without the per-turn recorder
225
+ // (only runLoopWithChat supplies it) it resolves to the outside-turn error.
196
226
  // - write/edit/bash consult the approve hook when one is provided (or a
197
227
  // pre-resolved batch decision); a "no" resolves to
198
228
  // "Error: denied by user: <tool>" (final, no retry/rollback). Without a
199
229
  // hook every tool executes immediately.
200
- async function runOneTool(call, parsed, opts, execute, preDecision) {
230
+ async function runOneTool(call, parsed, opts, execute, preDecision, onUpdateGoal) {
201
231
  const name = call?.function?.name ?? "(unknown)";
202
- // Unknown tool: model mistake list actual names, never execute.
203
- if (!toolNames().includes(name)) {
204
- return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
232
+ // update_goal bypasses the unknown-name gate (ticket 03): a
233
+ // loop-intercepted tool like ask_question — the registry owns builtins
234
+ // only, so the loop exempts it by name here; runOneToolWithArgs below
235
+ // validates it and resolves it without an executor (never needs approval).
236
+ if (name !== "update_goal" && !toolNames().includes(name)) {
237
+ return { result: `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`, args: parsed };
205
238
  }
239
+ const pre = await runBeforeIntercept(name, parsed);
240
+ if (pre.blocked !== null) {
241
+ return { result: pre.blocked, args: pre.args };
242
+ }
243
+ return runOneToolWithArgs(call, pre.args, opts, execute, preDecision, onUpdateGoal);
244
+ }
245
+ // Inner execution after pre-interception: validate (rewritten) args, then
246
+ // permission + ask_question/update_goal gates, then the executor. Shared by
247
+ // the serial path (via runOneTool) and the parallel batch (via the pre-pass
248
+ // below, which resolves pre-hooks serially so prompts never run concurrently).
249
+ async function runOneToolWithArgs(call, parsed, opts, execute, preDecision, onUpdateGoal) {
250
+ const name = call?.function?.name ?? "(unknown)";
206
251
  // Argument validation BEFORE approval/execution: model mistake, never runs.
252
+ // Pre-hook rewrites arrive here already applied, so they re-validate on
253
+ // exactly what would execute — invalid rewrites never reach an executor.
207
254
  const detail = validateToolArgs(name, parsed);
208
255
  if (detail) {
209
- return invalidCall(detail);
256
+ return { result: invalidCall(detail), args: parsed };
210
257
  }
211
258
  if (name === "ask_question") {
212
259
  throwIfCancelled(opts?.signal);
@@ -214,11 +261,23 @@ async function runOneTool(call, parsed, opts, execute, preDecision) {
214
261
  // LoopCancelledError (no result). If it resolves just as the signal
215
262
  // aborts, return the result — the loop records it, then stops before
216
263
  // the next POST (no new POSTs, pairing stays valid until rollback).
217
- return runAskQuestion(parsed, opts?.askUser, opts?.signal);
264
+ return { result: await runAskQuestion(parsed, opts?.askUser, opts?.signal), args: parsed };
265
+ }
266
+ if (name === "update_goal") {
267
+ throwIfCancelled(opts?.signal);
268
+ // Goal-disposition report (ticket 03): ask_question-shaped — validated,
269
+ // never needs approval, resolved without an executor (the per-turn slot
270
+ // recorder owns the record; execute never sees this name, so plan-mode
271
+ // and approval policy are untouched). Without the recorder (never from
272
+ // runLoopWithChat — both call sites thread it) the call is outside any
273
+ // goal turn by construction.
274
+ if (!onUpdateGoal)
275
+ return { result: goalReportOutsideError(), args: parsed };
276
+ return { result: onUpdateGoal(parsed), args: parsed };
218
277
  }
219
278
  const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
220
279
  if (decision === "no") {
221
- return `Error: denied by user: ${name}`;
280
+ return { result: `Error: denied by user: ${name}`, args: parsed };
222
281
  }
223
282
  // "once"/"always" run this call (the caller caches the always-allowed set
224
283
  // session-wide so later calls skip the prompt).
@@ -232,8 +291,8 @@ async function runOneTool(call, parsed, opts, execute, preDecision) {
232
291
  try {
233
292
  const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
234
293
  if (doNormalize)
235
- return normalizeToolResult(raw);
236
- return typeof raw === "string" ? raw : normalizeToolResult(raw);
294
+ return { result: normalizeToolResult(raw), args: parsed };
295
+ return { result: typeof raw === "string" ? raw : normalizeToolResult(raw), args: parsed };
237
296
  }
238
297
  catch (e) {
239
298
  if (isCancelError(e) || opts?.signal?.aborted)
@@ -368,14 +427,96 @@ export async function runLoopWithChat(chatFn, history, opts) {
368
427
  // Todo-guard cycles spent (bounds guard continues via MAX_TODO_ROUNDS —
369
428
  // a model that never resolves open todos still terminates).
370
429
  let todoRounds = 0;
371
- // At most one truncation notice per turn; silence when nothing dropped.
372
- let truncationNoticed = false;
373
- // Window-aware trimmer when the caller knows the model (App passes it);
374
- // created once per turn — ceiling sources resolve once, history is
375
- // re-measured every step. Absent the legacy fixed caps below.
376
- const contextManager = opts?.context
377
- ? createContextManager({ model: opts.context.model, toolsChars: opts.context.toolsChars })
378
- : null;
430
+ // Goal engagement (ticket 02): set the first time a completed POST
431
+ // observes a live goal. A goal cleared mid-run still counts its final
432
+ // turn (the work happened); a run that never saw a goal counts nothing.
433
+ let goalEngaged = false;
434
+ // Disposition slot (ticket 03): the model-reported outcome for the CURRENT
435
+ // goal turn, recorded by update_goal calls and consumed at the next turn
436
+ // end BEFORE the auto-continue decision. Reset per run and on every
437
+ // consumption — a report never leaks into the following turn.
438
+ let pendingDisposition = null;
439
+ // Read the live goal without ever throwing (a failing accessor ends the
440
+ // turn normally — the goal simply does not continue).
441
+ const readLiveGoal = () => {
442
+ try {
443
+ return opts?.goal?.getGoal?.() ?? null;
444
+ }
445
+ catch {
446
+ return null;
447
+ }
448
+ };
449
+ // Pause-with-preservation via the App-owned callback (state flip plus a
450
+ // visible notice; the goal text and stats survive). Never throws.
451
+ const pauseLiveGoal = (objective, reason) => {
452
+ try {
453
+ opts?.goal?.pauseGoal(goalPausedNotice(objective, reason));
454
+ }
455
+ catch {
456
+ // observer errors never break the loop
457
+ }
458
+ };
459
+ // Verdict pause (ticket 03): terminal dispositions pause with their own
460
+ // verdict wording — no goalPausedNotice wrap, the verdict IS the notice
461
+ // (it already names the objective and carries the reason). Never throws.
462
+ const pauseLiveGoalWithNotice = (notice) => {
463
+ try {
464
+ opts?.goal?.pauseGoal(notice);
465
+ }
466
+ catch {
467
+ // observer errors never break the loop
468
+ }
469
+ };
470
+ // Record one update_goal report into the per-turn slot (ticket 03), in
471
+ // order: bad args are a model mistake (invalid-call error, nothing
472
+ // recorded); with no live active goal the call is outside any goal turn
473
+ // (structured error, zero state change); after a terminal report anything
474
+ // further is rejected with a notice (first report sticks); the same value
475
+ // twice is idempotent; otherwise the report replaces any pending
476
+ // non-terminal one (last wins). Pure parts live in goal.ts; this closure
477
+ // only owns the slot read/write. Never throws — results are strings.
478
+ const recordGoalReport = (parsed) => {
479
+ const detail = validateUpdateGoalArgs(parsed);
480
+ if (detail)
481
+ return invalidCall(detail);
482
+ const live = readLiveGoal();
483
+ if (live === null || !live.active)
484
+ return goalReportOutsideError();
485
+ const next = updateGoalDisposition(parsed);
486
+ if (pendingDisposition !== null && pendingDisposition.status !== "continue") {
487
+ return goalReportRejectedNotice(pendingDisposition);
488
+ }
489
+ if (pendingDisposition !== null && sameGoalDisposition(pendingDisposition, next)) {
490
+ return goalReportAck(next);
491
+ }
492
+ pendingDisposition = next;
493
+ return goalReportAck(next);
494
+ };
495
+ // Take the pending report and clear the slot (ticket 03 consumption reads
496
+ // through here so the declared return type drives the turn-end checks —
497
+ // the captured slot's direct-flow narrowing would otherwise collapse the
498
+ // read to null). A report never leaks into the following turn.
499
+ const takeDisposition = () => {
500
+ const current = pendingDisposition;
501
+ pendingDisposition = null;
502
+ return current;
503
+ };
504
+ // Novelty progress (ticket 05): per-run memory of committed-result
505
+ // fingerprints plus the consecutive-non-novel streak. Recorded in the
506
+ // commit funnel below; read at goal turn end for the stall redirect.
507
+ // Loop-local is sufficient: a whole goal run normally lives inside one
508
+ // runLoopWithChat call (continuations `continue` the loop; only
509
+ // terminal/cancel/budget/failure return).
510
+ const goalProgress = emptyGoalProgress();
511
+ // One goal turn taken (guarded — accounting never breaks the turn).
512
+ const noteGoalTurn = () => {
513
+ try {
514
+ opts?.goal?.onGoalTurn?.();
515
+ }
516
+ catch {
517
+ // observer errors never break the loop
518
+ }
519
+ };
379
520
  // ---- Hardened-loop state (additive; explicit caps still honored —
380
521
  // see AgenticOpts docs) ----
381
522
  const maxTotalToolCalls = resolveMaxTotalToolCalls(opts?.maxTotalToolCalls);
@@ -399,7 +540,6 @@ export async function runLoopWithChat(chatFn, history, opts) {
399
540
  let modelCalls = 0;
400
541
  let toolCalls = 0;
401
542
  let failures = 0;
402
- let droppedTurnsTotal = 0;
403
543
  // Empty-response repairs spent (bounded by MAX_EMPTY_ROUNDS — a model
404
544
  // that only answers silence still terminates).
405
545
  let emptyRounds = 0;
@@ -434,7 +574,6 @@ export async function runLoopWithChat(chatFn, history, opts) {
434
574
  failures,
435
575
  repetitionHits: repGuard.hitCount,
436
576
  cacheHits,
437
- truncationNotices: droppedTurnsTotal,
438
577
  durationMs: Math.max(0, Date.now() - turnStartMs),
439
578
  bottleneck,
440
579
  contextGrowthChars: endChars - startChars,
@@ -450,41 +589,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
450
589
  throwIfCancelled(signal);
451
590
  // Steering seam: drain one pending steer message (if any) at this safe
452
591
  // point — previous tool batches are fully committed, so assistant/tool
453
- // pairing can never split. Runs before the budget trim so truncation
454
- // accounts for the injected message. No-op without the hook.
592
+ // pairing can never split. No-op without the hook.
455
593
  try {
456
594
  opts?.drainSteer?.();
457
595
  }
458
596
  catch {
459
597
  // observer errors never break the loop
460
598
  }
461
- // History budget (uniform for all providers — every POST flows through
462
- // here): trim oldest user-turns first before each send.
463
- const trimmed = contextManager
464
- ? contextManager.trimForSend(history, truncationNoticed
465
- ? undefined
466
- : (notice) => {
467
- try {
468
- opts?.onWarning?.(notice);
469
- }
470
- catch {
471
- // ignore observer errors
472
- }
473
- }, undefined, openTodoNeedles())
474
- : truncateHistory(history, truncationNoticed
475
- ? undefined
476
- : (notice) => {
477
- try {
478
- opts?.onWarning?.(notice);
479
- }
480
- catch {
481
- // ignore observer errors
482
- }
483
- });
484
- if (trimmed.droppedTurns > 0) {
485
- truncationNoticed = true;
486
- droppedTurnsTotal += trimmed.droppedTurns;
487
- }
488
599
  let msg;
489
600
  const modelStart = Date.now();
490
601
  try {
@@ -549,6 +660,17 @@ export async function runLoopWithChat(chatFn, history, opts) {
549
660
  // normalization never breaks the turn; the raw message stands
550
661
  }
551
662
  modelCalls += 1;
663
+ // Goal slice (ticket 02): one completed POST while a goal is live.
664
+ // Engages the run for turn accounting below; guarded, never breaks it.
665
+ try {
666
+ if (opts?.goal?.getGoal?.()?.active === true) {
667
+ goalEngaged = true;
668
+ opts.goal.onGoalRequest?.();
669
+ }
670
+ }
671
+ catch {
672
+ // observer errors never break the loop
673
+ }
552
674
  if (msg.usage !== undefined) {
553
675
  // Spend accounting: EVERY POST that reports usage forwards it, and the
554
676
  // caller accumulates each report as billed spend — tool-round POSTs,
@@ -614,6 +736,167 @@ export async function runLoopWithChat(chatFn, history, opts) {
614
736
  history.push({ role: "user", content: outcome.followUp });
615
737
  continue;
616
738
  }
739
+ // Disposition protocol (ticket 03): consume this turn's update_goal
740
+ // report BEFORE the auto-continue decision. Terminal dispositions stop
741
+ // the run with a verdict — `blocked` unconditionally (even with
742
+ // unaddressed tool errors), `complete` only through the honesty gate
743
+ // below (unverified code or open todos continue instead) — pausing
744
+ // the goal with the verdict as its notice (never clearing, like every
745
+ // other loop-driven goal ending). A `continue` report's next action
746
+ // becomes the follow-up below (generic text when absent); no report —
747
+ // or a goal gone mid-turn — flows into the existing path untouched.
748
+ // The slot clears on every consumption, so a report never leaks into
749
+ // the following turn; guard `continue`s above never reach here, so a
750
+ // report filed mid-turn survives them until a real turn end.
751
+ const dispositionGoal = readLiveGoal();
752
+ let disposition = takeDisposition();
753
+ let goalNextAction = null;
754
+ // Evaluator fallback (ticket 04): a report-less turn with a live goal
755
+ // gets exactly ONE bounded judge call before continuing — but only when
756
+ // a runner is configured. Without one the turn continues exactly as
757
+ // before (existing tests pin this). A clear verdict flows through the
758
+ // SAME terminal/continue handling below as a model report (a `complete`
759
+ // still passes the honesty gate below — one code path for both); a judge error
760
+ // or an unclear verdict pauses (preserves) instead of looping. The
761
+ // judge reads history only — this path pushes nothing, so the judge
762
+ // performs no state mutations.
763
+ if (disposition === null &&
764
+ dispositionGoal !== null &&
765
+ dispositionGoal.active &&
766
+ opts?.goalJudge) {
767
+ // Cancel wins before the extra POST: never judge into a lost turn.
768
+ throwIfCancelled(signal);
769
+ let verdict = null;
770
+ let judgeError = null;
771
+ try {
772
+ verdict = await opts.goalJudge({
773
+ goal: dispositionGoal.objective,
774
+ turns: recentTurnsForJudge(history),
775
+ });
776
+ }
777
+ catch (e) {
778
+ // Whole-turn cancellation still propagates (it pauses via the outer
779
+ // catch, like every other cancel) — anything else pauses here.
780
+ if (isCancelError(e) || signal?.aborted)
781
+ throw new LoopCancelledError();
782
+ const msg = e instanceof Error ? e.message : String(e);
783
+ judgeError = msg.length > 200 ? `${msg.slice(0, 200)}…` : msg;
784
+ }
785
+ // The goal may have cleared/paused mid-judge (slash runs while busy):
786
+ // then the verdict is dropped and the turn ends normally — never
787
+ // pause or continue a goal that is gone.
788
+ const liveAfterJudge = readLiveGoal();
789
+ if (liveAfterJudge === null || !liveAfterJudge.active) {
790
+ if (goalEngaged)
791
+ noteGoalTurn();
792
+ history.push({ role: "assistant", content: outcome.finalText });
793
+ try {
794
+ opts?.onPhase?.("done");
795
+ }
796
+ catch {
797
+ // ignore
798
+ }
799
+ return outcome.finalText;
800
+ }
801
+ if (judgeError !== null || verdict === null) {
802
+ // Pause-with-preservation (never clear): the goal, todos, and
803
+ // history stay for /goal resume. The turn was still taken.
804
+ pauseLiveGoal(liveAfterJudge.objective, judgeError !== null ? `(judge failed: ${judgeError})` : `(judge unclear — no clear verdict)`);
805
+ noteGoalTurn();
806
+ history.push({ role: "assistant", content: outcome.finalText });
807
+ try {
808
+ opts?.onPhase?.("done");
809
+ }
810
+ catch {
811
+ // ignore
812
+ }
813
+ return outcome.finalText;
814
+ }
815
+ disposition = verdict;
816
+ }
817
+ if (disposition !== null &&
818
+ disposition.status !== "continue" &&
819
+ dispositionGoal !== null &&
820
+ dispositionGoal.active) {
821
+ // Cancel wins even mid-verdict: never stop cleanly into a lost turn.
822
+ throwIfCancelled(signal);
823
+ // Honest completion gate (ticket 06): a `complete` only lands on
824
+ // genuinely finished work. With unverified code pending or todos
825
+ // still open the goal continues instead of stopping, naming the
826
+ // files/items through the gates' own follow-up voice (todos first,
827
+ // matching TURN_END_GATES order). `blocked` skips this entirely and
828
+ // stops unconditionally below. Evaluator verdicts share this path —
829
+ // they arrive in the same `disposition` slot, so one code path gates
830
+ // both. Guard `continue`s above never reach here, so a false
831
+ // `complete` filed mid-turn is still caught when its turn ends.
832
+ if (disposition.status === "complete") {
833
+ // Voice-only probe: budgets/rounds zeroed so the builders return
834
+ // their `continue` follow-up whenever their state is dirty — the
835
+ // live budget owns spinning protection in the branch below, and the
836
+ // pinned gates above own the per-turn nag bounds.
837
+ const honestCtx = {
838
+ step: 0,
839
+ maxSteps: Number.POSITIVE_INFINITY,
840
+ filesWritten,
841
+ verifiedAfterWrite,
842
+ needsVerification,
843
+ unverifiedPaths: [...unverifiedPaths],
844
+ verifyRounds: 0,
845
+ todoRounds: 0,
846
+ };
847
+ const todoProbe = todoCompletionGate(outcome.finalText, honestCtx);
848
+ const verifyProbe = verificationGate(outcome.finalText, honestCtx);
849
+ const honestBlock = todoProbe.action === "continue"
850
+ ? todoProbe
851
+ : verifyProbe.action === "continue"
852
+ ? verifyProbe
853
+ : null;
854
+ if (honestBlock !== null) {
855
+ // Spent budget cannot start another turn: pause (preserve) with
856
+ // the budget notice instead of completing dirty or spinning.
857
+ if (step >= maxSteps || toolCalls >= maxTotalToolCalls) {
858
+ pauseLiveGoal(dispositionGoal.objective, "(budget spent)");
859
+ noteGoalTurn();
860
+ history.push({ role: "assistant", content: outcome.finalText });
861
+ try {
862
+ opts?.onPhase?.("done");
863
+ }
864
+ catch {
865
+ // ignore
866
+ }
867
+ return outcome.finalText;
868
+ }
869
+ // A guard-style continue: not a turn end, so no goal-turn
870
+ // accounting — and the false report is already consumed, so the
871
+ // next turn must file fresh evidence.
872
+ history.push({ role: "assistant", content: honestBlock.assistantText });
873
+ history.push({ role: "user", content: honestBlock.followUp });
874
+ continue;
875
+ }
876
+ }
877
+ const verdict = disposition.status === "complete"
878
+ ? goalVerdictNotice(dispositionGoal.objective, "complete", disposition.reason, disposition.unverified)
879
+ : goalVerdictNotice(dispositionGoal.objective, disposition.status, disposition.reason);
880
+ pauseLiveGoalWithNotice(verdict);
881
+ noteGoalTurn();
882
+ const base = outcome.finalText;
883
+ const final = base ? `${base}\n${verdict}` : verdict;
884
+ history.push({ role: "assistant", content: final });
885
+ try {
886
+ opts?.onPhase?.("done");
887
+ }
888
+ catch {
889
+ // ignore
890
+ }
891
+ return final;
892
+ }
893
+ if (disposition !== null &&
894
+ disposition.status === "continue" &&
895
+ disposition.next !== undefined &&
896
+ dispositionGoal !== null &&
897
+ dispositionGoal.active) {
898
+ goalNextAction = disposition.next;
899
+ }
617
900
  // Error-streak recovery (additive, after the pinned gates): ending on
618
901
  // sustained unaddressed `Error:` results is almost always premature.
619
902
  // Single errors still end normally (the model may be reporting a
@@ -625,6 +908,58 @@ export async function runLoopWithChat(chatFn, history, opts) {
625
908
  history.push({ role: "user", content: errorStreakFollowUp(streak) });
626
909
  continue;
627
910
  }
911
+ // Goal auto-continue (tickets 02–03): with a live goal a would-be turn
912
+ // end starts the next turn through the same assistant+user follow-up
913
+ // seam the guards use above — the ONLY continuation message, so the
914
+ // transcript shows each turn normally with no synthetic user input
915
+ // beyond this mechanism. Unconditional (no turn cap): the run ends
916
+ // only via pause (cancel/spent budget), clear, a terminal disposition,
917
+ // or a thrown failure. A `continue` report's next action becomes the
918
+ // follow-up text (generic follow-up when absent or unreported).
919
+ // Runs after the error-streak hold so sustained tool failures still
920
+ // get their fix-forward guidance first (a hold is not a turn end, so
921
+ // it counts no goal turn — and a consumed `continue` report is dropped
922
+ // with it, so the fix-forward guidance wins that round).
923
+ const liveGoal = readLiveGoal();
924
+ if (liveGoal !== null && liveGoal.active) {
925
+ goalEngaged = true;
926
+ // Cancel wins even mid-continuation: never start another turn lost.
927
+ throwIfCancelled(signal);
928
+ // A spent budget can never make progress — pause (preserve) with a
929
+ // notice instead of POSTing forever. The turn was still taken.
930
+ if (step >= maxSteps || toolCalls >= maxTotalToolCalls) {
931
+ pauseLiveGoal(liveGoal.objective, "(budget spent)");
932
+ noteGoalTurn();
933
+ history.push({ role: "assistant", content: outcome.finalText });
934
+ try {
935
+ opts?.onPhase?.("done");
936
+ }
937
+ catch {
938
+ // ignore
939
+ }
940
+ return outcome.finalText;
941
+ }
942
+ noteGoalTurn();
943
+ history.push({ role: "assistant", content: outcome.finalText });
944
+ // Stall redirect (ticket 05): a run of exact repeats gets the replan
945
+ // nudge as its follow-up instead of the generic continuation — same
946
+ // assistant+user commit shape as the other gates, so the redirect is
947
+ // visible in the transcript. The epoch resets; the goal is never
948
+ // paused, cleared, or ended here (existing budgets still bound a run
949
+ // that keeps stalling, so recurring nudges cannot spin forever).
950
+ if (goalStallReached(goalProgress)) {
951
+ resetGoalStall(goalProgress);
952
+ history.push({ role: "user", content: goalStallNudge(liveGoal.objective, GOAL_STALL_REPEATS) });
953
+ }
954
+ else {
955
+ history.push({ role: "user", content: goalNextAction ?? goalFollowUp(liveGoal.objective) });
956
+ }
957
+ continue;
958
+ }
959
+ // Final turn of a run that engaged a goal before it cleared: the work
960
+ // happened, so it still counts (no continuation — the goal is gone).
961
+ if (goalEngaged)
962
+ noteGoalTurn();
628
963
  history.push({ role: "assistant", content: outcome.finalText });
629
964
  try {
630
965
  opts?.onPhase?.("done");
@@ -637,6 +972,18 @@ export async function runLoopWithChat(chatFn, history, opts) {
637
972
  if (step >= maxSteps) {
638
973
  const base = msg.content ?? "";
639
974
  const notice = `${base}${base ? "\n" : ""}(stopped: too many tool steps) (limit is ${maxSteps}; raise with ATOM_MAX_TOOL_STEPS=<n>)`;
975
+ // Spent budget during a goal run pauses (preserves) it with a notice
976
+ // instead of silently dropping it — the stop text stays the reply and
977
+ // the pause notice lands as its own line via the callback.
978
+ const overGoal = readLiveGoal();
979
+ if (overGoal !== null && overGoal.active) {
980
+ goalEngaged = true;
981
+ pauseLiveGoal(overGoal.objective, "(step budget spent)");
982
+ noteGoalTurn();
983
+ }
984
+ else if (goalEngaged) {
985
+ noteGoalTurn();
986
+ }
640
987
  history.push({ role: "assistant", content: notice });
641
988
  try {
642
989
  opts?.onPhase?.("done");
@@ -652,6 +999,16 @@ export async function runLoopWithChat(chatFn, history, opts) {
652
999
  if (toolCalls + calls.length > maxTotalToolCalls) {
653
1000
  const base = msg.content ?? "";
654
1001
  const notice = `${base}${base ? "\n" : ""}(stopped: too many tool calls) (limit is ${maxTotalToolCalls} per turn)`;
1002
+ // Same pause-with-preservation contract as the step-budget stop above.
1003
+ const overGoal = readLiveGoal();
1004
+ if (overGoal !== null && overGoal.active) {
1005
+ goalEngaged = true;
1006
+ pauseLiveGoal(overGoal.objective, "(tool-call budget spent)");
1007
+ noteGoalTurn();
1008
+ }
1009
+ else if (goalEngaged) {
1010
+ noteGoalTurn();
1011
+ }
655
1012
  history.push({ role: "assistant", content: notice });
656
1013
  try {
657
1014
  opts?.onPhase?.("done");
@@ -668,7 +1025,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
668
1025
  // `Error:` results) never ran, so they neither arm nor clear the gate.
669
1026
  const commitToolResult = async (name, parsed, call, result, durationMs) => {
670
1027
  const baseIsError = typeof result === "string" && result.startsWith("Error");
671
- const hooked = await applyToolResultHook(opts?.onToolResult, name, parsed, result, baseIsError);
1028
+ // Extension post-hooks observe the raw commit candidate first (every
1029
+ // committed result: executions, blocks, denials, validation errors);
1030
+ // the caller's onToolResult hook runs last on the patched version.
1031
+ // Patches apply per call in commit order, so tool_call_id re-pairing
1032
+ // and ordering are untouched; throwing patchers fail open above.
1033
+ const after = await runAfterIntercept(name, parsed, result, baseIsError);
1034
+ const hooked = await applyToolResultHook(opts?.onToolResult, name, parsed, after.content, after.isError);
672
1035
  // Veto: skip the commit entirely — no counters, no gates, no history,
673
1036
  // no activity. The turn continues; pairing risk is the hook author's.
674
1037
  if (hooked.veto)
@@ -679,6 +1042,15 @@ export async function runLoopWithChat(chatFn, history, opts) {
679
1042
  if (isError)
680
1043
  failures += 1;
681
1044
  errStreak.noteResult(isError);
1045
+ // Novelty progress (ticket 05): successful commits fingerprint into the
1046
+ // per-run seen-set (Error results are owned by the error-streak
1047
+ // machinery and never count). Guarded — accounting never breaks the turn.
1048
+ try {
1049
+ noteGoalProgress(goalProgress, name, parsed, finalResult, isError);
1050
+ }
1051
+ catch {
1052
+ // observer errors never break the loop
1053
+ }
682
1054
  if (typeof durationMs === "number")
683
1055
  noteBottleneck(name, durationMs);
684
1056
  if (!isError && (name === "write" || name === "edit")) {
@@ -810,6 +1182,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
810
1182
  continue;
811
1183
  }
812
1184
  let result;
1185
+ // Effective (post-rewrite) args: what validated, approved, and ran —
1186
+ // telemetry and the commit below must see these, not the originals.
1187
+ let effectiveArgs = parsed;
813
1188
  // Repetition guard (opt-in via maxRepeatedCalls; unset = track-only):
814
1189
  // a repeated signature skips execution and yields a guidance error;
815
1190
  // exhausted nudges stop hard.
@@ -860,7 +1235,9 @@ export async function runLoopWithChat(chatFn, history, opts) {
860
1235
  return stopNotice;
861
1236
  }
862
1237
  try {
863
- result = await runOneTool(call, parsed, opts, execute);
1238
+ const one = await runOneTool(call, parsed, opts, execute, undefined, recordGoalReport);
1239
+ result = one.result;
1240
+ effectiveArgs = one.args;
864
1241
  }
865
1242
  catch (e) {
866
1243
  // A cancelled/throwing tool still records its attempt (with the
@@ -899,13 +1276,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
899
1276
  startedAt: telemetryIso(toolStart),
900
1277
  endedAt: telemetryIso(toolEnd),
901
1278
  durationMs: Math.max(0, toolEnd - toolStart),
902
- argsJson: telemetryArgsJson(parsed),
1279
+ argsJson: telemetryArgsJson(effectiveArgs),
903
1280
  result,
904
1281
  batchIndex: 0,
905
1282
  batchSize: 1,
906
1283
  });
907
1284
  }
908
- await commitToolResult(name, parsed, call, result, Math.max(0, Date.now() - toolStart));
1285
+ await commitToolResult(name, effectiveArgs, call, result, Math.max(0, Date.now() - toolStart));
909
1286
  continue;
910
1287
  }
911
1288
  // Parallel batch: every member is pre-validated parallel-safe (see
@@ -937,10 +1314,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
937
1314
  repHardStop = { sig: note.signature, consecutive: note.consecutive };
938
1315
  }
939
1316
  }
940
- // Serial approval pre-pass (in call order, skipping repetition-guarded
941
- // members exactly as the serial path would): prompts resolve before
942
- // any member executes, so concurrent writes never prompt at once.
943
- // Cancel between prompts aborts the batch with nothing executed.
1317
+ const memberPlans = new Map();
944
1318
  const preDecisions = new Map();
945
1319
  for (let i = 0; i < batch.length; i++) {
946
1320
  throwIfCancelled(signal);
@@ -948,7 +1322,22 @@ export async function runLoopWithChat(chatFn, history, opts) {
948
1322
  continue;
949
1323
  const member = batch[i];
950
1324
  const memberName = member.call?.function?.name ?? "(unknown)";
951
- const decision = await resolveApproval(memberName, member.parsed, opts);
1325
+ if (!toolNames().includes(memberName)) {
1326
+ memberPlans.set(i, { args: member.parsed, blocked: null, invalid: null });
1327
+ continue;
1328
+ }
1329
+ const pre = await runBeforeIntercept(memberName, member.parsed);
1330
+ if (pre.blocked !== null) {
1331
+ memberPlans.set(i, { args: pre.args, blocked: pre.blocked, invalid: null });
1332
+ continue;
1333
+ }
1334
+ const invalid = validateToolArgs(memberName, pre.args);
1335
+ if (invalid) {
1336
+ memberPlans.set(i, { args: pre.args, blocked: null, invalid });
1337
+ continue;
1338
+ }
1339
+ memberPlans.set(i, { args: pre.args, blocked: null, invalid: null });
1340
+ const decision = await resolveApproval(memberName, pre.args, opts);
952
1341
  if (decision !== null)
953
1342
  preDecisions.set(i, decision);
954
1343
  }
@@ -979,7 +1368,46 @@ export async function runLoopWithChat(chatFn, history, opts) {
979
1368
  return guarded;
980
1369
  }
981
1370
  try {
982
- const r = await runOneTool(member.call, member.parsed, opts, execute, preDecisions.get(index) ?? null);
1371
+ // Pre-resolved members (blocked/invalid) never reach the
1372
+ // executor: their inline results commit in call order below.
1373
+ const plan = memberPlans.get(index);
1374
+ if (plan?.blocked !== null && plan?.blocked !== undefined) {
1375
+ const blocked = plan.blocked;
1376
+ const memberEnd = Date.now();
1377
+ memberDurations[index] = Math.max(0, memberEnd - memberStart);
1378
+ reportToolCall({
1379
+ step,
1380
+ toolCallId: member.call?.id ?? "",
1381
+ name: member.call?.function?.name ?? "(unknown)",
1382
+ startedAt: telemetryIso(memberStart),
1383
+ endedAt: telemetryIso(memberEnd),
1384
+ durationMs: Math.max(0, memberEnd - memberStart),
1385
+ argsJson: telemetryArgsJson(plan.args),
1386
+ result: blocked,
1387
+ batchIndex: index,
1388
+ batchSize: batch.length,
1389
+ });
1390
+ return blocked;
1391
+ }
1392
+ if (plan?.invalid) {
1393
+ const bad = invalidCall(plan.invalid);
1394
+ const memberEnd = Date.now();
1395
+ memberDurations[index] = Math.max(0, memberEnd - memberStart);
1396
+ reportToolCall({
1397
+ step,
1398
+ toolCallId: member.call?.id ?? "",
1399
+ name: member.call?.function?.name ?? "(unknown)",
1400
+ startedAt: telemetryIso(memberStart),
1401
+ endedAt: telemetryIso(memberEnd),
1402
+ durationMs: Math.max(0, memberEnd - memberStart),
1403
+ argsJson: telemetryArgsJson(plan.args),
1404
+ result: bad,
1405
+ batchIndex: index,
1406
+ batchSize: batch.length,
1407
+ });
1408
+ return bad;
1409
+ }
1410
+ const r = await runOneToolWithArgs(member.call, plan?.args ?? member.parsed, opts, execute, preDecisions.get(index) ?? null, recordGoalReport);
983
1411
  const memberEnd = Date.now();
984
1412
  memberDurations[index] = Math.max(0, memberEnd - memberStart);
985
1413
  reportToolCall({
@@ -989,12 +1417,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
989
1417
  startedAt: telemetryIso(memberStart),
990
1418
  endedAt: telemetryIso(memberEnd),
991
1419
  durationMs: Math.max(0, memberEnd - memberStart),
992
- argsJson: telemetryArgsJson(member.parsed),
993
- result: r,
1420
+ argsJson: telemetryArgsJson(plan?.args ?? member.parsed),
1421
+ result: r.result,
994
1422
  batchIndex: index,
995
1423
  batchSize: batch.length,
996
1424
  });
997
- return r;
1425
+ return r.result;
998
1426
  }
999
1427
  catch (e) {
1000
1428
  const memberEnd = Date.now();
@@ -1028,7 +1456,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
1028
1456
  }
1029
1457
  for (let i = 0; i < batch.length; i++) {
1030
1458
  const member = batch[i];
1031
- await commitToolResult(member.call?.function?.name ?? "(unknown)", member.parsed, member.call, results[i], memberDurations[i]);
1459
+ await commitToolResult(member.call?.function?.name ?? "(unknown)", memberPlans.get(i)?.args ?? member.parsed, member.call, results[i], memberDurations[i]);
1032
1460
  }
1033
1461
  if (repHardStop) {
1034
1462
  const stopBase = msg.content ?? "";
@@ -1045,6 +1473,19 @@ export async function runLoopWithChat(chatFn, history, opts) {
1045
1473
  }
1046
1474
  }
1047
1475
  }
1476
+ catch (e) {
1477
+ // Cancel during a live goal pauses (preserves) it with a notice — never
1478
+ // clears — so a later /goal resume can continue. Failed POSTs skip this
1479
+ // entirely: the caller rolls back per the existing splice contract and
1480
+ // the goal stays active and carries on.
1481
+ if (isCancelError(e) || signal?.aborted) {
1482
+ const cancelledGoal = readLiveGoal();
1483
+ if (cancelledGoal !== null && cancelledGoal.active) {
1484
+ pauseLiveGoal(cancelledGoal.objective, "(cancelled)");
1485
+ }
1486
+ }
1487
+ throw e;
1488
+ }
1048
1489
  finally {
1049
1490
  finishStats();
1050
1491
  }