gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -1,11 +1,14 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp } from "node:fs/promises";
2
+ import childProcess from "node:child_process";
3
+ import { syncBuiltinESMExports } from "node:module";
4
+ import { realpathSync } from "node:fs";
5
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
6
  import { tmpdir } from "node:os";
4
7
  import { join } from "node:path";
5
8
  import test from "node:test";
6
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
10
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
8
- import type { NativeReviewCli } from "../lib/native-review-cli.ts";
11
+ import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
9
12
  import type { ReviewStatusV3 } from "../lib/review-integration-v2.ts";
10
13
 
11
14
  // gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent finished an
@@ -14,36 +17,44 @@ import type { ReviewStatusV3 } from "../lib/review-integration-v2.ts";
14
17
  // that reminds the agent to call gentle_review before reporting completion,
15
18
  // without ever starting a review or answering consent itself.
16
19
  //
17
- // gentle-pi#568: `session_start` records the target identity STATUS reports
18
- // at session start as a baseline, so `agent_end` skips a candidate that
19
- // already existed before this session touched the worktree (the user's own
20
- // pre-session work, not this session's output). These tests point
20
+ // gentle-pi#568/#777: startup negotiates STATUS, but only successful own
21
+ // mutation receipts authorize agent_end to query a candidate. Pre-session
22
+ // work and foreign-session changes are not this session's output. These tests point
21
23
  // `GENTLE_PI_AGENT_HOME` and the session `cwd` at fresh temp directories so
22
24
  // `session_start`'s real SDD asset install and model config sweep never
23
- // touch this machine's actual home directory.
25
+ // touch this machine's actual home directory. The cwd is an isolated Git
26
+ // fixture so mutation receipts exercise canonical root resolution.
24
27
 
25
28
  type AnyHandler = (event: unknown, ctx: ExtensionContext) => unknown;
29
+ type RegisteredTool = Parameters<ExtensionAPI["registerTool"]>[0];
26
30
  type SentMessage = { message: Record<string, unknown>; options: Record<string, unknown> };
27
31
 
28
- function harness(nativeReviewCli: NativeReviewCli | null): {
32
+ type CustomEntry = { type: string; customType: string; data: unknown };
33
+ function harness(nativeReviewCli: NativeReviewCli | null, entries: CustomEntry[] = []): {
29
34
  handlers: Map<string, AnyHandler>;
30
35
  sent: SentMessage[];
36
+ tools: Map<string, RegisteredTool>;
31
37
  } {
32
38
  const handlers = new Map<string, AnyHandler>();
33
39
  const sent: SentMessage[] = [];
40
+ const tools = new Map<string, RegisteredTool>();
34
41
  const pi = {
42
+ appendEntry(customType: string, data: unknown) { entries.push({ type: "custom", customType, data }); },
35
43
  on(name: string, handler: AnyHandler) {
36
- handlers.set(name, handler);
44
+ handlers.set(name, (event, context) => {
45
+ context.sessionManager.getBranch ??= (() => entries) as typeof context.sessionManager.getBranch;
46
+ return handler(event, context);
47
+ });
37
48
  },
38
49
  events: { emit() {} },
39
50
  registerCommand() {},
40
- registerTool() {},
51
+ registerTool(tool: RegisteredTool) { tools.set(tool.name, tool); },
41
52
  sendMessage(message: Record<string, unknown>, options: Record<string, unknown> = {}) {
42
53
  sent.push({ message, options });
43
54
  },
44
55
  } as unknown as ExtensionAPI;
45
56
  createGentleAiExtension({ nativeReviewCli })(pi);
46
- return { handlers, sent };
57
+ return { handlers, sent, tools };
47
58
  }
48
59
 
49
60
  function ctx(sessionId: string, hasUI = true, cwd = process.cwd()): ExtensionContext {
@@ -65,6 +76,9 @@ async function withSessionStartEnv<T>(callback: (cwd: string) => Promise<T>): Pr
65
76
  process.env.GENTLE_PI_CONFIG_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-config-home-"));
66
77
  try {
67
78
  const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-cwd-"));
79
+ childProcess.execFileSync("git", ["init", "--quiet", cwd]);
80
+ await mkdir(join(cwd, "src"));
81
+ await writeFile(join(cwd, "src/example.ts"), "export const value = 1;");
68
82
  return await callback(cwd);
69
83
  } finally {
70
84
  if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
@@ -110,6 +124,123 @@ function stopStatus(targetIdentity: string): ReviewStatusV3 {
110
124
  }
111
125
 
112
126
  const agentEndEvent = { type: "agent_end", messages: [] };
127
+ let mutationCall = 0;
128
+ async function directWrite(handlers: Map<string, AnyHandler>, session: ExtensionContext): Promise<void> {
129
+ await handlers.get("tool_result")!({ type: "tool_result", toolName: "write", toolCallId: `own-${++mutationCall}`,
130
+ input: { path: session.cwd === process.cwd() || session.cwd === realpathSync(process.cwd()) ? "tests/review-agent-end-preflight.test.ts" : "src/example.ts" },
131
+ content: [], isError: false }, session);
132
+ }
133
+
134
+ // #772: use the acknowledgement vector and returned-envelope shapes from
135
+ // review-controller-native-routing.test.ts, through the registered tool rather
136
+ // than the controller helper, so agent_end shares the same extension state.
137
+ for (const scenario of ["same", "changed", "sibling-root", "nested-root", "failed", "unknown", "shutdown", "other-session", "legacy-success", "new-write", "concurrent-write"] as const) {
138
+ test(`agent_end after approved acknowledgement: ${scenario}`, async (t) => {
139
+ const changedTarget = scenario === "changed";
140
+ const unsuccessful = scenario === "failed" || scenario === "unknown";
141
+ const cwd = realpathSync(process.cwd());
142
+ const siblingRoot = realpathSync(tmpdir());
143
+ if (scenario === "sibling-root") {
144
+ // Model a sibling worktree sharing the real common directory without
145
+ // creating a worktree or mutating repository state.
146
+ const exec = childProcess.execFileSync;
147
+ const commonDir = exec("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8" }).trim();
148
+ t.mock.method(childProcess, "execFileSync", (file: string, args: string[], options: { cwd?: string }) => {
149
+ if (file === "git" && options.cwd === siblingRoot) {
150
+ if (args[1] === "--show-toplevel") return siblingRoot;
151
+ if (args[1] === "--git-common-dir") return commonDir;
152
+ }
153
+ return exec(file, args, options);
154
+ });
155
+ syncBuiltinESMExports();
156
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
157
+ }
158
+ const lineageId = `agent-end-post-ack-${scenario}`;
159
+ const targetIdentity = `sha256:${"a".repeat(64)}`;
160
+ const nextTarget = changedTarget ? `sha256:${"b".repeat(64)}` : targetIdentity;
161
+ const binding = { lineageId, targetIdentity, revision: targetIdentity };
162
+ const arguments_ = [
163
+ ["cwd", cwd], ["lineage", lineageId], ["target", targetIdentity],
164
+ ["expected-revision", targetIdentity], ["token", "provider-issued-once"],
165
+ ].map(([name, value]) => ({ name, value, token: `--${name}=${value}` }));
166
+ const approved = {
167
+ contract: "gentle-ai.review-integration/v2",
168
+ applicability: "current_target",
169
+ authority: { version: "compact-v2", lineageId, state: "approved", generation: 1, revision: targetIdentity },
170
+ receipt: { status: "expected_missing" },
171
+ action: "stop", replayability: "not_replayable", targetIdentity, candidates: [],
172
+ nextTransition: {
173
+ kind: "execute", reasonCode: "approved_acknowledgement_required",
174
+ execute: {
175
+ operation: "review.acknowledge-approved",
176
+ command: "gentle-ai review acknowledge-approved --provider-vector",
177
+ arguments: arguments_,
178
+ preconditions: [{ name: "state", value: "approved", token: "--state=approved" }],
179
+ binding,
180
+ },
181
+ },
182
+ raw: { schema: "gentle-ai.review-integration.status/v5" },
183
+ } as unknown as ReviewStatusV3;
184
+ const statusRequests: unknown[] = [];
185
+ const acknowledgementRequests: unknown[] = [];
186
+ let burned = false;
187
+ const native = {
188
+ reviewMode: onMode("on"),
189
+ targetStatus: async (request: unknown) => {
190
+ statusRequests.push(request);
191
+ return burned ? executeStartStatus(nextTarget) : approved;
192
+ },
193
+ acknowledgeApproved: async (request: unknown) => {
194
+ acknowledgementRequests.push(request);
195
+ if (scenario === "concurrent-write") await directWrite(handlers, session);
196
+ burned = true;
197
+ if (scenario === "failed") throw new TypeError("local acknowledgement validation failed");
198
+ if (scenario === "unknown") throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, "review/acknowledge-approved", true, true, "acknowledgement outcome unknown");
199
+ if (scenario === "legacy-success") return undefined;
200
+ return {
201
+ schema: "gentle-ai.review-acknowledged/v1",
202
+ operation: "review/acknowledge-approved", action: "acknowledged",
203
+ lineageId, targetIdentity, consumedRevision: targetIdentity, authority: "burned",
204
+ raw: { schema: "gentle-ai.review-acknowledged/v1" },
205
+ };
206
+ },
207
+ } as unknown as NativeReviewCli;
208
+ const { handlers, sent, tools } = harness(native);
209
+ const session = ctx(lineageId, true, cwd);
210
+ const review = tools.get("gentle_review");
211
+ assert.ok(review);
212
+ await directWrite(handlers, session);
213
+ const result = await review.execute("post-ack", { operation: "acknowledge-approved", lineageId }, undefined, undefined, session);
214
+ if (unsuccessful) {
215
+ assert.equal(result.details.outcome, scenario === "failed" ? "native-operation-failed" : "native-mutation-status-reconciled");
216
+ assert.equal(result.details.mutation_outcome, scenario === "failed" ? "none" : "unknown");
217
+ } else assert.deepEqual(result.details, {
218
+ operation: "acknowledge-approved", status: "closed",
219
+ outcome: "native-approved-acknowledgement-completed",
220
+ lineage_id: lineageId, target_identity: targetIdentity,
221
+ ...(scenario === "legacy-success" ? {} : { consumed_revision: targetIdentity, burn_evidence: "gentle-ai.review-acknowledged/v1" }),
222
+ authority: "burned",
223
+ delivery: "ordinary-repository-policy", mutation_performed: true, mutation_outcome: "committed",
224
+ });
225
+ assert.deepEqual(acknowledgementRequests, [{ cwd, argumentTokens: arguments_.map(({ token }) => token), binding }]);
226
+ if (scenario !== "unknown") assert.deepEqual(statusRequests, [{ cwd, lineageId }], "ACK reports burn without a later STATUS");
227
+ const callsBeforeEnd = statusRequests.length;
228
+ assert.deepEqual(sent, [], "no earlier reminder can mask the post-burn regression");
229
+
230
+ if (scenario === "shutdown") await handlers.get("session_shutdown")!({}, session);
231
+ const endSession = scenario === "sibling-root" ? ctx(lineageId, true, siblingRoot)
232
+ : scenario === "nested-root" ? ctx(lineageId, true, join(cwd, "tests"))
233
+ : scenario === "other-session" ? ctx(`${lineageId}-new`, true, cwd) : session;
234
+ if (changedTarget || scenario === "new-write") await directWrite(handlers, endSession);
235
+ await handlers.get("agent_end")!(agentEndEvent, endSession);
236
+ const shouldRemind = changedTarget || unsuccessful || scenario === "new-write" || scenario === "concurrent-write";
237
+ assert.deepEqual(statusRequests.slice(callsBeforeEnd), shouldRemind ? [{ cwd: endSession.cwd, agent: "pi" }] : []);
238
+ const reminders = sent.filter(({ message }) => message.customType === "gentle-pi.review-preflight");
239
+ assert.equal(reminders.length, shouldRemind ? 1 : 0,
240
+ shouldRemind ? "an unconsumed own mutation still requires preflight" : "an acknowledged or unowned target must not receive another preflight reminder");
241
+ if (changedTarget) assert.ok(String(reminders[0]?.message.content).includes(nextTarget));
242
+ });
243
+ }
113
244
 
114
245
  test("agent_end performs no STATUS call and sends nothing when RDD is off", async () => {
115
246
  const statusRequests: unknown[] = [];
@@ -140,13 +271,17 @@ test("agent_end nudges exactly once when RDD is on and STATUS offers review.star
140
271
  } as unknown as NativeReviewCli;
141
272
  const { handlers, sent } = harness(native);
142
273
  const agentEnd = handlers.get("agent_end");
143
- await agentEnd!(agentEndEvent, ctx("agent-end-execute"));
274
+ const session = ctx("agent-end-execute");
275
+ await directWrite(handlers, session);
276
+ await agentEnd!(agentEndEvent, session);
144
277
 
145
278
  assert.equal(sent.length, 1);
146
279
  const [entry] = sent;
147
280
  assert.equal(entry?.message.customType, "gentle-pi.review-preflight");
148
281
  const content = String(entry?.message.content);
149
282
  assert.match(content, /gentle_review/);
283
+ assert.match(content, /first determine whether the user explicitly left this exact target unreviewed\. if yes, do not invoke review; report that disposition and continue\. only otherwise, call the gentle_review tool with \{"operation":"inspect"\}/i);
284
+ assert.doesNotMatch(content, /run the review preflight before reporting completion\. if the user explicitly left/i);
150
285
  assert.ok(content.includes(targetIdentity), "message must name the target identity");
151
286
  assert.equal(entry?.options.triggerTurn, true);
152
287
  assert.equal(statusRequests[0]?.agent, "pi");
@@ -161,12 +296,14 @@ test("agent_end nudges once per target identity and again for a fresh identity",
161
296
  const { handlers, sent } = harness(native);
162
297
  const agentEnd = handlers.get("agent_end");
163
298
  const session = ctx("agent-end-repeat");
299
+ await directWrite(handlers, session);
164
300
 
165
301
  await agentEnd!(agentEndEvent, session);
166
302
  await agentEnd!(agentEndEvent, session);
167
303
  assert.equal(sent.length, 1, "the same target identity nudges only once");
168
304
 
169
305
  targetIdentity = `sha256:${"e".repeat(64)}`;
306
+ await directWrite(handlers, session);
170
307
  await agentEnd!(agentEndEvent, session);
171
308
  assert.equal(sent.length, 2, "a different target identity nudges again");
172
309
  });
@@ -182,7 +319,9 @@ test("agent_end sends nothing when STATUS offers collect or stop", async () => {
182
319
  } as unknown as NativeReviewCli;
183
320
  const { handlers, sent } = harness(native);
184
321
  const agentEnd = handlers.get("agent_end");
185
- await agentEnd!(agentEndEvent, ctx(`agent-end-${label}`));
322
+ const session = ctx(`agent-end-${label}`);
323
+ await directWrite(handlers, session);
324
+ await agentEnd!(agentEndEvent, session);
186
325
  assert.deepEqual(sent, [], label);
187
326
  }
188
327
  });
@@ -213,6 +352,7 @@ test("agent_end pairs a named agent's start with its own end, then still nudges
213
352
  const agentEnd = handlers.get("agent_end");
214
353
  assert.equal(typeof beforeAgentStart, "function");
215
354
  const session = ctx("agent-end-subagent");
355
+ await directWrite(handlers, session);
216
356
 
217
357
  await beforeAgentStart!({ agentName: "review-readability", systemPrompt: "" }, session);
218
358
  await agentEnd!(agentEndEvent, session);
@@ -222,6 +362,27 @@ test("agent_end pairs a named agent's start with its own end, then still nudges
222
362
  assert.equal(sent.length, 1, "the primary loop's end still nudges once the subagent's end is paired off");
223
363
  });
224
364
 
365
+ test("gentle-ai-worker agent_end never queries native review", async () => {
366
+ const statusRequests: unknown[] = [];
367
+ const native = {
368
+ reviewMode: onMode("on"),
369
+ targetStatus: async (request: unknown) => {
370
+ statusRequests.push(request);
371
+ throw new Error("a bounded worker must not own native review");
372
+ },
373
+ } as unknown as NativeReviewCli;
374
+ const { handlers, sent } = harness(native);
375
+ const beforeAgentStart = handlers.get("before_agent_start");
376
+ const agentEnd = handlers.get("agent_end");
377
+ const session = ctx("agent-end-generic-worker");
378
+ assert.equal(typeof beforeAgentStart, "function");
379
+ await beforeAgentStart!({ agentName: "gentle-ai-worker", systemPrompt: "" }, session);
380
+ await directWrite(handlers, session);
381
+ await agentEnd!(agentEndEvent, session);
382
+ assert.deepEqual(statusRequests, []);
383
+ assert.deepEqual(sent, []);
384
+ });
385
+
225
386
  test("agent_end resets the subagent depth when a fresh primary loop starts", async () => {
226
387
  const targetIdentity = `sha256:${"1".repeat(64)}`;
227
388
  const native = {
@@ -232,6 +393,7 @@ test("agent_end resets the subagent depth when a fresh primary loop starts", asy
232
393
  const beforeAgentStart = handlers.get("before_agent_start");
233
394
  const agentEnd = handlers.get("agent_end");
234
395
  const session = ctx("agent-end-subagent-reset");
396
+ await directWrite(handlers, session);
235
397
 
236
398
  await beforeAgentStart!({ agentName: "review-readability", systemPrompt: "" }, session);
237
399
  await beforeAgentStart!({ systemPrompt: "" }, session);
@@ -257,11 +419,13 @@ test("agent_end sends nothing and does not throw when target STATUS rejects", as
257
419
  } as unknown as NativeReviewCli;
258
420
  const { handlers, sent } = harness(native);
259
421
  const agentEnd = handlers.get("agent_end");
260
- await assert.doesNotReject(async () => agentEnd!(agentEndEvent, ctx("agent-end-status-throws")));
422
+ const session = ctx("agent-end-status-throws");
423
+ await directWrite(handlers, session);
424
+ await assert.doesNotReject(async () => agentEnd!(agentEndEvent, session));
261
425
  assert.deepEqual(sent, []);
262
426
  });
263
427
 
264
- test("session_shutdown clears the nudged-target set for its session key", async () => {
428
+ test("session_shutdown ignores late agent_end after a consumed mutation", async () => {
265
429
  const targetIdentity = `sha256:${"9".repeat(64)}`;
266
430
  const native = {
267
431
  reviewMode: onMode("on"),
@@ -272,16 +436,91 @@ test("session_shutdown clears the nudged-target set for its session key", async
272
436
  const shutdown = handlers.get("session_shutdown");
273
437
  assert.equal(typeof shutdown, "function");
274
438
  const session = ctx("agent-end-shutdown");
439
+ await directWrite(handlers, session);
275
440
 
276
441
  await agentEnd!(agentEndEvent, session);
277
442
  assert.equal(sent.length, 1);
278
443
 
279
444
  await shutdown!({}, session);
280
445
  await agentEnd!(agentEndEvent, session);
281
- assert.equal(sent.length, 2, "shutdown clears the nudged set so the same identity nudges again");
446
+ assert.equal(sent.length, 1, "late agent_end after shutdown cannot remind");
282
447
  });
283
448
 
284
- test("session_start records the baseline target identity when RDD is on", async () => {
449
+ for (const scenario of ["reload", "fork", "new", "off-branch"] as const) {
450
+ test(`agent_end restores pending receipts only for the active session branch: ${scenario}`, async () => {
451
+ await withSessionStartEnv(async (cwd) => {
452
+ const entries: CustomEntry[] = [];
453
+ let statusCalls = 0;
454
+ const native = { reviewMode: onMode("on"), targetStatus: async () => { statusCalls += 1; return executeStartStatus("whole-target"); } } as unknown as NativeReviewCli;
455
+ const first = harness(native, entries);
456
+ const session = ctx(`receipt-${scenario}`, true, cwd);
457
+ await first.handlers.get("session_start")!({}, session);
458
+ await directWrite(first.handlers, session);
459
+ await first.handlers.get("session_shutdown")!({ reason: "reload" }, session);
460
+ const resumed = harness(native, entries);
461
+ const next = ctx(scenario === "reload" || scenario === "off-branch" ? `receipt-${scenario}` : `replacement-${scenario}`, true, cwd);
462
+ if (scenario === "off-branch") next.sessionManager.getBranch = () => [];
463
+ await resumed.handlers.get("session_start")!({ reason: scenario }, next);
464
+ const beforeEnd = statusCalls;
465
+ await resumed.handlers.get("agent_end")!(agentEndEvent, next);
466
+ assert.equal(statusCalls - beforeEnd, scenario === "reload" ? 1 : 0);
467
+ assert.equal(resumed.sent.length, scenario === "reload" ? 1 : 0);
468
+ });
469
+ });
470
+ }
471
+
472
+ for (const scenario of ["concurrent-write", "shutdown"] as const) {
473
+ test(`agent_end binds consumption to the captured mutation during async STATUS: ${scenario}`, async () => {
474
+ const session = ctx(`status-race-${scenario}`);
475
+ let resolveStatus!: (status: ReviewStatusV3) => void;
476
+ let entered!: () => void;
477
+ const statusEntered = new Promise<void>((resolve) => { entered = resolve; });
478
+ let calls = 0;
479
+ const native = { reviewMode: onMode("on"), targetStatus: async () => {
480
+ calls += 1;
481
+ if (calls > 1) return executeStartStatus("next-whole-target");
482
+ entered();
483
+ return new Promise<ReviewStatusV3>((resolve) => { resolveStatus = resolve; });
484
+ } } as unknown as NativeReviewCli;
485
+ const h = harness(native);
486
+ await directWrite(h.handlers, session);
487
+ const ending = h.handlers.get("agent_end")!(agentEndEvent, session);
488
+ await statusEntered;
489
+ if (scenario === "shutdown") await h.handlers.get("session_shutdown")!({}, session);
490
+ await directWrite(h.handlers, session);
491
+ resolveStatus(executeStartStatus("captured-whole-target"));
492
+ await ending;
493
+ await h.handlers.get("agent_end")!(agentEndEvent, session);
494
+ assert.equal(h.sent.length, scenario === "concurrent-write" ? 2 : 0);
495
+ assert.equal(calls, scenario === "concurrent-write" ? 2 : 1);
496
+ });
497
+ }
498
+
499
+ test("successful direct writes in another canonical root do not authorize this root's reminder", async () => {
500
+ await withSessionStartEnv(async (otherRoot) => {
501
+ let statusCalls = 0;
502
+ const h = harness({ reviewMode: onMode("on"), targetStatus: async () => { statusCalls += 1; return executeStartStatus("foreign"); } } as unknown as NativeReviewCli);
503
+ const session = ctx("direct-other-root");
504
+ await h.handlers.get("tool_result")!({ type: "tool_result", toolName: "write", toolCallId: "other-root", input: { path: join(otherRoot, "src/example.ts") }, isError: false, content: [] }, session);
505
+ await h.handlers.get("agent_end")!(agentEndEvent, session);
506
+ assert.equal(statusCalls, 0);
507
+ assert.deepEqual(h.sent, []);
508
+ });
509
+ });
510
+
511
+ for (const toolName of ["read", "bash", "subagent_run", "edit", "write"]) {
512
+ test(`unsuccessful or non-mutating direct ${toolName} never authorizes shared STATUS`, async () => {
513
+ let statusCalls = 0;
514
+ const h = harness({ reviewMode: onMode("on"), targetStatus: async () => { statusCalls += 1; return executeStartStatus("foreign"); } } as unknown as NativeReviewCli);
515
+ const session = ctx(`rejected-${toolName}`);
516
+ await h.handlers.get("tool_result")!({ type: "tool_result", toolName, toolCallId: "rejected", input: { path: "tests/review-agent-end-preflight.test.ts" }, isError: toolName === "write" || toolName === "edit", content: [] }, session);
517
+ await h.handlers.get("agent_end")!(agentEndEvent, session);
518
+ assert.equal(statusCalls, 0);
519
+ assert.deepEqual(h.sent, []);
520
+ });
521
+ }
522
+
523
+ test("session_start negotiates the current target identity when RDD is on", async () => {
285
524
  const targetIdentity = `sha256:${"2".repeat(64)}`;
286
525
  const statusRequests: Array<{ agent?: string }> = [];
287
526
  const native = {
@@ -307,7 +546,7 @@ test("session_start records the baseline target identity when RDD is on", async
307
546
  });
308
547
  });
309
548
 
310
- test("agent_end skips the candidate that matches the session's recorded baseline, then nudges for a new one", async () => {
549
+ test("agent_end skips pre-session work, then nudges after an own mutation", async () => {
311
550
  let targetIdentity = `sha256:${"3".repeat(64)}`;
312
551
  const native = {
313
552
  reviewMode: onMode("on"),
@@ -324,12 +563,79 @@ test("agent_end skips the candidate that matches the session's recorded baseline
324
563
  assert.deepEqual(sent, [], "the baseline candidate predates the session and is not nudged");
325
564
 
326
565
  targetIdentity = `sha256:${"4".repeat(64)}`;
566
+ await directWrite(handlers, session);
327
567
  await agentEnd!(agentEndEvent, session);
328
568
  assert.equal(sent.length, 1, "a candidate identity different from the baseline still nudges");
329
569
  });
330
570
  });
331
571
 
332
- test("session_start with RDD off records no baseline, so agent_end still reminds once", async () => {
572
+ // #777: a shared worktree's changed target is not evidence that this session
573
+ // mutated it. Exercise real registered handlers; all native operations are mocks.
574
+ for (const ownsMutation of [false, true]) {
575
+ test(ownsMutation
576
+ ? "agent_end still checks and reminds after this session's successful direct write"
577
+ : "agent_end ignores a foreign session's changed target after read-only work", async () => {
578
+ const targetA = `sha256:${"a".repeat(64)}`;
579
+ const targetB = `sha256:${"b".repeat(64)}`;
580
+ let targetIdentity = targetA;
581
+ const statusRequests: unknown[] = [];
582
+ const native = {
583
+ reviewMode: onMode("on"),
584
+ targetStatus: async (request: unknown) => {
585
+ statusRequests.push(request);
586
+ return executeStartStatus(targetIdentity);
587
+ },
588
+ } as unknown as NativeReviewCli;
589
+ await withSessionStartEnv(async (cwd) => {
590
+ const { handlers, sent } = harness(native);
591
+ const session = ctx(`session-777-${ownsMutation ? "direct-write" : "read-only"}`, true, cwd);
592
+ const sessionStart = handlers.get("session_start");
593
+ const toolResult = handlers.get("tool_result");
594
+ const agentEnd = handlers.get("agent_end");
595
+ assert.equal(typeof sessionStart, "function");
596
+ assert.equal(typeof agentEnd, "function");
597
+
598
+ await sessionStart!({}, session);
599
+ assert.deepEqual(statusRequests, [{ cwd, agent: "pi" }], "startup records targetA separately");
600
+ assert.deepEqual(sent, []);
601
+ // Like host event delivery, an event without a subscriber is a no-op.
602
+ await toolResult?.({
603
+ type: "tool_result", toolName: "read", toolCallId: "777-read",
604
+ input: { path: "src/example.ts" },
605
+ content: [{ type: "text", text: "export const value = 1;" }],
606
+ isError: false,
607
+ }, session);
608
+ if (ownsMutation) {
609
+ await toolResult?.({
610
+ type: "tool_result", toolName: "write", toolCallId: "777-write",
611
+ input: { path: "src/example.ts", content: "export const value = 2;" },
612
+ content: [{ type: "text", text: "Successfully wrote src/example.ts" }],
613
+ isError: false,
614
+ }, session);
615
+ }
616
+ // In the read-only case, only foreign session B changes the shared
617
+ // candidate. No successful mutation event is delivered to session A.
618
+ // In the positive control, the direct write above owns the change.
619
+ targetIdentity = targetB;
620
+ const callsBeforeEnd = statusRequests.length;
621
+ await agentEnd!(agentEndEvent, session);
622
+ const reminders = sent.filter(({ message }) => message.customType === "gentle-pi.review-preflight");
623
+ assert.deepEqual({
624
+ statusRequests: statusRequests.slice(callsBeforeEnd),
625
+ reminderCount: reminders.length,
626
+ }, ownsMutation ? {
627
+ statusRequests: [{ cwd, agent: "pi" }], reminderCount: 1,
628
+ } : {
629
+ statusRequests: [], reminderCount: 0,
630
+ }, ownsMutation
631
+ ? "a successful direct write must retain native preflight and its reminder"
632
+ : "read-only session A must neither query shared STATUS nor remind for session B's target");
633
+ if (ownsMutation) assert.ok(String(reminders[0]?.message.content).includes(targetB));
634
+ });
635
+ });
636
+ }
637
+
638
+ test("agent_end reminds for an own write after startup with RDD off", async () => {
333
639
  const targetIdentity = `sha256:${"5".repeat(64)}`;
334
640
  let mode: "on" | "off" = "off";
335
641
  const statusRequests: unknown[] = [];
@@ -355,12 +661,13 @@ test("session_start with RDD off records no baseline, so agent_end still reminds
355
661
  assert.deepEqual(statusRequests, []);
356
662
 
357
663
  mode = "on";
664
+ await directWrite(handlers, session);
358
665
  await agentEnd!(agentEndEvent, session);
359
- assert.equal(sent.length, 1, "no baseline was recorded while RDD was off, so the first dirty candidate still reminds");
666
+ assert.equal(sent.length, 1, "startup with RDD off does not prevent an own-write reminder after RDD is enabled");
360
667
  });
361
668
  });
362
669
 
363
- test("session_start whose targetStatus rejects records no baseline, so agent_end still reminds once", async () => {
670
+ test("agent_end reminds for an own write after startup STATUS rejects", async () => {
364
671
  const targetIdentity = `sha256:${"6".repeat(64)}`;
365
672
  let shouldThrow = true;
366
673
  const native = {
@@ -379,12 +686,13 @@ test("session_start whose targetStatus rejects records no baseline, so agent_end
379
686
  await assert.doesNotReject(async () => sessionStart!({}, session));
380
687
 
381
688
  shouldThrow = false;
689
+ await directWrite(handlers, session);
382
690
  await agentEnd!(agentEndEvent, session);
383
- assert.equal(sent.length, 1, "STATUS threw at session_start, so no baseline was recorded and the candidate still reminds");
691
+ assert.equal(sent.length, 1, "startup STATUS failure does not prevent a subsequent own-write reminder");
384
692
  });
385
693
  });
386
694
 
387
- test("session_shutdown clears the recorded baseline so the same identity reminds again", async () => {
695
+ test("session_shutdown does not turn the baseline into an own mutation", async () => {
388
696
  const targetIdentity = `sha256:${"7".repeat(64)}`;
389
697
  const native = {
390
698
  reviewMode: onMode("on"),
@@ -403,6 +711,6 @@ test("session_shutdown clears the recorded baseline so the same identity reminds
403
711
 
404
712
  await shutdown!({}, session);
405
713
  await agentEnd!(agentEndEvent, session);
406
- assert.equal(sent.length, 1, "shutdown cleared the baseline, so the same identity reminds again in the next session");
714
+ assert.equal(sent.length, 0, "shutdown never turns a foreign baseline into an own mutation");
407
715
  });
408
716
  });