@visual-z/xeno 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +39 -12
  3. package/docs/foundations.md +1 -1
  4. package/docs/install.md +18 -5
  5. package/package.json +3 -1
  6. package/src/apps/cli.ts +4 -3
  7. package/src/apps/commands.ts +9 -1
  8. package/src/apps/goal-flow.ts +70 -0
  9. package/src/apps/render.ts +0 -2
  10. package/src/apps/session-hub.ts +6 -5
  11. package/src/apps/session-lifecycle.ts +64 -0
  12. package/src/apps/shell.ts +90 -35
  13. package/src/apps/startup.ts +33 -32
  14. package/src/apps/ui/app.ts +47 -10
  15. package/src/apps/ui/control-model.ts +18 -1
  16. package/src/apps/ui/surface.ts +2 -0
  17. package/src/apps/update-check.ts +34 -4
  18. package/src/core/acceptance.ts +41 -9
  19. package/src/core/blind-checks.ts +72 -0
  20. package/src/core/branch-receipt.ts +3 -1
  21. package/src/core/checkpoints.ts +21 -0
  22. package/src/core/churn.ts +2 -2
  23. package/src/core/completion-review.ts +18 -11
  24. package/src/core/continuation.ts +11 -10
  25. package/src/core/control-view.ts +5 -8
  26. package/src/core/deadline.ts +2 -1
  27. package/src/core/derive.ts +0 -7
  28. package/src/core/environment.ts +2 -2
  29. package/src/core/event-validation.ts +19 -6
  30. package/src/core/events.ts +41 -32
  31. package/src/core/evidence-ladder.ts +62 -0
  32. package/src/core/evidence-links.ts +0 -10
  33. package/src/core/evidence.ts +1 -2
  34. package/src/core/export.ts +6 -11
  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 +2 -1
  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/core/update-check.ts +40 -0
  55. package/src/runtime/blind-check.ts +36 -0
  56. package/src/runtime/counter-check.ts +74 -0
  57. package/src/runtime/exec-sessions.ts +11 -0
  58. package/src/runtime/foundation.ts +46 -8
  59. package/src/runtime/loop.ts +239 -430
  60. package/src/runtime/oauth.ts +1 -3
  61. package/src/runtime/provider-request.ts +2 -1
  62. package/src/runtime/runtime-target.ts +2 -2
  63. package/src/runtime/self-update.ts +74 -0
  64. package/src/runtime/session-control.ts +4 -4
  65. package/src/runtime/tools.ts +44 -42
  66. package/src/runtime/version.ts +12 -0
  67. package/src/runtime/workspace-lifecycle.ts +31 -0
  68. package/src/store/blind-checks.ts +32 -0
  69. package/src/store/checkpoint-history.ts +1 -12
  70. package/src/store/file-index.ts +88 -7
  71. package/src/store/log.ts +25 -27
  72. package/src/store/recovery.ts +1 -3
  73. package/src/store/replay.ts +2 -2
  74. package/src/store/restore.ts +10 -202
  75. package/src/store/search.ts +7 -5
  76. package/src/store/session-archive.ts +0 -1
  77. package/src/store/sessions.ts +31 -33
  78. package/src/store/workspace-clone.ts +42 -0
  79. package/src/store/workspace.ts +10 -157
  80. package/src/tui/ui/screen.ts +5 -4
  81. package/src/tui/ui/status.ts +6 -2
  82. package/src/core/benchmark.ts +0 -195
  83. package/src/core/evaluation-coverage.ts +0 -74
  84. package/src/core/experiment.ts +0 -173
  85. package/src/core/harness-comparison.ts +0 -90
  86. package/src/core/harness-diagnostics.ts +0 -206
  87. package/src/core/native-eval.ts +0 -25
  88. package/src/core/policy-evaluation.ts +0 -55
  89. package/src/core/query-eval-contract.ts +0 -14
  90. package/src/core/recall-analysis.ts +0 -90
  91. package/src/core/recall-trial.ts +0 -153
  92. package/src/core/recovery-corpus.ts +0 -17
  93. package/src/core/recovery-evaluation.ts +0 -60
  94. package/src/core/tool-mode-protocol.ts +0 -25
  95. package/src/core/trial-lifecycle.ts +0 -14
  96. package/src/core/window-trial.ts +0 -65
  97. package/src/index.ts +0 -10
  98. package/src/runtime/benchmark.ts +0 -187
  99. package/src/runtime/harness-comparison.ts +0 -376
  100. package/src/runtime/harness-eval.ts +0 -462
  101. package/src/runtime/native-eval-fixtures.ts +0 -33
  102. package/src/runtime/native-eval.ts +0 -146
  103. package/src/runtime/query-eval-verifier.ts +0 -28
  104. package/src/runtime/recall-trial.ts +0 -226
  105. package/src/runtime/recovery-coverage.ts +0 -71
  106. package/src/runtime/recovery-evidence.ts +0 -76
  107. package/src/runtime/recovery-fixtures.ts +0 -116
  108. package/src/runtime/tool-mode-eval.ts +0 -146
  109. package/src/runtime/trial-control.ts +0 -45
  110. package/src/runtime/window-trial.ts +0 -141
  111. package/src/store/git-branch.ts +0 -35
  112. package/src/tui/ui/testing.ts +0 -5
package/src/apps/shell.ts CHANGED
@@ -6,10 +6,13 @@
6
6
  * by scripts, while the interactive app projects the durable transcript.
7
7
  */
8
8
 
9
+ import { criterionSummary, draftGoal, pickCriteria, type PendingGoal } from "./goal-flow.ts";
10
+ import { closeAppSession, switchAppSession } from "./session-lifecycle.ts";
9
11
  import { basename, dirname, join, resolve } from "node:path";
10
12
  import { tmpdir } from "node:os";
11
13
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
12
14
  import { assertNever } from "../core/exhaustive.ts";
15
+ import type { CounterVerdict } from "../core/acceptance.ts";
13
16
  import type { LogEvent } from "../core/events.ts";
14
17
  import type { Skill } from "../core/skills.ts";
15
18
  import { workReadiness } from "../core/work-state.ts";
@@ -29,7 +32,7 @@ import {
29
32
  type ContextDecision, type ContextLimits, type ContextPolicy, type GaugeLevel,
30
33
  } from "../core/context-policy.ts";
31
34
  import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
32
- import type { RestorePoint, RewindMode } from "../runtime/loop.ts";
35
+ import type { RestorePoint, RewindMode, RequestOptions } from "../runtime/loop.ts";
33
36
  import { createProvider, providerSessionId, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
34
37
  import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
35
38
  import { scopedModels } from "../runtime/model-scope.ts";
@@ -335,10 +338,10 @@ async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
335
338
 
336
339
  export async function runPrompt(app: App, prompt: string): Promise<void> {
337
340
  await bindExtensions(app);
338
- if (app.shutdownRequested) { await app.session.dispose(); await app.extensions?.dispose(); return; }
341
+ if (app.shutdownRequested) { await closeAppSession(app); return; }
339
342
  let ok: boolean;
340
343
  try { ok = await run(app, prompt); }
341
- finally { await app.session.dispose(); await app.extensions?.dispose(); }
344
+ finally { await closeAppSession(app); }
342
345
  printUsage(app);
343
346
  // One-shot mode is scriptable, so a failed turn has to be a failed exit.
344
347
  if (!ok) process.exit(1);
@@ -387,8 +390,7 @@ async function runLines(app: App): Promise<void> {
387
390
  if (typeof outcome === "object") awaitingKeyFor = outcome.login;
388
391
  }
389
392
  await cancelPendingOAuth(app);
390
- await app.session.dispose();
391
- await app.extensions?.dispose();
393
+ await closeAppSession(app);
392
394
  printUsage(app);
393
395
  }
394
396
 
@@ -408,7 +410,8 @@ async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
408
410
  */
409
411
  export const interactiveHooks = {
410
412
  handleLine: (app: App, text: string, ui: Ui) => handleLine(app, text, ui),
411
- run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill) => run(app, prompt, signal, ui, images, skill),
413
+ run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill, options?: RequestOptions) =>
414
+ run(app, prompt, signal, ui, images, skill, options),
412
415
  parseLine: (app: App, text: string) => parseLine(app, text),
413
416
  bindExtensions: (app: App, ui?: Ui) => bindExtensions(app, ui),
414
417
  extensionContext: (app: App, ui?: Ui) => extensionContext(app, ui),
@@ -503,6 +506,9 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
503
506
  await app.session.acceptCriterion(command.criterion);
504
507
  await printAcceptance(app);
505
508
  return "continue";
509
+ case "goal":
510
+ await goalCommand(app, command, tui ?? undefined);
511
+ return "continue";
506
512
  case "help":
507
513
  printHelp(app);
508
514
  return "continue";
@@ -656,7 +662,7 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
656
662
  }
657
663
  case "update":
658
664
  if (tui) tui.checkForUpdate();
659
- else await runManualUpdate({ offline: app.args.offline === true }, app.io.say);
665
+ else await runManualUpdate({ offline: app.args.offline === true, install: true }, app.io.say);
660
666
  return "continue";
661
667
  case "copy":
662
668
  await copyLastReply(app, tui ?? undefined);
@@ -693,6 +699,70 @@ async function handleLine(app: App, text: string, tui: Ui): Promise<Outcome> {
693
699
  return execute(app, parseLine(app, text), tui);
694
700
  }
695
701
 
702
+ /**
703
+ * S.2 / S.5. Line mode keeps a drafted goal until `/goal accept` names the
704
+ * criteria to keep; the TUI asks with a checklist instead.
705
+ */
706
+ const pendingGoals = new WeakMap<App, PendingGoal>();
707
+
708
+ async function goalCommand(app: App, command: Extract<Command, { kind: "goal" }>, tui?: Ui): Promise<void> {
709
+ const C = app.io.palette;
710
+ switch (command.action) {
711
+ case "status": await printGoal(app); return;
712
+ case "clear": app.io.say(await app.session.clearGoal() ? C.green(" goal cleared") : C.dim(" no active goal")); return;
713
+ case "set": {
714
+ if (tui) { tui.startGoal(command.objective); return; }
715
+ const drafted = await draftGoal(app, command.objective, (prompt, options) => run(app, prompt, undefined, undefined, [], undefined, options));
716
+ if (!drafted) return;
717
+ pendingGoals.set(app, drafted);
718
+ app.io.say(C.bold("\ndrafted criteria") + C.dim(" (accept with /goal accept all, or /goal accept 1,3)"));
719
+ drafted.criteria.forEach((item, index) => app.io.say(` ${index + 1}. ${item.id} · ${criterionSummary(item)}`));
720
+ return;
721
+ }
722
+ case "accept": {
723
+ const drafted = pendingGoals.get(app);
724
+ if (!drafted) { app.io.say(C.yellow(" no drafted goal; run /goal <objective> first")); return; }
725
+ const picked = pickCriteria(drafted.criteria, command.pick);
726
+ if (!picked.ok) { app.io.say(C.yellow(` ${picked.error}`)); return; }
727
+ pendingGoals.delete(app);
728
+ await app.session.setGoal({ ...drafted, accepted: picked.ids });
729
+ app.io.say(C.green(` goal set with ${picked.ids.length} accepted criteria`));
730
+ await run(app, drafted.objective);
731
+ await printGoal(app);
732
+ return;
733
+ }
734
+ default: return assertNever(command, "goal command");
735
+ }
736
+ }
737
+
738
+ async function printGoal(app: App): Promise<void> {
739
+ const C = app.io.palette, goal = await app.session.goal();
740
+ if (!goal) { app.io.say(C.dim(" no active goal; /goal <objective> drafts one")); return; }
741
+ const request = await app.session.requestState();
742
+ const current = request?.acceptance?.contract_id === goal.contract.id ? request : undefined;
743
+ app.io.say(C.bold("\ngoal") + ` ${goal.objective}`);
744
+ for (const item of goal.contract.criteria) {
745
+ const criterion = current?.criteria.find(entry => entry.id === item.id), status = criterion?.status ?? "not_run";
746
+ const guard = item.kind === "command" && item.guard === true;
747
+ app.io.say(` ${status === "pass" || status === "accepted" ? C.green("✓") : C.yellow("○")} ${item.id}${guard ? " (guard)" : ""} · ${status} · ${item.description}` +
748
+ (criterion?.counter ? counterSuffix(C, criterion.counter) : ""));
749
+ }
750
+ for (const blocker of current?.reported_blockers ?? []) app.io.say(C.yellow(` reported blocker: ${blocker.reason}`));
751
+ if (current && current.evidence.independent !== "not_applicable") app.io.say(` blind checks ${current.evidence.independent}`);
752
+ }
753
+
754
+ /** One counter-check verdict after a criterion, in the words of its role (S.7). */
755
+ function counterSuffix(C: App["io"]["palette"], verdict: CounterVerdict): string {
756
+ switch (verdict) {
757
+ case "vacuous": return C.yellow(" · also passes without the changes; it does not test them");
758
+ case "discriminating": return C.green(" · fails without the changes");
759
+ case "held": return C.green(" · held before the changes too");
760
+ case "not_held": return C.dim(" · did not hold before the changes; it tests them rather than guarding");
761
+ case "unavailable": return C.dim(" · counter-check unavailable");
762
+ default: return assertNever(verdict, "counter verdict");
763
+ }
764
+ }
765
+
696
766
  function printUsage(app: App): void {
697
767
  const C = app.io.palette;
698
768
  const t = app.session.totals;
@@ -756,7 +826,10 @@ async function printState(app: App): Promise<void> {
756
826
  app.io.say(` command evidence ${request.commands.filter(command => command.status === "pass").length} current passes / ${request.commands.length} receipts`);
757
827
  app.io.say(` observations ${request.progress.observations.length} new; artifact changes ${request.progress.artifact_changes.length}; acceptance advances ${request.progress.acceptance_advances.length}`);
758
828
  app.io.say(` diagnostics ${request.progress.new_diagnostics.length} new; unchanged retries ${request.progress.unchanged_retries.length}; unknown ${request.progress.unknown_diagnostics.length}`);
759
- if (!request.acceptance) app.io.say(" task verification no declared acceptance contract; command success alone does not verify the task");
829
+ app.io.say(` evidence ran ${request.evidence.ran} · discriminating ${request.evidence.discriminating} · independent ${request.evidence.independent} · human ${request.evidence.human}`);
830
+ for (const gap of request.unmet) app.io.say(` unmet ${gap}`);
831
+ for (const blocker of request.reported_blockers) app.io.say(` reported blocker ${blocker.reason}`);
832
+ if (!request.acceptance) app.io.say(" task verification no declared contract; a fresh check shows only that the changes ran, not that the request was met");
760
833
  }
761
834
  if (s.handoff) {
762
835
  const h = s.handoff;
@@ -805,7 +878,7 @@ async function printAcceptance(app: App): Promise<void> {
805
878
  app.io.say(` build ${target.build_event ?? "none"}; instance ${target.binding ?? "unknown"}; observation ${target.observation ?? "none"}; ${target.reason ?? "start the declared target after its build"}`);
806
879
  }
807
880
  for (const item of state?.criteria ?? []) {
808
- app.io.say(` ${item.id}: ${item.status} · ${item.description}`);
881
+ app.io.say(` ${item.id}${item.kind === "command" && item.guard ? " (guard)" : ""}: ${item.status} · ${item.description}`);
809
882
  if (item.kind === "command") app.io.say(` command: ${item.command}; evidence: ${item.evidence?.event_id ?? "none"}`);
810
883
  else if (item.status !== "accepted") app.io.say(` /accept ${item.id} records your acceptance${item.target ? ` of the observed target ${item.target}` : ""}`);
811
884
  }
@@ -841,8 +914,7 @@ async function applySettingsCommand(
841
914
  app.io.say(app.io.palette.bold("\nsettings"));
842
915
  app.io.say(` theme ${s?.theme ?? "dark"}`);
843
916
  app.io.say(` thinking ${s?.thinking ?? app.providerConfig.effort}`);
844
- app.io.say(` statusBar ${s?.statusBar === false ? "off" : "on"}`);
845
- app.io.say(app.io.palette.dim(" /settings <theme|thinking|statusBar> <value> to change.\n"));
917
+ app.io.say(app.io.palette.dim(" /settings <theme|thinking> <value> to change; /settings alone opens every option.\n"));
846
918
  return "continue";
847
919
  }
848
920
 
@@ -858,7 +930,7 @@ async function commitSetting(app: App, field: string, value: string, tui: Ui | n
858
930
  if (effort) await applyThinking(app, effort, tui);
859
931
  return;
860
932
  }
861
- const parsed = settingPatch(key === "status" ? "statusBar" : field, value);
933
+ const parsed = settingPatch(field, value);
862
934
  if (parsed.diagnostics.length) { app.io.say(C.yellow(parsed.diagnostics.join("\n"))); return; }
863
935
  if (key === "theme") {
864
936
  const diagnostics: string[] = [];
@@ -1639,25 +1711,7 @@ async function resumeSession(app: App, idOrPath: string, tui?: Ui): Promise<void
1639
1711
 
1640
1712
  async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
1641
1713
  try {
1642
- app.session.cancel(); await app.session.whenIdle();
1643
- if (app.extensions && !await app.extensions.releaseIntegrations()) throw Error("An integration still owns unfinished work or unconfirmed cleanup. Wait for it to settle before switching sessions.");
1644
- const opened = await openSessionFor({ ...app, logDir, fallback: app.providerConfig, interactive: true });
1645
- await app.session.dispose();
1646
- await app.extensions?.dispose();
1647
- app.session = opened.session;
1648
- app.providerConfig = opened.providerConfig;
1649
- app.credential = opened.credential;
1650
- app.logDir = logDir;
1651
- app.workspace = opened.workspace;
1652
- app.blobDir = opened.blobDir;
1653
- app.system = opened.system;
1654
- app.loadedContext = opened.resources.loadedContext;
1655
- app.slash = opened.resources.slash;
1656
- app.ui = opened.resources.ui;
1657
- app.resources = opened.resources.index;
1658
- app.projectTrusted = opened.resources.trusted;
1659
- app.extensions = opened.resources.extensions;
1660
- app.foundation = opened.resources.foundation;
1714
+ await switchAppSession(app, logDir, (current, target) => openSessionFor({ ...current, logDir: target, fallback: current.providerConfig, interactive: true }));
1661
1715
  } catch (err) {
1662
1716
  app.io.say(app.io.palette.red(` ${describeError(err)}`));
1663
1717
  return false;
@@ -1793,7 +1847,7 @@ async function nameSession(app: App, name: string, tui?: Ui): Promise<void> {
1793
1847
  }
1794
1848
 
1795
1849
  /** Runs a prompt. Returns false if the turn failed, having already said why. */
1796
- async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill): Promise<boolean> {
1850
+ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill, options?: RequestOptions): Promise<boolean> {
1797
1851
  if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
1798
1852
  for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
1799
1853
  if (result["action"] === "handled") return true;
@@ -1873,7 +1927,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, ima
1873
1927
  return false;
1874
1928
  }
1875
1929
  try {
1876
- await runTurn(app, input, signal, tui, skill);
1930
+ await runTurn(app, input, signal, tui, skill, options);
1877
1931
  return true;
1878
1932
  } catch (error) {
1879
1933
  tui?.discardStream();
@@ -1895,6 +1949,7 @@ export async function runTurn(
1895
1949
  signal?: AbortSignal,
1896
1950
  tui?: TurnSurface,
1897
1951
  skill?: Skill,
1952
+ options?: RequestOptions,
1898
1953
  ): Promise<void> {
1899
1954
  // A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
1900
1955
  // deltas and discard are ignored, and the final block prints once.
@@ -1914,7 +1969,7 @@ export async function runTurn(
1914
1969
  }
1915
1970
  await registry?.emit("agent_start");
1916
1971
  await registry?.emit("turn_start", { turnIndex: 0 });
1917
- try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull)) {
1972
+ try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull, options)) {
1918
1973
  // The log retains every response, but a script's final answer must be the
1919
1974
  // accepted last response. A rejected JSON document cannot be unwritten.
1920
1975
  if (e.kind === "turn_start" || e.kind === "discard") finalText = [];
@@ -1943,7 +1998,7 @@ export async function runTurn(
1943
1998
  if (e.kind === "discard") tui.discardStream();
1944
1999
  await tui.syncTranscript();
1945
2000
  if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
1946
- if (e.kind === "snapshot_failed" || e.kind === "undeclared_change" || e.kind === "stop_and_ask") {
2001
+ if (e.kind === "stop_and_ask") {
1947
2002
  for (const line of formatEvent(e, app.io.palette, Math.max(20, (process.stdout.columns ?? 80) - 1), {}, app.ui?.style)) app.io.say(line);
1948
2003
  }
1949
2004
  }
@@ -1,3 +1,6 @@
1
+ import { loadBlindChecks } from "../store/blind-checks.ts";
2
+ import type { BlindChecks } from "../core/blind-checks.ts";
3
+ import { appSessionFields } from "./session-lifecycle.ts";
1
4
  import { release } from "node:os";
2
5
  import { codingWorkingProtocol } from "../core/continuation.ts";
3
6
  import { taskGuidance } from "../core/prompt-policy.ts";
@@ -40,6 +43,7 @@ import { readModelSources, readCatalogCacheText, writeCatalogCache, catalogCache
40
43
  import { refreshRemoteCatalog } from "../runtime/models-fetch.ts";
41
44
  import { readCursor, writeCursor, type Cursor } from "../store/log.ts";
42
45
  import { getBlob } from "../store/blobs.ts";
46
+ import { parseSessionContext } from "../core/session-format.ts";
43
47
  import { resolveLogDir, findSessionByPrefix, listSessions, sessionsDir, type ResolveLogOpts } from "../store/sessions.ts";
44
48
  import { recoverCut } from "../store/recovery.ts";
45
49
  import { planSessionEntry, restoreSessionModel } from "../core/session-entry.ts";
@@ -111,12 +115,12 @@ export function sessionEnvironment(workspace: string): SessionEnvironment {
111
115
  * Session policy a CLI coding session differs from a benchmark run in.
112
116
  * Benchmarks construct their own SessionConfig and keep the core defaults.
113
117
  */
114
- export function cliSessionConfig(args: Args): { continuationAfter: number; workingSet: true; workingSetVersion: 2; guidanceVersion: 1; strictVerification: true; timeBudgetMs?: number } {
118
+ export function cliSessionConfig(args: Args): { continuationAfter: number; workingSet: true; strictVerification: true; timeBudgetMs?: number } {
115
119
  // Never within a request. A real Kimi K3 run with the reminder at reply six
116
120
  // spent half its requests on notes after the work was already verified
117
121
  // (fix-cli-continuation-noise). The reminder still fires where it earns its
118
122
  // place: a resumed handoff that cannot continue, and boundary preparation.
119
- return { continuationAfter: Infinity, workingSet: true, workingSetVersion: 2, guidanceVersion: 1, strictVerification: true, ...(args.timeBudgetMs === undefined ? {} : { timeBudgetMs: args.timeBudgetMs }) };
123
+ return { continuationAfter: Infinity, workingSet: true, strictVerification: true, ...(args.timeBudgetMs === undefined ? {} : { timeBudgetMs: args.timeBudgetMs }) };
120
124
  }
121
125
 
122
126
  /**
@@ -190,6 +194,8 @@ export interface Args {
190
194
  /** New sessions default to coding; restored context keeps its frozen schemas. */
191
195
  toolProfile?: ToolProfile;
192
196
  acceptanceFile?: string;
197
+ /** feat-blind-checks: a directory outside the workspace holding blind.json and its fixtures. */
198
+ blindChecks?: string;
193
199
  excludeTools: string[];
194
200
  noTools: boolean;
195
201
  /** `--no-builtin-tools`. False when the flag was not passed. */
@@ -288,6 +294,7 @@ const KNOWN_FLAGS: readonly string[] = [
288
294
  "--tools", "-t",
289
295
  "--tool-profile",
290
296
  "--acceptance-file",
297
+ "--blind-checks",
291
298
  "--exclude-tools", "-xt",
292
299
  "--no-tools", "-nt",
293
300
  "--no-builtin-tools",
@@ -359,7 +366,9 @@ export function parseArgs(argv: string[], options: { deferExtensions?: boolean;
359
366
  let updateExtensions = false;
360
367
  const args: Args = {
361
368
  cliFlags: argv.filter(value => value.startsWith("-")),
362
- workspace: process.cwd(), logDir: "", maxTurns: 40, prompt: "",
369
+ // No default request cap (S.6 item 1): the stall watchdog ends a run that
370
+ // stops producing evidence; --max-turns or an agent budget sets a hard cap.
371
+ workspace: process.cwd(), logDir: "", maxTurns: Infinity, prompt: "",
363
372
  // Overridable so a node boundary can be forced in a short session --
364
373
  // otherwise cutting is only reachable after tens of thousands of tokens,
365
374
  // which makes the most interesting path the least exercised one.
@@ -443,6 +452,10 @@ export function parseArgs(argv: string[], options: { deferExtensions?: boolean;
443
452
  else if (a === "--system-prompt") args.systemPrompt = argv[++i] ?? "";
444
453
  else if (a === "--append-system-prompt") args.appendSystemPrompt.push(argv[++i] ?? "");
445
454
  else if (a === "--tools" || a === "-t") args.tools = parseToolList(argv[++i] ?? "");
455
+ else if (a === "--blind-checks") {
456
+ const path = argv[++i]; if (!path || path.startsWith("--")) throw new Error("--blind-checks requires a directory outside the workspace");
457
+ args.blindChecks = path;
458
+ }
446
459
  else if (a === "--acceptance-file") {
447
460
  const path = argv[++i]; if (!path || path.startsWith("--")) throw new Error("--acceptance-file requires a JSON path");
448
461
  args.acceptanceFile = path;
@@ -942,6 +955,7 @@ export function createSessionFor(opts: {
942
955
  extensions?: ExtensionRegistry;
943
956
  foundation?: FoundationRuntime;
944
957
  acceptanceContract?: AcceptanceContract;
958
+ blindChecks?: BlindChecks;
945
959
  skills?: readonly Skill[];
946
960
  }): Session {
947
961
  const { args, workspace, logDir, blobDir, system, providerConfig, credential, io, askSlot } = opts;
@@ -975,6 +989,7 @@ export function createSessionFor(opts: {
975
989
  ...cliSessionConfig(args),
976
990
  ...(opts.skills ? { skills: opts.skills } : {}),
977
991
  ...(opts.acceptanceContract ? { acceptanceContract: opts.acceptanceContract } : {}),
992
+ ...(opts.blindChecks ? { blindChecks: opts.blindChecks } : {}),
978
993
  // Explicit legacy thresholds still select that experiment unless overridden.
979
994
  contextPolicy: args.contextPolicy ?? (args.trigger > 0 ? "segmented" : DEFAULT_CONTEXT_POLICY),
980
995
  ...(args.trigger > 0
@@ -1115,26 +1130,16 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1115
1130
  if (priorCursor?.context_ref) {
1116
1131
  const savedContext = await getBlob(join(opts.logDir, "artifacts"), priorCursor.context_ref);
1117
1132
  if (!savedContext) throw Error("missing frozen session context");
1118
- const metadata: unknown = JSON.parse(savedContext);
1119
- if (metadata && typeof metadata === "object") {
1120
- if (!("agentProfile" in metadata) && !opts.args.cliFlags?.includes("--workflow") && "workflowMode" in metadata) {
1121
- if (metadata.workflowMode !== "build" && metadata.workflowMode !== "plan") throw Error("unsupported saved workflow mode");
1122
- opts.args.workflow = metadata.workflowMode;
1123
- }
1124
- if ("workflowToolProfile" in metadata && metadata.workflowToolProfile !== undefined) {
1125
- if (metadata.workflowToolProfile !== "foundation") throw Error("unsupported saved workflow tool profile");
1126
- if (!opts.args.cliFlags?.some(flag => ["--tool-profile", "--tools", "-t", "--no-tools", "--no-builtin-tools"].includes(flag))) opts.args.toolProfile = "foundation";
1127
- }
1128
- }
1129
- if (metadata && typeof metadata === "object" && "agentProfile" in metadata && metadata.agentProfile !== undefined) {
1130
- const role = metadata.agentProfile;
1131
- if (!role || typeof role !== "object" || !("name" in role) || typeof role.name !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/.test(role.name) || !("workflowExplicit" in role) || typeof role.workflowExplicit !== "boolean") throw Error("unsupported saved agent profile selector");
1132
- if (!opts.args.cliFlags?.includes("--agent")) opts.args.agent = role.name;
1133
- if (role.workflowExplicit && !opts.args.cliFlags?.includes("--workflow") && "workflowMode" in metadata) {
1134
- if (metadata.workflowMode !== "build" && metadata.workflowMode !== "plan") throw Error("unsupported saved workflow mode");
1135
- opts.args.workflow = metadata.workflowMode; opts.args.cliFlags = [...opts.args.cliFlags ?? [], "--workflow"];
1133
+ // S.3: the same parser Session.start uses; there is one reader of this format.
1134
+ const saved = parseSessionContext(JSON.parse(savedContext));
1135
+ const explicit = (...flags: string[]) => opts.args.cliFlags?.some(flag => flags.includes(flag)) === true;
1136
+ if (saved.agentProfile) {
1137
+ if (!explicit("--agent")) opts.args.agent = saved.agentProfile.name;
1138
+ if (saved.agentProfile.workflowExplicit && saved.workflowMode && !explicit("--workflow")) {
1139
+ opts.args.workflow = saved.workflowMode; opts.args.cliFlags = [...opts.args.cliFlags ?? [], "--workflow"];
1136
1140
  }
1137
- }
1141
+ } else if (saved.workflowMode && !explicit("--workflow")) opts.args.workflow = saved.workflowMode;
1142
+ if (saved.workflowToolProfile && !explicit("--tool-profile", "--tools", "-t", "--no-tools", "--no-builtin-tools")) opts.args.toolProfile = "foundation";
1138
1143
  }
1139
1144
  const workspace = priorCursor?.workspace ?? opts.workspace;
1140
1145
  if (opts.args.pathRoot) {
@@ -1196,6 +1201,8 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1196
1201
  const oauth = (model: ModelSpec, got: Resolved) => got.ok && storedIsOAuth(opts.auth, model.provider) && opts.args.apiKey.length === 0;
1197
1202
  const acceptanceContract = priorCursor === null
1198
1203
  ? await loadAcceptanceContract(workspace, resources.trusted, opts.args.acceptanceFile) : undefined;
1204
+ // Frozen at session start like the contract; a resumed session reads its own copy from the saved context.
1205
+ const blindChecks = priorCursor === null && opts.args.blindChecks ? await loadBlindChecks(opts.args.blindChecks, workspace) : undefined;
1199
1206
  const session = createSessionFor({
1200
1207
  ...opts,
1201
1208
  system: resources.system,
@@ -1203,6 +1210,7 @@ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1203
1210
  foundation: resources.foundation,
1204
1211
  skills: [...resources.slash.skills.values()],
1205
1212
  ...(acceptanceContract ? { acceptanceContract } : {}),
1213
+ ...(blindChecks ? { blindChecks } : {}),
1206
1214
  workspace: priorCursor?.workspace ?? opts.workspace,
1207
1215
  blobDir: priorCursor?.blob_dir ?? (priorCursor?.workspace && priorCursor.workspace !== opts.workspace
1208
1216
  ? statePaths(priorCursor.workspace, opts.args.stateDir).blobs : opts.blobDir),
@@ -1429,7 +1437,6 @@ export async function startup(args: Args, input?: StartupInput): Promise<App> {
1429
1437
  sessionOpts = { explicit: picked };
1430
1438
  }
1431
1439
  const resolved = await resolveLogDir(workspace, sessionOpts);
1432
- if (resolved.legacyNotice !== null && !args.logDir) io.say(C.yellow(` ${resolved.legacyNotice}`));
1433
1440
  logDir = resolved.logDir;
1434
1441
  } catch (err) {
1435
1442
  const detail = err instanceof Error ? err.message : String(err);
@@ -1483,13 +1490,7 @@ export async function startup(args: Args, input?: StartupInput): Promise<App> {
1483
1490
  tools: visible.map((t) => t.name),
1484
1491
  });
1485
1492
 
1486
- return {
1487
- args, session, workspace: opened.workspace, logDir, blobDir: opened.blobDir, system: opened.system, providerConfig, credential,
1488
- auth, effects, loadedContext: opened.resources.loadedContext, credentialOf, catalog, io, askSlot,
1489
- ui: opened.resources.ui, slash: opened.resources.slash, resources: opened.resources.index, projectTrusted: opened.resources.trusted,
1490
- extensions: opened.resources.extensions,
1491
- foundation: opened.resources.foundation,
1492
- };
1493
+ return { args, logDir, auth, effects, credentialOf, catalog, io, askSlot, ...appSessionFields(opened) };
1493
1494
  }
1494
1495
 
1495
1496
  /** The banner's words for the shared limits; the loop acts at exactly these numbers. */
@@ -1588,7 +1589,7 @@ export function printCliHelp(flags?: ReadonlyMap<string, ExtensionFlag>): void {
1588
1589
  " [-n|--name name] [--session-id id] [--version|-v]\n" +
1589
1590
  " [models --refresh]\n" +
1590
1591
  " [install|remove <source>... [--local]] [update [source] [--extensions]] [list]\n" +
1591
- " [update --check] Check for a newer xeno without installing\n" +
1592
+ " [update --check] Check for a newer xeno without installing (/update installs)\n" +
1592
1593
  " [config [--local]] [--export session [output.html]] [--offline]\n" +
1593
1594
  " [--system-prompt <text-or-file>] [--append-system-prompt <text>]\n" +
1594
1595
  " [--no-context-files|-nc]\n" +
@@ -1596,7 +1597,7 @@ export function printCliHelp(flags?: ReadonlyMap<string, ExtensionFlag>): void {
1596
1597
  " [--tool-profile coding|readonly|research|data|foundation|full]\n" +
1597
1598
  " [--workflow build|plan] [--agent name]\n" +
1598
1599
  " [--output-schema schema.json] [--output-last-message file]\n" +
1599
- " [--acceptance-file contract.json]\n" +
1600
+ " [--acceptance-file contract.json] [--blind-checks dir]\n" +
1600
1601
  " [--control-socket /private/directory/control.sock]\n" +
1601
1602
  " [--no-builtin-tools]\n" +
1602
1603
  " [--allow-tools names] [--yolo|-y] [--approve|--no-approve]\n" +
@@ -10,6 +10,10 @@
10
10
  * either rebuilt from the log (transcript) or deliberately lost on exit
11
11
  * (draft queue that was never sent, scroll position).
12
12
  */
13
+ import { ladderView } from "./control-model.ts";
14
+ import { criterionSummary, draftGoal } from "../goal-flow.ts";
15
+ import type { RequestOptions } from "../../runtime/loop.ts";
16
+ import { closeAppSession } from "../session-lifecycle.ts";
13
17
  import { homedir } from "node:os";
14
18
  import { basename, dirname, resolve } from "node:path";
15
19
  import { fileURLToPath } from "node:url";
@@ -29,7 +33,7 @@ import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settin
29
33
  import { THINKING_LEVELS, asThinkingLevel, supportedThinkingLevels } from "../../core/thinking.ts";
30
34
  import { builtinTheme } from "../../core/theme.ts";
31
35
  import { projectTranscript, type TranscriptItem } from "../../core/transcript.ts";
32
- import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, sessionExitStatus } from "../../core/session-exit.ts";
36
+ import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, resumeCommand, sessionExitStatus } from "../../core/session-exit.ts";
33
37
  import { lastAssistantText } from "../../core/export.ts";
34
38
  import type { PermissionRequest } from "../../core/permissions.ts";
35
39
  import type { ApprovalChoice } from "../../runtime/tools.ts";
@@ -37,14 +41,17 @@ import { attachImageBytes, attachImageFile } from "../../runtime/images.ts";
37
41
  import { readClipboard, writeClipboard, type ClipboardContent } from "../../runtime/clipboard.ts";
38
42
  import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runtime/rich-text.ts";
39
43
  import { SOURCE_PROVIDERS } from "../../runtime/model-data.ts";
40
- import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
44
+ import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer } from "../../runtime/oauth.ts";
45
+ import { XENO_VERSION } from "../../runtime/version.ts";
41
46
  import { reportedCost } from "../../runtime/usage.ts";
42
47
  import { UpdateChecks } from "../update-check.ts";
43
48
  import { updateNotice } from "../../core/update-check.ts";
49
+ import { assertNever } from "../../core/exhaustive.ts";
44
50
  import { isUpgrade } from "../../core/version.ts";
45
51
  import type { Effort } from "../../runtime/model.ts";
46
52
  import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
47
53
  import { readNodeHistory } from "../../store/restore.ts";
54
+ import { sessionsDir } from "../../store/sessions.ts";
48
55
  import { readGitStatus } from "../../store/git-status.ts";
49
56
  import type { GitStatus } from "../../core/git-status.ts";
50
57
  import { activityStatus } from "../../core/activity-status.ts";
@@ -361,13 +368,17 @@ export class InteractiveApp implements Ui {
361
368
  checkForUpdate(): void {
362
369
  // The explicit action owns its result even when it joins a startup request.
363
370
  this.manualUpdateRequested = true;
364
- void this.updateChecks.manual({ offline: this.app.args.offline === true }, (text, tone) => {
371
+ void this.updateChecks.manual({ offline: this.app.args.offline === true, install: true }, (text, tone) => {
365
372
  if (!this.disposed) this.notice(text, tone);
366
373
  }, activity => {
367
374
  if (this.disposed) return;
368
- this.screen.setTransientActivity(activity.state === "checking" ? {
369
- label: `Checking for xeno updates (current ${activity.current})`, timeoutMs: activity.timeoutMs,
370
- } : undefined);
375
+ switch (activity.state) {
376
+ case "checking": this.screen.setTransientActivity({ label: `Checking for xeno updates (current ${activity.current})`, timeoutMs: activity.timeoutMs }); break;
377
+ case "installing": this.screen.setTransientActivity({ label: `Installing xeno ${activity.version}`, timeoutMs: activity.timeoutMs,
378
+ startedAt: activity.startedAt, ...(activity.detail ? { detail: activity.detail } : {}) }); break;
379
+ case "idle": this.screen.setTransientActivity(undefined); break;
380
+ default: assertNever(activity, "manual update activity");
381
+ }
371
382
  }).catch(() => {
372
383
  if (!this.disposed) {
373
384
  this.screen.setTransientActivity(undefined);
@@ -590,6 +601,7 @@ export class InteractiveApp implements Ui {
590
601
  status: this.controlSnapshot.goal.state.status,
591
602
  execution: this.inflight ? "running" : this.controlSnapshot.goal.state.execution?.reason ?? "idle",
592
603
  observations: this.controlSnapshot.goal.state.progress.observations.length,
604
+ ...ladderView(this.controlSnapshot.goal.state),
593
605
  retries: this.controlSnapshot.goal.state.progress.unchanged_retries.length } } : {}),
594
606
  files: this.files,
595
607
  resources: { contextFiles: app.loadedContext.agents.map(file => basename(file.path)), skills: app.slash?.skills.size ?? 0, extensions: app.extensions?.loaded.length ?? 0 },
@@ -846,7 +858,31 @@ export class InteractiveApp implements Ui {
846
858
  this.paint();
847
859
  }
848
860
 
849
- private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
861
+ /**
862
+ * S.2 / S.5. The draft runs as an ordinary, visible and cancellable turn;
863
+ * its checklist opens once the reply is in. Only the checked criteria
864
+ * become the goal, and the goal's own request starts from the objective.
865
+ */
866
+ startGoal(objective: string): void {
867
+ this.track(async () => {
868
+ const pending = await draftGoal(this.app, objective, async (prompt, options) => {
869
+ await this.runPromptTurn(prompt, [], undefined, options);
870
+ // A cancelled draft is not a draft to accept, nor one to send back.
871
+ return this.lastOutcome?.status !== "cancelled";
872
+ });
873
+ if (!pending) return;
874
+ this.push({ kind: "list", title: `Accept the checks for: ${objective}`, multi: true, checked: pending.criteria.map(item => item.id), search: false,
875
+ items: pending.criteria.map(item => ({ value: item.id, label: item.id, description: criterionSummary(item) })),
876
+ onSubmitChecked: async ids => {
877
+ if (ids.length === 0) { this.notice("No criteria accepted; the goal was not set.", "warning"); return; }
878
+ await this.app.session.setGoal({ ...pending, accepted: ids });
879
+ this.notice(`Goal set with ${ids.length} accepted ${ids.length === 1 ? "check" : "checks"}.`, "success");
880
+ this.turn = this.runPromptTurn(objective).catch(error => this.notice(describeError(error), "error"));
881
+ } });
882
+ });
883
+ }
884
+
885
+ private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill, options?: RequestOptions): Promise<void> {
850
886
  this.abort = new AbortController();
851
887
  this.inflight = true;
852
888
  this.lastOutcome = undefined;
@@ -855,7 +891,7 @@ export class InteractiveApp implements Ui {
855
891
  this.executionKind = text.startsWith("!") ? "bash" : "model";
856
892
  this.paint();
857
893
  try {
858
- const sent = await this.hooks.run(this.app, text, this.abort.signal, this, images, skill);
894
+ const sent = await this.hooks.run(this.app, text, this.abort.signal, this, images, skill, options);
859
895
  if (!sent && !this.app.credential.ok) {
860
896
  this.programResult = true; this.program?.update(programStatus({ running: false, waiting: "auth" }));
861
897
  }
@@ -1553,7 +1589,7 @@ export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: (
1553
1589
  await ui.closed;
1554
1590
  await ui.settle();
1555
1591
  } finally {
1556
- try { await app.session.dispose(); await app.extensions?.dispose(); await hooks.cancelPendingOAuth(app); }
1592
+ try { await closeAppSession(app); await hooks.cancelPendingOAuth(app); }
1557
1593
  finally {
1558
1594
  ui.dispose();
1559
1595
  if (!renderer) r.destroy();
@@ -1577,7 +1613,8 @@ async function printExit(app: App): Promise<void> {
1577
1613
  }
1578
1614
  }
1579
1615
  process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
1580
- process.stdout.write(`\n resume with: xeno --session ${JSON.stringify(app.logDir)}\n`);
1616
+ const path = resolve(app.logDir);
1617
+ process.stdout.write(`\n resume with: ${resumeCommand({ id: basename(path), directory: dirname(path), path }, resolve(sessionsDir(process.cwd())))}\n`);
1581
1618
  }
1582
1619
 
1583
1620
  /** The session-start rule first, then the projection (refactor-tui-dock-layout DoD 7). */
@@ -1,5 +1,15 @@
1
1
  import type { ControlSnapshot } from "../../runtime/session-control.ts";
2
2
  import type { ControlPanelModel } from "../../tui/ui/control-panel.ts";
3
+ import type { RequestState } from "../../core/request-state.ts";
4
+ import { evidenceLadder, ladderGlyph, RUNGS } from "../../core/evidence-ladder.ts";
5
+
6
+ /** The sidebar's compact ladder: rung names with their glyphs, then the reading. */
7
+ export function ladderView(state: RequestState): { ladder?: { glyphs: string; summary: string } } {
8
+ const ladder = evidenceLadder(state);
9
+ if (!ladder) return {};
10
+ const names = { ran: "ran", discriminating: "tests", independent: "blind", human: "human" } as const;
11
+ return { ladder: { glyphs: RUNGS.map(rung => `${names[rung]} ${ladderGlyph(ladder[rung])}`).join(" "), summary: ladder.summary } };
12
+ }
3
13
 
4
14
  export function controlModel(snapshot: ControlSnapshot): ControlPanelModel {
5
15
  const goal = snapshot.goal, state = goal?.state, context = snapshot.context;
@@ -7,7 +17,7 @@ export function controlModel(snapshot: ControlSnapshot): ControlPanelModel {
7
17
  return { thread: snapshot.threadId, turn: snapshot.activeTurnId ?? state?.request.event ?? null,
8
18
  goal: goal?.text ?? "Send a request to establish a goal.", status: state?.status ?? "idle",
9
19
  execution: snapshot.waiting ? `waiting for ${snapshot.waiting}` : snapshot.activeTurnId ? "running" : state?.execution?.reason ?? "idle",
10
- gaps: goal?.gaps ?? [], checks: state?.commands.map(command => `${command.status} · exit ${command.exit} · ${command.command} · ${command.source.node}/${command.source.event}`) ?? [],
20
+ gaps: [...ladderLines(state), ...goal?.gaps ?? []], checks: state?.commands.map(command => `${command.status} · exit ${command.exit} · ${command.command} · ${command.source.node}/${command.source.event}`) ?? [],
11
21
  progress: state ? [
12
22
  progress("New observations", state.progress.observations),
13
23
  progress("Artifact changes", state.progress.artifact_changes),
@@ -24,3 +34,10 @@ export function controlModel(snapshot: ControlSnapshot): ControlPanelModel {
24
34
  (section.recovery ? ` · history(${JSON.stringify(section.recovery)})` : "")),
25
35
  };
26
36
  }
37
+
38
+ /** The control panel's goal page spells each rung out on its own line. */
39
+ function ladderLines(state: RequestState | undefined): string[] {
40
+ const ladder = state ? evidenceLadder(state) : null;
41
+ if (!ladder) return [];
42
+ return [`Evidence: ${ladder.summary}`, ...RUNGS.map(rung => ` ${ladderGlyph(ladder[rung])} ${rung}: ${ladder[rung]}`), ""];
43
+ }
@@ -55,6 +55,8 @@ export interface Ui extends TurnSurface {
55
55
  openTree(): Promise<void>;
56
56
  openResources(): Promise<void>;
57
57
  openControl(tab?: "goal" | "progress" | "context"): Promise<void>;
58
+ /** S.5: draft criteria for an objective, ask the user which to accept, then work toward them. */
59
+ startGoal(objective: string): void;
58
60
  openTrust(): Promise<void>;
59
61
  openAttachments(): void;
60
62