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
@@ -6,13 +6,24 @@ import fsp from "node:fs/promises";
6
6
  import os, { tmpdir } from "node:os";
7
7
  import { syncBuiltinESMExports } from "node:module";
8
8
  import { dirname, join, resolve, sep } from "node:path";
9
- import test from "node:test";
9
+ import test, { after, before } from "node:test";
10
+ import { setWindowsAclAuthorityForTesting } from "../lib/review-candidate-view-owner.ts";
10
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
12
  import { __testing, createGentleAiExtension, PendingReviewConsentRegistry } from "../extensions/gentle-ai.ts";
12
13
  import { CandidateViewError, CandidateViewRegistry } from "../lib/review-candidate-view.ts";
13
14
  import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, NativeReviewConsentRequiredError, type NativeReviewCli } from "../lib/native-review-cli.ts";
14
15
  import { decodeReviewLastEventClosureV1, decodeReviewConsentV3, decodeReviewStatusV3, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
15
16
 
17
+ // Coordination tests use fixture authority; real Windows ACL coverage lives
18
+ // in review-candidate-view.test.ts, in a separate test process.
19
+ let fixtureAclChecks = 0;
20
+ before(() => {
21
+ if (process.platform === "win32") {
22
+ setWindowsAclAuthorityForTesting(() => { fixtureAclChecks++; });
23
+ }
24
+ });
25
+ after(() => setWindowsAclAuthorityForTesting());
26
+
16
27
  const SHA = `sha256:${"a".repeat(64)}`;
17
28
  const TREE = "b".repeat(40);
18
29
 
@@ -1076,7 +1087,9 @@ function reviewContext(cwd: string): ExtensionContext {
1076
1087
 
1077
1088
  function startStatus(cwd: string, baseRef?: string, intendedUntracked: readonly string[] = []): ReviewStatusV3 {
1078
1089
  const candidateViews = new CandidateViewRegistry();
1090
+ const checksBefore = fixtureAclChecks;
1079
1091
  const view = candidateViews.create({ contributorRoot: cwd, intendedUntracked, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }) });
1092
+ if (process.platform === "win32") assert.ok(fixtureAclChecks > checksBefore, "coordination candidates must use the fixture ACL authority");
1080
1093
  try {
1081
1094
  return {
1082
1095
  contract: "gentle-ai.review-integration/v2",
@@ -406,7 +406,7 @@ test("shipped controller fails closed while static prompts defer RDD lifecycle o
406
406
  const core = readFileSync("assets/orchestrator.md", "utf8");
407
407
  assert.match(core, new RegExp(boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
408
408
 
409
- for (const path of ["assets/orchestrator-delegation.md", "skills/gentle-ai/SKILL.md"]) {
409
+ for (const path of ["assets/orchestrator-delegation.md", "assets/orchestrator-delegation-reference.md", "skills/gentle-ai/SKILL.md"]) {
410
410
  const contract = readFileSync(path, "utf8");
411
411
  assert.doesNotMatch(contract, /INSPECT before START|start -> finalize -> validate|next_transition|review\.capture-result/is, path);
412
412
  }
@@ -10,9 +10,9 @@ import { localProfilePinPath, repoProfileDeclarationPath, writeProfilePinSync }
10
10
  import { createProfile, emptyProfilesFile, profilesFilePath, writeProfilesFileSync } from "../lib/agent-profiles.ts";
11
11
  import type { AgentModelConfig } from "../lib/model-routing-authority.ts";
12
12
  import { GENTLE_AI_DEV_BINARY_ENV, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
13
- import type { NativeReviewCli, NativeReviewUnachievableLensCaptureArtifact, NativeReviewUnachievableLensCaptureRequest } from "../lib/native-review-cli.ts";
13
+ import { NativeReviewIntegrationError, type NativeReviewCli, type NativeReviewUnachievableLensCaptureArtifact, type NativeReviewUnachievableLensCaptureRequest } from "../lib/native-review-cli.ts";
14
14
  import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
15
- import { decodeReviewNextTransitionV3, decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
15
+ import { decodeReviewFailureV2, decodeReviewNextTransitionV3, decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
16
16
  import type { InProcessReviewerRegistry } from "../lib/inprocess-reviewer.ts";
17
17
 
18
18
  // One-slot capture routing: the host relay runs only when the selected
@@ -444,6 +444,63 @@ async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId:
444
444
 
445
445
  function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
446
446
 
447
+ for (const failAt of [0, 1, 2]) {
448
+ for (const typed of [true, false]) {
449
+ test(`grouped STATUS failure preserves ${typed ? "decoded diagnostics" : "untyped behavior"} at call ${failAt + 1}`, async (t) => {
450
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
451
+ const cwd = repository(t), lineageId = "relay-group-status-failure", inputs = groupInputs(lineageId);
452
+ const failure = decodeReviewFailureV2({
453
+ schema: "gentle-ai.review-integration.failure/v2", contract: "gentle-ai.review-integration/v2",
454
+ operation: "review.status", phase: "native_running", code: "native_operation_timeout",
455
+ message: "The negotiated review operation exceeded its aggregate time budget.",
456
+ mutation_outcome: "not_started", authority_applicability: "current_target", retry_safe: false,
457
+ replayability: "manual_action_required", required_inputs: [], next_action: "explicit-maintainer-action",
458
+ cause: "STATUS budget exhausted", context: { binding_revision: { expected: SHA, current: PHASE_REVISION } },
459
+ });
460
+ const error = typed ? new NativeReviewIntegrationError(failure) : Object.assign(new Error(failure.message), {
461
+ failureEnvelope: { raw: { secret: "must not be projected" } }, stdout: "private stdout", stderr: "private stderr",
462
+ });
463
+ const harness = nativeHarness([]);
464
+ let calls = 0, preparations = 0, submissions = 0;
465
+ harness.native.targetStatus = async () => {
466
+ const index = calls++;
467
+ if (index === failAt) throw error;
468
+ return finalizeStatus(lineageId, index === 0 ? inputs : inputs.slice(index - 1));
469
+ };
470
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => {
471
+ preparations += 1;
472
+ return requests.map(prepared);
473
+ }, async (result) => {
474
+ submissions += 1;
475
+ return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" };
476
+ });
477
+ const result = await runCaptureGroup(cwd, harness, lineageId, inputs);
478
+ assert.equal(result.outcome, "native-status-failed");
479
+ assert.equal(result.status, "blocked");
480
+ assert.equal(result.reason, failure.message);
481
+ assert.equal(calls, failAt + 1, "no STATUS retry");
482
+ assert.equal(preparations, failAt === 0 ? 0 : 1);
483
+ assert.equal(submissions, Math.max(0, failAt - 1), "no submission after STATUS failure");
484
+ assert.equal(result.mutation_performed, submissions > 0);
485
+ assert.equal(result.mutation_outcome, submissions > 0 ? "partial" : "none");
486
+ if (failAt > 0) {
487
+ const progress = result.host_relay as { reviewers: unknown[] };
488
+ assert.equal(progress.reviewers.length, submissions);
489
+ }
490
+ if (typed) {
491
+ assert.deepEqual(result.native_failure, failure.raw);
492
+ assert.equal(result.replayability, failure.replayability);
493
+ assert.equal(result.next_action, failure.nextAction);
494
+ } else {
495
+ assert.equal(result.native_failure, undefined);
496
+ assert.equal(result.replayability, undefined);
497
+ assert.equal(result.next_action, undefined);
498
+ assert.doesNotMatch(JSON.stringify(result), /must not be projected|private stdout|private stderr/);
499
+ }
500
+ });
501
+ }
502
+ }
503
+
447
504
  test("grouped capture forecasts once, reaches a four-reviewer barrier, and reconciles unclosed submissions", async (t) => {
448
505
  t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
449
506
  const cwd = repository(t), lineageId = "relay-group", inputs = groupInputs(lineageId), lenses = inputs.map((input) => input.arguments.find((argument) => argument.name === "lens")!.value);
@@ -0,0 +1,237 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import type { AssistantMessage, JsonObject, Tool, ToolCall } from "@earendil-works/pi-ai";
8
+ import type { NativeReviewCli } from "../lib/native-review-cli.ts";
9
+
10
+ // gentle-pi S11 T1: with receipt-driven development off, the orchestrator only
11
+ // ever calls gentle_review ASSESS, so the parent request carries a short ASSESS
12
+ // guideline and leaves the capture, capture-group, and scope declarations out.
13
+ // With RDD on, the request is exactly what the registered tools declare. Both
14
+ // paths run through a real Pi SDK session and inspect the provider request.
15
+
16
+ const HIDDEN_WHEN_OFF = ["gentle_review_capture", "gentle_review_capture_group", "gentle_review_scope"] as const;
17
+
18
+ interface RecordedRequest { systemPrompt: string; tools: Tool[] }
19
+ interface SessionRun {
20
+ requests: RecordedRequest[];
21
+ registered: Map<string, { description: string; promptGuidelines?: readonly string[] }>;
22
+ assessResults: Array<{ isError: boolean; details: unknown }>;
23
+ errors: string[];
24
+ }
25
+
26
+ type RddMode = "on" | "off" | "throws";
27
+
28
+ function nativeCli(mode: () => RddMode): NativeReviewCli {
29
+ return {
30
+ reviewMode: async () => {
31
+ const effective = mode();
32
+ if (effective === "throws") throw new Error("native review mode is unavailable");
33
+ return { operation: "status", scope: "clone", status: { global: effective, cloneLocal: "", effective, source: "global" } };
34
+ },
35
+ assess: async () => ({
36
+ schema: "gentle-ai.review-assessment/v1",
37
+ risk: "medium",
38
+ reasons: [],
39
+ changedPaths: 1,
40
+ changedLines: 3,
41
+ candidate: { kind: "current-changes", baseRef: undefined },
42
+ }),
43
+ } as unknown as NativeReviewCli;
44
+ }
45
+
46
+ async function runSession(
47
+ root: string,
48
+ label: string,
49
+ bindings: Record<string, string>,
50
+ mode: () => RddMode,
51
+ beforeSecondPrompt: () => void = () => {},
52
+ ): Promise<SessionRun> {
53
+ const sdk = await import("@earendil-works/pi-coding-agent");
54
+ const ai = await import("@earendil-works/pi-ai");
55
+ const { createGentleAiExtension } = await import("../extensions/gentle-ai.ts");
56
+ const cwd = join(root, `repo-${label}`);
57
+ mkdirSync(cwd);
58
+ const gitEnv = { PATH: process.env.PATH, HOME: root, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: join(root, "gitconfig") };
59
+ writeFileSync(gitEnv.GIT_CONFIG_GLOBAL, "");
60
+ execFileSync("git", ["init", "--initial-branch=fixture"], { cwd, env: gitEnv, stdio: "ignore" });
61
+ execFileSync("git", ["-c", "user.name=SDK Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--allow-empty", "-m", "fixture"], { cwd, env: gitEnv, stdio: "ignore" });
62
+ const run: SessionRun = { requests: [], registered: new Map(), assessResults: [], errors: [] };
63
+ let next: { name: string; arguments: JsonObject } | undefined;
64
+ let calls = 0;
65
+ const model = { id: "driver", name: "Local driver", provider: "loadout-fixture", api: "loadout-fixture", baseUrl: "http://invalid.local", reasoning: false, input: ["text"], contextWindow: 1_000_000, maxTokens: 1024, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } } as const;
66
+ const manager = sdk.SessionManager.inMemory(cwd);
67
+ const settings = sdk.SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false }, cacheWarming: "off", packages: [] });
68
+ const runtime = await sdk.ModelRuntime.create({ credentials: new ai.InMemoryCredentialStore(), modelsPath: null, modelsStorePath: join(root, `models-${label}.json`), refreshOnCreate: false, allowModelNetwork: false });
69
+ const loader = new sdk.DefaultResourceLoader({
70
+ cwd, agentDir: bindings.PI_CODING_AGENT_DIR, settingsManager: settings,
71
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
72
+ extensionFactories: [(pi) => {
73
+ const registerTool = pi.registerTool.bind(pi);
74
+ pi.registerTool = ((definition: { name: string; description: string; promptGuidelines?: readonly string[] }) => {
75
+ run.registered.set(definition.name, definition);
76
+ return registerTool(definition as never);
77
+ }) as typeof pi.registerTool;
78
+ createGentleAiExtension({
79
+ nativeReviewCli: nativeCli(mode),
80
+ candidateViews: null,
81
+ processEnv: { ...bindings, GENTLE_AI_TELEMETRY: "0" },
82
+ resolveTelemetryTriggerBinary: () => { throw new Error("fixture"); },
83
+ })(pi);
84
+ pi.on("tool_result", (event) => {
85
+ if (event.toolName === "gentle_review") run.assessResults.push({ isError: event.isError, details: event.details });
86
+ });
87
+ pi.registerProvider("loadout-fixture", {
88
+ api: "loadout-fixture", apiKey: "fixture-only", baseUrl: "http://invalid.local",
89
+ models: [{ id: model.id, name: model.name, reasoning: false, input: ["text"], cost: model.cost, contextWindow: model.contextWindow, maxTokens: model.maxTokens }],
90
+ streamSimple(_model, context) {
91
+ run.requests.push({ systemPrompt: ai.getCurrentSystemPrompt(context.messages), tools: ai.getCurrentTools(context.messages) });
92
+ const stream = ai.createAssistantMessageEventStream();
93
+ const request = next;
94
+ next = undefined;
95
+ calls += 1;
96
+ const message: AssistantMessage = { role: "assistant", api: model.api, provider: model.provider, model: model.id, content: [], stopReason: "pending" as never, timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
97
+ queueMicrotask(() => {
98
+ stream.push({ type: "start", partial: message });
99
+ if (request) {
100
+ const toolCall: ToolCall = { type: "toolCall", id: `call-${calls}`, ...request };
101
+ message.content.push(toolCall);
102
+ stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
103
+ stream.push({ type: "toolcall_delta", contentIndex: 0, delta: JSON.stringify(request.arguments), partial: message });
104
+ stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial: message });
105
+ }
106
+ message.stopReason = request ? "toolUse" : "stop";
107
+ stream.push({ type: "done", reason: message.stopReason, message });
108
+ stream.end();
109
+ });
110
+ return stream;
111
+ },
112
+ });
113
+ }],
114
+ });
115
+ await loader.reload();
116
+ assert.deepEqual(loader.getExtensions().errors, []);
117
+ const { session } = await sdk.createAgentSession({
118
+ cwd, agentDir: bindings.PI_CODING_AGENT_DIR, modelRuntime: runtime, model: model as never, thinkingLevel: "off", resourceLoader: loader,
119
+ tools: ["read", "gentle_review", ...HIDDEN_WHEN_OFF], sessionManager: manager, settingsManager: settings,
120
+ });
121
+ try {
122
+ await session.bindExtensions({ mode: "json", onError: (error) => run.errors.push(error.error) });
123
+ next = { name: "gentle_review", arguments: { operation: "assess" } };
124
+ await session.prompt("Assess the current change.", { source: "interactive" });
125
+ beforeSecondPrompt();
126
+ await session.prompt("Report the plan.", { source: "interactive" });
127
+ } finally {
128
+ await session.abort();
129
+ session.dispose();
130
+ }
131
+ return run;
132
+ }
133
+
134
+ const LEAN_GUIDELINE = /gentle_review only for \{"operation":"assess"\}/;
135
+
136
+ function fixture(t: { after: (fn: () => void) => void }): { root: string; bindings: Record<string, string> } {
137
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-review-loadout-")));
138
+ const profile = join(root, "profile");
139
+ mkdirSync(profile);
140
+ const bindings = {
141
+ PI_CODING_AGENT_DIR: profile, PI_CODING_AGENT_SESSION_DIR: join(root, "sessions"),
142
+ GENTLE_PI_AGENT_HOME: profile, GENTLE_PI_CONFIG_HOME: join(root, "config"),
143
+ };
144
+ const previous = Object.fromEntries(Object.keys(bindings).map((key) => [key, process.env[key]]));
145
+ Object.assign(process.env, bindings);
146
+ t.after(() => {
147
+ for (const [key, value] of Object.entries(previous)) {
148
+ if (value === undefined) delete process.env[key];
149
+ else process.env[key] = value;
150
+ }
151
+ rmSync(root, { recursive: true, force: true });
152
+ });
153
+ return { root, bindings };
154
+ }
155
+
156
+ function registeredGuidelines(run: SessionRun): string[] {
157
+ const guidelines = ["gentle_review", ...HIDDEN_WHEN_OFF].flatMap((name) => run.registered.get(name)?.promptGuidelines ?? []);
158
+ assert.ok(guidelines.length > 0);
159
+ return guidelines;
160
+ }
161
+
162
+ function assertLeanRequest(request: RecordedRequest, run: SessionRun): void {
163
+ assert.match(request.systemPrompt, /Receipt-driven development: off /);
164
+ assert.match(request.systemPrompt, LEAN_GUIDELINE);
165
+ for (const guideline of registeredGuidelines(run)) assert.equal(request.systemPrompt.includes(guideline), false, `RDD off must leave out: ${guideline.slice(0, 80)}`);
166
+ const names = request.tools.map((tool) => tool.name);
167
+ assert.ok(names.includes("gentle_review"));
168
+ for (const hidden of HIDDEN_WHEN_OFF) assert.equal(names.includes(hidden), false, `${hidden} is not declared while RDD is off`);
169
+ assert.doesNotMatch(request.tools.find((tool) => tool.name === "gentle_review")!.description, /gentle_review_capture/);
170
+ }
171
+
172
+ function assertRegisteredRequest(request: RecordedRequest, run: SessionRun): void {
173
+ for (const guideline of registeredGuidelines(run)) assert.ok(request.systemPrompt.includes(guideline), `the registered loadout keeps: ${guideline.slice(0, 80)}`);
174
+ assert.doesNotMatch(request.systemPrompt, LEAN_GUIDELINE);
175
+ for (const name of ["gentle_review", ...HIDDEN_WHEN_OFF]) {
176
+ const declared = request.tools.find((tool) => tool.name === name);
177
+ assert.ok(declared, `${name} is declared`);
178
+ assert.equal(declared.description, run.registered.get(name)?.description);
179
+ }
180
+ }
181
+
182
+ test("RDD off: the parent request carries only the lean ASSESS loadout; RDD on: the registered review loadout", { timeout: 30_000 }, async (t) => {
183
+ const { root, bindings } = fixture(t);
184
+ const off = await runSession(root, "off", bindings, () => "off");
185
+ const on = await runSession(root, "on", bindings, () => "on");
186
+ assert.deepEqual(off.errors, []);
187
+ assert.deepEqual(on.errors, []);
188
+ assert.equal(off.requests.length >= 3, true, "assess turn, tool result turn, follow-up turn");
189
+
190
+ for (const request of off.requests) assertLeanRequest(request, off);
191
+ // The prefix is stable across turns of the same session.
192
+ assert.equal(new Set(off.requests.map((request) => request.systemPrompt)).size, 1);
193
+ assert.equal(new Set(off.requests.map((request) => JSON.stringify(request.tools))).size, 1);
194
+ // ASSESS stays callable and returns its plan.
195
+ assert.equal(off.assessResults.length, 1);
196
+ assert.equal(off.assessResults[0].isError, false);
197
+ assert.equal((off.assessResults[0].details as { schema?: string }).schema, "gentle-pi.review-assessment-plan/v1");
198
+
199
+ for (const request of on.requests) {
200
+ assert.match(request.systemPrompt, /Receipt-driven development: on /);
201
+ assertRegisteredRequest(request, on);
202
+ }
203
+ assert.equal(on.assessResults.length, 1);
204
+ assert.equal(on.assessResults[0].isError, false);
205
+
206
+ const bytes = (request: RecordedRequest) => ({
207
+ systemPrompt: Buffer.byteLength(request.systemPrompt),
208
+ tools: Buffer.byteLength(JSON.stringify(request.tools)),
209
+ });
210
+ t.diagnostic(`RDD off request bytes: ${JSON.stringify(bytes(off.requests[0]))}`);
211
+ t.diagnostic(`RDD on request bytes: ${JSON.stringify(bytes(on.requests[0]))}`);
212
+ });
213
+
214
+ test("an unknown RDD line keeps the registered loadout; turning RDD on mid-session restores it at the next run", { timeout: 30_000 }, async (t) => {
215
+ const { root, bindings } = fixture(t);
216
+ const { __testing } = await import("../extensions/gentle-ai.ts");
217
+ __testing.clearRddStatusMemoForTesting();
218
+ t.after(() => __testing.clearRddStatusMemoForTesting());
219
+
220
+ const unknown = await runSession(root, "unknown", bindings, () => "throws");
221
+ assert.deepEqual(unknown.errors, []);
222
+ for (const request of unknown.requests) {
223
+ assert.match(request.systemPrompt, /Receipt-driven development: unknown/);
224
+ assertRegisteredRequest(request, unknown);
225
+ }
226
+
227
+ let mode: RddMode = "off";
228
+ const flipped = await runSession(root, "flip", bindings, () => mode, () => {
229
+ mode = "on";
230
+ __testing.clearRddStatusMemoForTesting();
231
+ });
232
+ assert.deepEqual(flipped.errors, []);
233
+ const last = flipped.requests.at(-1)!;
234
+ for (const request of flipped.requests.slice(0, -1)) assertLeanRequest(request, flipped);
235
+ assert.match(last.systemPrompt, /Receipt-driven development: on /);
236
+ assertRegisteredRequest(last, flipped);
237
+ });
@@ -0,0 +1,132 @@
1
+ import assert from "node:assert/strict";
2
+ import childProcess from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import { syncBuiltinESMExports } from "node:module";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import test from "node:test";
8
+ import { createCandidateOwner, prepareCandidateOwnerParent } from "../lib/review-candidate-view-owner.ts";
9
+
10
+ function fixture(t: test.TestContext) {
11
+ fs.mkdirSync(resolve("tmp"), { recursive: true });
12
+ const base = fs.realpathSync(fs.mkdtempSync(join(resolve("tmp"), "owner-identity-")));
13
+ const commonDir = join(base, "common");
14
+ const parent = join(commonDir, "gentle-ai", "candidate-views");
15
+ fs.mkdirSync(parent, { recursive: true });
16
+ const boundary = [commonDir, dirname(parent), parent];
17
+ let user = "S-1-5-21-1-2-3-1001";
18
+ let identityFailure = "";
19
+ let replacedMarker = false;
20
+ const probes = { user: 0, administrator: 0, owner: [] as string[], dacl: [] as string[], enforce: [] as string[] };
21
+ const native = fs.realpathSync.native;
22
+ t.mock.method(fs.realpathSync, "native", (path: string) =>
23
+ path.startsWith("\\\\?\\GLOBALROOT\\SystemRoot\\System32") ? path : native(path));
24
+ t.mock.method(childProcess, "execFileSync", (command: string, args: string[], options: any) => {
25
+ const name = command.split(/[\\/]/).at(-1);
26
+ if (name === "whoami.exe") {
27
+ probes.user++;
28
+ return identityFailure === "user" ? "unavailable" : user;
29
+ }
30
+ if (name === "icacls.exe") {
31
+ probes.dacl.push(args[0]);
32
+ const directory = fs.lstatSync(args[0]).isDirectory();
33
+ const flags = directory ? "OICI" : "ID";
34
+ const dacl = `D:${directory ? "P" : ""}(A;${flags};FA;;;${user})(A;${flags};FA;;;SY)(A;${flags};FA;;;BA)`;
35
+ fs.writeFileSync(join(options.cwd, args[2]), dacl, "utf16le");
36
+ return "";
37
+ }
38
+ if (name === "powershell.exe") {
39
+ const script = Buffer.from(args.at(-1)!, "base64").toString("utf16le");
40
+ if (script.includes("RawSecurityDescriptor")) {
41
+ probes.administrator++;
42
+ return identityFailure === "administrator" ? "unavailable" : "S-1-5-21-1-2-3-500";
43
+ }
44
+ if (script.includes("SetAccessControl")) {
45
+ probes.enforce.push(options.env.GENTLE_PI_CANDIDATE_ACL_PATH);
46
+ return "";
47
+ }
48
+ const path = options.env.GENTLE_PI_CANDIDATE_OWNER_PATH;
49
+ probes.owner.push(path);
50
+ return replacedMarker && path.endsWith(".owner.json") ? "S-1-5-21-9-8-7-1002" : user;
51
+ }
52
+ throw new Error(`Unexpected subprocess: ${name}`);
53
+ });
54
+ syncBuiltinESMExports();
55
+ t.after(() => {
56
+ t.mock.restoreAll();
57
+ syncBuiltinESMExports();
58
+ fs.rmSync(base, { recursive: true, force: true });
59
+ });
60
+ return { commonDir, parent, boundary, probes,
61
+ changeUser: () => { user = "S-1-5-21-4-5-6-1002"; },
62
+ failIdentity: (kind: string) => { identityFailure = kind; },
63
+ replaceMarker: () => { replacedMarker = true; } };
64
+ }
65
+
66
+ test("Windows creation reuses identity only within one operation and preserves every filesystem probe", (t) => {
67
+ const f = fixture(t);
68
+ for (let operation = 1; operation <= 2; operation++) {
69
+ if (operation === 2) f.changeUser();
70
+ const root = join(f.parent, randomUUID());
71
+ const owner = createCandidateOwner(f.commonDir, root, "win32");
72
+ const marker = `${root}.owner.json`;
73
+ assert.equal(owner.root, root);
74
+ assert.deepEqual(JSON.parse(fs.readFileSync(marker, "utf8")), owner);
75
+ assert.equal(fs.existsSync(root), false);
76
+ assert.equal(f.probes.user, operation, "one fresh user SID per creation");
77
+ assert.equal(f.probes.administrator, operation, "one fresh administrator SID per creation");
78
+ assert.deepEqual(f.probes.owner.slice((operation - 1) * 4), [...f.boundary, marker]);
79
+ assert.deepEqual(f.probes.dacl.slice((operation - 1) * 4), [...f.boundary, marker]);
80
+ assert.deepEqual(f.probes.enforce, []);
81
+ }
82
+ });
83
+
84
+ test("Windows preparation retains fresh pre-write and post-write owner/DACL checks", (t) => {
85
+ const f = fixture(t);
86
+ assert.equal(prepareCandidateOwnerParent(f.commonDir, "win32"), f.parent);
87
+ assert.equal(f.probes.user, 1);
88
+ assert.equal(f.probes.administrator, 1);
89
+ assert.deepEqual(f.probes.owner, [...f.boundary, ...f.boundary.flatMap(path => [path, path])]);
90
+ assert.deepEqual(f.probes.dacl, f.boundary);
91
+ assert.deepEqual(f.probes.enforce, f.boundary);
92
+ });
93
+
94
+ for (const kind of ["user", "administrator"]) test(`Windows creation fails closed on fresh ${kind} identity failure`, (t) => {
95
+ const f = fixture(t);
96
+ const first = createCandidateOwner(f.commonDir, join(f.parent, randomUUID()), "win32");
97
+ const marker = `${first.root}.owner.json`;
98
+ const before = fs.readFileSync(marker, "utf8");
99
+ const stored = fs.readdirSync(f.parent);
100
+ f.failIdentity(kind);
101
+ const root = join(f.parent, randomUUID());
102
+ assert.throws(() => createCandidateOwner(f.commonDir, root, "win32"), /SID is unavailable/);
103
+ assert.equal(fs.existsSync(root), false);
104
+ assert.equal(fs.existsSync(`${root}.owner.json`), false);
105
+ assert.equal(fs.readFileSync(marker, "utf8"), before);
106
+ assert.deepEqual(fs.readdirSync(f.parent), stored);
107
+ assert.equal(f.probes.owner.length, 4);
108
+ assert.equal(f.probes.dacl.length, 4);
109
+ });
110
+
111
+ test("Windows creation rollback rechecks a replaced marker owner before removal", (t) => {
112
+ const f = fixture(t);
113
+ const write = fs.writeFileSync;
114
+ const root = join(f.parent, randomUUID());
115
+ const marker = `${root}.owner.json`;
116
+ t.mock.method(fs, "writeFileSync", (...args: any[]) => {
117
+ if (typeof args[0] === "number") {
118
+ f.replaceMarker();
119
+ throw new Error("fixture write failure");
120
+ }
121
+ return (write as any)(...args);
122
+ });
123
+ syncBuiltinESMExports();
124
+ assert.throws(() => createCandidateOwner(f.commonDir, root, "win32"), /fixture write failure/);
125
+ assert.equal(f.probes.user, 1);
126
+ assert.equal(f.probes.administrator, 1);
127
+ assert.deepEqual(f.probes.owner, [...f.boundary, marker, marker]);
128
+ assert.deepEqual(f.probes.dacl, [...f.boundary, marker]);
129
+ assert.equal(fs.existsSync(marker), true, "unproven marker survives rollback");
130
+ assert.equal(fs.readFileSync(marker, "utf8"), "");
131
+ assert.equal(fs.existsSync(root), false);
132
+ });