@visual-z/xeno 0.7.2 → 0.8.1

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 (117) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +36 -9
  3. package/docs/foundations.md +1 -1
  4. package/package.json +3 -1
  5. package/src/apps/cli.ts +4 -3
  6. package/src/apps/commands.ts +8 -0
  7. package/src/apps/goal-flow.ts +70 -0
  8. package/src/apps/render.ts +0 -2
  9. package/src/apps/session-hub.ts +6 -5
  10. package/src/apps/session-lifecycle.ts +64 -0
  11. package/src/apps/shell.ts +89 -34
  12. package/src/apps/startup.ts +32 -31
  13. package/src/apps/ui/app.ts +105 -13
  14. package/src/apps/ui/control-model.ts +18 -1
  15. package/src/apps/ui/surface.ts +2 -0
  16. package/src/apps/update-check.ts +1 -1
  17. package/src/core/acceptance.ts +41 -9
  18. package/src/core/blind-checks.ts +72 -0
  19. package/src/core/branch-receipt.ts +3 -1
  20. package/src/core/checkpoints.ts +21 -0
  21. package/src/core/churn.ts +2 -2
  22. package/src/core/completion-review.ts +18 -11
  23. package/src/core/continuation.ts +11 -10
  24. package/src/core/control-view.ts +5 -8
  25. package/src/core/deadline.ts +2 -1
  26. package/src/core/derive.ts +0 -7
  27. package/src/core/environment.ts +2 -2
  28. package/src/core/event-validation.ts +19 -6
  29. package/src/core/events.ts +41 -32
  30. package/src/core/evidence-ladder.ts +62 -0
  31. package/src/core/evidence-links.ts +0 -10
  32. package/src/core/evidence.ts +1 -2
  33. package/src/core/export.ts +6 -11
  34. package/src/core/goal-view.ts +64 -0
  35. package/src/core/goal.ts +200 -0
  36. package/src/core/history.ts +1 -11
  37. package/src/core/json-event.ts +0 -6
  38. package/src/core/outcome.ts +0 -1
  39. package/src/core/program-status.ts +11 -2
  40. package/src/core/progress.ts +14 -8
  41. package/src/core/project.ts +6 -16
  42. package/src/core/read-bounds.ts +11 -2
  43. package/src/core/read-observation.ts +24 -0
  44. package/src/core/request-budget.ts +13 -3
  45. package/src/core/request-state.ts +178 -19
  46. package/src/core/response-recovery.ts +15 -2
  47. package/src/core/session-entry.ts +8 -3
  48. package/src/core/session-exit.ts +19 -3
  49. package/src/core/session-format.ts +169 -0
  50. package/src/core/settings.ts +7 -27
  51. package/src/core/stall.ts +30 -7
  52. package/src/core/stop-report.ts +4 -3
  53. package/src/core/transcript.ts +22 -2
  54. package/src/runtime/blind-check.ts +36 -0
  55. package/src/runtime/counter-check.ts +74 -0
  56. package/src/runtime/exec-sessions.ts +11 -0
  57. package/src/runtime/foundation.ts +46 -8
  58. package/src/runtime/loop.ts +239 -430
  59. package/src/runtime/oauth.ts +1 -3
  60. package/src/runtime/openai.ts +2 -2
  61. package/src/runtime/pi-provider.ts +3 -3
  62. package/src/runtime/provider-request.ts +2 -1
  63. package/src/runtime/provider.ts +33 -3
  64. package/src/runtime/retry.ts +9 -2
  65. package/src/runtime/runtime-target.ts +2 -2
  66. package/src/runtime/session-control.ts +4 -4
  67. package/src/runtime/tools.ts +44 -42
  68. package/src/runtime/usage.ts +28 -0
  69. package/src/runtime/version.ts +12 -0
  70. package/src/runtime/workspace-lifecycle.ts +31 -0
  71. package/src/store/blind-checks.ts +32 -0
  72. package/src/store/checkpoint-history.ts +1 -12
  73. package/src/store/file-index.ts +88 -7
  74. package/src/store/log.ts +25 -27
  75. package/src/store/recovery.ts +1 -3
  76. package/src/store/replay.ts +2 -2
  77. package/src/store/restore.ts +10 -202
  78. package/src/store/search.ts +7 -5
  79. package/src/store/session-archive.ts +0 -1
  80. package/src/store/sessions.ts +31 -33
  81. package/src/store/workspace-clone.ts +42 -0
  82. package/src/store/workspace.ts +10 -157
  83. package/src/tui/ui/duration.ts +16 -0
  84. package/src/tui/ui/layout.ts +4 -4
  85. package/src/tui/ui/screen.ts +13 -7
  86. package/src/tui/ui/status.ts +99 -17
  87. package/src/core/benchmark.ts +0 -195
  88. package/src/core/evaluation-coverage.ts +0 -74
  89. package/src/core/experiment.ts +0 -173
  90. package/src/core/harness-comparison.ts +0 -90
  91. package/src/core/harness-diagnostics.ts +0 -206
  92. package/src/core/native-eval.ts +0 -25
  93. package/src/core/policy-evaluation.ts +0 -55
  94. package/src/core/query-eval-contract.ts +0 -14
  95. package/src/core/recall-analysis.ts +0 -90
  96. package/src/core/recall-trial.ts +0 -153
  97. package/src/core/recovery-corpus.ts +0 -17
  98. package/src/core/recovery-evaluation.ts +0 -60
  99. package/src/core/tool-mode-protocol.ts +0 -25
  100. package/src/core/trial-lifecycle.ts +0 -14
  101. package/src/core/window-trial.ts +0 -65
  102. package/src/index.ts +0 -10
  103. package/src/runtime/benchmark.ts +0 -187
  104. package/src/runtime/harness-comparison.ts +0 -376
  105. package/src/runtime/harness-eval.ts +0 -462
  106. package/src/runtime/native-eval-fixtures.ts +0 -33
  107. package/src/runtime/native-eval.ts +0 -146
  108. package/src/runtime/query-eval-verifier.ts +0 -28
  109. package/src/runtime/recall-trial.ts +0 -226
  110. package/src/runtime/recovery-coverage.ts +0 -71
  111. package/src/runtime/recovery-evidence.ts +0 -76
  112. package/src/runtime/recovery-fixtures.ts +0 -116
  113. package/src/runtime/tool-mode-eval.ts +0 -146
  114. package/src/runtime/trial-control.ts +0 -45
  115. package/src/runtime/window-trial.ts +0 -141
  116. package/src/store/git-branch.ts +0 -35
  117. package/src/tui/ui/testing.ts +0 -5
@@ -25,7 +25,12 @@ import type { Handoff } from "../core/handoff.ts";
25
25
  import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
26
26
  import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
27
27
  import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
28
- import { projectContext, PROJECTION_VERSION, toolCallIdentityViolations } from "../core/project.ts";
28
+ import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
29
+ import { parseSessionContext, type SessionContext } from "../core/session-format.ts";
30
+ import { activeGoal, goalContract, goalDecision, idleGoalChecks, type ActiveGoal } from "../core/goal.ts";
31
+ import { counterCheck } from "./counter-check.ts";
32
+ import { runBlindChecks } from "./blind-check.ts";
33
+ import { blindExposure, type BlindChecks } from "../core/blind-checks.ts";
29
34
  import { deriveHandoff, carryForward } from "../core/derive.ts";
30
35
  import { latestObservedFileVersion } from "../core/file-precondition.ts";
31
36
  import { latestProcessReceipt } from "../core/process-receipt.ts";
@@ -44,7 +49,7 @@ import { pendingToolCalls } from "../core/execution.ts";
44
49
  import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
45
50
  import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
46
51
  import { executionOutcome, modelOutcome, type ExecutionOutcome, type ExecutionReason } from "../core/outcome.ts";
47
- import { finalizationNotice, requestPhase } from "../core/request-budget.ts";
52
+ import { finalizationNotice, requestPhase, validateRequestBudget } from "../core/request-budget.ts";
48
53
  import { stopReport } from "../core/stop-report.ts";
49
54
  import { supportsToolDisabling, type ProviderSendOptions } from "./provider-tool-policy.ts";
50
55
  import { commandCompletion } from "../core/command-contract.ts";
@@ -63,12 +68,12 @@ import { schedulingWindow } from "../core/scheduling.ts";
63
68
  import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
64
69
  import { isContextOverflow } from "../core/overflow.ts";
65
70
  import { requestCompletion } from "../core/completion-review.ts";
66
- import { deriveRequestState, type RequestState } from "../core/request-state.ts";
71
+ import { currentRequestRecords, deriveRequestState, type RequestState } from "../core/request-state.ts";
67
72
  import { churnReview } from "../core/churn.ts";
68
73
  import { deriveBranchReceipt } from "../core/branch-receipt.ts";
69
74
  import { skillTools, type Skill } from "../core/skills.ts";
70
75
  import type { WorkItem } from "../core/work-state.ts";
71
- import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceState } from "../core/acceptance.ts";
76
+ import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
72
77
  import { deadlineNotice } from "../core/deadline.ts";
73
78
  import { statusOf } from "./retry.ts";
74
79
  import { assertNever } from "../core/exhaustive.ts";
@@ -83,13 +88,9 @@ import { runHistoryTool } from "./history-tools.ts";
83
88
  import { prepareWorkingNote } from "./working-notes.ts";
84
89
  import { copyAncestryTo } from "../store/sessions.ts";
85
90
  import { FailedResponseError, type Provider, type Turn, type StreamDelta } from "./provider.ts";
86
- import {
87
- scanWorkspace, diffScans, reconcile, DEFAULT_IGNORE, workspaceVersion,
88
- type FileSet, type RestoreResult,
89
- } from "../store/workspace.ts";
90
- import { putBlob, getBlob, putFileBlob } from "../store/blobs.ts";
91
+ import { putBlob, getBlob } from "../store/blobs.ts";
91
92
  import { hydrateProcessReceipts } from "../store/process-receipts.ts";
92
- import { readNodeHistory, planRestoreToNode, applyRestorePlan } from "../store/restore.ts";
93
+ import { readNodeHistory } from "../store/restore.ts";
93
94
  import { createWorktree, removeWorktree } from "../store/worktree.ts";
94
95
  import {
95
96
  parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
@@ -108,7 +109,8 @@ import { observeCommand } from "../store/command.ts";
108
109
  import { headTailView } from "../core/observation-view.ts";
109
110
  import { withProgress } from "./progress.ts";
110
111
  import { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
111
- import { changedPaths, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
112
+ import { changedPaths, invertFileChanges, sameVersion, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
113
+ import type { GitCheckpoint } from "../store/git-checkpoints.ts";
112
114
  import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
113
115
  import { planCheckpointRestore } from "../store/checkpoint-history.ts";
114
116
 
@@ -125,6 +127,27 @@ export function systemClock(): Clock {
125
127
  };
126
128
  }
127
129
 
130
+ /** Per-request options that are not session configuration. */
131
+ export interface RequestOptions {
132
+ /**
133
+ * A read-only planning request (S.5 goal drafts). The Plan-mode tool gate
134
+ * refuses anything that could change the workspace, the request runs at
135
+ * most READ_ONLY_REQUEST_LIMIT model requests with the last one answering
136
+ * without tools, and no goal or completion check applies. A draft written
137
+ * without seeing the workspace guessed its commands, and a request with
138
+ * tools disabled outright was answered with tool calls anyway
139
+ * (fix-goal-read-only-draft).
140
+ */
141
+ readOnly?: boolean;
142
+ }
143
+
144
+ /**
145
+ * Assumed, not measured: room to list the workspace and read a few files
146
+ * before answering. The last request answers with tools disabled.
147
+ */
148
+ export const READ_ONLY_REQUEST_LIMIT = 8;
149
+ const READ_ONLY_HINT = "this request only reads the workspace. Use read, find or grep, then answer.";
150
+
128
151
  export interface LoopEvent {
129
152
  kind:
130
153
  | "turn_start"
@@ -141,8 +164,6 @@ export interface LoopEvent {
141
164
  | "stop_and_ask"
142
165
  | "branch_start"
143
166
  | "branch_done"
144
- | "snapshot_failed"
145
- | "undeclared_change"
146
167
  | "done";
147
168
  text?: string;
148
169
  tool?: { name: string; input: unknown };
@@ -197,18 +218,14 @@ export interface SessionConfig {
197
218
  agentProfile?: { name: string; workflowExplicit: boolean };
198
219
  /** An explicit mode command's capability default, independent of role authority. */
199
220
  workflowToolProfile?: "foundation";
200
- /** P.3: legacy full snapshots are an explicit compatibility mode, never the CLI default. */
201
- workspaceTracking?: "journal" | "legacy";
202
221
  shellCheckpoints?: boolean;
203
222
  skills?: readonly Skill[];
204
223
  acceptanceContract?: AcceptanceContract | null;
224
+ /** feat-blind-checks: hidden checks, frozen at session start; never part of any projection. */
225
+ blindChecks?: BlindChecks;
205
226
  strictVerification?: boolean;
206
- /** O.10: opt-in carried view; legacy sessions and evaluations retain D20. */
227
+ /** O.10: opt-in carried view (source-priority, R9); evaluations compare it against D20's plain handoff. */
207
228
  workingSet?: boolean;
208
- /** R9: CLI opts into source-priority views; absent preserves v1 experiment/old-session behavior. */
209
- workingSetVersion?: 1 | 2;
210
- /** R10: new CLI guidance; absence retains old carried/prefix wording on resume. */
211
- guidanceVersion?: 1;
212
229
  logDir: string;
213
230
  frozen: FrozenPrefix;
214
231
  tools: ToolContext;
@@ -258,8 +275,6 @@ export interface SessionConfig {
258
275
  */
259
276
  blobDir?: string;
260
277
  imageOptions?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
261
- /** Directory names the scan skips, relative to the workspace root. */
262
- snapshotIgnore?: readonly string[];
263
278
  }
264
279
 
265
280
  export interface SessionState {
@@ -278,7 +293,6 @@ export interface SessionState {
278
293
  /** Next free node number. Branches take one each. */
279
294
  nextNode: number;
280
295
  /** The workspace as of the last snapshot, for diffing the next one. */
281
- lastScan: FileSet;
282
296
  /** Worktrees created for writing branches, by node. */
283
297
  worktrees: Map<number, { path: string; branch: string }>;
284
298
  }
@@ -353,6 +367,10 @@ export class Session {
353
367
  private cleanup: Promise<void> | null = null;
354
368
  private closing: Promise<void> | null = null;
355
369
  private turnTools: ToolContext | null = null;
370
+ /** The running request is read-only (RequestOptions.readOnly); every tool call goes through the Plan gate. */
371
+ private readOnlyRequest = false;
372
+ /** The source snapshot taken when the current goal request began; a counter-check reverts to it. In memory: a request never outlives its process. */
373
+ private goalBaseline: { request: string; snapshot: GitCheckpoint } | null = null;
356
374
  private readonly execution = new ExecutionControl();
357
375
  private readonly runtimeTargets = new RuntimeTargets();
358
376
  private rootTools: ToolQueue | undefined;
@@ -382,7 +400,7 @@ export class Session {
382
400
  stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
383
401
  validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
384
402
  schedulingWindow([], 0, config.maxParallelTools ?? 3);
385
- requestPhase(0, config.maxTurns);
403
+ validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
386
404
  this.allocation = allocation;
387
405
  this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
388
406
  processWait: { wake: () => this.processWake.signal } } };
@@ -430,7 +448,7 @@ export class Session {
430
448
  };
431
449
  this.state = {
432
450
  node: 0, depth: 1, handoff: null, events: [], usage: [],
433
- anchor: null, branchesOpened: 0, lastScan: {},
451
+ anchor: null, branchesOpened: 0,
434
452
  get nextNode() { return allocation.next; },
435
453
  set nextNode(value: number) { allocation.next = value; },
436
454
  worktrees: new Map(),
@@ -441,8 +459,7 @@ export class Session {
441
459
  private installWorkspace(): void {
442
460
  delete this.config.tools.workspace;
443
461
  this.config.tools.ownedStatePaths = [this.config.logDir, ...(this.config.blobDir ? [this.config.blobDir] : [])];
444
- this.config.tools.legacyWorkspace = this.config.workspaceTracking === "legacy";
445
- if (this.config.blobDir && this.config.workspaceTracking !== "legacy") this.config.tools.workspace = new WorkspaceLifecycle(
462
+ if (this.config.blobDir) this.config.tools.workspace = new WorkspaceLifecycle(
446
463
  this.config.tools.root, this.config.blobDir, this.config.shellCheckpoints === true,
447
464
  event => this.record({ ...event, id: this.clock.nextId(), at: this.clock.now() }),
448
465
  join(this.config.logDir, "checkpoints"),
@@ -492,68 +509,39 @@ export class Session {
492
509
  this.config.stallLimit ??= DEFAULT_STALL_LIMIT;
493
510
  this.config.outputRecoveryLimit ??= DEFAULT_OUTPUT_RECOVERY_LIMIT;
494
511
  await initNode(dir, 0, null, this.clock.now());
495
- for await (const event of this.snapshotTurn([], true)) {
496
- if (event.kind === "snapshot_failed") throw new Error(`cannot establish workspace baseline: ${event.reason}`);
497
- }
498
512
  break;
499
513
 
500
514
  case "resume": {
501
- this.config.acceptanceContract = null;
502
- this.config.strictVerification = false;
503
- this.config.workingSet = false;
504
- this.config.workingSetVersion = 1;
505
- delete this.config.guidanceVersion;
506
- // Logs predating explicit policies meant segmented, regardless of today's default.
507
- this.config.contextPolicy = "segmented";
508
- this.config.maxParallelTools = 1;
509
- this.config.stallLimit = 0;
510
- this.config.outputRecoveryLimit = 0;
511
515
  const replayed = await replayContext(dir, plan.cursor.node);
512
- if (plan.cursor.context_ref) {
513
- const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
514
- if (saved === null) throw new Error("missing frozen session context");
515
- const context = JSON.parse(saved) as { agentProfile?: { name: string; workflowExplicit: boolean }; workflowMode?: "build" | "plan"; skills?: Skill[]; acceptanceContract?: AcceptanceContract; strictVerification?: boolean; workingSet?: boolean; workingSetVersion?: 1 | 2; guidanceVersion?: 1; projectionVersion?: number; frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workspaceTracking?: "journal" | "legacy"; shellCheckpoints?: boolean; workNamespace?: string; maxParallelTools?: number };
516
- if (context.agentProfile !== undefined) {
517
- if (!context.agentProfile || typeof context.agentProfile.name !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/.test(context.agentProfile.name) || typeof context.agentProfile.workflowExplicit !== "boolean") throw Error("unsupported saved agent profile selector");
518
- this.config.agentProfile = { ...context.agentProfile };
519
- }
520
- if (context.workflowMode !== undefined) { if (!["build", "plan"].includes(context.workflowMode)) throw Error("unsupported saved workflow mode"); this.config.tools.workflowMode = context.workflowMode; }
521
- if ("workflowToolProfile" in context && context.workflowToolProfile !== undefined) {
522
- if (context.workflowToolProfile !== "foundation") throw Error("unsupported saved workflow tool profile");
523
- this.config.workflowToolProfile = "foundation";
524
- }
525
- if (context.projectionVersion !== undefined && context.projectionVersion !== PROJECTION_VERSION) throw new Error("unsupported saved projection version");
526
- this.config.workingSet = context.workingSet === true;
527
- if (context.workingSetVersion !== undefined && ![1, 2].includes(context.workingSetVersion)) throw new Error("unsupported saved working-set version");
528
- this.config.workingSetVersion = context.workingSetVersion ?? 1;
529
- if (context.guidanceVersion !== undefined && context.guidanceVersion !== 1) throw new Error("unsupported saved guidance version");
530
- if (context.guidanceVersion === 1) this.config.guidanceVersion = 1;
531
- this.config.workspaceTracking = context.workspaceTracking ?? "journal";
532
- this.config.shellCheckpoints = context.shellCheckpoints === true;
533
- if (context.acceptanceContract) { parseAcceptanceContract(context.acceptanceContract); this.config.acceptanceContract = context.acceptanceContract; }
534
- this.config.strictVerification = context.strictVerification === true;
535
- this.config.skills = context.skills ?? [];
536
- this.installSkillResources();
537
- this.config.workNamespace = context.workNamespace ?? "main";
538
- this.config.maxParallelTools = context.maxParallelTools ?? 1;
539
- schedulingWindow([], 0, this.config.maxParallelTools);
540
- stallDecision(emptyStallState(), context.stallLimit ?? 0);
541
- this.config.stallLimit = context.stallLimit ?? 0;
542
- validateOutputRecoveryLimit(context.outputRecoveryLimit ?? 0);
543
- this.config.outputRecoveryLimit = context.outputRecoveryLimit ?? 0;
544
- this.config.frozen = context.frozen;
545
- this.config.tools.gates = context.gates;
546
- if (context.boundary !== undefined) this.config.boundary = context.boundary;
547
- else delete this.config.boundary;
548
- if (context.contextPolicy !== undefined) {
549
- if (!isContextPolicy(context.contextPolicy)) throw new Error("unknown saved context policy");
550
- this.config.contextPolicy = context.contextPolicy;
551
- } else this.config.contextPolicy = "segmented";
552
- if (context.guidance !== undefined) this.config.guidance = context.guidance;
553
- else delete this.config.guidance;
554
- if (context.snapshotIgnore !== undefined) this.config.snapshotIgnore = context.snapshotIgnore;
555
- else delete this.config.snapshotIgnore;
556
- }
516
+ if (!plan.cursor.context_ref) throw new Error("missing frozen session context");
517
+ const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
518
+ if (saved === null) throw new Error("missing frozen session context");
519
+ // S.3: one format, parsed once. Every field below was written by a
520
+ // session of this format, so it is restored as recorded, not defaulted.
521
+ const context = parseSessionContext(JSON.parse(saved));
522
+ if (context.agentProfile) this.config.agentProfile = { ...context.agentProfile };
523
+ else delete this.config.agentProfile;
524
+ if (context.workflowMode) this.config.tools.workflowMode = context.workflowMode;
525
+ if (context.workflowToolProfile) this.config.workflowToolProfile = context.workflowToolProfile;
526
+ this.config.workingSet = context.workingSet;
527
+ this.config.shellCheckpoints = context.shellCheckpoints;
528
+ this.config.acceptanceContract = context.acceptanceContract ?? null;
529
+ this.config.strictVerification = context.strictVerification;
530
+ this.config.skills = context.skills;
531
+ this.installSkillResources();
532
+ this.config.workNamespace = context.workNamespace;
533
+ this.config.maxParallelTools = context.maxParallelTools;
534
+ this.config.stallLimit = context.stallLimit;
535
+ this.config.outputRecoveryLimit = context.outputRecoveryLimit;
536
+ this.config.frozen = context.frozen;
537
+ this.config.tools.gates = context.gates;
538
+ this.config.contextPolicy = context.contextPolicy;
539
+ if (context.boundary !== undefined) this.config.boundary = context.boundary;
540
+ else delete this.config.boundary;
541
+ if (context.guidance !== undefined) this.config.guidance = context.guidance;
542
+ else delete this.config.guidance;
543
+ if (context.blindChecks) this.config.blindChecks = context.blindChecks;
544
+ else delete this.config.blindChecks;
557
545
  if (plan.cursor.workspace !== undefined) this.config.tools.root = plan.cursor.workspace;
558
546
  if (plan.cursor.blob_dir !== undefined) this.config.blobDir = plan.cursor.blob_dir;
559
547
  for (const id of existing) {
@@ -583,14 +571,6 @@ export class Session {
583
571
  this.state.anchor = null;
584
572
  // Compare disk against the durable file projection. Treating current
585
573
  // disk as the baseline would erase unlogged effects of a crashed tool.
586
- if (this.config.blobDir && this.config.workspaceTracking === "legacy") {
587
- const files = await planRestoreToNode(dir, this.state.node);
588
- if (!files.ok) throw new Error(files.error);
589
- this.state.lastScan = files.files;
590
- for await (const event of this.snapshotTurn([], false, "idle")) {
591
- if (event.kind === "snapshot_failed") throw new Error(event.reason);
592
- }
593
- }
594
574
  const pending = (await readNode(dir, plan.cursor.node)).events.findLast((e) => e.t === "adopt" && !e.applied);
595
575
  if (pending?.t === "adopt") {
596
576
  this.pendingAdoption = pending.branch.node;
@@ -604,7 +584,7 @@ export class Session {
604
584
  }
605
585
 
606
586
  this.installWorkspace();
607
- if (this.config.workspaceTracking !== "legacy") await this.recoverFileOperations();
587
+ await this.recoverFileOperations();
608
588
  this.restoreDeclaredTools();
609
589
  // Consume the original pending receipt before another checkpoint can replace this projection.
610
590
  const declarationCut = await this.applyDiscoveredTools();
@@ -623,7 +603,7 @@ export class Session {
623
603
  */
624
604
  async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
625
605
  await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
626
- if (untilEvent !== undefined && this.config.workspaceTracking !== "legacy") {
606
+ if (untilEvent !== undefined) {
627
607
  const cursor = await readCursor(destLogDir);
628
608
  if (!cursor) throw new Error("missing fork cursor");
629
609
  const tip = (await readNode(this.config.logDir, this.state.node)).events.at(-1);
@@ -677,9 +657,6 @@ export class Session {
677
657
  this.rootTools ??= new ToolQueue(this.config.maxParallelTools ?? 3);
678
658
  release = await this.rootTools.acquire("exclusive");
679
659
  if (!command.trim()) throw new Error("empty user command");
680
- for await (const event of this.snapshotTurn([], false, "idle")) {
681
- if (event.kind === "snapshot_failed") throw new Error(event.reason);
682
- }
683
660
  const execute = async (): Promise<ToolResult> => {
684
661
  const captured = await observeCommand({ command, cwd: this.config.tools.root,
685
662
  outputStore: join(this.config.logDir, "artifacts"), maxOutputBytes: this.config.tools.maxOutputBytes,
@@ -696,9 +673,6 @@ export class Session {
696
673
  const event: Extract<LogEvent, { t: "user_bash" }> = { t: "user_bash", id: this.clock.nextId(), at: this.clock.now(),
697
674
  command, content: result.content, exit: result.effect.exit, excludeFromContext, outcome: result.outcome, content_ref: result.content_ref };
698
675
  await this.record(event);
699
- for await (const snapshot of this.snapshotTurn([])) {
700
- if (snapshot.kind === "snapshot_failed") throw new Error(snapshot.reason);
701
- }
702
676
  await this.saveCursor();
703
677
  return event;
704
678
  } catch (error) {
@@ -729,12 +703,9 @@ export class Session {
729
703
  const target = await resolveHistoryPosition(dir, eventId, users.some(user => user.id === eventId) ? "before" : "at");
730
704
  const previous = (await readNode(dir, this.state.node)).events.at(-1);
731
705
  const filesFrom = { ...await digestedRef(dir, this.state.node), ...(previous ? { at_event: previous.id } : {}) };
732
- for await (const event of this.snapshotTurn([], false, "idle")) {
733
- if (event.kind === "snapshot_failed") throw new Error(event.reason);
734
- }
735
706
  const node = this.state.nextNode++;
736
707
  await initNode(dir, node, target.parent, this.clock.now(), target.basis);
737
- if (this.config.workspaceTracking !== "legacy") await appendEvent(dir, node, {
708
+ await appendEvent(dir, node, {
738
709
  t: "rewind", id: this.clock.nextId(), at: this.clock.now(), mode: "context", from: target.parent ?? filesFrom,
739
710
  files_from: filesFrom, prompt: target.editorText ?? "Selected conversation position", restored: [], removed: [], unrecoverable: [], divergence: [],
740
711
  });
@@ -877,29 +848,26 @@ export class Session {
877
848
  * handlers.
878
849
  */
879
850
  private async persistContext(frozen = this.config.frozen, skills = this.config.skills): Promise<string> {
880
- return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify({
881
- projectionVersion: PROJECTION_VERSION,
882
- workspaceTracking: this.config.workspaceTracking ?? "journal",
883
- shellCheckpoints: this.config.shellCheckpoints === true,
884
- ...(skills?.length ? { skills } : {}),
885
- ...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
886
- ...(this.config.strictVerification ? { strictVerification: true } : {}),
887
- ...(this.config.workingSet ? { workingSet: true } : {}),
888
- ...(this.config.workingSet && this.config.workingSetVersion === 2 ? { workingSetVersion: 2 } : {}),
889
- ...(this.config.guidanceVersion === 1 ? { guidanceVersion: 1 } : {}),
851
+ const context: SessionContext = {
890
852
  frozen, gates: this.config.tools.gates,
891
- ...(this.config.tools.workflowMode ? { workflowMode: this.config.tools.workflowMode } : {}),
892
- ...(this.config.agentProfile ? { agentProfile: this.config.agentProfile } : {}),
893
- ...(this.config.workflowToolProfile ? { workflowToolProfile: this.config.workflowToolProfile } : {}),
853
+ shellCheckpoints: this.config.shellCheckpoints === true,
854
+ skills: skills ?? [],
855
+ strictVerification: this.config.strictVerification === true,
856
+ workingSet: this.config.workingSet === true,
894
857
  workNamespace: this.config.workNamespace ?? "main",
895
858
  maxParallelTools: this.config.maxParallelTools ?? 3,
896
- ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
897
859
  contextPolicy: this.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
898
860
  stallLimit: this.config.stallLimit ?? DEFAULT_STALL_LIMIT,
899
861
  outputRecoveryLimit: this.config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT,
862
+ ...(this.config.acceptanceContract ? { acceptanceContract: this.config.acceptanceContract } : {}),
863
+ ...(this.config.tools.workflowMode ? { workflowMode: this.config.tools.workflowMode } : {}),
864
+ ...(this.config.agentProfile ? { agentProfile: this.config.agentProfile } : {}),
865
+ ...(this.config.workflowToolProfile ? { workflowToolProfile: this.config.workflowToolProfile } : {}),
866
+ ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
900
867
  ...(this.config.guidance === undefined ? {} : { guidance: this.config.guidance }),
901
- ...(this.config.snapshotIgnore ? { snapshotIgnore: this.config.snapshotIgnore } : {}),
902
- }));
868
+ ...(this.config.blindChecks ? { blindChecks: this.config.blindChecks } : {}),
869
+ };
870
+ return putBlob(join(this.config.logDir, "artifacts"), JSON.stringify(context));
903
871
  }
904
872
 
905
873
  private async saveCursor(model = this.provider.config.spec.id, provider = this.provider.config.spec.provider,
@@ -1023,32 +991,106 @@ export class Session {
1023
991
  contextInput(): ProjectInput {
1024
992
  return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
1025
993
  logHint: { manifestPath: manifestPath(this.config.logDir),
1026
- ...(this.config.guidanceVersion === 1 ? { scopedGuidance: true } : {}),
1027
994
  segmentPath: this.state.handoff?.predecessor?.log_ref ?? nodePath(this.config.logDir, this.state.node) } };
1028
995
  }
1029
996
 
1030
997
  private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
1031
998
  return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
1032
- ...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
1033
- ...(this.config.guidanceVersion === 1 ? { guidance_tools: tools } : {}) });
999
+ ...(this.config.workingSet ? { working_set_version: 2 as const } : {}), guidance_tools: tools });
1034
1000
  }
1035
1001
 
1036
1002
  async acceptanceState(): Promise<AcceptanceState | undefined> {
1037
1003
  if (!this.running) await this.observeEvidence();
1038
1004
  // External editor changes must invalidate proof before the user reads it.
1039
- if (!this.running) for await (const result of this.snapshotTurn([], false, "idle")) {
1040
- if (result.kind === "snapshot_failed") throw new Error(`cannot inspect an unobserved workspace: ${result.reason}`);
1041
- }
1042
1005
  if (!this.running) await this.refreshRuntimeTargets();
1043
1006
  return (await this.currentHandoff()).acceptance;
1044
1007
  }
1045
1008
 
1009
+ /** S.5: the goal in force, derived from the active log ancestry, so it survives restarts and resume. */
1010
+ async goal(): Promise<ActiveGoal | null> {
1011
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1012
+ if (!history.ok) throw new Error(history.error);
1013
+ return activeGoal(history.nodes.flatMap(part => part.events));
1014
+ }
1015
+
1016
+ /**
1017
+ * Records a goal from criteria the user accepted out of a model draft. The
1018
+ * draft event must be this session's assistant reply; the contract holds
1019
+ * only what was accepted (S.2).
1020
+ */
1021
+ async setGoal(input: { objective: string; draft: string; criteria: readonly AcceptanceCriterion[]; accepted: readonly string[] }): Promise<ActiveGoal> {
1022
+ if (this.running || this.isHalfRound()) throw new Error("a goal can be set only while the session is idle");
1023
+ if (!input.objective.trim()) throw new Error("a goal needs an objective");
1024
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1025
+ if (!history.ok) throw new Error(history.error);
1026
+ if (!history.nodes.some(part => part.events.some(event => event.t === "assistant" && event.id === input.draft))) throw new Error("the goal draft is not in this session");
1027
+ const contract = goalContract(input.criteria, input.accepted);
1028
+ const accepted = contract.criteria.map(item => item.id);
1029
+ const event: ActiveGoal = { t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "set", objective: input.objective.trim(),
1030
+ contract, draft: input.draft, accepted, rejected: input.criteria.map(item => item.id).filter(id => !accepted.includes(id)) };
1031
+ await this.record(event);
1032
+ await this.saveCursor();
1033
+ return event;
1034
+ }
1035
+
1036
+ /**
1037
+ * The discriminating rung: each passing accepted check re-run in a clone with
1038
+ * this request's changes undone, recorded as counter_check facts with the
1039
+ * role each criterion was declared with. Guards run too: passing there is
1040
+ * what makes a guard a guard (S.7). The goal's verdict does not depend on it;
1041
+ * it tells the user which checks actually tested the change.
1042
+ */
1043
+ private async counterCheckRequest(state: RequestState, signal?: AbortSignal): Promise<void> {
1044
+ const checks = state.criteria.flatMap(item => item.kind === "command" && item.status === "pass" && item.command
1045
+ ? [{ id: item.id, command: item.command, ...(item.guard ? { guard: true as const } : {}) }] : []);
1046
+ if (checks.length === 0 || signal?.aborted) return;
1047
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1048
+ if (!history.ok) throw new Error(history.error);
1049
+ const events = currentRequestRecords(history.nodes).map(record => record.event);
1050
+ // A snapshot taken when this request began reverts shell changes too; otherwise the journal does.
1051
+ const workspace = this.config.tools.workspace, base = this.goalBaseline?.request === state.request.event ? this.goalBaseline.snapshot : null;
1052
+ const now = base && workspace ? await workspace.snapshot(signal) : null;
1053
+ const revert = base && now && workspace ? invertFileChanges(await workspace.snapshotChanges(base, now, signal)) : undefined;
1054
+ const results = !this.config.blobDir ? checks.map(check => ({ ...check, reason: "this session keeps no file history to undo changes from" }))
1055
+ : await counterCheck({ root: this.config.tools.root, blobs: this.config.blobDir, ownedPaths: this.config.tools.ownedStatePaths ?? [],
1056
+ events, checks, timeoutMs: this.config.tools.timeoutMs, maxOutputBytes: this.config.tools.maxOutputBytes,
1057
+ outputStore: join(this.config.logDir, "artifacts"), ...(revert ? { revert } : {}), ...(signal ? { signal } : {}) });
1058
+ for (const result of results) await this.record({ t: "counter_check", id: this.clock.nextId(), at: this.clock.now(), request: state.request.event,
1059
+ criterion: result.id, command: result.command, ...(result.guard ? { guard: true as const } : {}),
1060
+ ...("exit" in result ? { exit: result.exit } : { reason: result.reason }) });
1061
+ }
1062
+
1063
+ /**
1064
+ * The independent rung: blind checks run on a clone of the final result once
1065
+ * the goal's own checks pass. The scan for exposure covers the whole log, not
1066
+ * just this request -- a blind file read yesterday is still seen.
1067
+ */
1068
+ private async blindCheckRequest(state: RequestState, signal?: AbortSignal): Promise<void> {
1069
+ const blind = this.config.blindChecks;
1070
+ if (!blind || signal?.aborted) return;
1071
+ const nodes = await listNodes(this.config.logDir);
1072
+ const everything = (await Promise.all(nodes.map(node => readNode(this.config.logDir, node)))).flatMap(read => read.events);
1073
+ const exposure = blindExposure(everything, blind);
1074
+ const results = await runBlindChecks({ root: this.config.tools.root, blind, timeoutMs: this.config.tools.timeoutMs,
1075
+ maxOutputBytes: this.config.tools.maxOutputBytes, outputStore: join(this.config.logDir, "artifacts"), ...(signal ? { signal } : {}) });
1076
+ for (const result of results) await this.record({ t: "blind_check", id: this.clock.nextId(), at: this.clock.now(), request: state.request.event,
1077
+ check: result.id, exposed: exposure !== null, ...(exposure ? { exposure } : {}), ...("exit" in result ? { exit: result.exit } : { reason: result.reason }) });
1078
+ }
1079
+
1080
+ async clearGoal(): Promise<boolean> {
1081
+ if (this.running) throw new Error("a goal can be cleared only while the session is idle");
1082
+ if (!await this.goal()) return false;
1083
+ await this.record({ t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "clear", reason: "user" });
1084
+ await this.saveCursor();
1085
+ return true;
1086
+ }
1087
+
1046
1088
  async requestState(): Promise<RequestState | undefined> {
1047
1089
  // Use the same idle workspace/target observation boundary as /acceptance.
1048
1090
  if (!this.running) await this.acceptanceState();
1049
1091
  const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
1050
1092
  if (!history.ok) throw new Error(history.error);
1051
- return deriveRequestState(history.nodes);
1093
+ return deriveRequestState(history.nodes, { strict: this.config.strictVerification === true, blind: this.config.blindChecks !== undefined });
1052
1094
  }
1053
1095
 
1054
1096
  /** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
@@ -1131,9 +1173,6 @@ export class Session {
1131
1173
  /** Only the app's user command calls this; note never grants human acceptance. */
1132
1174
  async acceptCriterion(criterion: string): Promise<AcceptanceState> {
1133
1175
  if (this.running || this.isHalfRound()) throw new Error("acceptance requires an idle, paired request");
1134
- for await (const result of this.snapshotTurn([], true)) {
1135
- if (result.kind === "snapshot_failed") throw new Error(`cannot accept an unobserved workspace: ${result.reason}`);
1136
- }
1137
1176
  const current = await this.acceptanceState();
1138
1177
  const item = current?.criteria.find(item => item.id === criterion && item.kind === "human");
1139
1178
  if (!current || !item || item.kind !== "human") throw new Error("unknown human acceptance criterion");
@@ -1192,6 +1231,7 @@ export class Session {
1192
1231
  signal?: AbortSignal,
1193
1232
  pull?: (phase: Phase) => Promise<UserInput | null>,
1194
1233
  requestId?: string,
1234
+ options: RequestOptions = {},
1195
1235
  ): AsyncGenerator<LoopEvent> {
1196
1236
  if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
1197
1237
  const owned = this.execution.begin(signal);
@@ -1200,7 +1240,7 @@ export class Session {
1200
1240
  this.turnTools = savedTools;
1201
1241
  this.config.tools = { ...savedTools };
1202
1242
  this.installSkillResources();
1203
- const events = this.run(userText, owned.signal, pull, requestId);
1243
+ const events = this.run(userText, owned.signal, pull, requestId, options);
1204
1244
  let terminal = false;
1205
1245
  let exhausted = false;
1206
1246
  try {
@@ -1219,10 +1259,11 @@ export class Session {
1219
1259
  yield item.value;
1220
1260
  }
1221
1261
  if (!terminal) {
1222
- const outcome = executionOutcome("snapshot_failed");
1223
- await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "workspace snapshot failed", outcome });
1262
+ // Every path through run() ends with `done` or throws; this records the impossible case honestly.
1263
+ const outcome = executionOutcome("execution_error");
1264
+ await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "request ended without a terminal outcome", outcome });
1224
1265
  const request = await this.requestState();
1225
- yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome, ...(request ? { request } : {}) };
1266
+ yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
1226
1267
  }
1227
1268
  } catch (error) {
1228
1269
  // Raw provider exceptions can contain credentials; persist only the execution fact.
@@ -1238,7 +1279,7 @@ export class Session {
1238
1279
  }
1239
1280
  } finally {
1240
1281
  while (this.hostPending.size > 0) await Promise.allSettled(this.hostPending);
1241
- this.config.tools = savedTools; this.turnTools = null;
1282
+ this.config.tools = savedTools; this.turnTools = null; this.readOnlyRequest = false;
1242
1283
  if (owned.signal.aborted) this.cancel();
1243
1284
  owned.finish();
1244
1285
  if (owned.signal.aborted) await this.whenIdle();
@@ -1251,6 +1292,7 @@ export class Session {
1251
1292
  signal?: AbortSignal,
1252
1293
  pull?: (phase: Phase) => Promise<UserInput | null>,
1253
1294
  requestId?: string,
1295
+ options: RequestOptions = {},
1254
1296
  ): AsyncGenerator<LoopEvent> {
1255
1297
  // A crash after a discovery receipt but before its cut is recovered from that same receipt.
1256
1298
  if (!signal?.aborted) await this.applyDiscoveredTools();
@@ -1265,48 +1307,53 @@ export class Session {
1265
1307
  await this.performCut("model_checkpoint");
1266
1308
  }
1267
1309
  const request = requestId ?? this.clock.nextId();
1310
+ // S.5: a draft request has no goal yet and must not inherit the current one.
1311
+ this.readOnlyRequest = options.readOnly === true;
1312
+ const goal = options.readOnly ? null : await this.goal();
1313
+ const contract = goal?.contract ?? this.config.acceptanceContract;
1268
1314
  await this.record({ t: "user", id: request, at: this.clock.now(), ...userInputData(userText, blocked) });
1269
- if (this.config.acceptanceContract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
1270
- request: this.state.events.at(-1)!.id, contract: this.config.acceptanceContract });
1315
+ if (contract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
1316
+ request: this.state.events.at(-1)!.id, contract });
1317
+ // The state a counter-check must rebuild before it may run (feat-counter-check).
1318
+ if (goal) {
1319
+ await this.config.tools.workspace?.baseline(signal);
1320
+ const snapshot = await this.config.tools.workspace?.snapshot(signal);
1321
+ this.goalBaseline = snapshot ? { request, snapshot } : null;
1322
+ }
1271
1323
  // Idle changes belong to the new prompt. Recording them before `user`
1272
1324
  // would silently move the preceding exchange's restore point forward.
1273
- for await (const event of this.snapshotTurn([])) {
1274
- yield event;
1275
- if (event.kind === "snapshot_failed") return;
1276
- }
1277
1325
 
1278
1326
  const counter = this.config.counter ?? estimateTokens;
1279
- const completedThisTurn: string[] = [];
1280
1327
 
1281
1328
  try {
1282
1329
  let overflowRetried = false;
1283
1330
  const requestStarted = this.clock.now();
1284
- for (let turn = 0; turn < this.config.maxTurns; turn++) {
1285
- const phase = requestPhase(turn, this.config.maxTurns);
1286
- completedThisTurn.length = 0;
1331
+ const requestLimit = options.readOnly ? Math.min(READ_ONLY_REQUEST_LIMIT, this.config.maxTurns) : this.config.maxTurns;
1332
+ const report = (cause: string, requests: number) => this.terminationReport(cause, requests, requestLimit);
1333
+ for (let turn = 0; turn < requestLimit; turn++) {
1334
+ const phase = requestPhase(turn, requestLimit);
1287
1335
  if (signal?.aborted) {
1288
- yield* this.closeInterrupted(completedThisTurn);
1336
+ yield* this.closeInterrupted();
1289
1337
  return;
1290
1338
  }
1291
1339
 
1292
1340
  if (yield* this.checkStall()) {
1293
- yield { kind: "done", reason: await this.terminationReport("no-progress allowance exhausted", turn), outcome: executionOutcome("stalled") };
1341
+ yield { kind: "done", reason: await report("no-progress allowance exhausted", turn), outcome: executionOutcome("stalled") };
1294
1342
  return;
1295
1343
  }
1296
1344
  if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
1297
- yield { kind: "done", reason: await this.terminationReport("work budget exhausted; protocol cannot disable tools while preserving frozen definitions", turn), outcome: executionOutcome("max_turns") };
1345
+ yield { kind: "done", reason: await report("work budget exhausted; protocol cannot disable tools while preserving frozen definitions", turn), outcome: executionOutcome("max_turns") };
1298
1346
  return;
1299
1347
  }
1300
1348
  yield { kind: "turn_start", node: this.state.node };
1301
1349
 
1302
1350
  if (phase === "finalize") {
1303
1351
  await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "budget", sources: [], text: finalizationNotice() });
1304
- } else if (this.config.guidance !== false) {
1352
+ } else if (!options.readOnly && this.config.guidance !== false) {
1305
1353
  const names = this.adviceNames();
1306
1354
  const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
1307
1355
  continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
1308
1356
  tools: names, remainingRequests: this.config.maxTurns - turn,
1309
- ...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
1310
1357
  ...(this.config.continuationAfter === undefined ? {} : { minAssistantTurns: this.config.continuationAfter }) }) ??
1311
1358
  (this.config.timeBudgetMs === undefined ? null
1312
1359
  : deadlineNotice(requestStarted, this.clock.now(), this.config.timeBudgetMs, this.state.events)) ??
@@ -1348,12 +1395,12 @@ export class Session {
1348
1395
  // D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
1349
1396
  if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
1350
1397
  if (signal?.aborted) {
1351
- yield* this.closeInterrupted(completedThisTurn);
1398
+ yield* this.closeInterrupted();
1352
1399
  return;
1353
1400
  }
1354
1401
  if (phase === "finalize") {
1355
1402
  yield { kind: "discard" };
1356
- yield { kind: "done", reason: await this.terminationReport("final response failed; no additional request was made", turn + 1), outcome: executionOutcome("provider_error") };
1403
+ yield { kind: "done", reason: await report("final response failed; no additional request was made", turn + 1), outcome: executionOutcome("provider_error") };
1357
1404
  return;
1358
1405
  }
1359
1406
  if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
@@ -1382,7 +1429,7 @@ export class Session {
1382
1429
  if (signal?.aborted) {
1383
1430
  yield { kind: "usage", usage: result.usage, ok: true };
1384
1431
  yield { kind: "discard" };
1385
- yield* this.closeInterrupted(completedThisTurn);
1432
+ yield* this.closeInterrupted();
1386
1433
  return;
1387
1434
  }
1388
1435
  const promptTokens =
@@ -1452,7 +1499,7 @@ export class Session {
1452
1499
  !result.blocks.some(block => block.type === "text" && block.text.trim()))) {
1453
1500
  for (const item of await this.answerUnpairedTools("not executed: tools are disabled in the final request", "not_started"))
1454
1501
  yield { kind: "tool_result", text: item.content, ok: false };
1455
- yield { kind: "done", reason: await this.terminationReport("request budget exhausted; final response did not provide a complete text answer", turn + 1), outcome: executionOutcome("max_turns") };
1502
+ yield { kind: "done", reason: await report("request budget exhausted; final response did not provide a complete text answer", turn + 1), outcome: executionOutcome("max_turns") };
1456
1503
  return;
1457
1504
  }
1458
1505
 
@@ -1491,13 +1538,21 @@ export class Session {
1491
1538
  });
1492
1539
  continue;
1493
1540
  }
1494
- if (this.config.acceptanceContract) await this.refreshRuntimeTargets();
1541
+ if (contract) await this.refreshRuntimeTargets();
1495
1542
  const state = await this.requestState();
1496
- const decision = !state || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
1497
- : requestCompletion(state, this.config.maxTurns - turn - 1, {
1498
- ...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
1499
- ...(this.config.strictVerification ? { strictVerification: true } : {}),
1500
- });
1543
+ const remaining = requestLimit - turn - 1;
1544
+ // S.5: an active goal replaces the one-time completion check. It keeps
1545
+ // the request going until the ledger says the goal is met; a stated
1546
+ // blocker, a spent budget or the stall watchdog still end the run.
1547
+ const verdict = goal && state ? goalDecision(state, remaining, idleGoalChecks(this.state.events)) : null;
1548
+ if (state && (verdict?.action === "met" || verdict?.action === "await_human")) {
1549
+ await this.counterCheckRequest(state, signal);
1550
+ await this.blindCheckRequest(state, signal);
1551
+ }
1552
+ if (verdict?.action === "met") await this.record({ t: "goal", id: this.clock.nextId(), at: this.clock.now(), action: "clear", reason: "met" });
1553
+ const decision = verdict?.action === "continue" ? { action: "continue" as const, guidance: { reason: "goal" as const, sources: [state!.request.event], text: verdict.text } }
1554
+ : verdict || !state || options.readOnly || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
1555
+ : requestCompletion(state, remaining);
1501
1556
  if (decision.action === "continue") {
1502
1557
  if (yield* this.maybeCut(counter, true)) {
1503
1558
  yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
@@ -1530,7 +1585,7 @@ export class Session {
1530
1585
  try { for (let callIndex = 0; callIndex < calls.length; callIndex++) {
1531
1586
  const call = calls[callIndex]!;
1532
1587
  if (signal?.aborted && !pending.has(call.call_id)) {
1533
- yield* this.closeInterrupted(completedThisTurn);
1588
+ yield* this.closeInterrupted();
1534
1589
  return;
1535
1590
  }
1536
1591
 
@@ -1553,9 +1608,9 @@ export class Session {
1553
1608
  if (!settled.ok) throw settled.error;
1554
1609
  receipt = settled.value;
1555
1610
  } else {
1556
- if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
1611
+ if (signal?.aborted) { yield* this.closeInterrupted(); return; }
1557
1612
  yield { kind: "tool_call", tool: { name: call.name, input: call.input } };
1558
- if (signal?.aborted) { yield* this.closeInterrupted(completedThisTurn); return; }
1613
+ if (signal?.aborted) { yield* this.closeInterrupted(); return; }
1559
1614
  const prepared = yield* this.prepareBrokerCall(call, () => branchesThisTurn++, blocked, signal);
1560
1615
  receipt = yield* withProgress<SessionBrokerReceipt, LoopEvent>(emit => prepared.run(emit));
1561
1616
  }
@@ -1567,9 +1622,8 @@ export class Session {
1567
1622
  yield { kind: "tool_result", text: out.content, ok: out.ok, outcome: out.outcome! };
1568
1623
  await receipt.commit();
1569
1624
 
1570
- completedThisTurn.push(call.call_id);
1571
1625
  if (signal?.aborted && pending.size === 0) {
1572
- yield* this.closeInterrupted(completedThisTurn);
1626
+ yield* this.closeInterrupted();
1573
1627
  return;
1574
1628
  }
1575
1629
  } } finally { await Promise.allSettled(pending.values()); }
@@ -1577,7 +1631,6 @@ export class Session {
1577
1631
  // One scan per turn, not per tool call: a turn is the smallest unit
1578
1632
  // that means anything, and per-call would be the character-level
1579
1633
  // granularity the research warns against.
1580
- yield* this.snapshotTurn(calls.map((c) => c.call_id));
1581
1634
  const declarationNode = this.state.node;
1582
1635
  if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
1583
1636
  if (this.pendingAdoption !== null) await this.adoptPendingBranch();
@@ -1596,7 +1649,7 @@ export class Session {
1596
1649
  }
1597
1650
  }
1598
1651
 
1599
- yield { kind: "done", reason: await this.terminationReport("request budget exhausted", this.config.maxTurns), outcome: executionOutcome("max_turns") };
1652
+ yield { kind: "done", reason: await report("request budget exhausted", requestLimit), outcome: executionOutcome("max_turns") };
1600
1653
  } finally {
1601
1654
  // If the consumer aborts and stops pulling, the generator is closed
1602
1655
  // without running the yield* closeInterrupted path. Pairing still
@@ -1636,14 +1689,11 @@ export class Session {
1636
1689
  return added;
1637
1690
  }
1638
1691
 
1639
- private async *closeInterrupted(completedCallIds: readonly string[]): AsyncGenerator<LoopEvent> {
1692
+ private async *closeInterrupted(): AsyncGenerator<LoopEvent> {
1640
1693
  const added = await this.answerUnpairedTools();
1641
1694
  for (const item of added) {
1642
1695
  yield { kind: "tool_result", text: item.content, ok: false };
1643
1696
  }
1644
- if (completedCallIds.length > 0) {
1645
- yield* this.snapshotTurn(completedCallIds);
1646
- }
1647
1697
  await this.recordInterrupt();
1648
1698
  yield { kind: "done", reason: "interrupted", outcome: executionOutcome("cancelled") };
1649
1699
  }
@@ -1709,78 +1759,6 @@ export class Session {
1709
1759
  * Returning the verdict rather than only announcing it is the point: an
1710
1760
  * advisory the loop ignores looks like a guard while the budget drains.
1711
1761
  */
1712
- /**
1713
- * Records what the workspace actually did this turn.
1714
- *
1715
- * Content goes to the blob store so a later restore has something to
1716
- * restore from; the event holds only digests, which is what keeps it a
1717
- * delta rather than a copy of the workspace.
1718
- */
1719
- private async *snapshotTurn(callIds: readonly string[], baseline = false, phase: "turn" | "idle" = "turn"): AsyncGenerator<LoopEvent> {
1720
- // P.3: retained only for explicit historical compatibility. New sessions never scan here.
1721
- if (this.config.workspaceTracking !== "legacy") return;
1722
- const blobDir = this.config.blobDir;
1723
- if (!blobDir) return;
1724
-
1725
- const scan = await scanWorkspace(
1726
- this.config.tools.root,
1727
- this.config.snapshotIgnore ?? DEFAULT_IGNORE,
1728
- );
1729
- if (!scan.ok) {
1730
- // Reported rather than skipped: without a scan a restore would delete
1731
- // files it never recorded, so the caller has to know it is blind.
1732
- yield { kind: "snapshot_failed", reason: scan.error };
1733
- return;
1734
- }
1735
-
1736
- const delta = diffScans(this.state.lastScan, scan.files);
1737
- const nothingHappened =
1738
- Object.keys(delta.added).length === 0 &&
1739
- Object.keys(delta.changed).length === 0 &&
1740
- delta.removed.length === 0;
1741
- if (nothingHappened) return;
1742
-
1743
- // Only this turn's effects: reconciling against the whole node would
1744
- // report every earlier edit as declared-but-unchanged.
1745
- const answered = new Set(callIds);
1746
- const effects = this.state.events
1747
- .filter((e) => e.t === "tool_result" && answered.has(e.call_id) && e.effect)
1748
- .map((e) => (e as Extract<LogEvent, { t: "tool_result" }>).effect!);
1749
- const rec = reconcile(effects, delta);
1750
-
1751
- for (const [path, sha] of Object.entries({ ...delta.added, ...delta.changed })) {
1752
- const stored = await putFileBlob(blobDir, join(this.config.tools.root, path));
1753
- if (stored !== sha) {
1754
- // The file changed between the scan and the read. Recording the
1755
- // digest that was actually stored keeps the event honest.
1756
- if (path in delta.added) delta.added[path] = stored;
1757
- else delta.changed[path] = stored;
1758
- scan.files[path] = stored;
1759
- }
1760
- }
1761
-
1762
- await this.record({
1763
- t: "snapshot",
1764
- id: this.clock.nextId(),
1765
- at: this.clock.now(),
1766
- added: delta.added,
1767
- changed: delta.changed,
1768
- removed: delta.removed,
1769
- undeclared: baseline ? [] : rec.undeclared,
1770
- declared_but_unchanged: rec.declaredButUnchanged,
1771
- workspace_sha256: workspaceVersion(scan.files),
1772
- phase,
1773
- });
1774
- this.state.lastScan = scan.files;
1775
-
1776
- if (!baseline && rec.undeclared.length > 0) {
1777
- yield {
1778
- kind: "undeclared_change",
1779
- reason: `changed with no tool claiming it: ${rec.undeclared.join(", ")}`,
1780
- };
1781
- }
1782
- }
1783
-
1784
1762
  private get limits(): BranchLimits {
1785
1763
  return { ...DEFAULT_BRANCH_LIMITS, ...this.config.branches };
1786
1764
  }
@@ -1843,7 +1821,8 @@ export class Session {
1843
1821
  try {
1844
1822
  const events: LoopEvent[] = [];
1845
1823
  let emit = (event: LoopEvent) => { events.push(event); };
1846
- let context: ToolContext = { ...this.config.tools, imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
1824
+ let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
1825
+ imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
1847
1826
  advice: this.allowedNames(),
1848
1827
  imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}) };
1849
1828
  if (native) context = nativeCommandContext(context);
@@ -1935,7 +1914,8 @@ export class Session {
1935
1914
  ...(out.outcome ? { outcome: out.outcome } : {}), ...(data !== undefined ? { data } : {}), ...(dataRef ? { data_ref: dataRef } : {}),
1936
1915
  ...(out.observation_sha256 !== undefined ? { observation_sha256: out.observation_sha256 } : {}),
1937
1916
  ...(out.observation ? { observation: out.observation } : {}), ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
1938
- ...(out.warnings ? { warnings: out.warnings } : {}), ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
1917
+ ...(out.warnings ? { warnings: out.warnings } : {}), ...(out.instructions ? { instructions: out.instructions } : {}),
1918
+ ...(out.content_sha256 ? { content_sha256: out.content_sha256 } : {}),
1939
1919
  ...(out.content_ref ? { content_ref: out.content_ref } : {}),
1940
1920
  ...(out.images?.length ? { images: submissionImages(out.images, blockedImages) } : {}), ...(out.effect ? { effect: out.effect } : {}) });
1941
1921
  }
@@ -2052,7 +2032,6 @@ export class Session {
2052
2032
  this.state.branchesOpened += 1;
2053
2033
  const ref = await this.nodeRefFor(node);
2054
2034
  // A fork point must include any preceding tool effects in this batch.
2055
- yield* this.snapshotTurn([]);
2056
2035
 
2057
2036
  // A writing branch needs its own checkout, or parallel branches overwrite
2058
2037
  // each other's files with nothing to show isolation was never in place.
@@ -2107,7 +2086,6 @@ export class Session {
2107
2086
  );
2108
2087
  branch.state.node = node;
2109
2088
  branch.state.nextNode = this.state.nextNode;
2110
- branch.state.lastScan = { ...this.state.lastScan };
2111
2089
  if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
2112
2090
  this.children.set(node, branch);
2113
2091
  yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
@@ -2115,9 +2093,6 @@ export class Session {
2115
2093
  let finalParts: string[] = [];
2116
2094
  let outcome = executionOutcome("unknown");
2117
2095
  try {
2118
- for await (const event of branch.snapshotTurn([], true)) {
2119
- if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
2120
- }
2121
2096
  for await (const e of branch.send(prompt, signal)) {
2122
2097
  if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
2123
2098
  if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
@@ -2342,11 +2317,6 @@ export class Session {
2342
2317
  }
2343
2318
  const checkout = this.state.worktrees.get(root);
2344
2319
  if (checkout) this.config.tools.root = checkout.path;
2345
- if (this.config.workspaceTracking === "legacy") {
2346
- const scan = await scanWorkspace(this.config.tools.root, this.ignore);
2347
- if (!scan.ok) throw new Error(`cannot adopt workspace: ${scan.error}`);
2348
- this.state.lastScan = scan.files;
2349
- }
2350
2320
  this.installWorkspace();
2351
2321
  await this.saveCursor();
2352
2322
  this.pendingAdoption = null;
@@ -2466,10 +2436,6 @@ export class Session {
2466
2436
  } catch (error) { return { ok: false, error: String(error) }; }
2467
2437
  }
2468
2438
 
2469
- private get ignore(): readonly string[] {
2470
- return this.config.snapshotIgnore ?? DEFAULT_IGNORE;
2471
- }
2472
-
2473
2439
  /**
2474
2440
  * Every completed exchange, including abandoned branches and conversation-only turns.
2475
2441
  *
@@ -2519,14 +2485,6 @@ export class Session {
2519
2485
  for (const e of n.events) {
2520
2486
  if (e.t === "workspace_observation") continue;
2521
2487
  if (e.t === "checkpoint" && e.source === "restore") { close(); open = null; continue; }
2522
- if (e.t === "snapshot" && e.phase === "idle") {
2523
- close();
2524
- open = null;
2525
- points.push({ node: n.node, atEvent: e.id, prompt: "Observed workspace changes",
2526
- changed: Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length,
2527
- onPath: onPath.has(e.id) });
2528
- continue;
2529
- }
2530
2488
  if (e.t === "user") {
2531
2489
  close();
2532
2490
  open = { prompt: e.text, changed: 0, last: e.id, userId: e.id };
@@ -2535,10 +2493,6 @@ export class Session {
2535
2493
  if (!open) continue;
2536
2494
  open.last = e.id;
2537
2495
  if (e.t === "checkpoint" && e.phase === "applied") open.changed += changedPaths(e.changes).length;
2538
- if (e.t === "snapshot") {
2539
- open.changed +=
2540
- Object.keys(e.added).length + Object.keys(e.changed).length + e.removed.length;
2541
- }
2542
2496
  }
2543
2497
  close();
2544
2498
  }
@@ -2560,21 +2514,12 @@ export class Session {
2560
2514
  return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2561
2515
  willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2562
2516
  }
2563
- if (this.config.workspaceTracking !== "legacy") {
2564
- try {
2565
- const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
2566
- if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
2567
- return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2568
- willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2569
- } catch (error) { return { ok: false, error: String(error) }; }
2570
- }
2571
- const plan = await planRestoreToNode(
2572
- this.config.logDir, { node: point.node, atEvent: point.atEvent },
2573
- undefined, this.config.tools.root, this.ignore,
2574
- );
2575
- return plan.ok
2576
- ? { ok: true, willWrite: plan.willWrite, willDelete: plan.willDelete }
2577
- : { ok: false, error: plan.error };
2517
+ try {
2518
+ const changes = await planCheckpointRestore(this.config.logDir, this.state.node, { node: point.node, atEvent: point.atEvent });
2519
+ if (this.config.blobDir) await preflightChanges(this.config.tools.root, this.config.blobDir, changes);
2520
+ return { ok: true, willWrite: changedPaths(changes).filter(path => changes[path]!.after !== null),
2521
+ willDelete: changedPaths(changes).filter(path => changes[path]!.after === null) };
2522
+ } catch (error) { return { ok: false, error: String(error) }; }
2578
2523
  }
2579
2524
 
2580
2525
  /**
@@ -2592,145 +2537,9 @@ export class Session {
2592
2537
  * conversation node. File-only restoration appends its observed transition.
2593
2538
  */
2594
2539
  async rewind(point: RestorePoint, mode: RewindMode = "both"): Promise<RewindResult> {
2595
- if (this.config.workspaceTracking !== "legacy") return this.rewindJournal(point, mode);
2596
- const dir = this.config.logDir;
2597
- const root = this.config.tools.root;
2598
- const target = { node: point.node, atEvent: point.atEvent };
2599
- const movesFiles = mode !== "context";
2600
-
2601
- const blobDir = this.config.blobDir;
2602
- // Only the modes that move files need somewhere to read them from. A
2603
- // context-only rewind is the one thing still worth offering a session
2604
- // that never took snapshots, so requiring them would refuse it for no
2605
- // reason.
2606
- if (movesFiles && !blobDir) {
2607
- return {
2608
- ok: false,
2609
- error: "this session stores no snapshots, so the files cannot be moved back",
2610
- };
2611
- }
2612
-
2613
- // Planned before anything is written: an unresolvable target must leave
2614
- // the session exactly where it was, not half moved. The plan is also
2615
- // what every mode needs -- to apply, or to say what it is NOT applying.
2616
- const plan = await planRestoreToNode(dir, target, undefined, root, this.ignore);
2617
- if (!plan.ok) return { ok: false, error: plan.error };
2618
- const historical = await readCursorAt(dir, point.node, point.atEvent);
2619
- // Preserve idle edits before moving away from them. An unobserved file is
2620
- // not disposable just because the target predates it (D8/appendix H).
2621
- for await (const event of this.snapshotTurn([], false, "idle")) {
2622
- if (event.kind === "snapshot_failed") return { ok: false, error: event.reason ?? "snapshot failed" };
2623
- }
2624
- const beforeFiles = { ...this.state.lastScan };
2625
-
2626
- // Computed BEFORE anything is applied. Both partial modes diverge over
2627
- // the same set -- where the target and the workspace as it stands now
2628
- // disagree -- and only the story differs, which `mode` already carries.
2629
- // Computing it afterwards gave "files" an empty set, because by then the
2630
- // disk had become the target: the note still said to re-read but named
2631
- // nothing, which is the vague form this is meant to replace.
2632
- const divergence = mode === "both" ? [] : await this.divergenceFrom(plan.files);
2633
-
2634
- let moved: RestoreResult = {
2635
- restored: [], removed: [], unrecoverable: [],
2636
- note: "The workspace was left exactly as it is; only the conversation moved.",
2637
- };
2638
- if (movesFiles && blobDir) {
2639
- // Applied from the plan just computed rather than through
2640
- // restoreToNode, which would re-plan: a second traversal, and a guard
2641
- // here that could never fire because the only way the apply fails is a
2642
- // plan that failed.
2643
- moved = await applyRestorePlan(
2644
- root, blobDir, plan.files, this.ignore,
2645
- `the point where you were asked: ${point.prompt.slice(0, 40)}`,
2646
- );
2647
- if (moved.unrecoverable.length > 0) return { ok: true, node: this.state.node, ...moved };
2648
- }
2649
-
2650
- const from: NodeRef = { ...await digestedRef(dir, point.node), at_event: point.atEvent };
2651
- const stamp = { id: this.clock.nextId(), at: this.clock.now() };
2652
- const event: LogEvent = {
2653
- t: "rewind", ...stamp, mode, from, prompt: point.prompt,
2654
- restored: moved.restored, removed: moved.removed,
2655
- unrecoverable: moved.unrecoverable, divergence,
2656
- };
2657
-
2658
- if (mode === "files") {
2659
- // No fork. The conversation did not move, so the node did not either;
2660
- // a new node would be identical to this one but for a single event.
2661
- await this.record(event);
2662
- this.state.lastScan = beforeFiles;
2663
- for await (const result of this.snapshotTurn([], true)) {
2664
- if (result.kind === "snapshot_failed") throw new Error(result.reason);
2665
- }
2666
- return { ok: true, node: this.state.node, ...moved };
2667
- }
2668
-
2669
- const node = this.state.nextNode++;
2670
- // "inherit": the point of a rewind is to continue from the target as if
2671
- // the turns after it had not happened, so the target's events replay.
2672
- await initNode(dir, node, from, this.clock.now(), "inherit");
2673
-
2674
- // The running session projects from state.events, which a new node starts
2675
- // empty -- correct for a cut, where the handoff replaces them, and wrong
2676
- // here. The inherited events come from the ancestry walk, which is the
2677
- // same function the projection would use and already honours both the
2678
- // fork point and any cut further up.
2679
- const replayed = await replayContext(dir, node);
2680
-
2681
- this.state.node = node;
2682
- this.state.events = [];
2683
- this.state.handoff = replayed.handoff;
2684
- this.resetDerivedToolState();
2685
- this.state.depth = replayed.depth;
2686
- // The new node's prefix has never been sent, so nothing to anchor to.
2687
- this.state.anchor = null;
2688
- // The scan baseline follows the files, which a context-only rewind did
2689
- // not move. Setting it to the plan there would make the next turn report
2690
- // the untouched workspace as a change nobody declared.
2691
- this.state.lastScan = plan.files;
2692
- // Path depth comes from replay; the monotonic allocator still accounts
2693
- // for every node spent outside this selected path.
2694
-
2695
- await this.record(event);
2696
- // Recorded first, then replayed, so the rewind event stays the node's own
2697
- // annotation rather than something the inherited context contains.
2698
- this.state.events = [...replayed.contextEvents, ...this.state.events];
2699
- // A context-only fork inherits old file history while the disk stays
2700
- // newer. Record that delta now, so later replay cannot resurrect a stale
2701
- // workspace and the next tool starts from the actual files.
2702
- for await (const result of this.snapshotTurn([], true)) {
2703
- if (result.kind === "snapshot_failed") throw new Error(result.reason);
2704
- }
2705
- const incompatible = historical === null || !sameModel(historical, {
2706
- model: this.provider.config.spec.id, provider: this.provider.config.spec.provider,
2707
- });
2708
- if (incompatible) {
2709
- // Persist the guard before opening the new model context, including on crash recovery.
2710
- const current = await readCursor(dir);
2711
- if (current) await writeCursor(dir, { ...current, node, next_node: this.state.nextNode, needs_model_checkpoint: true });
2712
- await this.performCut("model_checkpoint", this.provider.config.spec.id);
2713
- }
2714
- await this.saveCursor();
2715
-
2716
- return { ok: true, node: this.state.node, ...moved };
2540
+ return this.rewindJournal(point, mode);
2717
2541
  }
2718
2542
 
2719
- /**
2720
- * Paths where the target and the workspace as it stands now disagree.
2721
- *
2722
- * Read off the disk rather than inferred from the plan, because a file a
2723
- * person changed by hand belongs in the answer just as much as one a
2724
- * rewound turn wrote.
2725
- */
2726
- private async divergenceFrom(target: FileSet): Promise<string[]> {
2727
- const current = await scanWorkspace(this.config.tools.root, this.ignore);
2728
- if (!current.ok) return [];
2729
- const delta = diffScans(target, current.files);
2730
- return [
2731
- ...Object.keys(delta.added), ...Object.keys(delta.changed), ...delta.removed,
2732
- ].sort();
2733
- }
2734
2543
 
2735
2544
  private resetDerivedToolState(): void {
2736
2545
  this.stall = null;
@@ -2767,10 +2576,10 @@ export class Session {
2767
2576
  /** A steering signal releases only the wait; cancellation owns process termination. */
2768
2577
  wakeProcessWaits(): void { this.processWake.abort(); }
2769
2578
 
2770
- private async terminationReport(cause: string, requests: number): Promise<string> {
2579
+ private async terminationReport(cause: string, requests: number, limit = this.config.maxTurns): Promise<string> {
2771
2580
  const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
2772
2581
  if (!history.ok) throw new Error(history.error);
2773
- return stopReport(history.nodes.flatMap(part => part.events), requests, this.config.maxTurns, cause);
2582
+ return stopReport(history.nodes.flatMap(part => part.events), requests, limit, cause);
2774
2583
  }
2775
2584
 
2776
2585
  /** N.8: build and check the replacement before committing a durable boundary. */
@@ -2965,10 +2774,10 @@ export class Session {
2965
2774
  const fromDisk = replayed.events;
2966
2775
  const retained = retainedFrom === undefined ? [] : retainedContext(replayed.contextEvents, retainedFrom);
2967
2776
  let guidanceTools = this.adviceNames();
2968
- if (this.config.guidanceVersion === 1 && nextContextRef !== undefined) {
2777
+ if (nextContextRef !== undefined) {
2969
2778
  const saved = await getBlob(join(dir, "artifacts"), nextContextRef);
2970
2779
  if (saved === null) throw new Error("missing successor guidance context");
2971
- guidanceTools = this.adviceNames((JSON.parse(saved) as { frozen: FrozenPrefix }).frozen);
2780
+ guidanceTools = this.adviceNames(parseSessionContext(JSON.parse(saved)).frozen);
2972
2781
  }
2973
2782
 
2974
2783
  // The cut event is part of the closed file. Digest after appending it
@@ -2986,8 +2795,8 @@ export class Session {
2986
2795
  next_thinking: nextThinking,
2987
2796
  ...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
2988
2797
  handoff_tokens: DEFAULT_HANDOFF_TOKENS,
2989
- ...(this.config.workingSet ? { working_set_version: this.config.workingSetVersion ?? 1 } : {}),
2990
- ...(this.config.guidanceVersion === 1 ? { guidance_tools: guidanceTools } : {}),
2798
+ ...(this.config.workingSet ? { working_set_version: 2 as const } : {}),
2799
+ guidance_tools: guidanceTools,
2991
2800
  ...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
2992
2801
  });
2993
2802
  const self = await digestedRef(dir, node);