gentle-pi 4.2.0 → 4.3.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 (202) hide show
  1. package/README.md +336 -137
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/gentle-shell.mjs +170 -22
  12. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  13. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  15. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  16. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  18. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  19. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  20. package/docs/assets/diagrams/measured-routing.svg +49 -0
  21. package/docs/assets/diagrams/native-review.svg +52 -12
  22. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  23. package/docs/assets/diagrams/permission-modes.svg +51 -0
  24. package/docs/assets/features/audio-notifications.png +0 -0
  25. package/docs/assets/features/command-palette.png +0 -0
  26. package/docs/bundled-install.md +33 -3
  27. package/docs/delegated-verification.md +10 -8
  28. package/docs/gentle-agents-activity.md +11 -5
  29. package/docs/gentle-shell-uninstall.md +8 -1
  30. package/docs/gentle-shell.md +5 -4
  31. package/docs/install-wizard.md +8 -1
  32. package/docs/permission-modes.md +1 -1
  33. package/docs/readme-reference.md +107 -19
  34. package/docs/yolo-mode.md +21 -8
  35. package/extensions/codegraph-tools.ts +68 -6
  36. package/extensions/gentle-agents.ts +99 -12
  37. package/extensions/gentle-ai.ts +284 -52
  38. package/extensions/gentle-herdr-activity.ts +2 -3
  39. package/extensions/gentle-shell.ts +27 -12
  40. package/extensions/gentle-todo.ts +43 -9
  41. package/extensions/quiet-tools.ts +1 -1
  42. package/extensions/session-scratch.ts +365 -0
  43. package/extensions/startup-banner.ts +2 -3
  44. package/lib/agent-home.ts +8 -3
  45. package/lib/agents-protocol.ts +3 -0
  46. package/lib/agents-runner.ts +55 -6
  47. package/lib/agents-view.ts +3 -1
  48. package/lib/bounded-writer-admission.ts +51 -0
  49. package/lib/command-palette-catalog.ts +0 -4
  50. package/lib/commit-slice-signal.ts +100 -0
  51. package/lib/context-backstop.ts +84 -0
  52. package/lib/delivered-test-lint.ts +80 -0
  53. package/lib/destructive-command-guard.ts +120 -9
  54. package/lib/gauge-theme.ts +146 -0
  55. package/lib/gentle-ai-binary.ts +28 -7
  56. package/lib/gentle-shell-bundled.ts +39 -3
  57. package/lib/gentle-shell-import-pi.ts +233 -0
  58. package/lib/gentle-shell-launcher.ts +145 -11
  59. package/lib/gentle-shell-uninstall.ts +18 -1
  60. package/lib/herdr-activity.ts +24 -0
  61. package/lib/native-review-cli.ts +9 -1
  62. package/lib/orchestrator-discovery.ts +5 -1
  63. package/lib/orchestrator-presence.ts +9 -0
  64. package/lib/orchestrator-scope.ts +21 -3
  65. package/lib/provider-contract-bundle.ts +99 -53
  66. package/lib/review-candidate-view-owner.ts +29 -19
  67. package/lib/review-candidate-view.ts +36 -2
  68. package/lib/review-risk-assessment.ts +43 -38
  69. package/lib/review-session-standing-permission.ts +3 -1
  70. package/lib/session-profile-freeze.ts +30 -1
  71. package/lib/session-scratch.ts +474 -0
  72. package/lib/shell-bar.ts +25 -25
  73. package/lib/shell-changes-view.ts +80 -13
  74. package/lib/shell-gauge.ts +49 -6
  75. package/lib/shell-todo.ts +23 -4
  76. package/lib/shell-usage-view.ts +5 -1
  77. package/lib/shell-usage.ts +7 -7
  78. package/lib/verifier-admission.ts +29 -0
  79. package/lib/yolo-temp-scratch.ts +69 -0
  80. package/package.json +1 -2
  81. package/runtime/gentle-ai-binary.mjs +28 -7
  82. package/runtime/gentle-shell-bundled.mjs +38 -2
  83. package/runtime/gentle-shell-import-pi.mjs +234 -0
  84. package/runtime/gentle-shell-launcher.mjs +143 -9
  85. package/runtime/gentle-shell-uninstall.mjs +18 -1
  86. package/runtime/native-review-cli.mjs +8 -0
  87. package/runtime/review-risk-assessment.mjs +43 -38
  88. package/scripts/build-runtime-modules.mjs +1 -0
  89. package/scripts/bundled-engram-migration.mjs +125 -0
  90. package/scripts/gentle-ai-installer.mjs +10 -10
  91. package/scripts/installer-server.mjs +14 -4
  92. package/scripts/main-channel.mjs +3 -2
  93. package/scripts/run-test-suite.mjs +5 -0
  94. package/scripts/verify-package-files.mjs +19 -13
  95. package/skills/gentle-ai/SKILL.md +3 -3
  96. package/skills/work-unit-commits/SKILL.md +5 -3
  97. package/tests/agent-risk-escalation.test.ts +37 -10
  98. package/tests/agents-runner.test.ts +260 -3
  99. package/tests/autonomous-guard.test.ts +122 -0
  100. package/tests/bounded-writer-admission.test.ts +11 -3
  101. package/tests/card-content-policy.test.ts +19 -9
  102. package/tests/child-safety.test.ts +39 -0
  103. package/tests/codegraph-tools.test.ts +123 -2
  104. package/tests/command-palette.test.ts +14 -4
  105. package/tests/commit-slice-signal.test.ts +162 -0
  106. package/tests/context-backstop.test.ts +159 -0
  107. package/tests/delegate-for-reason-contract.test.ts +91 -24
  108. package/tests/delivered-test-lint.test.ts +105 -0
  109. package/tests/destructive-command-guard.test.ts +85 -1
  110. package/tests/engram-safety.test.ts +1 -1
  111. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  115. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  116. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  117. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  118. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  119. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  120. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  121. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  122. package/tests/gauge-theme.test.ts +120 -0
  123. package/tests/gentle-agents.test.ts +282 -3
  124. package/tests/gentle-ai-binary.test.ts +1 -1
  125. package/tests/gentle-ai-child-guards.test.ts +61 -0
  126. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  127. package/tests/gentle-ai-installer.test.ts +51 -51
  128. package/tests/gentle-shell-bin.test.ts +163 -0
  129. package/tests/gentle-shell-bundled.test.ts +169 -6
  130. package/tests/gentle-shell-import-pi.test.ts +262 -0
  131. package/tests/gentle-shell-launcher.test.ts +79 -0
  132. package/tests/gentle-shell-uninstall.test.ts +30 -0
  133. package/tests/gentle-shell.test.ts +42 -9
  134. package/tests/gentle-todo.test.ts +179 -0
  135. package/tests/headless-delivery-question.test.ts +58 -0
  136. package/tests/herdr-activity.test.ts +34 -1
  137. package/tests/installer-server.test.ts +10 -2
  138. package/tests/installer-windows-bootstrap.test.ts +33 -4
  139. package/tests/native-review-capability-contract.test.ts +7 -1
  140. package/tests/native-review-parity.test.ts +22 -1
  141. package/tests/no-self-pause-contract.test.ts +5 -2
  142. package/tests/odd-integration.test.ts +5 -1
  143. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  144. package/tests/odd-routing-contract.test.ts +115 -30
  145. package/tests/orchestrator-budget.test.ts +19 -8
  146. package/tests/orchestrator-discovery.test.ts +29 -0
  147. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  148. package/tests/orchestrator-scope.test.ts +66 -0
  149. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  150. package/tests/package-manifest.test.ts +24 -20
  151. package/tests/pi-update-launcher.test.ts +21 -2
  152. package/tests/primary-session-prompt.test.ts +101 -0
  153. package/tests/provider-contract-bundle.test.ts +118 -0
  154. package/tests/provider-contract-mirror.test.ts +24 -0
  155. package/tests/quiet-tool-rendering.test.ts +23 -0
  156. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  157. package/tests/rdd-status-line.test.ts +79 -0
  158. package/tests/review-agent-end-preflight.test.ts +6 -0
  159. package/tests/review-candidate-view.test.ts +49 -0
  160. package/tests/review-contract-prompt.test.ts +5 -2
  161. package/tests/review-controller-native-routing.test.ts +14 -1
  162. package/tests/review-controller.test.ts +1 -1
  163. package/tests/review-host-relay-routing.test.ts +59 -2
  164. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  165. package/tests/review-owner-identity.test.ts +132 -0
  166. package/tests/review-risk-assessment.test.ts +163 -76
  167. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  168. package/tests/review-session-standing-permission.test.ts +27 -1
  169. package/tests/run-test-suite.test.ts +15 -0
  170. package/tests/runtime-harness.mjs +16 -6
  171. package/tests/session-changes-shell.test.ts +1 -1
  172. package/tests/session-profile-freeze.test.ts +23 -0
  173. package/tests/session-scratch.test.ts +945 -0
  174. package/tests/session-subject-routing-contract.test.ts +2 -1
  175. package/tests/shell-bar.test.ts +7 -0
  176. package/tests/shell-changes-view.test.ts +137 -1
  177. package/tests/shell-gauge.test.ts +42 -0
  178. package/tests/small-task-inline-contract.test.ts +37 -0
  179. package/tests/standalone-config-home.test.ts +324 -0
  180. package/tests/startup-banner.test.ts +5 -4
  181. package/tests/startup-home-repo.test.ts +34 -0
  182. package/tests/support/orchestrator-modules.ts +1 -0
  183. package/tests/task-size-routing-contract.test.ts +69 -9
  184. package/tests/test-scratch-contract.test.ts +102 -0
  185. package/tests/test-strength-contract.test.ts +36 -1
  186. package/tests/tool-batching-contract.test.ts +64 -0
  187. package/tests/verification-proportionality.test.ts +46 -0
  188. package/tests/verifier-admission.test.ts +137 -0
  189. package/tests/verify-agent-contract.test.ts +8 -8
  190. package/tests/windows-hidden-processes.test.ts +5 -0
  191. package/tests/writer-edit-surface-scope.test.ts +3 -0
  192. package/tests/writer-feature-document-admission.test.ts +156 -0
  193. package/tests/yolo-mode.test.ts +18 -0
  194. package/tests/yolo-temp-scratch.test.ts +71 -0
  195. package/scripts/mirror-odd-routing.mjs +0 -242
  196. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  197. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  198. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  199. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  200. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  201. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  202. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -5,7 +5,7 @@ import { test } from 'node:test';
5
5
  const PI = '@earendil-works/pi-coding-agent';
6
6
  // A virtual symlink filesystem: listed global links point into a pnpm store.
7
7
  // The store's adjacent peer is a sibling, not Shell/node_modules/Pi.
8
- function launch(args: string[], scenario = 'owned') {
8
+ function launch(args: string[], scenario = 'owned', piVersion = '1.2.0') {
9
9
  const adjacent = scenario.startsWith('adjacent');
10
10
  const npm = scenario.startsWith('npm-');
11
11
  const nodeModules = '/global/v11/selected/node_modules';
@@ -31,7 +31,7 @@ function launch(args: string[], scenario = 'owned') {
31
31
  source = source.replace('const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));', ${JSON.stringify(`const packageRoot = '${shellRoot}';`)});
32
32
  source = source.replace('const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs, verifying });', 'const bundled = undefined;');
33
33
  source = source.replace('runtime = resolvePiRuntime({', ${JSON.stringify(`runtime = ({ kind: '${adjacent ? 'bundled' : 'env'}', command: '${adjacent ? 'node' : piRoot + '/dist/bundle/cli.js'}', args: ${JSON.stringify(adjacent ? [piRoot + '/dist/bundle/cli.js'] : [])} }); /*`)}).replace('nodeExecPath: process.execPath,\\n\\t\\t});', 'nodeExecPath: process.execPath, */');
34
- source = source.replace('const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, {', 'const versionProbe = { stdout: "1.2.0" }; /*').replace('env: bundledEnv ?? process.env,\\n\\t});', 'env: bundledEnv ?? process.env, */');
34
+ source = source.replace('const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, {', 'const versionProbe = { stdout: "${piVersion}" }; /*').replace('env: bundledEnv ?? process.env,\\n\\t});', 'env: bundledEnv ?? process.env, */');
35
35
  source = source.replace('async function packageManagerAdapters() {', 'async function packageManagerAdapters() { return globalThis.fakeAdapters; } async function unusedAdapters() {');
36
36
  source = source.replace('let homeHadContentBeforeBootstrap = false;', 'if (passthrough.length === 1) { console.error("error: pi cannot self-update this installation. This installation is not managed by a global pnpm install."); process.exitCode = 1; return; } console.log("FORWARD " + JSON.stringify(passthrough)); return; let homeHadContentBeforeBootstrap = false;');
37
37
  return {format:'module', source, shortCircuit:true};
@@ -79,6 +79,25 @@ for (const scenario of ['ambiguous', 'malformed', 'npm-link', 'npm-file', 'share
79
79
  assert.doesNotMatch(result.stdout + result.stderr, /MUTATION/);
80
80
  });
81
81
 
82
+ // `update` is how an old Pi gets replaced, so the minimum-version gate must not block it.
83
+ test('public update replaces a Pi older than the required minimum', () => {
84
+ const result = launch(['update'], 'owned', '1.1.0');
85
+ assert.equal(result.status, 0, result.stderr);
86
+ assert.match(result.stdout, /MUTATION \["add","-g","@earendil-works\/pi-coding-agent@latest"\]/);
87
+ assert.doesNotMatch(result.stdout + result.stderr, /older than the required minimum/);
88
+ assert.equal(result.stderr, '');
89
+ });
90
+
91
+ test('an old Pi still fails the minimum-version gate outside a self update', () => {
92
+ for (const args of [['list'], ['update', '--extensions-only']]) {
93
+ const result = launch(args, 'owned', '1.1.0');
94
+ assert.equal(result.status, 1, result.stdout);
95
+ assert.equal(result.stdout, '');
96
+ assert.match(result.stderr, /pi version 1\.1\.0 is older than the required minimum 1\.2\.0\./);
97
+ assert.doesNotMatch(result.stdout, /MUTATION/);
98
+ }
99
+ });
100
+
82
101
  test('public extensions-only update preserves the forwarded command', () => {
83
102
  const result = launch(['update', '--extensions-only']);
84
103
  assert.equal(result.status, 0, result.stderr);
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test, { after, before } from "node:test";
7
+ import { __testing } from "../extensions/gentle-ai.ts";
8
+
9
+ // gentle-ai R3 (odd/tasks/pi-routing-from-bundle.md): the harness ODD step
10
+ // lines 1-7, the phase-reporting line, and the resume line come from the
11
+ // mirrored provider contract bundle's routing/pi.md instead of a hand-copied
12
+ // literal, and the rendered primary-session prompt stays byte-identical.
13
+ //
14
+ // The golden was captured from the base commit (main 4372ff2d, after R1) before the steps moved into the
15
+ // bundle, with the inputs below and the package assets root normalized. When a
16
+ // prompt change is intended, regenerate it with
17
+ // UPDATE_PRIMARY_SESSION_PROMPT_GOLDEN=1 and review the golden diff.
18
+
19
+ const REPO_ROOT = join(import.meta.dirname, "..");
20
+ const GOLDEN_PATH = join(REPO_ROOT, "tests", "fixtures", "primary-session-prompt", "gentleman.golden.md");
21
+ const ACTIVE_TOOLS = ["read", "bash", "edit", "write", "subagent_run"] as const;
22
+ const MIRROR_ROOT = join(REPO_ROOT, "contracts", "review-provider-contract-mirror");
23
+
24
+ let scratch: string;
25
+ const previousEnvironment = new Map<string, string | undefined>();
26
+ before(() => {
27
+ scratch = mkdtempSync(join(tmpdir(), "gentle-pi-primary-prompt-"));
28
+ for (const key of ["GENTLE_PI_CONFIG_HOME", "GENTLE_PI_BACKGROUND_SUBAGENTS"]) previousEnvironment.set(key, process.env[key]);
29
+ process.env.GENTLE_PI_CONFIG_HOME = join(scratch, "config");
30
+ delete process.env.GENTLE_PI_BACKGROUND_SUBAGENTS;
31
+ });
32
+ after(() => {
33
+ for (const [key, value] of previousEnvironment) {
34
+ if (value === undefined) delete process.env[key];
35
+ else process.env[key] = value;
36
+ }
37
+ rmSync(scratch, { recursive: true, force: true });
38
+ });
39
+
40
+ function renderPrimaryPrompt(): string {
41
+ const prompt = __testing.buildGentlePrompt("gentleman", join(scratch, "project"), ACTIVE_TOOLS);
42
+ return prompt.replaceAll(join(REPO_ROOT, "assets"), "{{GENTLE_PI_ASSETS_ROOT}}");
43
+ }
44
+
45
+ function mirroredRoutingText(): string {
46
+ const lock = JSON.parse(readFileSync(join(MIRROR_ROOT, "provider-contract.lock.json"), "utf8")) as { contract_semver: string };
47
+ return readFileSync(join(MIRROR_ROOT, `v${lock.contract_semver}`, "bundle", "routing", "pi.md"), "utf8");
48
+ }
49
+
50
+ // Writes a one-file mirror whose lock records `entrySha256` for routing/pi.md.
51
+ function tempRoutingMirror(text: string, entrySha256 = createHash("sha256").update(text, "utf8").digest("hex")): string {
52
+ const root = mkdtempSync(join(scratch, "mirror-"));
53
+ const routingDir = join(root, "v9.9.9", "bundle", "routing");
54
+ mkdirSync(routingDir, { recursive: true });
55
+ writeFileSync(join(routingDir, "pi.md"), text, "utf8");
56
+ writeFileSync(join(root, "provider-contract.lock.json"), JSON.stringify({ contract_semver: "9.9.9", entries: { "routing/pi.md": entrySha256 } }), "utf8");
57
+ return root;
58
+ }
59
+
60
+ test("the rendered primary-session prompt is byte-identical to the golden captured from the base", () => {
61
+ const rendered = renderPrimaryPrompt();
62
+ if (process.env.UPDATE_PRIMARY_SESSION_PROMPT_GOLDEN === "1") writeFileSync(GOLDEN_PATH, rendered, "utf8");
63
+ assert.equal(rendered, readFileSync(GOLDEN_PATH, "utf8"));
64
+ });
65
+
66
+ test("the ODD steps, phase reporting, and resume lines come from the mirrored routing/pi.md without its blank lines", () => {
67
+ const routing = mirroredRoutingText();
68
+ const section = routing.slice(routing.indexOf("### ODD protocol"), routing.indexOf("\n### Task Size"));
69
+ const expected = section.split("\n").slice(1).filter((line) => line !== "");
70
+ assert.equal(expected.length, 9);
71
+ assert.equal(__testing.readMirroredOddProtocolSteps(), expected.join("\n"));
72
+ assert.ok(renderPrimaryPrompt().includes(`Run these steps in this order on every request:\n${expected.join("\n")}\n\nHarness principles:`));
73
+ assert.ok(routing.includes(`${expected[6]}\n\n${expected[7]}\n\n${expected[8]}`), "the bundle separates step 7, phase reporting, and resume with blank lines");
74
+ });
75
+
76
+ test("extensions/gentle-ai.ts no longer carries the ODD step lines as string literals", () => {
77
+ const source = readFileSync(join(REPO_ROOT, "extensions", "gentle-ai.ts"), "utf8");
78
+ for (const literal of ["1. **Authorize.**", "5. **Track before the first write.**", "7. **Close.**", "Phase reporting: the Gentle Shell prompt label", "Resume an interrupted feature with"]) {
79
+ assert.ok(!source.includes(literal), `extensions/gentle-ai.ts still hard-codes ${JSON.stringify(literal)}`);
80
+ }
81
+ });
82
+
83
+ test("a changed mirrored routing block changes the rendered steps", () => {
84
+ const routing = mirroredRoutingText().replace("1. **Authorize.** ", "1. **Authorize.** Changed upstream. ");
85
+ const mirror = tempRoutingMirror(routing);
86
+ assert.match(__testing.readMirroredOddProtocolSteps(mirror), /^1\. \*\*Authorize\.\*\* Changed upstream\. /);
87
+ });
88
+
89
+ test("a tampered or missing mirrored routing/pi.md fails closed with an explicit error", () => {
90
+ const unavailable = /Gentle Shell cannot render its ODD steps: the mirrored provider bundle's routing\/pi\.md is missing, unreadable, or fails digest verification/;
91
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(mirroredRoutingText(), "0".repeat(64))), unavailable);
92
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(join(scratch, "absent-mirror")), unavailable);
93
+ });
94
+
95
+ test("a routing block whose ODD protocol section changed shape fails closed", () => {
96
+ const unexpectedShape = /routing\/pi\.md ODD protocol section has an unexpected shape/;
97
+ const withoutResume = mirroredRoutingText().replace(/\nResume an interrupted feature[^\n]*\n/, "\n");
98
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(withoutResume)), unexpectedShape);
99
+ const withoutSection = mirroredRoutingText().replace("### ODD protocol (MANDATORY, in this order, on every request)", "### ODD protocol");
100
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(withoutSection)), unexpectedShape);
101
+ });
@@ -45,6 +45,26 @@ function fixture120Entries(): Map<string, Buffer> {
45
45
  return new Map(FIXTURE_1_2_0_PATHS.map((path) => [path, readFileSync(join(FIXTURE_1_2_0_ROOT, path))]));
46
46
  }
47
47
 
48
+ // Fixture provenance: the gentle-ai provider contract bundle 1.4.0 archive
49
+ // built by goreleaser (archives `review-provider-contract`) from gentle-ai
50
+ // 9e6f24f6, extracted. Adds `routing` to the manifest: Pi's ODD routing steps.
51
+ const FIXTURE_1_4_0_ROOT = join(import.meta.dirname, "fixtures", "provider-contract-bundle", "v1.4.0");
52
+ const FIXTURE_1_4_0_PATHS = [...FIXTURE_1_2_0_PATHS, "routing/pi.md"] as const;
53
+
54
+ function fixture140Entries(): Map<string, Buffer> {
55
+ return new Map(FIXTURE_1_4_0_PATHS.map((path) => [path, readFileSync(join(FIXTURE_1_4_0_ROOT, path))]));
56
+ }
57
+
58
+ // A valid 1.3.0-shaped bundle: the 1.4.0 fixture without the routing field and file.
59
+ function contractOneThreeEntries(): Map<string, Buffer> {
60
+ const entries = withManifest(fixture140Entries(), (manifest) => {
61
+ manifest.contract_semver = "1.3.0";
62
+ delete manifest.routing;
63
+ });
64
+ entries.delete("routing/pi.md");
65
+ return entries;
66
+ }
67
+
48
68
  function sha256Hex(bytes: Buffer): string {
49
69
  return createHash("sha256").update(bytes).digest("hex");
50
70
  }
@@ -391,6 +411,104 @@ test("rejects an orchestration array on a 1.1.0 manifest", () => {
391
411
  );
392
412
  });
393
413
 
414
+ // --- routing (contract 1.4.0) ----------------------------------------------------------
415
+
416
+ test("verifies the 1.4.0 fixture tree and exposes the mirrored pi routing text", () => {
417
+ const bundle = verifyProviderContractBundleTree(FIXTURE_1_4_0_ROOT);
418
+ assert.equal(bundle.contractSemver, "1.4.0");
419
+ assert.equal(bundle.entries.size, 10);
420
+ assert.deepEqual([...bundle.orchestration.keys()], [PI_RUNTIME_IDENTITY]);
421
+ assert.deepEqual([...bundle.routing.keys()], [PI_RUNTIME_IDENTITY]);
422
+ assert.equal(bundle.routing.get(PI_RUNTIME_IDENTITY), readFileSync(join(FIXTURE_1_4_0_ROOT, "routing/pi.md"), "utf8"));
423
+ assert.equal(bundle.entrySha256.get("routing/pi.md"), sha256Hex(readFileSync(join(FIXTURE_1_4_0_ROOT, "routing/pi.md"))));
424
+ });
425
+
426
+ test("verifies the 1.4.0 release archive in memory", () => {
427
+ withTemporaryDirectory((directory) => {
428
+ const archivePath = join(directory, "gentle-ai-review-provider-contract-1.4.0.tar.gz");
429
+ writeFileSync(archivePath, buildTarGz([...fixture140Entries()].map(([name, bytes]) => ({ name, bytes }))));
430
+ const { bundle } = verifyProviderContractBundleArchive(archivePath);
431
+ assert.equal(bundle.contractSemver, "1.4.0");
432
+ assert.deepEqual([...bundle.routing.keys()], [PI_RUNTIME_IDENTITY]);
433
+ });
434
+ });
435
+
436
+ test("a 1.3.0 bundle without routing still verifies with an empty routing inventory", () => {
437
+ const bundle = verifyProviderContractBundleEntries(contractOneThreeEntries());
438
+ assert.equal(bundle.contractSemver, "1.3.0");
439
+ assert.equal(bundle.entries.size, 9);
440
+ assert.equal(bundle.routing.size, 0);
441
+ assert.deepEqual([...bundle.orchestration.keys()], [PI_RUNTIME_IDENTITY]);
442
+ });
443
+
444
+ test("rejects a 1.4.0 manifest without the required routing array", () => {
445
+ const entries = withManifest(fixture140Entries(), (manifest) => {
446
+ delete manifest.routing;
447
+ });
448
+ assertRejects(entries, "manifest.routing is required from contract 1.4.0");
449
+ });
450
+
451
+ test("rejects a routing array on a 1.3.0 manifest", () => {
452
+ const entries = withManifest(fixture140Entries(), (manifest) => {
453
+ manifest.contract_semver = "1.3.0";
454
+ });
455
+ assertRejects(entries, "manifest.routing is not part of contract 1.3.0");
456
+ });
457
+
458
+ test("rejects a 1.4.0 routing entry whose runtime is not registered in manifest.runtimes", () => {
459
+ assertRejects(
460
+ withManifest(fixture140Entries(), (manifest) => {
461
+ const routing = manifest.routing as { runtime: string; file: { path: string } }[];
462
+ routing[0]!.runtime = "aider";
463
+ routing[0]!.file.path = "routing/aider.md";
464
+ }),
465
+ 'manifest.routing[0].runtime "aider" is not registered in manifest.runtimes',
466
+ );
467
+ });
468
+
469
+ test("rejects a 1.4.0 routing entry that points outside routing/<runtime>.md", () => {
470
+ assertRejects(
471
+ withManifest(fixture140Entries(), (manifest) => {
472
+ (manifest.routing as { file: { path: string } }[])[0]!.file.path = "orchestration/pi.md";
473
+ }),
474
+ 'manifest.routing[0].file.path must be "routing/pi.md"',
475
+ );
476
+ });
477
+
478
+ test("rejects a 1.4.0 routing entry whose file bytes do not match its manifest SHA-256", () => {
479
+ const entries = fixture140Entries();
480
+ const tampered = Buffer.from(entries.get("routing/pi.md") as Buffer);
481
+ tampered[tampered.length - 2] = 0x20;
482
+ entries.set("routing/pi.md", tampered);
483
+ assertRejects(entries, "does not match its manifest SHA-256");
484
+ });
485
+
486
+ test("rejects a 1.4.0 bundle missing its routing file", () => {
487
+ const entries = fixture140Entries();
488
+ entries.delete("routing/pi.md");
489
+ assertRejects(entries, "bundle inventory has 9 files; exactly 10 are expected");
490
+ });
491
+
492
+ test("the raw-entry bound leaves room for one routing file per runtime before the exact count decides", () => {
493
+ const entries = fixture140Entries();
494
+ for (let index = 0; index < 70; index += 1) entries.set(`extra/${index}.md`, Buffer.from("x\n"));
495
+ assertRejects(entries, "bundle inventory has 80 files; exactly 10 are expected");
496
+ });
497
+
498
+ test("generated 1.4.0 baselines record the mirrored routing entry", () => {
499
+ const bundle = verifyProviderContractBundleTree(FIXTURE_1_4_0_ROOT);
500
+ const capabilities = JSON.parse(
501
+ generateProviderContractBaselines(bundle).get("provider-capabilities.baseline.json") as string,
502
+ ) as { routing: { runtime: string; path: string; sha256: string }[] };
503
+ assert.deepEqual(capabilities.routing, [
504
+ { runtime: PI_RUNTIME_IDENTITY, path: "routing/pi.md", sha256: bundle.entrySha256.get("routing/pi.md") },
505
+ ]);
506
+ const older = JSON.parse(
507
+ generateProviderContractBaselines(verifyProviderContractBundleTree(FIXTURE_1_2_0_ROOT)).get("provider-capabilities.baseline.json") as string,
508
+ ) as { routing: unknown[] };
509
+ assert.deepEqual(older.routing, []);
510
+ });
511
+
394
512
  // --- archive safety ------------------------------------------------------------------
395
513
 
396
514
  test("rejects a tar entry that escapes the bundle root", () => {
@@ -31,6 +31,10 @@ const FIXTURE_PATHS = [
31
31
  // release archive, extracted. gentle-pi#560 / gentle-ai#4056, #4057.
32
32
  const FIXTURE_1_2_0_ROOT = join(import.meta.dirname, "fixtures", "provider-contract-bundle", "v1.2.0");
33
33
 
34
+ // Fixture provenance: the gentle-ai provider contract bundle 1.4.0 archive
35
+ // built by goreleaser, extracted. Adds Pi's ODD routing block as routing/pi.md.
36
+ const FIXTURE_1_4_0_ROOT = join(import.meta.dirname, "fixtures", "provider-contract-bundle", "v1.4.0");
37
+
34
38
  interface LockRecord {
35
39
  schema: string;
36
40
  acquisition: string;
@@ -124,6 +128,26 @@ test("mirrors a verified local 1.2.0 bundle tree, including its orchestration en
124
128
  });
125
129
  });
126
130
 
131
+ test("mirrors a verified local 1.4.0 bundle tree, including its routing entry, and the offline drift check passes", () => {
132
+ withTemporaryRoot((packageRoot) => {
133
+ mirrorProviderContractBundle(FIXTURE_1_4_0_ROOT, packageRoot);
134
+ const lock = JSON.parse(
135
+ readFileSync(join(packageRoot, PROVIDER_CONTRACT_MIRROR_ROOT, PROVIDER_CONTRACT_LOCK_FILE), "utf8"),
136
+ ) as LockRecord;
137
+ assert.equal(lock.contract_semver, "1.4.0");
138
+ assert.equal(Object.keys(lock.entries).length, 10);
139
+ assert.ok("routing/pi.md" in lock.entries, "the mirrored lock must record routing/pi.md");
140
+ const mirrored = join(packageRoot, PROVIDER_CONTRACT_MIRROR_ROOT, "v1.4.0", "bundle", "routing", "pi.md");
141
+ assert.deepEqual(readFileSync(mirrored), readFileSync(join(FIXTURE_1_4_0_ROOT, "routing", "pi.md")));
142
+ assert.deepEqual(checkProviderContractMirror(packageRoot), []);
143
+
144
+ writeFileSync(mirrored, `${readFileSync(mirrored, "utf8")}tampered\n`);
145
+ const problems = checkProviderContractMirror(packageRoot);
146
+ assert.equal(problems.length, 1);
147
+ assert.match(problems[0] ?? "", /mirrored bundle failed verification: .*"routing\/pi\.md" does not match its manifest SHA-256/);
148
+ });
149
+ });
150
+
127
151
  test("mirrors a verified local archive and records the archive digest in the lock", () => {
128
152
  withTemporaryRoot((packageRoot) => {
129
153
  const archivePath = join(packageRoot, "gentle-ai-review-provider-contract-1.1.0.tar.gz");
@@ -1232,6 +1232,29 @@ test("quiet tool rendering preserves complete expanded text and delegates saniti
1232
1232
  assert.doesNotMatch(image, /\x1b/);
1233
1233
  });
1234
1234
 
1235
+ test("quiet tool rendering re-renders expanded read-with-image results without crashing when lastComponent is passed (#1620)", () => {
1236
+ const tools = registeredQuietTools();
1237
+ const readTool = tools.get("read");
1238
+ const result = {
1239
+ content: [{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }],
1240
+ details: { path: "image.png" },
1241
+ };
1242
+ const context = { args: { path: "image.png" }, cwd: "/repo", showImages: false, isError: false };
1243
+
1244
+ const initialComponent = readTool.renderResult(result, { expanded: true, isPartial: false }, passthroughTheme, context);
1245
+ const initialOutput = renderToString(initialComponent);
1246
+ assert.ok(initialOutput.includes(imageFallback("image/png")));
1247
+
1248
+ const reRenderComponent = readTool.renderResult(
1249
+ result,
1250
+ { expanded: true, isPartial: false },
1251
+ passthroughTheme,
1252
+ { ...context, lastComponent: initialComponent },
1253
+ );
1254
+ const reRenderOutput = renderToString(reRenderComponent);
1255
+ assert.ok(reRenderOutput.includes(imageFallback("image/png")));
1256
+ });
1257
+
1235
1258
  test("quiet tool rendering preserves directional visual preview rows at narrow widths", () => {
1236
1259
  const tools = registeredQuietTools();
1237
1260
  const bash = tools.get("bash");
@@ -48,13 +48,18 @@ const ROUTING_SENTENCE =
48
48
  'After the writer returns, call `gentle_review` with `{"operation":"assess"}` over the writer\'s diff and follow the returned plan, whatever the `Receipt-driven development:` line reads (`on`, `off`, or `unknown`), instead of judging non-triviality from the task description: the operation resolves the native risk tier and states exactly who verifies next.';
49
49
  const RDD_ON_SENTENCE =
50
50
  "When the line reads `on`, the native review is an additional outside view of the change; it never replaces or skips the tier's verification, and the tier's verifier runs before review.";
51
- const TIER_TABLE_HEADER = "| Native risk tier | Verification (any RDD state) |";
51
+ // Verification by named risk changed the tier table and replaced the
52
+ // small-model bias sentence: the independent verifier runs only through an
53
+ // `escalate` naming a Task Size high-risk item; a native high, a failed
54
+ // assessment, and the writer's model or effort inform only.
55
+ const TIER_TABLE_HEADER = "| Tier | Verification (any RDD state) |";
52
56
  const PASSIVE_TIER_ROW = "| passive | structural readback by the parent; no separate verifier, no tests |";
53
- 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) |";
54
- const HIGH_TIER_ROW = "| high | writer self-verification plus a separate `gentle-ai-verify` run, always |";
55
- const UNASSESSABLE_TIER_ROW = "| unknown / assess failed | treated as high |";
56
- const SMALL_MODEL_BIAS_SENTENCE =
57
- "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`.";
57
+ const MEDIUM_TIER_ROW = "| medium | writer self-verification stands; no separate verifier, whatever model wrote the change |";
58
+ const HIGH_TIER_ROW = "| high | only through `escalate` naming a Task Size high-risk item: writer self-verification plus one separate `gentle-ai-verify` run |";
59
+ const UNASSESSABLE_TIER_ROW = "| native high, unknown, or assess failed | informs only: writer self-verification stands; escalate when a named high-risk item applies |";
60
+ // odd/tasks/verifier-admission.md S6: the sentence names the runtime admission of the verifier launch.
61
+ const NAMED_RISK_SENTENCE =
62
+ "The independent verifier runs only when the change touches a named Task Size high-risk item, passed as `escalate` (the runtime admits its launch on a `Risk: item N` line, a feature-document task line, or past the context backstop); never for size, a native tier alone, a failed assessment, or the model or effort that wrote the change. An unknown `Receipt-driven development:` line never lowers a tier below `off`.";
58
63
  const SPOT_CHECK_SENTENCE =
59
64
  "The parent spot check (re-running one reported command before delivery) stays required in every tier.";
60
65
  test("trigger 5 (Verification rule) routes every RDD line through assess and the risk tier", () => {
@@ -74,8 +79,9 @@ test("trigger 5 states the native risk tier table exactly once, with all four ro
74
79
  }
75
80
  });
76
81
 
77
- test("trigger 5 states the small-model bias and the unknown-never-lowers-a-tier rule", () => {
78
- assert.ok(delegation.includes(SMALL_MODEL_BIAS_SENTENCE), "trigger 5 is missing the small-model bias sentence");
82
+ test("trigger 5 states the named-risk verifier rule and the unknown-never-lowers-a-tier rule", () => {
83
+ assert.ok(delegation.includes(NAMED_RISK_SENTENCE), "trigger 5 is missing the named-risk verifier sentence");
84
+ assert.ok(!delegation.includes("small-model bias"), "the retired small-model bias must not survive in any orchestrator module");
79
85
  });
80
86
 
81
87
  test("trigger 5 keeps the parent spot check requirement in every tier", () => {
@@ -99,10 +105,11 @@ test("trigger 5 never restates the retired #661 off/unknown non-trivial judgment
99
105
  assert.doesNotMatch(delegation, /purely passive documentation with no behavior to verify/);
100
106
  });
101
107
 
108
+ // S9 canon change (gentle-ai 87a3b2eb): the trigger section heading dropped "Mandatory".
102
109
  test("the Simple Delegation paragraph references trigger 3 instead of restating the on/off/unknown routing", () => {
103
110
  assert.match(
104
111
  delegation,
105
- /per the RDD-aware Verification rule \(trigger 3 under Mandatory Delegation Triggers, gentle-pi#661\)/,
112
+ /per the RDD-aware Verification rule \(trigger 3 under Delegation Triggers, gentle-pi#661\)/,
106
113
  );
107
114
  assert.match(delegation, /the normative on\/off\/unknown routing lives in `orchestrator-verification.md`, not here/);
108
115
  });
@@ -223,7 +230,9 @@ test("worker asset keeps the existing Return contract fields", () => {
223
230
  // delegation asset, and docs/delegated-verification.md -- and they must say
224
231
  // the same thing about the behavior ASSESS implements: closure only from the
225
232
  // native `candidate.consumed` fact for this candidate, the runtime writer
226
- // profile with a caller fallback, the conservative small-model bias, the
233
+ // profile with a caller fallback, a writer profile that never adds a verifier
234
+ // (verification by named risk retired the small-model bias), the escalate-only
235
+ // independent verifier, the
227
236
  // declined/unavailable/unknown fallback, and functional checks that a native
228
237
  // review never replaces. Assertions use short stable phrases, normalized for
229
238
  // case, quoting, and line wrapping, not whole paragraphs.
@@ -267,7 +276,8 @@ const SHARED_POLICY_PHRASES: ReadonlyArray<readonly [string, string]> = [
267
276
  ["caller closure claim is not authority", "a caller-supplied closed is not authority"],
268
277
  ["runtime writer profile", "runtime-recorded model and effort of the pending mutations"],
269
278
  ["caller profile is only a fallback", "only a fallback when no runtime evidence exists"],
270
- ["conservative small-model bias", "keeps the conservative small-model bias"],
279
+ ["writer profile never adds a verifier", "the writer profile is reported but never adds a verifier"],
280
+ ["independent verifier only through escalate", "only when escalate names a task size high-risk item"],
271
281
  ["native review never replaces verification (verify-always-rdd-high S1)", "native review is an additional outside view"],
272
282
  ["functional checks still run", "a native code review is not a substitute for applicable functional checks"],
273
283
  ];
@@ -280,6 +290,14 @@ test("the three verification policy surfaces carry the same ASSESS closure, prof
280
290
  }
281
291
  });
282
292
 
293
+ test("no verification policy surface still carries the retired small-model bias or unassessable-as-high rule", () => {
294
+ for (const [surface, text] of POLICY_SURFACES) {
295
+ for (const stale of ["small-model bias", "treated as high", "verified exactly like high", "fail closed to high"]) {
296
+ assert.ok(!text.includes(stale), `${surface} still carries the retired wording: "${stale}"`);
297
+ }
298
+ }
299
+ });
300
+
283
301
  test("no verification policy surface still tells callers to pass closed explicitly (gentle-pi#1175)", () => {
284
302
  for (const [surface, text] of POLICY_SURFACES) {
285
303
  for (const stale of ["closed is never derived", "closed is never auto-derived", "closed is never inferred", "pass it explicitly", "pass it only right after"]) {
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { __testing } from "../extensions/gentle-ai.ts";
4
4
  import {
5
+ NATIVE_REVIEW_ERROR_CODE,
5
6
  NATIVE_REVIEW_MODE_OPERATION,
7
+ NATIVE_REVIEW_OPERATION,
8
+ NativeReviewCliError,
6
9
  NATIVE_REVIEW_MODE_SOURCE,
7
10
  type NativeReviewCli,
8
11
  type NativeReviewModeRequest,
@@ -31,6 +34,7 @@ import {
31
34
 
32
35
  const {
33
36
  renderRddStatusLine,
37
+ pinRddStatusLine,
34
38
  resolveRddModeStatus,
35
39
  resolveRddStatusLine,
36
40
  getOrchestratorPrompt,
@@ -358,3 +362,78 @@ test("resolveRddModeStatus stays unknown when no status was ever read", async ()
358
362
  const cli = fakeReviewMode(() => { throw new Error("never available"); });
359
363
  assert.equal(await resolveRddModeStatus(cli, "/repo-never-known"), undefined);
360
364
  });
365
+
366
+ // gentle-shell#2250: the one-shot unavailable notice must name the cause so a
367
+ // user on a slow or foreign-owned filesystem can tell a timeout from a native
368
+ // refusal instead of reading a generic "absent, timed out, or failed".
369
+ function captureNotices(): { ctx: { hasUI: true; ui: { notify(message: string, level: string): void } }; messages: string[] } {
370
+ const messages: string[] = [];
371
+ return { ctx: { hasUI: true, ui: { notify(message: string) { messages.push(message); } } }, messages };
372
+ }
373
+
374
+ test("the unavailable notice names the native failure and its stderr", async () => {
375
+ clearRddStatusMemoForTesting();
376
+ const { ctx, messages } = captureNotices();
377
+ const cli = fakeReviewMode(() => {
378
+ throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.MODE, true, false, "native process failed", {
379
+ operation: NATIVE_REVIEW_OPERATION.MODE,
380
+ error_code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
381
+ exit_code: 1,
382
+ timed_out: false,
383
+ output_limit_exceeded: false,
384
+ stderr: "fatal: detected dubious ownership in repository",
385
+ });
386
+ });
387
+ assert.equal(await resolveRddModeStatus(cli, "/repo-native-failure", undefined, Date.now, ctx as never), undefined);
388
+ assert.equal(messages.length, 1);
389
+ assert.match(messages[0], /status is unavailable: native `gentle-ai review mode status` failed \(non-zero, exit 1\): fatal: detected dubious ownership in repository\./);
390
+ assert.match(messages[0], /keeps the last known status/);
391
+ });
392
+
393
+ test("the unavailable notice reports a timeout distinctly", async () => {
394
+ clearRddStatusMemoForTesting();
395
+ const { ctx, messages } = captureNotices();
396
+ const keepalive = setTimeout(() => {}, 2000);
397
+ try {
398
+ await resolveRddModeStatus(fakeReviewMode(() => new Promise<never>(() => {})), "/repo-timeout", AbortSignal.timeout(50), Date.now, ctx as never);
399
+ } finally {
400
+ clearTimeout(keepalive);
401
+ }
402
+ assert.equal(messages.length, 1);
403
+ assert.match(messages[0], new RegExp(`status is unavailable: native \`gentle-ai review mode status\` did not answer within ${RDD_STATUS_TIMEOUT_MS} ms\.`));
404
+ });
405
+
406
+ test("the unavailable notice reports an absent native review CLI", async () => {
407
+ clearRddStatusMemoForTesting();
408
+ const { ctx, messages } = captureNotices();
409
+ await resolveRddModeStatus(null, "/repo-absent", undefined, Date.now, ctx as never);
410
+ assert.equal(messages.length, 1);
411
+ assert.match(messages[0], /status is unavailable: the native gentle-ai review CLI is not available\./);
412
+ });
413
+
414
+ test("the unavailable notice still fires only once per session", async () => {
415
+ clearRddStatusMemoForTesting();
416
+ const { ctx, messages } = captureNotices();
417
+ const cli = fakeReviewMode(() => { throw new Error("boom"); });
418
+ await resolveRddModeStatus(cli, "/repo-once-a", undefined, Date.now, ctx as never);
419
+ await resolveRddModeStatus(cli, "/repo-once-b", undefined, Date.now, ctx as never);
420
+ assert.equal(messages.length, 1);
421
+ assert.match(messages[0], /status is unavailable: boom\./);
422
+ });
423
+
424
+ test("pinRddStatusLine keeps the session's first line until the off state changes (#2195)", () => {
425
+ const unknown = "Receipt-driven development: unknown (native status unavailable)";
426
+ const onGlobal = "Receipt-driven development: on (decided by global)";
427
+ const onClone = "Receipt-driven development: on (decided by clone)";
428
+ const off = "Receipt-driven development: off (decided by clone)";
429
+ const offGlobal = "Receipt-driven development: off (decided by global)";
430
+ assert.equal(pinRddStatusLine(undefined, onGlobal), onGlobal, "the first render of a session is the resolved line");
431
+ assert.equal(pinRddStatusLine(unknown, onGlobal), unknown, "unknown to on keeps the cached prompt prefix");
432
+ assert.equal(pinRddStatusLine(onGlobal, unknown), onGlobal, "on to unknown keeps the cached prompt prefix");
433
+ assert.equal(pinRddStatusLine(onGlobal, onClone), onGlobal, "a deciding-source change alone keeps the prefix");
434
+ assert.equal(pinRddStatusLine(off, offGlobal), off, "a source change while off keeps the prefix");
435
+ assert.equal(pinRddStatusLine(onGlobal, off), off, "turning RDD off re-renders, since it drops the review contract");
436
+ assert.equal(pinRddStatusLine(unknown, off), off, "unknown to off re-renders");
437
+ assert.equal(pinRddStatusLine(off, onGlobal), onGlobal, "turning RDD back on re-renders");
438
+ assert.equal(pinRddStatusLine(off, unknown), unknown, "off to unknown re-renders, since unknown keeps the review contract");
439
+ });
@@ -128,13 +128,18 @@ function toolContext(context: ExtensionContext): ExtensionToolContext {
128
128
  async function withSessionStartEnv<T>(callback: (cwd: string) => Promise<T>, initializeGit = true): Promise<T> {
129
129
  const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
130
130
  const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
131
+ // Each call made three temp dirs and never removed them (87 left behind per suite run).
132
+ const created: string[] = [];
131
133
  process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-agent-home-"));
134
+ created.push(process.env.GENTLE_PI_AGENT_HOME);
132
135
  // Isolates both the model-config sweep and the dev-binary registration
133
136
  // lookup from this machine's real ~/.pi/gentle-ai, so `session_start`'s
134
137
  // unrelated notifications never leak into these assertions.
135
138
  process.env.GENTLE_PI_CONFIG_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-config-home-"));
139
+ created.push(process.env.GENTLE_PI_CONFIG_HOME);
136
140
  try {
137
141
  const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-cwd-"));
142
+ created.push(cwd);
138
143
  if (initializeGit) childProcess.execFileSync("git", ["init", "--quiet", cwd]);
139
144
  await mkdir(join(cwd, "src"));
140
145
  await writeFile(join(cwd, "src/example.ts"), "export const value = 1;");
@@ -144,6 +149,7 @@ async function withSessionStartEnv<T>(callback: (cwd: string) => Promise<T>, ini
144
149
  else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
145
150
  if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
146
151
  else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
152
+ for (const dir of created) await rm(dir, { recursive: true, force: true });
147
153
  }
148
154
  }
149
155
 
@@ -1326,6 +1326,55 @@ test("candidate view recursively protects nested content and worktree metadata,
1326
1326
  }
1327
1327
  });
1328
1328
 
1329
+ // macOS File Provider (iCloud-synced Desktop/Documents) asynchronously resets
1330
+ // directory modes to exactly 0700 while leaving file modes intact.
1331
+ function simulateFileProviderDirectoryReset(root: string, directories: readonly string[]): void {
1332
+ for (const directory of directories) chmodSync(join(root, directory), 0o700);
1333
+ }
1334
+
1335
+ test("candidate view repairs owner-only directories reset by macOS File Provider and stays verifiable", { skip: process.platform === "win32" }, (t) => {
1336
+ const contributorRoot = repository(t);
1337
+ mkdirSync(join(contributorRoot, "nested", "deeper"), { recursive: true });
1338
+ writeFileSync(join(contributorRoot, "nested", "deeper", "candidate.txt"), "candidate\n");
1339
+ const view = createCandidateView({ contributorRoot });
1340
+ try {
1341
+ simulateFileProviderDirectoryReset(view.root, ["nested/deeper", "nested", "."]);
1342
+ view.verify();
1343
+ for (const directory of [".", "nested", "nested/deeper"]) assert.equal(lstatSync(join(view.root, directory)).mode & 0o777, 0o555, directory);
1344
+ assert.equal(lstatSync(join(view.root, "nested", "deeper", "candidate.txt")).mode & 0o777, 0o444);
1345
+ } finally {
1346
+ view.cleanup();
1347
+ }
1348
+ });
1349
+
1350
+ test("candidate view does not repair owner-only directories carrying special mode bits", { skip: process.platform === "win32" }, (t) => {
1351
+ const contributorRoot = repository(t);
1352
+ mkdirSync(join(contributorRoot, "nested"), { recursive: true });
1353
+ writeFileSync(join(contributorRoot, "nested", "candidate.txt"), "candidate\n");
1354
+ const view = createCandidateView({ contributorRoot });
1355
+ try {
1356
+ chmodSync(join(view.root, "nested"), 0o1700);
1357
+ assert.throws(() => view.verify(), /candidate view directory is unsafe or writable/);
1358
+ } finally {
1359
+ view.cleanup();
1360
+ }
1361
+ });
1362
+
1363
+ test("candidate view refuses an ignored entry injected into an owner-only directory it would repair", { skip: process.platform === "win32" }, (t) => {
1364
+ const contributorRoot = repository(t);
1365
+ mkdirSync(join(contributorRoot, "nested"), { recursive: true });
1366
+ writeFileSync(join(contributorRoot, ".gitignore"), "*.log\n");
1367
+ writeFileSync(join(contributorRoot, "nested", "candidate.txt"), "candidate\n");
1368
+ const view = createCandidateView({ contributorRoot });
1369
+ try {
1370
+ simulateFileProviderDirectoryReset(view.root, ["nested"]);
1371
+ writeFileSync(join(view.root, "nested", "injected.log"), "injected\n");
1372
+ assert.throws(() => view.verify(), /candidate view directory contains unexpected entries/);
1373
+ } finally {
1374
+ view.cleanup();
1375
+ }
1376
+ });
1377
+
1329
1378
  test("candidate view registry rejects unsafe, moved, writable, stale, and unselected lens contexts before dispatch", (t) => {
1330
1379
  const contributorRoot = repository(t);
1331
1380
  writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
@@ -109,9 +109,12 @@ test("before_agent_start injects the mirrored review execution contract for the
109
109
  assert.match(appended, /no meaningful RED, explain why and run proportionate ordinary functional or structural verification/);
110
110
  assert.match(appended, /Never invent lifecycle evidence or skip checks/);
111
111
  assert.doesNotMatch(appended, /If tests exist, use strict TDD/);
112
- assert.match(appended, /ODD \(Default Workflow, harness section above\) is mandatory on every request/);
112
+ // S11 T3: deduplicated. The core pointer sentence restated the harness; the
113
+ // mandatory ODD entry is injected once, from the harness steps' header.
114
+ assert.match(appended, /Organic Driven Development \(ODD\) is the predefined workflow of this orchestrator\. Every request enters it/);
115
+ assert.doesNotMatch(appended, /ODD \(Default Workflow, harness section above\)/);
113
116
  assert.doesNotMatch(appended, /Prefer SDD\/OpenSpec artifacts/);
114
- assert.match(appended, /## Gentle AI review execution contract \(mirrored provider bundle 1\.3\.0\)/);
117
+ assert.match(appended, /## Gentle AI review execution contract \(mirrored provider bundle 1\.4\.0\)/);
115
118
  assert.ok(appended.includes(expected), "the mirrored orchestration/pi.md text must appear verbatim");
116
119
  assert.match(appended, /call `gentle_review` with {"operation":"inspect"}/);
117
120
  assert.match(appended, /call `gentle_review` with operation `status`, the exact retained `lineageId`, and `workspaceRoot`/);