gentle-pi 2.4.0 → 2.5.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 (116) hide show
  1. package/README.md +170 -12
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/orchestrator-delegation.md +19 -9
  4. package/assets/orchestrator.md +5 -5
  5. package/docs/delegated-verification.md +25 -0
  6. package/docs/telemetry.md +38 -0
  7. package/extensions/ask-user-choice.ts +26 -20
  8. package/extensions/codegraph-tools.ts +94 -5
  9. package/extensions/gentle-agents.ts +588 -0
  10. package/extensions/gentle-ai.ts +898 -79
  11. package/extensions/gentle-shell.ts +547 -0
  12. package/extensions/gentle-todo.ts +199 -0
  13. package/extensions/quiet-tools.ts +1 -1
  14. package/lib/agents-config.ts +318 -0
  15. package/lib/agents-history.ts +80 -0
  16. package/lib/agents-protocol.ts +429 -0
  17. package/lib/agents-runner.ts +490 -0
  18. package/lib/agents-transcript.ts +87 -0
  19. package/lib/agents-view.ts +557 -0
  20. package/lib/agents-widget.ts +222 -0
  21. package/lib/gentle-ai-renderer.ts +142 -26
  22. package/lib/native-choice-list.ts +194 -0
  23. package/lib/native-fullscreen-interaction.ts +47 -0
  24. package/lib/native-pointer-region.ts +164 -0
  25. package/lib/native-review-cli.ts +88 -12
  26. package/lib/review-candidate-view-owner.ts +177 -0
  27. package/lib/review-candidate-view.ts +127 -35
  28. package/lib/review-consent-ui.ts +65 -0
  29. package/lib/review-integration-v2.ts +58 -8
  30. package/lib/review-last-event-controller.ts +1 -0
  31. package/lib/review-relay-contract.ts +11 -0
  32. package/lib/review-repository.ts +2 -2
  33. package/lib/review-risk-assessment.ts +339 -0
  34. package/lib/review-session-standing-permission-ipc.ts +309 -0
  35. package/lib/review-session-standing-permission.ts +219 -0
  36. package/lib/shell-bar.ts +138 -0
  37. package/lib/shell-card.ts +136 -0
  38. package/lib/shell-changes-view.ts +205 -0
  39. package/lib/shell-changes.ts +210 -0
  40. package/lib/shell-gauge.ts +40 -0
  41. package/lib/shell-prompt.ts +119 -0
  42. package/lib/shell-todo.ts +280 -0
  43. package/lib/shell-usage-view.ts +76 -0
  44. package/lib/shell-usage.ts +246 -0
  45. package/lib/telemetry-trigger.ts +151 -0
  46. package/package.json +4 -4
  47. package/runtime/native-review-cli.mjs +87 -11
  48. package/runtime/review-integration-v2.mjs +58 -8
  49. package/runtime/review-relay-contract.mjs +11 -0
  50. package/runtime/review-risk-assessment.mjs +340 -0
  51. package/runtime/telemetry-trigger.mjs +152 -0
  52. package/scripts/build-runtime-modules.mjs +2 -0
  53. package/scripts/gentle-ai-installer.mjs +10 -10
  54. package/scripts/test-packed-runner.mjs +22 -0
  55. package/scripts/verify-package-files.mjs +6 -2
  56. package/skills/_shared/review-ledger-contract.md +3 -1
  57. package/tests/agents-config.test.ts +143 -0
  58. package/tests/agents-fake-child.ts +52 -0
  59. package/tests/agents-history.test.ts +54 -0
  60. package/tests/agents-protocol.test.ts +153 -0
  61. package/tests/agents-runner-process.test.ts +111 -0
  62. package/tests/agents-runner.test.ts +402 -0
  63. package/tests/agents-transcript.test.ts +30 -0
  64. package/tests/agents-view.test.ts +274 -0
  65. package/tests/agents-widget.test.ts +111 -0
  66. package/tests/ask-user-choice.test.ts +157 -3
  67. package/tests/codegraph-tools.test.ts +110 -1
  68. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  69. package/tests/fixtures/agents-process-child.mjs +23 -0
  70. package/tests/gentle-agents.test.ts +741 -0
  71. package/tests/gentle-ai-binary.test.ts +1 -1
  72. package/tests/gentle-ai-installer.test.ts +47 -47
  73. package/tests/gentle-ai-renderer.test.ts +65 -0
  74. package/tests/gentle-ai.test.ts +28 -12
  75. package/tests/gentle-card-text.ts +35 -0
  76. package/tests/gentle-shell.test.ts +527 -0
  77. package/tests/gentle-todo.test.ts +182 -0
  78. package/tests/native-choice-list.test.ts +202 -0
  79. package/tests/native-fullscreen-interaction.test.ts +125 -0
  80. package/tests/native-pointer-region.test.ts +245 -0
  81. package/tests/native-review-capability-contract.test.ts +16 -1
  82. package/tests/native-review-cli.test.ts +40 -0
  83. package/tests/native-review-consent.test.ts +91 -0
  84. package/tests/native-review-parity-runtime.test.ts +8 -2
  85. package/tests/native-review-parity.test.ts +29 -22
  86. package/tests/orchestrator-budget.test.ts +69 -0
  87. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  88. package/tests/package-manifest.test.ts +17 -6
  89. package/tests/quiet-tool-rendering.test.ts +96 -37
  90. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  91. package/tests/rdd-status-line.test.ts +286 -0
  92. package/tests/review-candidate-view.test.ts +452 -6
  93. package/tests/review-contract-prompt.test.ts +3 -0
  94. package/tests/review-controller-native-recovery.test.ts +29 -4
  95. package/tests/review-controller-native-routing.test.ts +321 -4
  96. package/tests/review-controller-workspace-root.test.ts +45 -2
  97. package/tests/review-controller.test.ts +25 -0
  98. package/tests/review-host-relay-routing.test.ts +20 -4
  99. package/tests/review-integration-v2.test.ts +112 -0
  100. package/tests/review-last-event-closure.test.ts +7 -2
  101. package/tests/review-relay-contract.test.ts +26 -0
  102. package/tests/review-repository.test.ts +28 -1
  103. package/tests/review-risk-assessment.test.ts +626 -0
  104. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  105. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  106. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  107. package/tests/review-session-standing-permission.test.ts +126 -0
  108. package/tests/shell-bar.test.ts +176 -0
  109. package/tests/shell-card.test.ts +118 -0
  110. package/tests/shell-changes-view.test.ts +146 -0
  111. package/tests/shell-changes.test.ts +182 -0
  112. package/tests/shell-prompt.test.ts +118 -0
  113. package/tests/shell-todo.test.ts +170 -0
  114. package/tests/shell-usage-view.test.ts +62 -0
  115. package/tests/shell-usage.test.ts +197 -0
  116. package/tests/telemetry-trigger.test.ts +349 -0
@@ -60,6 +60,15 @@ test("rendered parent prompt keeps the RDD boundary while omitting lifecycle mir
60
60
  for (const marker of ["Authority-First Terminal Procedure", "reconcile-terminal-mirrors", "next_transition"]) {
61
61
  assert.ok(!rendered.includes(marker), `rendered parent prompt leaked: ${marker}`);
62
62
  }
63
+ // gentle-pi#661: getOrchestratorPrompt()'s no-argument default renders the
64
+ // "unknown (native status unavailable)" RDD status line -- the longest of
65
+ // the three renderable forms -- so this IS the worst-case render the 8 KiB
66
+ // budget below must cover, not a smaller placeholder production later
67
+ // exceeds.
68
+ assert.ok(
69
+ rendered.includes("Receipt-driven development: unknown (native status unavailable)"),
70
+ "the default render must include the worst-case RDD status line",
71
+ );
63
72
  assert.ok(Buffer.byteLength(rendered, "utf8") <= 8192, "the rendered parent prompt must stay below the reduced 8 KiB budget");
64
73
  });
65
74
 
@@ -80,6 +80,8 @@ interface PackageJson {
80
80
  files?: string[];
81
81
  scripts?: Record<string, string>;
82
82
  dependencies?: Record<string, string>;
83
+ peerDependencies?: Record<string, string>;
84
+ devDependencies?: Record<string, string>;
83
85
  bundledDependencies?: string[];
84
86
  bundleDependencies?: string[];
85
87
  repository?: {
@@ -99,6 +101,15 @@ function readPackageJson(): PackageJson {
99
101
  }
100
102
  }
101
103
 
104
+ test("package declares the tested Pi minimum required for agent_settled", () => {
105
+ const manifest = readPackageJson();
106
+ assert.equal(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"], ">=0.85.1");
107
+ assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], "0.85.1");
108
+ const readme = readFileSync(join(PACKAGE_ROOT, "README.md"), "utf8");
109
+ assert.match(readme, /Pi 0\.85\.1 or newer/);
110
+ assert.match(readme, /agent_settled/);
111
+ });
112
+
102
113
  test("package manifest has no obsolete native activation build surface", () => {
103
114
  const packageJson = readPackageJson();
104
115
  const manifest = JSON.stringify(packageJson);
@@ -262,20 +273,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
262
273
  );
263
274
  });
264
275
 
265
- test("package verification binds the published Gentle AI v2.6.0 runtime pin", () => {
276
+ test("package verification binds the published Gentle AI v2.7.0 runtime pin", () => {
266
277
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
267
278
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
268
279
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
269
280
 
270
- assert.match(installer, /INSTALLER_VERSION = "2\.6\.0"/);
281
+ assert.match(installer, /INSTALLER_VERSION = "2\.7\.0"/);
271
282
  assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
272
- assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:scGoZYnPHh4oCVqlcfQAOSpm22Ii5lmB0kBbiBKvNZk="/);
283
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:SE4KCLo3y1qWaTq\+7HJKZ1DkAAAl\+w\+82YY0\+qQkFwM="/);
273
284
  assert.match(installer, /GOTOOLCHAIN: "local"/);
274
285
  assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
275
286
  assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
276
287
  assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
277
288
  assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
278
- assert.match(verifier, /v2\.6\.0/);
289
+ assert.match(verifier, /v2\.7\.0/);
279
290
  });
280
291
 
281
292
 
@@ -1277,9 +1288,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
1277
1288
  assert.match(wrapper, /quietToolsEnabled/);
1278
1289
  });
1279
1290
 
1280
- test("v2.4.0 release package and runtime stop before publication", () => {
1291
+ test("v2.5.0 release package and runtime stop before publication", () => {
1281
1292
  const packageJson = readPackageJson();
1282
- assert.equal(packageJson.version, "2.4.0", "the release manifest must remain explicitly pinned to v2.4.0");
1293
+ assert.equal(packageJson.version, "2.5.0", "the release manifest must remain explicitly pinned to v2.5.0");
1283
1294
  assert.equal(
1284
1295
  packageJson.scripts?.test,
1285
1296
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
@@ -3,6 +3,7 @@ import { realpathSync } from "node:fs";
3
3
  import test from "node:test";
4
4
  import { initTheme, keyHint } from "@earendil-works/pi-coding-agent";
5
5
  import { imageFallback, visibleWidth } from "@earendil-works/pi-tui";
6
+ import { cardBody, cardHint, cardTitle, cardTone } from "./gentle-card-text.ts";
6
7
  import piPretty from "../extensions/pi-pretty.ts";
7
8
  import quietTools, {
8
9
  countNonEmptyLines,
@@ -180,7 +181,16 @@ test("quiet tool rendering registers noisy built-in tools", () => {
180
181
 
181
182
  test("quiet tool execution uses the tool-call cwd", async () => {
182
183
  const tool = registeredQuietTools().get("bash");
183
- const output = extractTextContent(await tool.execute("tool-call", { command: "pwd" }, new AbortController().signal, undefined, { cwd: "/tmp" })).trim();
184
+ const context = {
185
+ cwd: "/tmp",
186
+ sessionManager: {
187
+ getSessionId: () => "quiet-tool-test",
188
+ getSessionFile: () => undefined,
189
+ },
190
+ };
191
+ const output = extractTextContent(
192
+ await tool.execute("tool-call", { command: "pwd" }, new AbortController().signal, undefined, context),
193
+ ).trim();
184
194
  // pwd prints the physical directory: on macOS /tmp is a symlink to /private/tmp.
185
195
  assert.equal(output, realpathSync("/tmp"));
186
196
  assert.notEqual(output, process.cwd());
@@ -472,21 +482,21 @@ test("quiet tool rendering hides every collapsed direct Gentle AI result and pre
472
482
  const nonText = renderToString(tool.renderResult({ content: [{ type: "image", data: "opaque", mimeType: "image/png" }] }, { expanded: false, isPartial: false }, passthroughTheme, { args: { command } }));
473
483
 
474
484
  assert.equal([...textRose].length, 2);
475
- assert.equal(call.trimEnd(), `${textRose} Gentle AI · running · review status`);
476
- assert.equal(collapsed, expandHint);
477
- assert.equal(collapsed.split("\n")[0], expandHint);
485
+ assert.equal(cardTitle(call), `🌹︎ Gentle AI · running · review status`);
486
+ assert.match(cardBody(collapsed), /\d+ lines?\b/);
487
+ assert.match(cardBody(collapsed), /\d+ lines?\b/);
478
488
  assert.doesNotMatch(collapsed, /next_transition|stop/);
479
- assert.equal(expanded.split("\n")[0], '{"next_transition":"stop"}');
489
+ assert.equal(cardBody(expanded).split("\n")[0], '{"next_transition":"stop"}');
480
490
  assert.match(expanded, /"next_transition":"stop"/);
481
491
  assert.doesNotMatch(expanded, /to expand/);
482
- assert.equal(failure, expandHint);
492
+ assert.match(cardBody(failure), /\d+ lines?\b/);
483
493
  assert.doesNotMatch(failure, /review status failed|authority unavailable|lineage=secret/);
484
494
  assert.match(expandedFailure, /review status failed: authority unavailable/);
485
495
  assert.match(expandedFailure, /lineage=secret/);
486
496
  assert.doesNotMatch(expandedFailure, /to expand/);
487
- assert.doesNotMatch(expandedFailure, /\x1b\[/);
488
- assert.equal(empty, "");
489
- assert.equal(nonText, "");
497
+ assert.doesNotMatch(cardBody(expandedFailure), /\x1b\[/);
498
+ assert.equal(cardBody(empty), "");
499
+ assert.equal(cardBody(nonText), "");
490
500
  });
491
501
 
492
502
  test("quiet tool rendering transitions one Gentle AI header through lifecycle states", () => {
@@ -528,16 +538,16 @@ test("quiet tool rendering transitions one Gentle AI header through lifecycle st
528
538
  assert.strictEqual(initial, running);
529
539
  assert.strictEqual(running, completed);
530
540
  assert.strictEqual(completed, failed);
531
- assert.equal(initialText, "<warning>🌹︎ Gentle AI · running · review status</warning>");
532
- assert.equal(runningText, "<warning>🌹︎ Gentle AI · running · review status</warning>");
533
- assert.equal(completedText, "<success>🌹︎ Gentle AI · completed · review status</success>");
534
- assert.equal(failedText, "<error>🌹︎ Gentle AI · failed · review status</error>");
541
+ assert.equal(cardTitle(initialText), "🌹︎ Gentle AI · running · review status"); assert.equal(cardTone(initialText), "warning");
542
+ assert.equal(cardTitle(runningText), "🌹︎ Gentle AI · running · review status"); assert.equal(cardTone(runningText), "warning");
543
+ assert.equal(cardTitle(completedText), "🌹︎ Gentle AI · completed · review status"); assert.equal(cardTone(completedText), "success");
544
+ assert.equal(cardTitle(failedText), "🌹︎ Gentle AI · failed · review status"); assert.equal(cardTone(failedText), "error");
535
545
  assert.doesNotMatch(failedText, /private-change/);
536
546
  });
537
547
 
538
548
  test("quiet Bash keeps direct Gentle AI calls seamless while streaming", () => {
539
549
  const tool = registeredQuietTools().get("bash");
540
- const rose = /🌹︎/;
550
+ const rose = /🌹︎ Gentle AI/;
541
551
  const render = (command: string, state: Record<string, unknown>, overrides: Record<string, unknown> = {}) => {
542
552
  const component = tool.renderCall({ command }, passthroughTheme, routineRenderContext({ args: { command }, state, argsComplete: false, ...overrides }));
543
553
  return [component, renderToString(component)] as const;
@@ -545,14 +555,14 @@ test("quiet Bash keeps direct Gentle AI calls seamless while streaming", () => {
545
555
  const command = "gentle-ai review status --token 'lineage-secret";
546
556
  const state = {};
547
557
  const [preparing, collapsed] = render(command, state);
548
- assert.equal(collapsed, "🌹︎ Gentle AI · preparing · review status");
558
+ assert.equal(cardTitle(collapsed), "🌹︎ Gentle AI · preparing · review status");
549
559
  assert.doesNotMatch(collapsed, /token|lineage-secret/);
550
560
  const expanded = render(command, state, { expanded: true, lastComponent: preparing })[1];
551
- assert.match(expanded, /^🌹︎ Gentle AI · preparing · review status\n\$ gentle-ai review status --token 'lineage-secret$/);
561
+ assert.equal(cardTitle(expanded), "🌹︎ Gentle AI · preparing · review status"); assert.equal(cardBody(expanded), "$ gentle-ai review status --token 'lineage-secret");
552
562
 
553
563
  const direct = "gentle-ai review status";
554
564
  const [running, runningText] = render(direct, state, { argsComplete: true, executionStarted: true, lastComponent: preparing });
555
- assert.strictEqual(running, preparing); assert.equal(runningText, "🌹︎ Gentle AI · running · review status");
565
+ assert.strictEqual(running, preparing); assert.equal(cardTitle(runningText), "🌹︎ Gentle AI · running · review status");
556
566
  const genericState = {};
557
567
  const [generic, genericText] = render("gentle-ai review status | cat", genericState);
558
568
  assert.doesNotMatch(genericText, rose); assert.match(genericText, /\| cat/);
@@ -569,7 +579,7 @@ test("quiet Bash keeps direct Gentle AI calls seamless while streaming", () => {
569
579
  assert.equal(genericResult.split("\n")[0], "generic result");
570
580
  const directState = {}; render(direct, directState, { argsComplete: true });
571
581
  const directResult = renderToolResult(tool, textResult("direct result"), { expanded: false, isPartial: false }, { args: { command: direct }, state: directState, argsComplete: true });
572
- assert.equal(directResult, keyHint("app.tools.expand", "to expand"));
582
+ assert.match(cardBody(directResult), /\d+ lines?\b/);
573
583
  });
574
584
 
575
585
  test("quiet tool rendering displays only finite safe Gentle AI operation paths", () => {
@@ -635,7 +645,7 @@ test("quiet tool rendering displays only finite safe Gentle AI operation paths",
635
645
  routineRenderContext({ args: { command } }),
636
646
  ),
637
647
  );
638
- assert.equal(rendered.trimEnd(), `${rose} Gentle AI · running · ${path}`, command);
648
+ assert.equal(cardTitle(rendered), `🌹︎ Gentle AI · running · ${path}`, command);
639
649
  assert.doesNotMatch(rendered, /change-123|private|secret|lineage|sha256|result\.json|incident\.json/);
640
650
  }
641
651
  });
@@ -658,7 +668,7 @@ test("quiet tool rendering covers version and future standalone Gentle AI comman
658
668
  const rendered = renderToString(
659
669
  tool.renderCall({ command }, passthroughTheme, routineRenderContext({ args: { command } })),
660
670
  );
661
- assert.equal(rendered.trimEnd(), `${rose} Gentle AI · running · ${path}`, command);
671
+ assert.equal(cardTitle(rendered), `🌹︎ Gentle AI · running · ${path}`, command);
662
672
  assert.doesNotMatch(rendered, /authorization-root|secret-change|private|C:\\\\private/);
663
673
  }
664
674
  });
@@ -686,7 +696,7 @@ test("quiet tool rendering recognizes exact quoted, escaped, Windows, and comman
686
696
 
687
697
  for (const command of routineCommands) {
688
698
  const call = renderToString(tool.renderCall({ command }, passthroughTheme, { args: { command } }));
689
- assert.equal(call.trimEnd(), "🌹︎ Gentle AI · running · version", command);
699
+ assert.equal(cardTitle(call), "🌹︎ Gentle AI · running · version", command);
690
700
  }
691
701
 
692
702
  for (const command of ["'gentle-ai-copy' version", '"gentle-ai-copy.exe" version', "'gentle-ai' version && echo done"] as const) {
@@ -713,7 +723,7 @@ test("quiet tool rendering recognizes only the exact resolved dev binary", () =>
713
723
  ] as const;
714
724
  for (const [command, operationPath] of cases) {
715
725
  const call = renderToString(tool.renderCall({ command }, statusTheme, routineRenderContext({ args: { command } })));
716
- assert.equal(call.trimEnd(), `<warning>🌹︎ Gentle AI · running · ${operationPath}</warning>`, command);
726
+ assert.equal(cardTitle(call), `🌹︎ Gentle AI · running · ${operationPath}`, command); assert.equal(cardTone(call), "warning", command);
717
727
  assert.doesNotMatch(call, /gentle-ai-main|private|secret|hidden/);
718
728
  }
719
729
  const command = `${devPath} review status --prompt hidden-prompt --lineage lineage-secret --body private-body`;
@@ -728,11 +738,11 @@ test("quiet tool rendering recognizes only the exact resolved dev binary", () =>
728
738
  const expanded = renderToolResult(tool, textResult(text), { expanded: true, isPartial: false, isError: true }, lifecycleContext);
729
739
  const refreshed = renderToolResult(tool, textResult("result-secret"), { expanded: false, isPartial: false }, { args: { command: refreshedCommand } });
730
740
  const hint = keyHint("app.tools.expand", "to expand");
731
- assert.equal(collapsed, hint);
732
- assert.equal(collapsed.split(hint).length - 1, 1);
741
+ assert.match(cardBody(collapsed), /\d+ lines?\b/);
742
+ assert.equal(cardBody(collapsed).split(hint).length - 1, 0);
733
743
  assert.doesNotMatch(collapsed, /private|lineage|secret|hidden|error/);
734
744
  assert.match(expanded, /private failure|lineage=secret body=hidden/);
735
- assert.doesNotMatch(expanded, /to expand|\x1b\[/);
745
+ assert.doesNotMatch(cardBody(expanded), /to expand|\x1b\[/);
736
746
  assert.doesNotMatch(refreshed, /result-secret/);
737
747
  assert.ok(resolutions > 1);
738
748
  });
@@ -794,12 +804,12 @@ test("quiet tool rendering hides the routine partial result because the header o
794
804
  ),
795
805
  );
796
806
 
797
- assert.equal(partial, expandHint);
798
- assert.equal(partial.split(expandHint).length - 1, 1);
807
+ assert.match(cardBody(partial), /\d+ lines?\b/);
808
+ assert.equal(cardBody(partial).split(expandHint).length - 1, 0);
799
809
  assert.match(partialExpanded, /"status":"running"/);
800
810
  assert.doesNotMatch(partialExpanded, /to expand/);
801
- assert.equal(partialFailure, expandHint);
802
- assert.equal(completed, expandHint);
811
+ assert.match(cardBody(partialFailure), /\d+ lines?\b/);
812
+ assert.match(cardBody(completed), /\d+ lines?\b/);
803
813
 
804
814
  const partialExpandedFailure = renderToString(
805
815
  tool.renderResult(
@@ -810,7 +820,7 @@ test("quiet tool rendering hides the routine partial result because the header o
810
820
  ),
811
821
  );
812
822
  assert.match(partialExpandedFailure, /review status failed: authority unavailable/);
813
- assert.doesNotMatch(partialExpandedFailure, /\x1b\[/);
823
+ assert.doesNotMatch(cardBody(partialExpandedFailure), /\x1b\[/);
814
824
  });
815
825
 
816
826
  test("quiet tool rendering collapses grant calls to action and authorization-root cardinality", () => {
@@ -844,8 +854,8 @@ test("quiet tool rendering collapses grant calls to action and authorization-roo
844
854
  assert.strictEqual(running, completed);
845
855
  assert.strictEqual(completed, failed);
846
856
  const rendered = renderToString(failed);
847
- assert.equal(rendered, "<error>🌹︎ Gentle AI · failed · sdd attempt grant · 2 roots</error>");
848
- assert.doesNotMatch(rendered, /authorization-root|secret-change|repo\/root|other\/root|audit:|\x1b\[/);
857
+ assert.equal(cardTitle(rendered), "🌹︎ Gentle AI · failed · sdd attempt grant · 2 roots"); assert.equal(cardTone(rendered), "error");
858
+ assert.doesNotMatch(rendered.split(keyHint("app.tools.expand", "to expand")).join(""), /authorization-root|secret-change|repo\/root|other\/root|audit:|\x1b\[/);
849
859
  });
850
860
 
851
861
  test("quiet tool rendering counts grant authorization roots without rendering values", () => {
@@ -863,7 +873,7 @@ test("quiet tool rendering counts grant authorization roots without rendering va
863
873
 
864
874
  for (const [command, expected] of cases) {
865
875
  const rendered = renderToString(tool.renderCall({ command }, passthroughTheme, { args: { command } }));
866
- assert.equal(rendered.trimEnd(), `🌹︎ Gentle AI · running · ${expected}`, command);
876
+ assert.equal(cardTitle(rendered), `🌹︎ Gentle AI · running · ${expected}`, command);
867
877
  assert.doesNotMatch(rendered, /authorization-root|\/repo\/root|\/one|\/two|\/three|change/);
868
878
  }
869
879
  });
@@ -883,8 +893,8 @@ test("quiet tool rendering hides invocation secrets from collapsed Gentle AI cal
883
893
  ),
884
894
  );
885
895
 
886
- assert.equal(call.trimEnd(), "🌹︎ Gentle AI · running · review finalize");
887
- assert.equal(collapsed, keyHint("app.tools.expand", "to expand"));
896
+ assert.equal(cardTitle(call), "🌹︎ Gentle AI · running · review finalize");
897
+ assert.match(cardBody(collapsed), /\d+ lines?\b/);
888
898
  for (const forbidden of ["hidden-prompt", "lineage-secret", "private-body", "/private/root", "audit:"]) {
889
899
  assert.doesNotMatch(call, new RegExp(forbidden));
890
900
  assert.doesNotMatch(collapsed, new RegExp(forbidden));
@@ -973,7 +983,7 @@ test("quiet tool rendering sanitizes collapsed output and call rows", () => {
973
983
 
974
984
  const carriageReturn = renderToolResult(tools.get("bash"), textResult("prefix\rSECRET\r\nnext"), { expanded: false, isPartial: false }, { args: { command: "printf output" } });
975
985
  assert.match(collapsed, /safered/);
976
- assert.doesNotMatch(collapsed, /\x1b\[31m|\x1b\[0m/);
986
+ assert.doesNotMatch(cardBody(collapsed), /\x1b\[31m|\x1b\[0m/);
977
987
  assert.equal(call.trimEnd(), "$ echo red");
978
988
  assert.match(carriageReturn, /prefixSECRET\nnext/);
979
989
  assert.doesNotMatch(carriageReturn, /\r/);
@@ -1064,7 +1074,7 @@ test("quiet tool rendering recognizes a quoted exact dev override path containin
1064
1074
  const command = `"${devPath}" review status "literal \\$|#;"`;
1065
1075
  const call = renderToString(tool.renderCall({ command }, passthroughTheme, { args: { command } }));
1066
1076
  const collapsed = renderToolResult(tool, textResult("private result"), { expanded: false, isPartial: false }, { args: { command } });
1067
- assert.equal(call.trimEnd(), "🌹︎ Gentle AI · running · review status");
1077
+ assert.equal(cardTitle(call), "🌹︎ Gentle AI · running · review status");
1068
1078
  assert.doesNotMatch(call, /\/opt\/Gentle AI\/gentle-ai|literal/);
1069
1079
  assert.doesNotMatch(collapsed, /private result/);
1070
1080
  });
@@ -1274,3 +1284,52 @@ test("quiet tool rendering respects command and env wrapper order", () => {
1274
1284
  assert.equal(gentleAiRoutineCommand({ command }), undefined);
1275
1285
  assertGenericBash(bash, command);
1276
1286
  });
1287
+
1288
+ test("quiet tool rendering lets a final result promote a replayed call card to its outcome", async () => {
1289
+ // The promotion invalidates after the render returns (a microtask), never inside it.
1290
+ const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(undefined)));
1291
+ const { pi, tools } = createPi();
1292
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
1293
+ const tool = tools.get("bash");
1294
+ const command = "gentle-ai review status";
1295
+ const state = {};
1296
+ let invalidations = 0;
1297
+ const replayed = routineRenderContext({ args: { command }, state, argsComplete: false, executionStarted: false, isPartial: false, invalidate: () => { invalidations += 1; } });
1298
+
1299
+ const call = tool.renderCall({ command }, statusTheme, replayed);
1300
+ assert.equal(cardTitle(renderToString(call)), "🌹︎ Gentle AI · preparing · review status");
1301
+ renderToString(tool.renderResult(textResult("done"), { expanded: false, isPartial: false }, statusTheme, replayed));
1302
+ assert.equal(invalidations, 0, "never reentrant");
1303
+ await flush();
1304
+ assert.equal(invalidations, 1);
1305
+ const promoted = tool.renderCall({ command }, statusTheme, { ...replayed, lastComponent: call });
1306
+ assert.strictEqual(promoted, call);
1307
+ assert.equal(cardTitle(renderToString(promoted)), "🌹︎ Gentle AI · completed · review status");
1308
+ assert.equal(cardTone(renderToString(promoted)), "success");
1309
+
1310
+ renderToString(tool.renderResult(textResult("boom"), { expanded: false, isPartial: false }, statusTheme, { ...replayed, isError: true }));
1311
+ await flush();
1312
+ assert.equal(invalidations, 2);
1313
+ assert.equal(cardTitle(renderToString(tool.renderCall({ command }, statusTheme, { ...replayed, lastComponent: call }))), "🌹︎ Gentle AI · failed · review status");
1314
+ renderToString(tool.renderResult(textResult("boom"), { expanded: false, isPartial: false }, statusTheme, { ...replayed, isError: true }));
1315
+ await flush();
1316
+ assert.equal(invalidations, 2, "an unchanged outcome must not request another render");
1317
+ });
1318
+
1319
+ test("quiet tool rendering puts the expand key in the finished call card's top rule, not in the result", () => {
1320
+ const { pi, tools } = createPi();
1321
+ withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any));
1322
+ const tool = tools.get("bash");
1323
+ const command = "gentle-ai review status";
1324
+ const running = renderToString(tool.renderCall({ command }, passthroughTheme, routineRenderContext({ args: { command }, executionStarted: true, isPartial: true })));
1325
+ assert.equal(cardHint(running), undefined, "a running call has nothing to expand yet");
1326
+ const completed = renderToString(tool.renderCall({ command }, passthroughTheme, routineRenderContext({ args: { command }, executionStarted: true, isPartial: false, expanded: false })));
1327
+ assert.match(cardHint(completed) ?? "", /to expand$/);
1328
+ assert.equal(cardTitle(completed), "🌹︎ Gentle AI · completed · review status");
1329
+ const expanded = renderToString(tool.renderCall({ command }, passthroughTheme, routineRenderContext({ args: { command }, executionStarted: true, isPartial: false, expanded: true })));
1330
+ assert.match(cardHint(expanded) ?? "", /to collapse$/);
1331
+ const collapsedResult = renderToolResult(tool, textResult("{\"next_transition\":\"stop\"}"), { expanded: false, isPartial: false }, { args: { command } });
1332
+ assert.equal(cardBody(collapsedResult), "1 line");
1333
+ assert.equal(cardBody(renderToolResult(tool, textResult("a\nb\nc"), { expanded: false, isPartial: false }, { args: { command } })), "3 lines");
1334
+ assert.match(collapsedResult.split("\n").pop() ?? "", /╰─+╯$/);
1335
+ });
@@ -0,0 +1,216 @@
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
+
6
+ // ---------------------------------------------------------------------------
7
+ // gentle-pi#661/#662: RDD-aware verification rule for delegated work.
8
+ //
9
+ // The bounded writer always self-verifies: it runs the parent-authorized
10
+ // `## Verification` commands itself and reports observed output. Whether a
11
+ // SEPARATE `gentle-ai-verify` delegation is also required depends on the
12
+ // rendered `Receipt-driven development:` line, stated normatively exactly
13
+ // once in trigger 5 (Verification rule) and referenced -- not restated --
14
+ // everywhere else in this asset:
15
+ // - `on` -> the writer's own report is the verification of
16
+ // record; `gentle-ai-verify` is on-demand, except
17
+ // passive risk, which gets a structural readback.
18
+ // - `off`/`unknown` -> gentle-pi#662: the parent calls `gentle_review` with
19
+ // `{"operation":"assess"}` over the writer's diff and
20
+ // follows the returned plan by native risk tier
21
+ // (passive/medium/high/unassessable), instead of a
22
+ // blanket non-trivial judgment. An unknown RDD line
23
+ // never lowers a tier below `off`.
24
+ // These tests assert the exact distinctive sentences (not bare words like
25
+ // `off`/`unknown`/`partial`/`blocked`), that the tier table is stated exactly
26
+ // once, that the routing ladder paragraph references trigger 5 rather than
27
+ // restating it, and that `## Known environmental failures` has one canonical
28
+ // definition (owned by the worker asset) that the delegation asset
29
+ // references rather than duplicates.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const ROOT = join(import.meta.dirname, "..");
33
+
34
+ function read(relativePath: string): string {
35
+ return readFileSync(join(ROOT, relativePath), "utf8");
36
+ }
37
+
38
+ function countOccurrences(haystack: string, needle: string): number {
39
+ return haystack.split(needle).length - 1;
40
+ }
41
+
42
+ const delegation = read("assets/orchestrator-delegation.md");
43
+ const worker = read("assets/agents/gentle-ai-worker.md");
44
+
45
+ const ON_SENTENCE =
46
+ "When the line reads `on`, that writer report is the verification of record, and the native review is the independent check the writer cannot influence";
47
+ const OFF_UNKNOWN_SENTENCE =
48
+ 'When the line reads `off` or `unknown`, after the writer returns, call `gentle_review` with `{"operation":"assess"}` over the writer\'s diff and follow the returned plan instead of judging non-triviality from the task description: the operation resolves the native risk tier and states exactly who verifies next.';
49
+ const TIER_TABLE_HEADER = "| Native risk tier | Verification when RDD is `off`/`unknown` |";
50
+ const PASSIVE_TIER_ROW = "| passive | structural readback by the parent; no separate verifier, no tests |";
51
+ const MEDIUM_TIER_ROW = "| medium | writer self-verification stands; a separate `gentle-ai-verify` run is added only when the writer profile is a small model (mini or low effort) |";
52
+ const HIGH_TIER_ROW = "| high | writer self-verification plus a separate `gentle-ai-verify` run, always |";
53
+ const UNASSESSABLE_TIER_ROW = "| unknown / assess failed | treated as high |";
54
+ const SMALL_MODEL_BIAS_SENTENCE =
55
+ "The small-model bias raises the tier by one for verification purposes (medium becomes high); an unknown `Receipt-driven development:` line never lowers a tier below `off`.";
56
+ const SPOT_CHECK_SENTENCE =
57
+ "The parent spot check (re-running one reported command before delivery) stays required in every tier.";
58
+ // gentle-pi#668: the `on` branch of trigger 5 holds only while the native
59
+ // review actually reaches a terminal outcome for this candidate -- a decline,
60
+ // a clone-local disable, or a refused START/STATUS all fall back to the exact
61
+ // same risk-gated path as `off`.
62
+ const ON_BRANCH_FALLBACK_SENTENCE =
63
+ 'That `on` branch holds only while the native review actually reaches a terminal outcome for this candidate (gentle-pi#668): a human decline of the consent envelope for this candidate (candidate-scoped, never the RDD kill switch), a clone-local RDD disable discovered mid-flow, or a refused START/STATUS all fall back to the risk-gated path exactly as `off` -- call `gentle_review` with `{"operation":"assess"}` (pass `nativeReviewOutcome` when the parent already knows it; the tool derives it from what it itself observed for the candidate otherwise, failing closed to `unknown` when it cannot) and follow the returned plan.';
64
+
65
+ test("trigger 5 (Verification rule) states the exact on-line routing: writer report is the verification of record", () => {
66
+ assert.ok(delegation.includes(ON_SENTENCE), "trigger 5 is missing the exact on-line sentence");
67
+ });
68
+
69
+ test("trigger 5 states the exact off/unknown-line routing: the parent calls gentle_review's assess operation and follows the returned plan", () => {
70
+ assert.ok(delegation.includes(OFF_UNKNOWN_SENTENCE), "trigger 5 is missing the exact off/unknown-line sentence");
71
+ });
72
+
73
+ test("trigger 5 states the native risk tier table exactly once, with all four rows", () => {
74
+ for (const row of [TIER_TABLE_HEADER, PASSIVE_TIER_ROW, MEDIUM_TIER_ROW, HIGH_TIER_ROW, UNASSESSABLE_TIER_ROW]) {
75
+ assert.equal(countOccurrences(delegation, row), 1, `expected exactly one occurrence of tier table row: ${row}`);
76
+ }
77
+ });
78
+
79
+ test("trigger 5 states the small-model bias and the unknown-never-lowers-a-tier rule", () => {
80
+ assert.ok(delegation.includes(SMALL_MODEL_BIAS_SENTENCE), "trigger 5 is missing the small-model bias sentence");
81
+ });
82
+
83
+ test("trigger 5 keeps the parent spot check requirement in every tier", () => {
84
+ assert.ok(delegation.includes(SPOT_CHECK_SENTENCE), "trigger 5 is missing the parent spot check sentence");
85
+ });
86
+
87
+ test("trigger 5 states that the on branch holds only while the native review closes for this candidate, falling back to the off path exactly once (gentle-pi#668)", () => {
88
+ assert.equal(countOccurrences(delegation, ON_BRANCH_FALLBACK_SENTENCE), 1, "trigger 5 is missing (or duplicates) the on-branch fallback sentence");
89
+ });
90
+
91
+ test("the on-branch fallback sentence names all three non-closed triggers and never introduces a forbidden native RDD marker", () => {
92
+ for (const clause of ["decline", "clone-local", "refused START/STATUS"]) {
93
+ assert.ok(ON_BRANCH_FALLBACK_SENTENCE.includes(clause), `on-branch fallback sentence missing: ${clause}`);
94
+ }
95
+ for (const marker of ["gentle-ai review status", "next_transition", "review.capture-result", "review.validate", "reviewGate.result"]) {
96
+ assert.ok(!delegation.includes(marker), `stale/forbidden RDD marker introduced: ${marker}`);
97
+ }
98
+ });
99
+
100
+ test("the on-line and off/unknown-line routing sentences appear exactly once each (normative statement lives only in trigger 5)", () => {
101
+ for (const sentence of [ON_SENTENCE, OFF_UNKNOWN_SENTENCE]) {
102
+ assert.equal(countOccurrences(delegation, sentence), 1, `expected exactly one occurrence of: ${sentence.slice(0, 60)}...`);
103
+ }
104
+ });
105
+
106
+ test("trigger 5 never restates the retired #661 off/unknown non-trivial judgment", () => {
107
+ assert.doesNotMatch(delegation, /non-trivial change, in addition to the writer's own report/);
108
+ assert.doesNotMatch(delegation, /purely passive documentation with no behavior to verify/);
109
+ });
110
+
111
+ test("the Simple Delegation paragraph references trigger 5 instead of restating the on/off/unknown routing", () => {
112
+ assert.match(
113
+ delegation,
114
+ /per the RDD-aware Verification rule \(trigger 5 under Mandatory Delegation Triggers, gentle-pi#661\)/,
115
+ );
116
+ assert.match(delegation, /the normative on\/off\/unknown routing lives there, not here/);
117
+ });
118
+
119
+ test("delegation overlay's trigger 5 (Verification rule) is RDD-aware", () => {
120
+ assert.match(delegation, /\*\*Verification rule\*\*.*RDD-aware/);
121
+ });
122
+
123
+ test("delegation overlay reserves separate exploration for parent routing decisions", () => {
124
+ assert.match(delegation, /exploration stays reserved for when the parent needs the map to decide or route/i);
125
+ assert.match(delegation, /reading that prepares a write belongs with the writer/i);
126
+ });
127
+
128
+ test("delegation overlay keeps the required headings", () => {
129
+ for (const heading of [
130
+ "### Delegation Rules",
131
+ "#### Background Subagent Policy",
132
+ "#### Allowed edit surfaces (MANDATORY)",
133
+ "### 3. SDD (optional)",
134
+ ]) {
135
+ assert.ok(delegation.includes(heading), `delegation overlay lost required heading: ${heading}`);
136
+ }
137
+ });
138
+
139
+ test("worker asset declares the Verification section after Test discipline", () => {
140
+ const testDisciplineIndex = worker.indexOf("## Test discipline");
141
+ const verificationIndex = worker.indexOf("## Verification");
142
+ const interactionIndex = worker.indexOf("## Interaction contract");
143
+ assert.ok(testDisciplineIndex >= 0, "worker asset lost ## Test discipline");
144
+ assert.ok(verificationIndex >= 0, "worker asset is missing ## Verification");
145
+ assert.ok(interactionIndex >= 0, "worker asset lost ## Interaction contract");
146
+ assert.ok(
147
+ testDisciplineIndex < verificationIndex && verificationIndex < interactionIndex,
148
+ "## Verification must sit between ## Test discipline and ## Interaction contract",
149
+ );
150
+ });
151
+
152
+ test("worker asset requires foreground, one-at-a-time verification with nothing left unreported", () => {
153
+ for (const clause of [
154
+ "in the foreground",
155
+ "one at a time",
156
+ "Never launch a verification command in the background",
157
+ "never end the task with a listed command unreported",
158
+ ]) {
159
+ assert.ok(worker.includes(clause), `worker asset is missing: ${clause}`);
160
+ }
161
+ });
162
+
163
+ test("worker asset reports each verification command as <exact command>: <observed result> in validation", () => {
164
+ assert.ok(worker.includes("`<exact command>: <observed result>`"));
165
+ assert.ok(worker.includes("in `validation`"));
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Contract consistency (`## Known environmental failures`): defined exactly
170
+ // once, in the worker asset, as "exact test names (or exact command lines)
171
+ // that already fail on the base"; the writer reports those as evidence, but
172
+ // any OTHER failing required command still forces `status: partial`. The
173
+ // delegation asset must reference this same definition, not restate it.
174
+ // ---------------------------------------------------------------------------
175
+
176
+ test("worker asset owns the canonical Known environmental failures definition", () => {
177
+ assert.ok(worker.includes("## Known environmental failures"));
178
+ assert.match(worker, /this is the canonical definition; other assets reference it, they do not restate it/i);
179
+ assert.match(worker, /lists exact test names or exact command lines that already fail on the base/i);
180
+ assert.match(worker, /Any OTHER required command that fails -- one not named under that heading -- still forces `status: partial`\./);
181
+ });
182
+
183
+ test("delegation asset references the worker's Known environmental failures definition instead of restating it", () => {
184
+ assert.match(
185
+ delegation,
186
+ /`## Known environmental failures` follows the same definition as `gentle-ai-worker`'s Verification contract: exact pre-existing base failures reported as evidence, never blockers -- any other failing required command still forces `status: partial`\./,
187
+ );
188
+ // The full canonical wording ("lists exact test names or exact command
189
+ // lines that already fail on the base") must not be duplicated here.
190
+ assert.doesNotMatch(delegation, /lists exact test names or exact command lines that already fail on the base/i);
191
+ });
192
+
193
+ test("worker asset never claims completion while a required verification command fails under RDD, except a named environmental failure", () => {
194
+ assert.match(worker, /this report is the verification of record/i);
195
+ assert.match(worker, /native review remains the independent check/i);
196
+ assert.match(
197
+ worker,
198
+ /never report `status: completed` while a required command under `## Verification` is failing, unless that exact failure is named under `## Known environmental failures`\./i,
199
+ );
200
+ });
201
+
202
+ test("worker asset keeps the existing Return contract fields", () => {
203
+ for (const field of [
204
+ "status: completed | partial | blocked | interaction_required",
205
+ "summary:",
206
+ "files_changed:",
207
+ "tdd_evidence:",
208
+ "validation:",
209
+ "risks:",
210
+ "review_focus:",
211
+ "skill_resolution:",
212
+ "interaction_required:",
213
+ ]) {
214
+ assert.ok(worker.includes(field), `worker asset lost Return contract field: ${field}`);
215
+ }
216
+ });