gentle-pi 2.7.0 → 3.1.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 (126) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +28 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +27 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/gentle-ai-installer.mjs +12 -12
  59. package/scripts/test-packed-runner.mjs +1668 -20
  60. package/scripts/verify-package-files.mjs +2 -3
  61. package/tests/agent-home.test.ts +52 -0
  62. package/tests/agent-profiles.test.ts +30 -1
  63. package/tests/agents-config.test.ts +44 -0
  64. package/tests/agents-history.test.ts +12 -24
  65. package/tests/agents-runner.test.ts +307 -58
  66. package/tests/agents-session-transport-process.test.ts +249 -0
  67. package/tests/agents-session-transport.test.ts +823 -0
  68. package/tests/artifact-language.test.ts +10 -7
  69. package/tests/command-palette.test.ts +378 -0
  70. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  71. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  72. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  73. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  74. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  75. package/tests/gentle-agents.test.ts +849 -356
  76. package/tests/gentle-ai-binary.test.ts +3 -3
  77. package/tests/gentle-ai-installer.test.ts +51 -51
  78. package/tests/gentle-ai.test.ts +472 -4
  79. package/tests/gentle-shell.test.ts +201 -8
  80. package/tests/native-choice-list.test.ts +13 -0
  81. package/tests/native-review-capability-contract.test.ts +30 -1
  82. package/tests/native-review-cli.test.ts +0 -33
  83. package/tests/odd-routing-contract.test.ts +208 -0
  84. package/tests/orchestrator-budget.test.ts +17 -2
  85. package/tests/package-manifest.test.ts +119 -31
  86. package/tests/persona-single-channel.test.ts +3 -3
  87. package/tests/pi-pretty.test.ts +45 -0
  88. package/tests/profile-pin.test.ts +370 -0
  89. package/tests/quiet-tool-rendering.test.ts +32 -5
  90. package/tests/review-contract-prompt.test.ts +9 -0
  91. package/tests/review-controller.test.ts +0 -44
  92. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  93. package/tests/runtime-harness.mjs +4 -4
  94. package/tests/sdd-agent-tools.test.ts +15 -36
  95. package/tests/sdd-archive-replay.test.ts +82 -0
  96. package/tests/sdd-classical-continuation.test.ts +74 -0
  97. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  98. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  99. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  100. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  101. package/tests/sdd-odd-integration.test.ts +33 -0
  102. package/tests/sdd-optional-research.test.ts +124 -0
  103. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  104. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  105. package/tests/sdd-preflight.test.ts +1 -1
  106. package/tests/sdd-research-capabilities.test.ts +20 -162
  107. package/tests/sdd-selection-transport.test.ts +180 -88
  108. package/tests/sdd-status.test.ts +5 -778
  109. package/tests/sdd-task-truth.test.ts +43 -0
  110. package/tests/session-change-capture.test.ts +20 -2
  111. package/tests/session-changes.test.ts +11 -0
  112. package/tests/shell-bar.test.ts +21 -0
  113. package/tests/shell-card.test.ts +8 -6
  114. package/tests/shell-changes.test.ts +8 -0
  115. package/tests/shell-prompt.test.ts +41 -7
  116. package/tests/shell-sidebar-banner.test.ts +4 -4
  117. package/tests/shell-sidebar-layout.test.ts +97 -13
  118. package/tests/startup-banner.test.ts +55 -2
  119. package/tests/windows-hidden-processes.test.ts +303 -0
  120. package/tests/windows-session-bootstrap.test.ts +1772 -0
  121. package/tests/windows-session-compile.test.ts +170 -0
  122. package/tests/windows-session-transport.test.ts +754 -0
  123. package/assets/agents/sdd-sync.md +0 -146
  124. package/lib/openspec-guardrails.ts +0 -99
  125. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  126. package/tests/openspec-guardrails.test.ts +0 -71
@@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
- import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
8
8
  import type { TUI } from "@earendil-works/pi-tui";
9
9
  import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
10
10
  import { CHANGE_STATUS } from "../lib/shell-changes.ts";
@@ -63,12 +63,25 @@ interface ShortcutRegistration {
63
63
  type MessageRenderer = (message: { customType: string; content: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] };
64
64
  const renderers = new Map<string, MessageRenderer>();
65
65
 
66
- function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
66
+ const FAKE_SOURCE_INFO: SourceInfo = { path: "extensions/gentle-shell.ts", source: "gentle-shell", scope: "project", origin: "top-level" };
67
+
68
+ const DEFAULT_COMMANDS: SlashCommandInfo[] = [
69
+ { name: "gentle:models", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
70
+ { name: "gentle:changes", description: "Browse changes", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
71
+ { name: "gentle:status", description: "Show Gentle AI status", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
72
+ { name: "skill-registry:refresh", description: "Regenerate the skill registry", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
73
+ { name: "gentle:commands", description: "Open the command palette", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
74
+ { name: "gentle:not-in-catalog", description: "Not a curated command", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
75
+ { name: "skill:foo", description: "A skill", source: "skill", sourceInfo: FAKE_SOURCE_INFO },
76
+ ];
77
+
78
+ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commandsList: SlashCommandInfo[] = DEFAULT_COMMANDS) {
67
79
  const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
68
80
  const commands = new Map<string, CommandRegistration>();
69
81
  const shortcuts = new Map<string, ShortcutRegistration>();
70
82
  const git: string[][] = [];
71
83
  let entries: unknown[] = [];
84
+ const sentMessages: Array<{ content: string; options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean } }> = [];
72
85
  const tools = new Map<string, { renderShell?: string; execute(id: string, params: unknown, signal: undefined, update: undefined, ctx: ExtensionContext): Promise<unknown> }>();
73
86
  const listeners = new Map<string, (data: unknown) => void>();
74
87
  let round = 0;
@@ -94,6 +107,12 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
94
107
  getThinkingLevel() {
95
108
  return "medium";
96
109
  },
110
+ getCommands() {
111
+ return commandsList;
112
+ },
113
+ sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }) {
114
+ sentMessages.push({ content, options });
115
+ },
97
116
  async exec(_command: string, args: string[]) {
98
117
  git.push(args);
99
118
  if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
@@ -102,7 +121,7 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
102
121
  return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
103
122
  },
104
123
  } as unknown as ExtensionAPI;
105
- return { pi, handlers, git, commands, shortcuts, tools };
124
+ return { pi, handlers, git, commands, shortcuts, tools, sentMessages };
106
125
  }
107
126
 
108
127
  async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>, event: string, ctx: ExtensionContext): Promise<void> {
@@ -149,11 +168,15 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
149
168
  setWorkingVisible(visible: boolean) {
150
169
  ui.workingVisible = visible;
151
170
  },
152
- custom(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: null) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
171
+ // Generic over the result type: real callers resolve `custom` with
172
+ // whatever their `done` callback is given (see ExtensionUIContext.custom
173
+ // in pi-coding-agent), not always null. `closeOverlay` stays a
174
+ // null-resolving escape hatch for tests that only need to end the wait.
175
+ custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
153
176
  ui.overlay = factory;
154
- return new Promise<null>((resolve) => {
177
+ return new Promise<T | null>((resolve) => {
155
178
  ui.closeOverlay = () => resolve(null);
156
- ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, () => resolve(null));
179
+ ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, (value: T) => resolve(value));
157
180
  resolveOverlay();
158
181
  });
159
182
  },
@@ -382,15 +405,41 @@ test("gentleShell shows working while the agent runs and queued when messages wa
382
405
  const editor = installedPrompt(ctx, ui, handlers);
383
406
 
384
407
  for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
385
- assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] working ─+╮$/);
408
+ assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ working… ─+╮$/);
386
409
  pending.value = true;
387
410
  assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
388
411
  for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
412
+ assert.match(stripAnsi(editor.render(60)[0]), /queued/, "low-level run end is not settled");
389
413
  pending.value = false;
414
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
390
415
  assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ ─+╮$/);
391
416
  editor.dispose();
392
417
  });
393
418
 
419
+ test("prompt uses the compact banner cadence and releases its unref timer at settlement", (t) => {
420
+ const delays: number[] = [];
421
+ let active = 0;
422
+ let unrefs = 0;
423
+ t.mock.method(globalThis, "setInterval", (_callback: () => void, delay: number) => {
424
+ delays.push(delay);
425
+ active++;
426
+ return { unref() { unrefs++; } };
427
+ });
428
+ t.mock.method(globalThis, "clearInterval", () => { active--; });
429
+ const { pi, handlers } = fakePi();
430
+ gentleShell(pi, {});
431
+ const { ctx, ui } = fakeContext();
432
+ const editor = installedPrompt(ctx, ui, handlers);
433
+ assert.equal(active, 0);
434
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
435
+ assert.deepEqual(delays, [80]);
436
+ assert.equal(unrefs, 1);
437
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
438
+ assert.equal(active, 0);
439
+ editor.dispose();
440
+ assert.equal(active, 0);
441
+ });
442
+
394
443
  test("gentleShell leaves an editor another extension already installed", () => {
395
444
  const { pi, handlers } = fakePi();
396
445
  gentleShell(pi, {});
@@ -398,6 +447,26 @@ test("gentleShell leaves an editor another extension already installed", () => {
398
447
  const { ctx, ui } = fakeContext({ editorFactory: theirs });
399
448
  for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
400
449
  assert.equal(ui.editorFactory, theirs);
450
+ assert.notEqual(ui.workingVisible, false, "a custom owner still needs native working feedback");
451
+ });
452
+
453
+ test("Gentle replaces its retained factory on reload so new lifecycle handlers own the prompt", () => {
454
+ const first = fakePi();
455
+ gentleShell(first.pi, {});
456
+ const { ctx, ui } = fakeContext();
457
+ const oldEditor = installedPrompt(ctx, ui, first.handlers);
458
+ const previousFactory = ui.editorFactory;
459
+ const next = fakePi();
460
+ gentleShell(next.pi, {});
461
+ const editor = installedPrompt(ctx, ui, next.handlers);
462
+ try {
463
+ assert.notEqual(ui.editorFactory, previousFactory);
464
+ for (const handler of next.handlers.get("agent_start") ?? []) handler({}, ctx);
465
+ assert.match(stripAnsi(editor.render(60)[0]), /working/);
466
+ } finally {
467
+ oldEditor.dispose();
468
+ editor.dispose();
469
+ }
401
470
  });
402
471
 
403
472
  const footerData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
@@ -428,6 +497,13 @@ test("captured changes update the widget and bar without repository scans", asyn
428
497
  const factory=ui.widgets.get("gentle-shell-changes") as any;
429
498
  assert.match(factory(fakeTui,plainTheme).render(140)[0],/1 file · \+2 −0/);
430
499
  assert.match(renderFooter(ui),/main ±1/);
500
+ const rail = sidebarState(fakeTui as unknown as TUI).parts.get("footer")!;
501
+ const digest = rail.digest!();
502
+ assert.match(rail.render(46).join("\n"), /1 file · \+2 −0/);
503
+ sessionChange(ctx,"b","/repo","lib/b.ts","one\ntwo\n","one\ntwo\nthree\n");
504
+ await fire(handlers,"agent_end",ctx);
505
+ assert.notEqual(rail.digest!(), digest, "same-count line changes invalidate the unified Status");
506
+ assert.match(rail.render(46).join("\n"), /1 file · \+3 −0/);
431
507
  assert.equal(git.length,0);
432
508
  await fire(handlers,"session_shutdown",ctx);
433
509
  });
@@ -619,7 +695,7 @@ test("gentleShell binds the changes shortcut to the same handler as the command"
619
695
 
620
696
  const silent = fakePi();
621
697
  gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
622
- assert.equal(silent.shortcuts.size, 0);
698
+ assert.equal(silent.shortcuts.has("alt+g"), false, "the changes shortcut must not register when disabled");
623
699
  });
624
700
 
625
701
  test("external edits do not pollute Changes or trigger background Git scans", async () => {
@@ -756,3 +832,120 @@ test("gentleShell keeps a dev-binary override visible above the editor for the w
756
832
 
757
833
  assert.equal(devBinaryCard({ state: "invalid", reason: "binary missing" }).tone, "error");
758
834
  });
835
+
836
+ test("gentle:commands registers alt+k by default", () => {
837
+ const { pi, shortcuts } = fakePi();
838
+ gentleShell(pi, {});
839
+ assert.ok(shortcuts.has("alt+k"));
840
+ });
841
+
842
+ test("gentle:commands honors GENTLE_PI_COMMANDS_KEY", () => {
843
+ const { pi, shortcuts } = fakePi();
844
+ gentleShell(pi, { GENTLE_PI_COMMANDS_KEY: "ctrl+p" });
845
+ assert.ok(shortcuts.has("ctrl+p"));
846
+ assert.equal(shortcuts.has("alt+k"), false);
847
+ });
848
+
849
+ test("GENTLE_PI_COMMANDS_KEY=off registers no command-palette shortcut", () => {
850
+ const { pi, shortcuts } = fakePi();
851
+ gentleShell(pi, { GENTLE_PI_COMMANDS_KEY: "off" });
852
+ assert.equal(shortcuts.has("alt+k"), false);
853
+ assert.ok(shortcuts.has("alt+g"), "the unrelated changes shortcut still registers");
854
+ });
855
+
856
+ test("/gentle:commands shows only curated, registered commands, grouped, by their labels", async () => {
857
+ const { pi, commands } = fakePi();
858
+ gentleShell(pi, {});
859
+ const { ctx, ui, overlayReady } = fakeContext();
860
+ const opened = commands.get("gentle:commands")!.handler("", ctx);
861
+ await overlayReady;
862
+ const lines = ui.overlayView!.render(100);
863
+ const rendered = lines.join("\n");
864
+ assert.match(rendered, /Configuration/);
865
+ assert.match(rendered, /Session/);
866
+ assert.match(rendered, /Diagnostics/);
867
+ assert.match(rendered, /Skills/);
868
+ assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
869
+ assert.match(rendered, /Assign models and effort/);
870
+ assert.match(rendered, /Browse captured changes/);
871
+ assert.match(rendered, /Gentle AI status/);
872
+ assert.match(rendered, /Refresh skill registry/);
873
+ assert.doesNotMatch(rendered, /gentle:models|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
874
+ assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
875
+ assert.doesNotMatch(rendered, /skill:foo/);
876
+ const changesLine = lines.find((line) => line.includes("Browse captured changes"));
877
+ assert.match(changesLine ?? "", /alt\+g/, "expected the configured alt+g shortcut hint next to Browse captured changes");
878
+ ui.overlayView!.handleInput("\x1b");
879
+ await opened;
880
+ });
881
+
882
+ test("selecting a command from the palette by its label sends the underlying command as a slash message", async () => {
883
+ const { pi, commands, sentMessages } = fakePi();
884
+ gentleShell(pi, {});
885
+ const { ctx, ui, overlayReady } = fakeContext();
886
+ const opened = commands.get("gentle:commands")!.handler("", ctx);
887
+ await overlayReady;
888
+ // "mod" only matches the "Assign models and effort" label (it contains
889
+ // "mod" via "models"); nothing else in the fixture does.
890
+ for (const ch of "mod") ui.overlayView!.handleInput(ch);
891
+ assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
892
+ ui.overlayView!.handleInput("\r");
893
+ await opened;
894
+ assert.deepEqual(sentMessages, [{ content: "/gentle:models", options: { expandPromptTemplates: true } }]);
895
+ });
896
+
897
+ test("escaping the palette sends no message", async () => {
898
+ const { pi, commands, sentMessages } = fakePi();
899
+ gentleShell(pi, {});
900
+ const { ctx, ui, overlayReady } = fakeContext();
901
+ const opened = commands.get("gentle:commands")!.handler("", ctx);
902
+ await overlayReady;
903
+ ui.overlayView!.handleInput("\x1b");
904
+ await opened;
905
+ assert.deepEqual(sentMessages, []);
906
+ });
907
+
908
+ test("/gentle:commands notifies when nothing in the catalog is registered", async () => {
909
+ const { pi, commands } = fakePi(undefined, [
910
+ { name: "skill:foo", description: "A skill", source: "skill", sourceInfo: FAKE_SOURCE_INFO },
911
+ { name: "gentle:not-in-catalog", description: "Not curated", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
912
+ ]);
913
+ gentleShell(pi, {});
914
+ const { ctx, ui } = fakeContext();
915
+ await commands.get("gentle:commands")!.handler("", ctx);
916
+ assert.match(ui.notices.join("\n"), /No Gentle commands are registered\./);
917
+ assert.equal(ui.overlay, undefined);
918
+ });
919
+
920
+ test("/gentle:commands does nothing in a headless context", async () => {
921
+ const { pi, commands, sentMessages } = fakePi();
922
+ gentleShell(pi, {});
923
+ const { ctx, ui } = fakeContext({ hasUI: false });
924
+ await commands.get("gentle:commands")!.handler("", ctx);
925
+ assert.equal(ui.overlay, undefined);
926
+ assert.deepEqual(sentMessages, []);
927
+ });
928
+
929
+ test("the alt+k shortcut opens the same command palette as the command", async () => {
930
+ const { pi, shortcuts } = fakePi();
931
+ gentleShell(pi, {});
932
+ const { ctx, ui, overlayReady } = fakeContext();
933
+ const shortcut = shortcuts.get("alt+k");
934
+ assert.ok(shortcut, "alt+k not registered");
935
+ const opened = shortcut.handler(ctx);
936
+ await overlayReady;
937
+ assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
938
+ ui.overlayView!.handleInput("\x1b");
939
+ await opened;
940
+ });
941
+
942
+ test("resolving the overlay through closeOverlay sends nothing and does not throw", async () => {
943
+ const { pi, commands, sentMessages } = fakePi();
944
+ gentleShell(pi, {});
945
+ const { ctx, ui, overlayReady } = fakeContext();
946
+ const opened = commands.get("gentle:commands")!.handler("", ctx);
947
+ await overlayReady;
948
+ ui.closeOverlay?.();
949
+ await opened;
950
+ assert.deepEqual(sentMessages, []);
951
+ });
@@ -85,6 +85,19 @@ test("native choice list presents one hovered wrapped option without changing se
85
85
  assert.doesNotMatch(list.render(56).join("\n"), new RegExp(hoverBackground.replace(/[\[\]]/g, "\\$&")));
86
86
  });
87
87
 
88
+ test("native choice list refreshes existing item content without changing selection", () => {
89
+ const items = [
90
+ { id: "first", label: "First", description: "Initial detail." },
91
+ { id: "second", label: "Second", description: "Second detail." },
92
+ ];
93
+ const list = new NativeChoiceList(items, theme);
94
+ list.setSelectedIndex(1);
95
+ items[1]!.description = "Updated detail.";
96
+ list.refreshItems();
97
+ assert.equal(list.getSelectedItem()?.id, "second");
98
+ assert.match(stripTerminalSequences(list.render(40).join("\n")), /Updated detail\./);
99
+ });
100
+
88
101
  test("native choice list ignores Kitty key releases", () => {
89
102
  const list = new NativeChoiceList(
90
103
  [{ id: "first", label: "First" }, { id: "second", label: "Second" }],
@@ -246,11 +246,40 @@ test("2.9.1 repeats 2.9.0 because the negotiated lane Pi consumes is unchanged",
246
246
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.9.0"] as Record<string, boolean>);
247
247
  });
248
248
 
249
+ test("3.0.0 repeats 2.9.1 because the negotiated lane Pi consumes is unchanged", () => {
250
+ // v3.0.0 shipped ODD as the orchestrator's mandatory default protocol and
251
+ // integrated the simplified SDD workflow into it (gentle-ai #4642, #4644),
252
+ // with the provider contract byte-frozen at 1.2.0. Diffing
253
+ // contracts/review-integration/v2 and contracts/review-provider-contract
254
+ // between the v2.9.1 and v3.0.0 tags in the gentle-ai source tree showed
255
+ // zero byte changes, so this row repeats 2.9.1. riskEvidence and hint
256
+ // remain dark because neither is proven to reach Pi's negotiated START
257
+ // path.
258
+ const contract = NATIVE_CLI_CONTRACTS["3.0.0"] as Record<string, boolean>;
259
+ assert.equal(contract.riskEvidence, false);
260
+ assert.equal(contract.hint, false);
261
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.9.1"] as Record<string, boolean>);
262
+ });
263
+
264
+ test("3.0.1 repeats 3.0.0 because the negotiated lane Pi consumes is unchanged", () => {
265
+ // v3.0.1 moved the Go module path to
266
+ // github.com/gentleman-programming/gentle-ai/v3 with no contract change
267
+ // (gentle-ai #4683). Diffing contracts/review-integration/v2 and
268
+ // contracts/review-provider-contract between the v3.0.0 and v3.0.1 tags
269
+ // in the gentle-ai source tree showed zero byte changes, so this row
270
+ // repeats 3.0.0. riskEvidence and hint remain dark because neither is
271
+ // proven to reach Pi's negotiated START path.
272
+ const contract = NATIVE_CLI_CONTRACTS["3.0.1"] as Record<string, boolean>;
273
+ assert.equal(contract.riskEvidence, false);
274
+ assert.equal(contract.hint, false);
275
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.0.0"] as Record<string, boolean>);
276
+ });
277
+
249
278
  test("no shipped version key was added beyond the pin bump", () => {
250
279
  // Rows are promises to consumers, so a new key only ever appears in a
251
280
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
252
281
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
253
282
  // so they get no row: a row asserts ground truth measured against a binary
254
283
  // Pi actually ran, and the table only has to be ascending, not gapless.
255
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1"]);
284
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1"]);
256
285
  });
@@ -1023,36 +1023,3 @@ test("all twelve native actions retain their exact tokens without prose routing"
1023
1023
  assert.equal(decodeNativeSddStatusV2(status, { workspaceRoot: "/repo" }).nextRecommended, nextRecommended);
1024
1024
  }
1025
1025
  });
1026
-
1027
-
1028
- test("compact SDD bracket transports exact selection and remediation binding", async () => {
1029
- const revision = `sha256:${"a".repeat(64)}`;
1030
- const queued = queuedAdapter([{ stdout: JSON.stringify({ state: "proceed", token: revision }) }, { stdout: '{"state":"complete"}' }]);
1031
- const cli = client(queued.adapter);
1032
- const request = { workspaceRoot: "/repo", changeName: "fix", requestId: "acquire-one", workUnit: "correction", evidenceGoal: "Observed correction", maxAttempts: 1, maxChangedLines: 300, remediatesEvidenceRevision: revision };
1033
- assert.deepEqual(await cli.sddAttemptAcquire(request), { state: "proceed", token: revision });
1034
- assert.deepEqual(queued.calls[0].arguments, ["sdd-attempt", "acquire", "--cwd", "/repo", "--change", "fix", "--request-id", "acquire-one", "--work-unit", "correction", "--evidence-goal", "Observed correction", "--max-attempts", "1", "--max-changed-lines", "300", "--remediates-evidence-revision", revision]);
1035
- assert.deepEqual(await cli.sddAttemptSettle({ workspaceRoot: "/repo", changeName: "fix", token: revision, requestId: "settle-one", outcome: "interrupted", diagnosis: "Child could not spawn", harnessDisposition: "invalidated", cleanupEvidence: "No process created", processEvidence: "Spawn rejected", remediatesEvidenceRevision: revision }), { state: "complete" });
1036
- assert.equal(queued.calls[1].arguments.includes("--evidence-revision"), false);
1037
- assert.equal(queued.calls[1].timeoutMs, undefined);
1038
- });
1039
-
1040
- test("compact SDD refuses malformed result and invalid terminal evidence before execution", async () => {
1041
- const queued = queuedAdapter([{ stdout: '{"state":"proceed"}' }, { stdout: '{"state":"blocked","reason":"budget"}' }]);
1042
- const cli = client(queued.adapter);
1043
- const request = { workspaceRoot: "/repo", changeName: "fix", requestId: "one", workUnit: "correction", evidenceGoal: "Observed correction" };
1044
- await assert.rejects(cli.sddAttemptAcquire(request), /schema incompatible/);
1045
- assert.deepEqual(await cli.sddAttemptAcquire(request), { state: "blocked", reason: "budget" });
1046
- await assert.rejects(cli.sddAttemptSettle({ ...request, token: `sha256:${"a".repeat(64)}`, outcome: "failed", diagnosis: "Test failed", harnessDisposition: "reused", cleanupEvidence: "Process exited", processEvidence: "Exit one" }), /evidence/i);
1047
- assert.equal(queued.calls.length, 2);
1048
- });
1049
-
1050
-
1051
- test("compact admission accepts native empty CAS and never redirects uncertainty into review", async () => {
1052
- const queued = queuedAdapter([{ stdout: '{"state":"blocked","reason":"budget"}' }, { stdout: "", timedOut: true }]);
1053
- const cli = client(queued.adapter);
1054
- const request = { workspaceRoot: "/repo", changeName: "fix", requestId: "one", workUnit: "fix", evidenceGoal: "Observed correction", expectedRevision: "" };
1055
- await cli.sddAttemptAcquire(request);
1056
- assert.equal(queued.calls[0].arguments[queued.calls[0].arguments.indexOf("--expected-revision") + 1], "");
1057
- await assert.rejects(cli.sddAttemptAcquire({ ...request, expectedRevision: undefined }), error => error instanceof NativeReviewCliError && error.mutationOutcome === "unknown" && error.nextAction === undefined);
1058
- });
@@ -0,0 +1,208 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ import { __testing } from "../extensions/gentle-ai.ts";
6
+
7
+ // These are instruction-delivery contracts, not proof of autonomous model adherence.
8
+ const read = (path: string) => readFileSync(join(import.meta.dirname, "..", path), "utf8");
9
+ const core = read("assets/orchestrator.md");
10
+ const delegation = read("assets/orchestrator-delegation.md");
11
+ const memory = read("assets/orchestrator-memory.md");
12
+ const wrapper = read("extensions/gentle-ai.ts");
13
+
14
+ function containsAll(text: string, clauses: readonly string[]): void {
15
+ for (const clause of clauses) assert.ok(text.includes(clause), `missing contract: ${clause}`);
16
+ }
17
+
18
+ test("organic entry stays read-only without authorization and loads detail before work", () => {
19
+ containsAll(core, [
20
+ "Substantial authorized work: use ODD",
21
+ "ODD (Default Workflow, harness section above) is mandatory on every request",
22
+ "orchestrator-delegation.md",
23
+ "orchestrator-memory.md",
24
+ ]);
25
+ containsAll(delegation, [
26
+ "Investigation, explanation, review, comparison, and proposal-only requests remain read-only",
27
+ "without a task or storage permission prompt",
28
+ "Small, understood work creates no durable task artifacts",
29
+ ]);
30
+ assert.doesNotMatch(core + wrapper, /Prefer SDD\/OpenSpec artifacts|Substantial feature: suggest SDD organically/);
31
+ assert.doesNotMatch(core + delegation, /Suggest it when proposal\/spec\/design\/tasks|propose SDD only when durable proposal\/spec\/design\/tasks/);
32
+ });
33
+
34
+ test("research uses adaptive evidence gathering and existing general workers only", () => {
35
+ containsAll(delegation, [
36
+ "problem, intended outcome, constraints, and current evidence",
37
+ "no fixed questionnaire or mandatory rounds",
38
+ "one focused user question",
39
+ "stop and wait",
40
+ "workers return gaps to the parent",
41
+ "available authorized documentation/web tools",
42
+ "prefer primary sources",
43
+ "URLs or code locations",
44
+ "verified facts, assumptions, contradictions, freshness, and gaps",
45
+ "recommendation, tradeoffs, open questions, and implementation implications",
46
+ "Forward these research instructions",
47
+ "existing fresh general exploration/research worker",
48
+ "do not create a specialized agent or invoke `sdd-research`",
49
+ "no new persistence or readiness machinery",
50
+ ]);
51
+ });
52
+
53
+ test("task sizing is explicitly advisory and forwarded without cosmetic savings", () => {
54
+ containsAll(delegation, [
55
+ "about 400 authored changed lines",
56
+ "additions plus deletions",
57
+ "not a task acceptance criterion, hard cap, counter-trigger, automatic stop, forced split, or RDD trigger",
58
+ "Forward this same advisory-only instruction",
59
+ "Never delete spaces, blank lines, or comments",
60
+ "never omit tests, minify, add gratuitous abstractions, or split artificially",
61
+ "Existing PR size gates remain unchanged",
62
+ ]);
63
+ });
64
+
65
+ test("organic progress preserves both complete feature copies and reconciles actual evidence", () => {
66
+ containsAll(memory, [
67
+ "odd/tasks/<feature-name>.md",
68
+ "odd/<feature-name>/tasks",
69
+ "stable task IDs",
70
+ "full current document",
71
+ "repository-relative file locator",
72
+ "preserve valid completed and unrelated work",
73
+ "reopen invalidated items",
74
+ "Check off only observed outcomes",
75
+ "Read back both writes",
76
+ "not atomic",
77
+ "mirror pending",
78
+ "Preserve both versions",
79
+ "mem_context",
80
+ "mem_search",
81
+ "mem_get_observation",
82
+ "read the actual task file",
83
+ "not a third authority",
84
+ ]);
85
+ });
86
+
87
+ test("assumption challenge and task checks do not activate or duplicate native review", () => {
88
+ containsAll(delegation, [
89
+ "at most one scoped independent read-only assumption challenge",
90
+ "high-consequence unproven premise",
91
+ "Deterministic failures need fixes",
92
+ "native RDD refuter",
93
+ "functional checks per task, not an RDD cycle per TODO",
94
+ "deliverable candidate boundary",
95
+ "native candidate risk assessment",
96
+ "gentle_review` with `{\"operation\":\"assess\"}",
97
+ "Passive/low",
98
+ "no reviewer or consent ceremony",
99
+ "only on grant",
100
+ "decline continues under ordinary policy",
101
+ "never infer low risk from a failed assessment",
102
+ "When RDD is disabled, do not start or prompt for RDD",
103
+ ]);
104
+ });
105
+
106
+ test("user documentation shows recovery and candidate-level consent without claiming model proof", () => {
107
+ const docs = read("docs/readme-reference.md");
108
+ containsAll(docs, [
109
+ "## Organic Driven Development",
110
+ "```mermaid",
111
+ "Full feature memory and actual task file",
112
+ "Native candidate risk",
113
+ "advisory",
114
+ "Static prompt tests",
115
+ "autonomous",
116
+ ]);
117
+ assert.ok(read("README.md").includes("#organic-driven-development"));
118
+ });
119
+
120
+
121
+ test("one feature document carries intent, accepted rationale and worker context", () => {
122
+ containsAll(memory, [
123
+ "one feature document, not a separate plan file or topic",
124
+ "objective, problem, why, scope, constraints",
125
+ "progress, verification evidence, and next step",
126
+ "concise rationale for meaningful accepted changes",
127
+ "Routine corrections stay with their tasks; no exhaustive decision journal",
128
+ "Accepted user, review, or verification changes",
129
+ "automatically update affected intent and TODOs",
130
+ "add genuinely new tasks or reopen invalidated items with a reason",
131
+ "Findings alone never authorize scope expansion or automatic acceptance",
132
+ "Before implementation or resume, the parent reads both the actual file and full observation",
133
+ "passes the locator and relevant context; workers read the document before edits",
134
+ ]);
135
+ containsAll(read("assets/agents/gentle-ai-worker.md"), [
136
+ "Read the parent's ODD feature document locator before edits",
137
+ "Preserve valid completed work; return proposed intent/task changes and their reasons",
138
+ ]);
139
+ });
140
+
141
+ test("ODD forwards configured TDD without equating test presence with enablement", () => {
142
+ containsAll(delegation, [
143
+ "Resolve effective TDD on/off from existing project/session configuration or explicit user choice",
144
+ "retain its source and exact test runner",
145
+ "Record resolved mode, source, and runner in the feature document when present",
146
+ "Tests or frameworks being present does not enable TDD",
147
+ "Forward mode, source, and runner on every implementation delegation; refresh on resume",
148
+ "When enabled, require observed RED before implementation, GREEN, then REFACTOR",
149
+ "When disabled, run ordinary functional checks, not no checks",
150
+ "If mode is unknown/conflicting or the runner is missing",
151
+ "resolve only the ambiguity affecting the next action",
152
+ "never invent precedence or a command, and never invoke sdd-init to determine ODD TDD",
153
+ ]);
154
+ containsAll(read("assets/agents/gentle-ai-worker.md"), [
155
+ "Consume the parent's effective TDD mode, configuration/choice source, and exact runner",
156
+ "Missing or conflicting mode/source/runner is not disabled TDD",
157
+ ]);
158
+ assert.doesNotMatch(wrapper, /If tests exist, use strict TDD/);
159
+ });
160
+
161
+ test("ODD protocol is always-on in the rendered system prompt and runs by default", () => {
162
+ const orderedClauses = [
163
+ "Default workflow: Organic Driven Development (MANDATORY)",
164
+ "predefined workflow of this orchestrator",
165
+ "SDD is a branch inside ODD",
166
+ "Never describe this workflow only when asked about it: run it.",
167
+ "1. **Authorize.**",
168
+ "2. **Explore.**",
169
+ "3. **Resolve uncertainty.**",
170
+ "4. **Classify.**",
171
+ "two or more meaningful implementation steps",
172
+ "5. **Track before the first write.**",
173
+ "Tell the user in one line which feature document was created and how many tasks it holds",
174
+ "6. **Implement task by task.**",
175
+ "7. **Close.**",
176
+ "Harness principles:",
177
+ "# el Gentleman Orchestrator",
178
+ ];
179
+ for (const persona of ["gentleman", "neutral"] as const) {
180
+ const prompt = __testing.buildGentlePrompt(persona);
181
+ let cursor = -1;
182
+ for (const clause of orderedClauses) {
183
+ const index = prompt.indexOf(clause);
184
+ assert.ok(index !== -1, `[${persona}] missing contract: ${clause}`);
185
+ assert.ok(
186
+ index > cursor,
187
+ `[${persona}] clause out of order (must appear after the previous one): ${clause}`,
188
+ );
189
+ cursor = index;
190
+ }
191
+ }
192
+
193
+ assert.ok(
194
+ wrapper.includes("Organic Driven Development (ODD) is the predefined workflow for every request"),
195
+ "missing contract: extensions/gentle-ai.ts harness principle",
196
+ );
197
+ assert.ok(
198
+ wrapper.includes(
199
+ "I run Organic Driven Development by default and SDD/OpenSpec when explicitly selected",
200
+ ),
201
+ "missing contract: extensions/gentle-ai.ts identity sentence",
202
+ );
203
+ assert.ok(
204
+ core.includes("ODD (Default Workflow, harness section above) is mandatory on every request"),
205
+ "missing contract: assets/orchestrator.md pointer sentence",
206
+ );
207
+ containsAll(core, ["orchestrator-delegation.md", "orchestrator-memory.md"]);
208
+ });
@@ -281,8 +281,8 @@ function isNormativeLine(line: string): boolean {
281
281
  }
282
282
 
283
283
  const fixtureLines = readFileSync(FIXTURE_PATH, "utf8").split("\n");
284
- // Fixture lines 187 and 191 predate the root-relative lazy-asset contract and
285
- // canonical-authority resolution. Keep their coverage by asserting the
284
+ // Fixture line 36 is superseded by ODD (#1035); 187 and 191 predate
285
+ // root-relative lazy assets and canonical-authority resolution. Keep coverage by asserting the
286
286
  // intentionally updated production wording instead of weakening the range.
287
287
  const CURRENT_SDD_WORKFLOW_PATH = "`sdd-orchestrator-workflow.md`";
288
288
  const CURRENT_HARD_PREFLIGHT_INVARIANT = "Hard preflight invariant: `openspec/config.yaml`, existing SDD changes, installed `.pi`/global SDD assets, or a todo named \"preflight\" are not session preflight. Do not mark SDD preflight complete, start `sdd-init`, launch SDD subagents/chains, or move to explore/proposal/spec/design/tasks until this session has an injected `## SDD Session Preflight` block or a canonical-authority resolution. Defaults and capability constraints may resolve fields without confirmation prompts; preserve unresolved-choice and safety gates.";
@@ -320,11 +320,26 @@ for (const range of DISPOSITION_MAP) {
320
320
  if (raw === undefined || !isNormativeLine(raw)) continue;
321
321
  const trimmed = raw.trim();
322
322
  const expected =
323
+ ln === 36 ? "- Substantial authorized work: use ODD; track feature progress automatically." :
324
+ // #1051 keeps the selected store when memory is unavailable.
325
+ ln === 221 ? "do not switch the selected store" :
326
+ // Research returns findings; other phases keep direct backend ownership.
327
+ ln === 205 ? trimmed.replace("Each SDD phase", "Except for output-only `sdd-research`, each SDD phase") :
328
+ ln === 185 ? trimmed.replace("apply/verify/sync/archive", "apply/verify/archive") :
323
329
  ln === 187
324
330
  ? CURRENT_SDD_WORKFLOW_PATH
325
331
  : ln === 191
326
332
  ? CURRENT_HARD_PREFLIGHT_INVARIANT
327
333
  : trimmed;
334
+ // #1051 retires the standalone sync row and its artifact key, not
335
+ // the surrounding memory/recovery contract or historical fixture.
336
+ if (ln === 216 || ln === 220) {
337
+ assert.ok(!targetContent.includes(trimmed), `retired sync contract remains at fixture:${ln}`);
338
+ assert.match(targetContent, /sdd\/<change>\/archive-report/);
339
+ assert.match(targetContent, /sdd\/<change>\/verify-report/);
340
+ assert.doesNotMatch(targetContent, /sdd\/<change>\/sync-report|\| `sdd-sync`/);
341
+ continue;
342
+ }
328
343
  if (SUPERSEDED_LIFECYCLE_REVIEW_LINES.has(ln)) {
329
344
  assert.ok(
330
345
  !targetContent.includes(trimmed),