@xingular/harness 1.4.0-unstable → 1.5.0-unstable

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 (44) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/NOTICE.md +9 -7
  3. package/dist/cli.js +160 -456
  4. package/dist/sdk/prompt/compile.d.ts +4 -2
  5. package/dist/sdk/prompt/compile.js +10 -8
  6. package/dist/sdk/prompt/reporting.d.ts +2 -2
  7. package/dist/sdk/prompt/reporting.js +40 -250
  8. package/dist/sdk/prompt/standard.d.ts +4 -1
  9. package/dist/sdk/prompt/standard.js +6 -2
  10. package/dist/sdk/sessions/completionCheck.d.ts +4 -2
  11. package/dist/sdk/sessions/completionCheck.js +19 -1
  12. package/dist/sdk/sessions/contracts.js +2 -2
  13. package/dist/sdk/sessions/goalStop.d.ts +27 -0
  14. package/dist/sdk/sessions/goalStop.js +41 -0
  15. package/dist/sdk/sessions/runner.d.ts +0 -3
  16. package/dist/sdk/sessions/runner.js +16 -9
  17. package/dist/sdk/sessions/sessions.js +6 -36
  18. package/dist/sdk/sessions/workSlice.js +1 -1
  19. package/dist/sdk/terminal/piExtension.js +18 -31
  20. package/dist/sdk/terminal/terminals.js +1 -1
  21. package/dist/sdk/tools/askUser.js +1 -1
  22. package/dist/sdk/tools/todo.js +4 -3
  23. package/dist/terminal-opentui.js +108 -200
  24. package/dist/terminal-pi.js +262 -338
  25. package/docs/architecture/02-sessions-and-runs.md +13 -15
  26. package/docs/architecture/04-prompt.md +41 -35
  27. package/docs/architecture/07-tools-and-gate.md +2 -2
  28. package/docs/architecture/10-server-protocol.md +6 -5
  29. package/docs/architecture/11-terminal.md +7 -7
  30. package/docs/decisions/ADR-0008-flexible-reporting-guidance.md +2 -2
  31. package/docs/decisions/ADR-0069-stop-gate-and-finish.md +8 -2
  32. package/docs/decisions/ADR-0070-terminal-slice-continuation.md +4 -1
  33. package/docs/decisions/ADR-0072-codex-prompts-and-goal.md +3 -0
  34. package/docs/decisions/ADR-0073-large-reporting-prompt.md +6 -1
  35. package/docs/decisions/ADR-0074-report-content-not-report-blocks.md +90 -0
  36. package/docs/decisions/ADR-0075-no-todo-gate.md +84 -0
  37. package/docs/decisions/ADR-0076-codex-final-answer-verbatim.md +93 -0
  38. package/docs/releases/1.4.0-unstable.md +9 -2
  39. package/docs/releases/1.5.0-unstable.md +23 -0
  40. package/docs/study/STUDY-0004-turn-end-controls.md +7 -0
  41. package/docs/study/STUDY-0006-reporting-prompts.md +48 -10
  42. package/package.json +1 -1
  43. package/dist/sdk/sessions/stopGate.d.ts +0 -78
  44. package/dist/sdk/sessions/stopGate.js +0 -143
@@ -0,0 +1,41 @@
1
+ // The goal part of a stop: a /goal that ended blocked, paused or at a limit leaves work for the
2
+ // person, and its checkpoint's done/next comes from the reply's todo list. An ordinary reply has no
3
+ // gate and settles as it ends (ADR-0075).
4
+ import { MAX_CHECKPOINT_DONE } from "./contracts.js";
5
+ /** The todo items a reply has not finished. */
6
+ export function openItems(todo) {
7
+ return todo.filter((item) => item.status !== 'completed');
8
+ }
9
+ /**
10
+ * `goal` is the session's /goal, when it has one: a goal that ended blocked needs the person, and
11
+ * one that ended on a limit or a pause leaves work (ADR-0072).
12
+ */
13
+ export function goalStopOutcome(todo, goal) {
14
+ const open = openItems(todo);
15
+ if (goal?.status === 'blocked')
16
+ return { code: 'needs_person', reason: goal.lastReason || 'The goal is blocked.', open };
17
+ if (goal && (goal.status === 'budget_limited' || goal.status === 'usage_limited' || goal.status === 'paused')) {
18
+ return { code: 'unfinished', reason: goal.lastReason || `The goal is ${goal.status.replace('_', ' ')}.`, open };
19
+ }
20
+ return null;
21
+ }
22
+ /** What a checkpoint learns from the reply's todo list and the goal. */
23
+ export function handoffFields(todo, goal) {
24
+ const next = openItems(todo)[0]?.text || '';
25
+ const blocked = goal?.status === 'blocked' ? (goal.lastReason || 'The goal is blocked.').slice(0, 500) : '';
26
+ return {
27
+ done: todo.filter((item) => item.status === 'completed').slice(0, MAX_CHECKPOINT_DONE).map((item) => ({ item: item.text.slice(0, 300), evidence: [] })),
28
+ next: next.slice(0, 500),
29
+ ...(blocked ? { blocked } : {})
30
+ };
31
+ }
32
+ /** Why a checkpoint written for a stop outcome was written. */
33
+ export const stopCheckpointReason = (outcome) => outcome.code === 'needs_person' ? 'requested' : 'stuck';
34
+ /** The summary a stop outcome's checkpoint falls back to when the reply left no text. */
35
+ export function stopSummary(outcome) {
36
+ if (outcome.code === 'needs_person')
37
+ return `The reply stopped for the person: ${outcome.reason}`.slice(0, 1_200);
38
+ if (outcome.reason)
39
+ return `The reply ended with work left: ${outcome.reason}`.slice(0, 1_200);
40
+ return `The reply ended with ${outcome.open.length} open todo item${outcome.open.length === 1 ? '' : 's'}.`;
41
+ }
@@ -8,7 +8,6 @@ import { type ContextUpdate } from "../prompt/compile.js";
8
8
  import type { ToolSpec } from "../tools/types.js";
9
9
  import { type SliceState } from "./workSlice.js";
10
10
  import type { GoalController } from "./goal.js";
11
- import { type StopGateState } from "./stopGate.js";
12
11
  import { type ExtensionHost } from "./extensionUi.js";
13
12
  import type { ActiveRun } from "./registry.js";
14
13
  export { EFFORTS };
@@ -86,8 +85,6 @@ export interface TurnParams {
86
85
  state: SliceState;
87
86
  maxToolCalls: number;
88
87
  };
89
- /** The stop gate's view of this reply (ADR-0069): its todo list. */
90
- stopGate?: StopGateState;
91
88
  /** The run's /goal (ADR-0072); the runner counts a claim check's model request with the run's usage. */
92
89
  goal?: {
93
90
  controller: GoalController;
@@ -10,7 +10,9 @@ import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY } from "../prompt/compile.js
10
10
  import { PLAN_REPAIR_CUSTOM_TYPE, PLAN_REPAIR_INSTRUCTIONS, finalizePlanOutcome } from "./planMode.js";
11
11
  import { MAX_CHECKPOINT_CHECKS, MAX_CHECKPOINT_FILES, MAX_CHECKPOINT_SUMMARY } from "./contracts.js";
12
12
  import { derivedReason } from "./workSlice.js";
13
- import { createStopGateState, handoffFields, stopCheckpointReason, stopOutcome, stopSummary } from "./stopGate.js";
13
+ import { goalStopOutcome, handoffFields, stopCheckpointReason, stopSummary } from "./goalStop.js";
14
+ import { TODO_WRITE } from "../tools/todo.js";
15
+ import { validatedTodo } from "./compactionCheckpoint.js";
14
16
  import { toolAttachmentRefsSchema } from "./contracts.js";
15
17
  import { completedReasoningSummary, REASONING_SUMMARY_APIS, requestDetailedReasoningSummary } from "./reasoningSummaries.js";
16
18
  import { extensionUi } from "./extensionUi.js";
@@ -163,10 +165,10 @@ const lastLine = (text) => {
163
165
  * Files and checks come from the reply's tool calls, done and next from its todo list, and blocked
164
166
  * from a goal that ended blocked (ADR-0069, ADR-0072).
165
167
  */
166
- function derivedCheckpoint(ledger, slice, reason, fallback, gate, goal) {
168
+ function derivedCheckpoint(ledger, slice, reason, fallback, goal) {
167
169
  return {
168
170
  summary: ledger.lastText.trim().slice(0, MAX_CHECKPOINT_SUMMARY) || fallback,
169
- ...handoffFields(gate, goal),
171
+ ...handoffFields(ledger.todo, goal),
170
172
  files: [...ledger.files].slice(-MAX_CHECKPOINT_FILES).map(([path, why]) => ({ path: path.slice(0, 300), why: why.slice(0, 200) })),
171
173
  checks: ledger.checks.slice(-MAX_CHECKPOINT_CHECKS),
172
174
  decisions: [],
@@ -191,7 +193,7 @@ export async function runTurn(run, session, params) {
191
193
  let costReported = false;
192
194
  const toolStartedAt = new Map();
193
195
  const slice = params.slice?.state ?? null;
194
- const ledger = { files: new Map(), checks: [], lastText: '' };
196
+ const ledger = { files: new Map(), checks: [], lastText: '', todo: [] };
195
197
  const bashCommands = new Map();
196
198
  const heartbeat = slice
197
199
  ? setInterval(() => {
@@ -416,6 +418,11 @@ export async function runTurn(run, session, params) {
416
418
  if (typeof command === 'string')
417
419
  bashCommands.set(tool.toolCallId, command);
418
420
  }
421
+ if (tool.toolName === TODO_WRITE) {
422
+ const items = validatedTodo(tool.args && typeof tool.args === 'object' ? tool.args.items : undefined);
423
+ if (items)
424
+ ledger.todo = items;
425
+ }
419
426
  break;
420
427
  }
421
428
  case 'tool_execution_end': {
@@ -619,16 +626,16 @@ export async function runTurn(run, session, params) {
619
626
  }
620
627
  if (slice?.endedBy && !aborted && !lastError) {
621
628
  // The model stopped because its slice was over. The harness keeps the state for the next slice.
622
- run.emit('checkpoint', derivedCheckpoint(ledger, slice.slice, derivedReason(slice.endedBy), `The reply stopped after ${slice.steps} steps at its work-slice boundary.`, params.stopGate, params.goal?.controller.get()));
629
+ run.emit('checkpoint', derivedCheckpoint(ledger, slice.slice, derivedReason(slice.endedBy), `The reply stopped after ${slice.steps} steps at its work-slice boundary.`, params.goal?.controller.get()));
623
630
  run.finish('interrupted', undefined, 'slice_complete', slice.endedBy === 'budget' ? 'slice_budget' : 'compaction_thrash');
624
631
  return;
625
632
  }
626
- // A reply that ended with work left is not complete (ADR-0069, ADR-0072): open todo items, or a
627
- // goal that was blocked, paused or ran into a limit. Either way the person can continue it.
633
+ // An ordinary reply settles as it ends (ADR-0075). Only a goal that was blocked, paused or ran
634
+ // into a limit stops it for the person, and then the checkpoint carries what is left.
628
635
  const goalState = params.goal?.controller.get() ?? null;
629
- const stop = (params.stopGate || goalState) && !aborted && !lastError ? stopOutcome(params.stopGate ?? createStopGateState(), goalState) : null;
636
+ const stop = goalState && !aborted && !lastError ? goalStopOutcome(ledger.todo, goalState) : null;
630
637
  if (stop) {
631
- run.emit('checkpoint', derivedCheckpoint(ledger, slice?.slice ?? 1, stopCheckpointReason(stop), stopSummary(stop), params.stopGate, goalState));
638
+ run.emit('checkpoint', derivedCheckpoint(ledger, slice?.slice ?? 1, stopCheckpointReason(stop), stopSummary(stop), goalState));
632
639
  run.finish('interrupted', undefined, stop.code, stop.code);
633
640
  return;
634
641
  }
@@ -22,8 +22,7 @@ import { ASK_USER } from "../tools/askUser.js";
22
22
  import { defaultSkillPaths } from "../tools/defaultSkills.js";
23
23
  import { TODO_WRITE } from "../tools/todo.js";
24
24
  import { checkpointOf } from "./checkpoint.js";
25
- import { createStopGateState, sessionCheckModel, stopGate, stopGateNotice, STOP_GATE_EXTENSION } from "./stopGate.js";
26
- import { launchSwitchOff } from "./completionCheck.js";
25
+ import { launchSwitchOff, sessionCheckModel } from "./completionCheck.js";
27
26
  import { GOAL_TOOLS, GoalController, GoalError, continuationPrompt, goalTools, provenanceText } from "./goal.js";
28
27
  import { GOAL_EXTENSION, goalLoop, goalNotice } from "./goalLoop.js";
29
28
  import { SESSION_NAME, deriveSessionName, sessionNameTool } from "../tools/sessionName.js";
@@ -64,24 +63,6 @@ function checkCommand(command, historyFile, input) {
64
63
  }
65
64
  }
66
65
  const CONTINUE_TEXT = 'Continue from where you stopped.';
67
- /** The person's request behind a chain of continuations: the first goal that is not the continue text. */
68
- function requestOfChain(root, runId) {
69
- const seen = new Set();
70
- for (let current = runId; current && !seen.has(current);) {
71
- seen.add(current);
72
- let record;
73
- try {
74
- record = loadRun(root, current);
75
- }
76
- catch {
77
- return null;
78
- }
79
- if (record.goal && record.goal.trim() !== CONTINUE_TEXT)
80
- return record.goal;
81
- current = record.resumedFromRunId ?? null;
82
- }
83
- return null;
84
- }
85
66
  /** Fills a continuing run's input from the run it continues, refusing anything that would differ. */
86
67
  function continueInput(root, input) {
87
68
  const continuation = continuationOf(root, input.continueFrom, input.sessionDir);
@@ -557,6 +538,7 @@ export class Sessions {
557
538
  const own = [plan, resumed, profiles].filter((section) => section !== null);
558
539
  const effectiveLaunch = {
559
540
  ...launch,
541
+ model: input.model ?? launch.model ?? null,
560
542
  ...(sessionState ? { sessionState } : {}),
561
543
  sections: own.length ? [...(launch.sections ?? []).filter((section) => !own.some((entry) => entry.id === section.id)), ...own] : launch.sections,
562
544
  tools: capabilities.tools,
@@ -575,12 +557,9 @@ export class Sessions {
575
557
  const planResearchChild = input.plan === true && input.run.relation === 'subagent';
576
558
  const sliceEnabled = slicePolicy.enabled && (harnessStateTools || planResearchChild);
577
559
  const sliceState = createSliceState(slicePolicy, sliceOf(root, runId));
578
- // The stop gate (ADR-0069) keeps an attended top-level reply working while its todo list has open
579
- // items. Never for plan, command, automatic, subagent or other related runs.
580
- const gateEnabled = harnessStateTools && !input.plan && !input.command && !input.automatic && !input.run.relation && !launchSwitchOff('XINGULAR_STOP_GATE');
581
- // The completion check judges the person's request, not "Continue from where you stopped.".
582
- const originalRequest = input.text.trim() === CONTINUE_TEXT ? requestOfChain(root, previousRun?.id ?? null) : null;
583
- const stopGateState = gateEnabled ? createStopGateState(continuation?.todo) : null;
560
+ // The stop gate stays gone (ADR-0075): a reply the model ends settles as it ends, and the todo
561
+ // list is progress, not a gate. The goal loop below is the only layer that continues a reply.
562
+ // The completion check judges the goal's claim, not "Continue from where you stopped."
584
563
  // The goal: a continuation slice picks up the previous run's goal where it stopped.
585
564
  let goalController = null;
586
565
  if (goalWanted && input.goal) {
@@ -706,15 +685,7 @@ export class Sessions {
706
685
  { name: COMPACTION_CHECKPOINT_EXTENSION, factory: compactionCheckpoint({ initialTodo: continuation?.todo, planInstructions: checkpointPlanInstructions }) },
707
686
  ...(sliceEnabled ? [{ name: WORK_SLICE_EXTENSION, factory: workSliceGuard({ policy: slicePolicy, state: sliceState, onRefuse: (event, reason) => { if (typeof event.toolCallId === 'string')
708
687
  run.emit('tool.refused', { toolCallId: event.toolCallId, name: event.toolName, code: 'work_slice', reason: reason.slice(0, 2000) }); } }) }] : []),
709
- // Last, so delivered subagent results and plan repair continue first and the gate sees them.
710
- ...(stopGateState ? [{ name: STOP_GATE_EXTENSION, factory: stopGate({
711
- state: stopGateState,
712
- initialTodo: continuation?.todo,
713
- request: () => originalRequest,
714
- sliceEnded: () => sliceEnabled && sliceState.endedBy !== null,
715
- onEvent: (event) => run.emit('extension.notice', { kind: 'notify', level: 'info', extension: STOP_GATE_EXTENSION, text: stopGateNotice(event) })
716
- }) }] : []),
717
- // After the todo gate: a goal continues a reply the gate let end (ADR-0072).
688
+ // A goal continues a reply the model ended (ADR-0072).
718
689
  ...(goalController ? [{ name: GOAL_EXTENSION, factory: goalLoop({
719
690
  controller: goalController,
720
691
  check: launchSwitchOff('XINGULAR_COMPLETION_CHECK') ? null : sessionCheckModel,
@@ -727,7 +698,6 @@ export class Sessions {
727
698
  activatedPlugins: (ids) => updateRun(root, runId, (record) => { record.activatedPlugins = ids; }),
728
699
  system,
729
700
  ...(sliceEnabled ? { slice: { state: sliceState, maxToolCalls: slicePolicy.maxToolCalls } } : {}),
730
- ...(stopGateState ? { stopGate: stopGateState } : {}),
731
701
  ...(goalController ? { goal: goalHandle } : {}),
732
702
  // A command sends no request of its own, so the context update waits for the next turn.
733
703
  update: input.command ? null : update,
@@ -143,7 +143,7 @@ export function workSliceGuard(options) {
143
143
  return { block: true, terminate: true, reason };
144
144
  };
145
145
  return (pi) => {
146
- // Pi emits agent_start again for every continuation (a stop-gate nudge, delivered subagent
146
+ // Pi emits agent_start again for every continuation (a slice continuation, delivered subagent
147
147
  // results, a plan repair), and those belong to the same reply and the same budget. Only a
148
148
  // person's message starts a new slice.
149
149
  pi.on('before_agent_start', (event) => {
@@ -12,11 +12,11 @@ import { SPAWN_SUBAGENT, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, sub
12
12
  import { PLAN_RESEARCH_TOOLS } from "../agents/planResearch.js";
13
13
  import { askUserTool } from "../tools/askUser.js";
14
14
  import { TODO_WRITE, todoTool } from "../tools/todo.js";
15
- import { createStopGateState, handoffFields, sessionCheckModel, stopCheckpointReason, stopGate, stopGateNotice, stopOutcome, stopSummary, STOP_GATE_EXTENSION } from "../sessions/stopGate.js";
16
- import { launchSwitchOff } from "../sessions/completionCheck.js";
15
+ import { goalStopOutcome, handoffFields, stopCheckpointReason, stopSummary } from "../sessions/goalStop.js";
16
+ import { launchSwitchOff, sessionCheckModel } from "../sessions/completionCheck.js";
17
17
  import { GOAL_STATE_ENTRY, GOAL_TOOLS, goalTools } from "../sessions/goal.js";
18
18
  import { GOAL_EXTENSION, goalLoop, goalNotice } from "../sessions/goalLoop.js";
19
- import { createSliceState, DEFAULT_SLICE_POLICY, parseWorkSlice, sliceContinuation, workSliceGuard } from "../sessions/workSlice.js";
19
+ import { createSliceState, DEFAULT_SLICE_POLICY, parseWorkSlice, sliceContinuation, workSliceGuard, WORK_SLICE_EXTENSION } from "../sessions/workSlice.js";
20
20
  import { DEFAULT_COMMAND_TIMEOUT_SECONDS, withCommandBudget } from "../core/commandBudget.js";
21
21
  import { SESSION_NAME, deriveSessionName, sessionNameTool } from "../tools/sessionName.js";
22
22
  import { CAPABILITY_DISCOVERY, describeCapabilities } from "../tools/getCapabilities.js";
@@ -190,8 +190,6 @@ export default async function (pi, planRun, getSession, goal) {
190
190
  }
191
191
  const sliceState = createSliceState(slicePolicy);
192
192
  // `XINGULAR_WORK_SLICE=off` at launch leaves terminal replies unbounded, as before PROP-0041.
193
- // One stop-gate state per terminal session; each reply resets it.
194
- const gateState = createStopGateState();
195
193
  // A goal-claim check's model request counts with the reply's usage, like any other request.
196
194
  const recordCheckUsage = (raw) => {
197
195
  const message = raw;
@@ -490,21 +488,10 @@ export default async function (pi, planRun, getSession, goal) {
490
488
  continue: true
491
489
  };
492
490
  });
493
- // The stop gate (ADR-0069), after subagent delivery and plan repair so it sees their
494
- // continuations: a terminal reply that ends with open todo items is sent back up to its cap.
495
- // Plan replies are not gated.
496
- if (!launchSwitchOff('XINGULAR_STOP_GATE'))
497
- stopGate({
498
- state: gateState,
499
- active: () => !planRun?.active,
500
- sliceEnded: () => sliceState.endedBy !== null,
501
- failed: () => Boolean(failure),
502
- onEvent: (event) => {
503
- void terminalCall('notice', { kind: 'notify', level: 'info', extension: STOP_GATE_EXTENSION, text: stopGateNotice(event) }).catch(() => undefined);
504
- }
505
- })(pi);
506
- // A /goal (ADR-0072): while one is active, a reply that would end continues in place. After the
507
- // stop gate, so open todo items are nudged first; its claim check is the only completion check.
491
+ // The stop gate is gone (ADR-0075): a reply the model ends settles as it ends, and the todo list
492
+ // is progress, not a gate. A /goal (ADR-0072) is the only layer that continues a reply: while one
493
+ // is active, a reply that would end continues in place; its claim check is the only completion
494
+ // check.
508
495
  if (goal) {
509
496
  for (const spec of goalTools(() => goal))
510
497
  pi.registerTool({
@@ -556,8 +543,8 @@ export default async function (pi, planRun, getSession, goal) {
556
543
  }
557
544
  })(pi);
558
545
  }
559
- // After the stop gate, which stays silent at a slice boundary: a reply that did work and reached
560
- // its budget continues as the next slice of the same run (ADR-0070).
546
+ // A reply that did work and reached its budget continues as the next slice of the same run
547
+ // (ADR-0070).
561
548
  if (slicePolicy.enabled)
562
549
  sliceContinuation({
563
550
  policy: slicePolicy,
@@ -566,11 +553,11 @@ export default async function (pi, planRun, getSession, goal) {
566
553
  failed: () => Boolean(failure),
567
554
  blocked: () => goal?.get()?.status === 'blocked',
568
555
  goalActive: () => goal?.active() === true,
569
- todo: () => gateState.todo,
570
- next: () => handoffFields(gateState, goal?.get() ?? null).next,
556
+ todo: () => lastTodo,
557
+ next: () => handoffFields(lastTodo, goal?.get() ?? null).next,
571
558
  onContinue: (ended) => {
572
- void terminalCall('checkpoint', { summary: `Work slice ${ended.slice} reached its limit after ${ended.steps} steps; the reply continues.`, ...handoffFields(gateState, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: ended.slice, reason: 'budget' }).catch(() => undefined);
573
- void terminalCall('notice', { kind: 'notify', level: 'info', extension: STOP_GATE_EXTENSION, text: `Work slice ${ended.slice} reached ${ended.limit ?? 'its limit'}; continuing with slice ${ended.slice + 1} of up to ${slicePolicy.maxSlices}.` }).catch(() => undefined);
559
+ void terminalCall('checkpoint', { summary: `Work slice ${ended.slice} reached its limit after ${ended.steps} steps; the reply continues.`, ...handoffFields(lastTodo, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: ended.slice, reason: 'budget' }).catch(() => undefined);
560
+ void terminalCall('notice', { kind: 'notify', level: 'info', extension: WORK_SLICE_EXTENSION, text: `Work slice ${ended.slice} reached ${ended.limit ?? 'its limit'}; continuing with slice ${ended.slice + 1} of up to ${slicePolicy.maxSlices}.` }).catch(() => undefined);
574
561
  }
575
562
  })(pi);
576
563
  // Reply text as it streams, batched, for a paired phone (remote control). The completed message
@@ -777,16 +764,16 @@ export default async function (pi, planRun, getSession, goal) {
777
764
  subagentParent = false;
778
765
  // A reply that stopped at its work-slice boundary is not a completed task: the record says so,
779
766
  // so a host can offer the continuation instead of treating it as done (PROP-0041).
780
- // A reply that ended with open todo items is not completed either (ADR-0069): it stopped for the
781
- // person, or the stop gate's nudges ran out. The harness writes its checkpoint from the todo list.
767
+ // An ordinary reply settles as it ends (ADR-0075); only a goal that was blocked, paused or hit
768
+ // a limit stops it for the person, and the checkpoint carries what is left.
782
769
  const aborted = event.aborted || messageAborted;
783
- const stop = !sliceState.endedBy && !failure && !aborted && !planRun?.active ? stopOutcome(gateState, goal?.get() ?? null) : null;
770
+ const stop = !sliceState.endedBy && !failure && !aborted && !planRun?.active ? goalStopOutcome(lastTodo, goal?.get() ?? null) : null;
784
771
  if (stop) {
785
- await terminalCall('checkpoint', { summary: stopSummary(stop), ...handoffFields(gateState, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: stopCheckpointReason(stop) }).catch(() => undefined);
772
+ await terminalCall('checkpoint', { summary: stopSummary(stop), ...handoffFields(lastTodo, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: stopCheckpointReason(stop) }).catch(() => undefined);
786
773
  }
787
774
  else if (sliceState.endedBy && !failure && !aborted) {
788
775
  // The guard ended the reply and promised the model the harness would keep its state.
789
- await terminalCall('checkpoint', { summary: `The reply stopped after ${sliceState.steps} steps at its work-slice boundary.`, ...handoffFields(gateState, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: sliceState.endedBy }).catch(() => undefined);
776
+ await terminalCall('checkpoint', { summary: `The reply stopped after ${sliceState.steps} steps at its work-slice boundary.`, ...handoffFields(lastTodo, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: sliceState.endedBy }).catch(() => undefined);
790
777
  }
791
778
  const reason = sliceState.endedBy ? 'slice_complete' : stop?.code;
792
779
  const code = sliceState.endedBy === 'compaction-thrash' ? 'compaction_thrash' : sliceState.endedBy === 'budget' ? 'slice_budget' : stop?.code;
@@ -1217,7 +1217,7 @@ export class Terminals {
1217
1217
  const launchSections = profiles ? [...(input.launch?.sections ?? []).filter((section) => section.id !== profiles.id), profiles] : input.launch?.sections;
1218
1218
  // A terminal is a person's own session: the same standard sections a host's session has.
1219
1219
  const standard = input.launch?.standard === null ? null : input.launch?.standard ?? { cwd: input.cwd };
1220
- const pack = compile(env, caller, { ...input.launch, standard, sections: launchSections, tools: launchTools }, variant && profiles ? { ...variant, sections: variant.sections.filter((section) => section.id !== profiles.id) } : variant);
1220
+ const pack = compile(env, caller, { ...input.launch, model: input.model ?? input.launch?.model ?? null, standard, sections: launchSections, tools: launchTools }, variant && profiles ? { ...variant, sections: variant.sections.filter((section) => section.id !== profiles.id) } : variant);
1221
1221
  record(root, pack);
1222
1222
  mkdirSync(this.historyDir(input), { recursive: true });
1223
1223
  saveForSession(input.sessionFolder, pack);
@@ -19,7 +19,7 @@ export function askUserTool(run) {
19
19
  name: ASK_USER,
20
20
  label: 'Ask the user',
21
21
  description: 'Ask the user one to five related questions in one request and wait until they answer the batch. Give each question a concise 1–2 word label and aim to keep each question to 25 words or fewer. ' +
22
- 'Every question must offer 1–6 short choices, each with a helpful description of 25 words or fewer, and at least one choice must be marked recommended: true. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark at most one recommended choice for a single-choice question, or several when multiSelect is true. Every question also includes a free-text “Something else” answer. Set multiSelect when several can apply. When a UI diagram helps compare choices, set uiSketch: true and give every choice a detailed, bounded ASCII wireframe. Use ASCII tables and panel borders to show the affected element with relevant neighboring UI, readable labels and spacing. Keep each panel’s outer `+` corners in fixed columns, include side edges at those same columns on every content row, and run horizontal borders corner to corner; if a title sits in a top border, leave at least two dashes on both sides. Keep framing consistent across alternatives and concentrate detail on what changes. Do not substitute a symbolic logo or one-line illustration. The preview supplies its outer border; draw internal borders only for actual UI panels. Use it when you need a decision the request and the files do not answer, not for small talk and never to ask permission for work inside the request. If no answer arrives, continue with your best judgment instead of asking again or treating the turn as blocked.',
22
+ 'Every question must offer 1–6 short choices, each with a helpful description of 25 words or fewer, and at least one choice must be marked recommended: true. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark at most one recommended choice for a single-choice question, or several when multiSelect is true. Every question also includes a free-text “Something else” answer. Set multiSelect when several can apply. When a UI diagram helps compare choices, set uiSketch: true and give every choice a detailed, bounded ASCII wireframe. Use ASCII tables and panel borders to show the affected element with relevant neighboring UI, readable labels and spacing. Keep each panel’s outer `+` corners in fixed columns, include side edges at those same columns on every content row, and run horizontal borders corner to corner; if a title sits in a top border, leave at least two dashes on both sides. Keep framing consistent across alternatives and concentrate detail on what changes. Do not substitute a symbolic logo or one-line illustration. The preview supplies its outer border; draw internal borders only for actual UI panels. Use it when you need a decision the request and the files do not answer, not for small talk and never to ask permission for work inside the request. If no answer arrives, continue with your best judgment instead of asking again or treating the turn as blocked. Never use it to ask for a login, a secret or a credential: give the exact command in the reply instead.',
23
23
  parameters: (Type) => Type.Object({
24
24
  questions: Type.Array(Type.Object({
25
25
  summary: Type.String({ description: 'A concise label of one or two words' }),
@@ -3,9 +3,10 @@
3
3
  import { todoItemSchema, MAX_TODO_ITEMS } from "../sessions/contracts.js";
4
4
  export const TODO_WRITE = 'todo_write';
5
5
  /** The trigger the prompt and the tool description share, so progress guidance cannot drift. */
6
- export const TODO_GUIDANCE = 'If the work has several distinct steps or will take more than one tool call, call todo_write before your first other tool call with the steps you know so far, and keep the list current as steps start and finish. ' +
7
- 'The list shows the person your progress; it is not the scope of the work. The request, and any document it points to, is the scope: add steps as the work shows them, list the steps of a handoff or plan instead of folding them into one "remaining work" item, and never stop because the list is short or done. ' +
8
- 'Keep the list true at all times: mark a step completed only once it is done and checked, and never mark unfinished work completed. An open step is work to do in this reply, not something to report. Before you end the turn, compare the request, not the list, with what is done and checked, and do whatever is still yours to do. ' +
6
+ export const TODO_GUIDANCE = 'If the work has several distinct steps, call todo_write before your first other tool call with the steps you know so far, and keep the list current as steps start and finish. ' +
7
+ 'The list shows the person your progress; it is not the scope of the work. The request, and any document it points to, is the scope: add steps as the work shows them, and list the steps of a handoff or plan instead of folding them into one "remaining work" item. ' +
8
+ 'Keep the list true: mark a step completed only once it is done and checked, and never mark unfinished work completed. Before you end the turn, compare the request, not the list, with what is done and checked; if a step stays open, say why in the reply. ' +
9
+ 'When a login blocks a step, leave it open and give the exact command the person must run in the reply. ' +
9
10
  'The person follows your work from it, and a subagent’s todo list shows as its progress in the person’s list of subagents.';
10
11
  /** The event a run writes; `checklist.updated` is the pre-rename name, still read for old runs. */
11
12
  const TODO_EVENTS = new Set(['todo.updated', 'checklist.updated']);