gentle-pi 2.2.0 → 2.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 (130) hide show
  1. package/README.md +51 -80
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -1,9 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
- import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
3
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import test from "node:test";
7
+ import { gzipSync } from "node:zlib";
8
+ import { initTheme, keyHint } from "@earendil-works/pi-coding-agent";
7
9
  import type {
8
10
  ExtensionAPI,
9
11
  ExtensionContext,
@@ -11,24 +13,234 @@ import type {
11
13
  ToolCallEventResult,
12
14
  } from "@earendil-works/pi-coding-agent";
13
15
  import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
14
- import { GATE_TARGET_KIND } from "../lib/review-publication-gate.ts";
15
- import {
16
- REVIEW_MODE,
17
- REVIEW_TRANSITION,
18
- ReviewTransactionStore,
19
- createReceiptForState,
20
- createReviewState,
21
- type ReviewBudgetV1,
22
- } from "../lib/review-transaction.ts";
23
- import { REVIEW_LENS, REVIEW_ROUTE } from "../lib/review-triggers.ts";
16
+ import type { NativeReviewCli } from "../lib/native-review-cli.ts";
17
+ import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
24
18
  import { stripAnsi } from "../lib/terminal-theme.ts";
25
- import { qualifiedReviewLockPlatform, testSnapshot } from "./review-test-fixtures.ts";
19
+
20
+ initTheme("dark");
26
21
 
27
22
  function writeMarkdown(path: string, content: string): void {
28
23
  mkdirSync(dirname(path), { recursive: true });
29
24
  writeFileSync(path, content);
30
25
  }
31
26
 
27
+ const lifecycleTheme = {
28
+ bold(value: string): string {
29
+ return value;
30
+ },
31
+ fg(color: string, value: string): string {
32
+ return `<${color}>${value}</${color}>`;
33
+ },
34
+ };
35
+
36
+ function renderComponent(component: { render(width: number): string[] }): string {
37
+ return component.render(120).map((line) => line.replace(/[ \t]+$/g, "")).join("\n");
38
+ }
39
+
40
+ function registeredGentleTools(): Map<string, any> {
41
+ const tools = new Map<string, any>();
42
+ const pi = {
43
+ on() {},
44
+ registerCommand() {},
45
+ registerTool(tool: { name: string }) {
46
+ tools.set(tool.name, tool);
47
+ },
48
+ } as unknown as ExtensionAPI;
49
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
50
+ return tools;
51
+ }
52
+
53
+ function lifecycleContext(overrides: Record<string, unknown> = {}): Record<string, unknown> {
54
+ return {
55
+ executionStarted: false,
56
+ isPartial: true,
57
+ isError: false,
58
+ lastComponent: undefined,
59
+ ...overrides,
60
+ };
61
+ }
62
+
63
+ test("registered Gentle Review tools render reusable rose lifecycle call rows", () => {
64
+ const tools = registeredGentleTools();
65
+ const cases = [
66
+ ["gentle_review", { operation: "status" }, "review status"],
67
+ ["gentle_review", { operation: "future-operation", secret: "/private" }, "review"],
68
+ ["gentle_review_scope", {}, "review scope"],
69
+ [
70
+ "gentle_review_capture",
71
+ {
72
+ lineageId: "lineage-id",
73
+ collectBinding: "binding-id",
74
+ sha256: "sha256:hash-value",
75
+ secret: "secret-value",
76
+ arbitrary: "arbitrary-value",
77
+ },
78
+ "review capture",
79
+ ],
80
+ ] as const;
81
+
82
+ assert.deepEqual(
83
+ [...new Set(cases.map(([name]) => name))].sort(),
84
+ [...tools.keys()].filter((name) => name.startsWith("gentle_")).sort(),
85
+ );
86
+
87
+ for (const [name, args, operationPath] of cases) {
88
+ const tool = tools.get(name);
89
+ assert.ok(tool, `missing ${name}`);
90
+ const initial = tool.renderCall(args, lifecycleTheme, lifecycleContext());
91
+ const initialText = renderComponent(initial);
92
+ const running = tool.renderCall(
93
+ args,
94
+ lifecycleTheme,
95
+ lifecycleContext({ executionStarted: true, lastComponent: initial }),
96
+ );
97
+ const runningText = renderComponent(running);
98
+ const completed = tool.renderCall(
99
+ args,
100
+ lifecycleTheme,
101
+ lifecycleContext({ executionStarted: true, isPartial: false, lastComponent: running }),
102
+ );
103
+ const completedText = renderComponent(completed);
104
+ const failed = tool.renderCall(
105
+ args,
106
+ lifecycleTheme,
107
+ lifecycleContext({ executionStarted: true, isPartial: false, isError: true, lastComponent: completed }),
108
+ );
109
+ const failedText = renderComponent(failed);
110
+
111
+ assert.strictEqual(initial, running);
112
+ assert.strictEqual(running, completed);
113
+ assert.strictEqual(completed, failed);
114
+ assert.equal(initialText, `<warning>🌹︎ Gentle AI · running · ${operationPath}</warning>`);
115
+ assert.equal(runningText, `<warning>🌹︎ Gentle AI · running · ${operationPath}</warning>`);
116
+ assert.equal(completedText, `<success>🌹︎ Gentle AI · completed · ${operationPath}</success>`);
117
+ assert.equal(failedText, `<error>🌹︎ Gentle AI · failed · ${operationPath}</error>`);
118
+ assert.doesNotMatch(renderComponent(failed), /future-operation|secret|private/);
119
+ for (const forbiddenValue of ["lineage-id", "binding-id", "sha256:hash-value", "secret-value", "arbitrary-value"]) {
120
+ assert.doesNotMatch(failedText, new RegExp(forbiddenValue));
121
+ }
122
+ }
123
+ });
124
+
125
+ test("registered Gentle Review tools preserve result envelopes and redact collapsed result rendering", async () => {
126
+ const tools = registeredGentleTools();
127
+ const scope = tools.get("gentle_review_scope");
128
+ const manifest = { version: 1, scopeByMode: { "100644": ["src/file.ts"] }, gitlinks: {} };
129
+ const bytes = Buffer.from(JSON.stringify(manifest), "utf8");
130
+ const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url");
131
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
132
+
133
+ const result = await scope.execute(
134
+ "scope-call",
135
+ { manifest: encoded, sha256, cursor: 0 },
136
+ undefined,
137
+ undefined,
138
+ { cwd: process.cwd() } as ExtensionContext,
139
+ );
140
+ const visibleEnvelope = JSON.parse(result.content[0].text);
141
+ assert.deepEqual(visibleEnvelope, {
142
+ version: 1,
143
+ sha256,
144
+ cursor: 0,
145
+ totalPaths: 1,
146
+ entries: [{ path: "src/file.ts", mode: "100644" }],
147
+ });
148
+ assert.deepEqual(result.details, visibleEnvelope);
149
+
150
+ const resultText = "safe result\x1b[31m\nlineage=secret body=private";
151
+ const expandHint = keyHint("app.tools.expand", "to expand");
152
+ for (const name of ["gentle_review", "gentle_review_scope", "gentle_review_capture"]) {
153
+ const tool = tools.get(name);
154
+ assert.equal(typeof tool?.renderResult, "function", `${name} must define result rendering`);
155
+ for (const options of [
156
+ { expanded: false, isPartial: true, isError: false },
157
+ { expanded: false, isPartial: false, isError: false },
158
+ { expanded: false, isPartial: false, isError: true },
159
+ ]) {
160
+ const collapsed = renderComponent(tool.renderResult({ content: [{ type: "text", text: resultText }] }, options, lifecycleTheme, {}));
161
+ assert.equal(collapsed, expandHint, `${name} collapsed output must contain one expand hint`);
162
+ assert.equal(collapsed.split("\n")[0], expandHint, `${name} collapsed output must start with the hint`);
163
+ assert.doesNotMatch(collapsed, /safe result|lineage=secret|private/);
164
+ }
165
+ const expanded = renderComponent(tool.renderResult({ content: [{ type: "text", text: resultText }] }, { expanded: true, isPartial: false, isError: true }, lifecycleTheme, {}));
166
+ assert.equal(expanded.split("\n")[0], "safe result");
167
+ assert.match(expanded, /safe result/);
168
+ assert.match(expanded, /lineage=secret body=private/);
169
+ assert.doesNotMatch(expanded, /to expand/);
170
+ assert.doesNotMatch(expanded, /\x1b\[/);
171
+ const nonText = renderComponent(tool.renderResult({ content: [{ type: "image", data: "opaque", mimeType: "image/png" }] }, { expanded: true, isPartial: false }, lifecycleTheme, {}));
172
+ assert.equal(nonText, "");
173
+ const empty = renderComponent(tool.renderResult({ content: [{ type: "text", text: "" }] }, { expanded: false, isPartial: false }, lifecycleTheme, {}));
174
+ assert.equal(empty, "");
175
+ }
176
+ });
177
+
178
+ test("session startup reports invalid project routing without mutating the profile", async (t) => {
179
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-model-routing-startup-"));
180
+ const configHome = join(root, "global");
181
+ const projectConfigDir = join(root, ".pi", "gentle-ai");
182
+ const projectAgentsDir = join(root, ".pi", "agents");
183
+ const projectProfileDir = join(root, ".pi");
184
+ const rootAgentsDir = join(root, "agents");
185
+ const agentHome = join(root, "agent-home");
186
+ const agentHomeAgentsDir = join(agentHome, "agents");
187
+ const agentHomeSubagentsDir = join(agentHome, "subagents");
188
+ mkdirSync(configHome, { recursive: true });
189
+ mkdirSync(projectConfigDir, { recursive: true });
190
+ mkdirSync(projectAgentsDir, { recursive: true });
191
+ mkdirSync(projectProfileDir, { recursive: true });
192
+ mkdirSync(rootAgentsDir, { recursive: true });
193
+ mkdirSync(agentHomeAgentsDir, { recursive: true });
194
+ mkdirSync(agentHomeSubagentsDir, { recursive: true });
195
+ t.after(() => rmSync(root, { recursive: true, force: true }));
196
+
197
+ const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
198
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
199
+ process.env.GENTLE_PI_CONFIG_HOME = configHome;
200
+ process.env.GENTLE_PI_AGENT_HOME = agentHome;
201
+ t.after(() => {
202
+ if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
203
+ else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
204
+ if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
205
+ else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
206
+ });
207
+
208
+ writeFileSync(join(projectConfigDir, "models.json"), "[]");
209
+ writeMarkdown(join(projectAgentsDir, "worker.md"), "---\nname: worker\ndescription: Worker\n---\nbody\n");
210
+ const profilePath = join(projectProfileDir, "subagents.json");
211
+ const profileBytes = `${JSON.stringify({ unrelated: { keep: true } }, null, 2)}\n`;
212
+ writeFileSync(profilePath, profileBytes);
213
+ const before = readFileSync(profilePath, "utf8");
214
+
215
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void>>();
216
+ const pi = {
217
+ on(name: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void>) {
218
+ handlers.set(name, handler);
219
+ },
220
+ registerCommand() {},
221
+ registerTool() {},
222
+ } as unknown as ExtensionAPI;
223
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
224
+ const sessionStart = handlers.get("session_start");
225
+ assert.equal(typeof sessionStart, "function");
226
+ const notifications: Array<{ message: string; severity: string }> = [];
227
+ await sessionStart!({}, {
228
+ cwd: root,
229
+ hasUI: true,
230
+ ui: {
231
+ notify(message: string, severity: string) {
232
+ notifications.push({ message, severity });
233
+ },
234
+ },
235
+ } as unknown as ExtensionContext);
236
+
237
+ const warning = notifications.find((entry) => entry.message.includes(join(projectConfigDir, "models.json")));
238
+ assert.ok(warning, JSON.stringify(notifications));
239
+ assert.equal(warning!.severity, "warning");
240
+ assert.match(warning!.message, /skipped model config/);
241
+ assert.equal(readFileSync(profilePath, "utf8"), before);
242
+ });
243
+
32
244
  test("agent discovery skips skills directories", async (t) => {
33
245
  const root = mkdtempSync(join(tmpdir(), "gentle-pi-agents-"));
34
246
  t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -54,12 +266,8 @@ test("agent discovery skips skills directories", async (t) => {
54
266
  );
55
267
  });
56
268
 
57
- test("runtime guidance routes review intent to concrete lenses", () => {
58
- const guidedFiles = [
59
- "README.md",
60
- "assets/orchestrator.md",
61
- "skills/gentle-ai/SKILL.md",
62
- ];
269
+ test("runtime guidance keeps review policy out of the static orchestrator", () => {
270
+ const staticReferences = ["README.md", "skills/gentle-ai/SKILL.md"];
63
271
  const forbiddenGenericRoutes = [
64
272
  /fresh-context `reviewer`/,
65
273
  /fresh reviewer audits/,
@@ -67,15 +275,8 @@ test("runtime guidance routes review intent to concrete lenses", () => {
67
275
  /run a fresh-context `reviewer`/,
68
276
  ];
69
277
 
70
- for (const file of guidedFiles) {
71
- // orchestrator-lazy-diet: the 4R/Review Lens content is split between the
72
- // always-on core and `assets/orchestrator-delegation.md`. Only this one
73
- // loop entry is repointed to the core+delegation-ref union; README.md and
74
- // skills/gentle-ai/SKILL.md are unchanged single-file reads.
75
- const content =
76
- file === "assets/orchestrator.md"
77
- ? readFileSync(file, "utf8") + readFileSync("assets/orchestrator-delegation.md", "utf8")
78
- : readFileSync(file, "utf8");
278
+ for (const file of staticReferences) {
279
+ const content = readFileSync(file, "utf8");
79
280
  assert.match(content, /Review Lens Selection|review lens/);
80
281
  assert.match(content, /review-risk/);
81
282
  assert.match(content, /review-reliability/);
@@ -85,6 +286,93 @@ test("runtime guidance routes review intent to concrete lenses", () => {
85
286
  assert.doesNotMatch(content, forbidden, `${file} must not route to generic reviewer`);
86
287
  }
87
288
  }
289
+
290
+ const orchestrator = readFileSync("assets/orchestrator.md", "utf8")
291
+ + readFileSync("assets/orchestrator-delegation.md", "utf8");
292
+ assert.match(orchestrator, /Gentle AI dynamically supplies runtime-specific RDD instructions/);
293
+ assert.match(orchestrator, /this package does not invent or fall back/);
294
+ for (const lifecycleMarker of ["review-risk", "review-reliability", "review-resilience", "review-readability", "Authority-First Terminal Procedure", "reconcile-terminal-mirrors"]) {
295
+ assert.doesNotMatch(orchestrator, new RegExp(lifecycleMarker), `static orchestrator must not mirror ${lifecycleMarker}`);
296
+ }
297
+ });
298
+
299
+ test("ordinary native capture exposes a registered schema and STATUS binding copied unchanged to one slot", async (t) => {
300
+ const tools = new Map<string, { name: string; parameters: { required?: readonly string[] } }>();
301
+ const pi = {
302
+ on() {},
303
+ registerCommand() {},
304
+ registerTool(tool: { name: string; parameters: { required?: readonly string[] } }) {
305
+ tools.set(tool.name, tool);
306
+ },
307
+ } as unknown as ExtensionAPI;
308
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
309
+
310
+ assert.ok(tools.has("gentle_review_capture"));
311
+ assert.deepEqual(tools.get("gentle_review_capture")?.parameters.required, ["lineageId", "collectBinding"]);
312
+
313
+ const sha = `sha256:${"a".repeat(64)}`;
314
+ const lineageId = "ordinary-capture";
315
+ const collectInput: ReviewCollectInputV3 = {
316
+ name: "reviewer_result",
317
+ schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
318
+ captureOperation: "review.capture-result",
319
+ arguments: [
320
+ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
321
+ { name: "target", value: sha, token: `--target=${sha}` },
322
+ { name: "agent", value: "pi", token: "--agent=pi" },
323
+ { name: "materialize", value: "true", token: "--materialize=true" },
324
+ ],
325
+ submission: {
326
+ operationToken: "capture-result",
327
+ argumentTokens: ["--lineage=ordinary-capture", `--target=${sha}`, "--agent=pi", "--materialize=true", "--input={{value}}"],
328
+ values: [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: 4 }],
329
+ },
330
+ };
331
+ const currentStatus = {
332
+ contract: "gentle-ai.review-integration/v2",
333
+ applicability: "current_target",
334
+ authority: { version: "compact-v2", lineageId, state: "reviewing", generation: 1, revision: sha },
335
+ action: "stop",
336
+ replayability: "not_replayable",
337
+ targetIdentity: sha,
338
+ projection: {
339
+ schema: "gentle-ai.review-candidate-projection/v1",
340
+ kind: "current-changes",
341
+ projection: "workspace",
342
+ baseTree: "b".repeat(40),
343
+ initialReviewTree: "b".repeat(40),
344
+ currentCandidateTree: "b".repeat(40),
345
+ pathsDigest: sha,
346
+ paths: ["app.ts"],
347
+ intendedUntracked: [],
348
+ intendedUntrackedProof: sha,
349
+ initialSnapshotIdentity: sha,
350
+ currentSnapshotIdentity: sha,
351
+ },
352
+ candidates: [],
353
+ nextTransition: { kind: "collect", reasonCode: "capture_required", collect: { inputs: [collectInput] } },
354
+ raw: { schema: "gentle-ai.review-integration.status/v5" },
355
+ } as unknown as ReviewStatusV3;
356
+ const native = { targetStatus: async () => currentStatus } as unknown as NativeReviewCli;
357
+
358
+ const publicStatus = await __testing.executeReviewControllerOperation({ operation: "status" }, process.cwd(), native);
359
+ const bindings = publicStatus.collectBindings as readonly { collectBinding: unknown }[];
360
+ assert.equal(bindings.length, 1);
361
+ assert.equal(typeof bindings[0]?.collectBinding, "string");
362
+
363
+ let launches = 0;
364
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
365
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
366
+ launches += 1;
367
+ return { promptByteLength: 1, resultByteLength: 1, submission: "{}" };
368
+ });
369
+ const captured = await __testing.executeReviewCaptureOperation({
370
+ lineageId,
371
+ collectBinding: bindings[0]!.collectBinding,
372
+ reviewerRunAcknowledged: true,
373
+ }, process.cwd(), native);
374
+ assert.equal(captured.status, "captured");
375
+ assert.equal(launches, 1);
88
376
  });
89
377
 
90
378
  test("agent model discovery prioritizes SDD and Judgment Day agents", (t) => {
@@ -168,135 +456,536 @@ test("model panel render uses the Pi-provided current theme when supplied", () =
168
456
  assert.match(stripAnsi(rendered), /Assign Models and Effort to Agents/);
169
457
  });
170
458
 
171
- function runtimeBudget(): ReviewBudgetV1 {
172
- return {
173
- review_batches: 1,
174
- review_actors: 1,
175
- refuter_batches: 1,
176
- fix_batches: 1,
177
- validator_runs: 1,
178
- final_verifications: 1,
179
- judgment_rounds: 0,
180
- judge_runs: 0,
181
- };
182
- }
459
+ test("delivery commands bypass RDD under every mode outcome while command safety remains independent", async () => {
460
+ type ToolCallHandler = (
461
+ event: { toolName: string; input: unknown },
462
+ ctx: ExtensionContext,
463
+ ) => Promise<ToolCallEventResult | undefined>;
464
+ const commands = [
465
+ "git commit -m relay",
466
+ "git push origin feature/relay",
467
+ "gh pr create --base main --head feature/relay",
468
+ "gh release create v1.2.3",
469
+ "git status && git commit -m relay",
470
+ "env SAFE=1 git push origin feature/relay",
471
+ "sh -c 'gh pr create --base main --head feature/relay'",
472
+ "sh -c 'gh release create v1.2.3'",
473
+ ] as const;
474
+ const modes = [
475
+ { label: "no native CLI", nativeReviewCli: null },
476
+ { label: "RDD off", nativeReviewCli: { reviewMode: async () => ({ status: { effective: "off" } }) } },
477
+ { label: "RDD on", nativeReviewCli: { reviewMode: async () => ({ status: { effective: "on" } }) } },
478
+ { label: "mode failure", nativeReviewCli: { reviewMode: async () => { throw new Error("mode unavailable"); } } },
479
+ ] as const;
183
480
 
184
- function runtimeAuthority(t: test.TestContext) {
185
- const parent = mkdtempSync(join(tmpdir(), "gentle-pi-runtime-gate-"));
186
- const repository = join(parent, "repo");
187
- mkdirSync(repository);
188
- t.after(() => rmSync(parent, { recursive: true, force: true }));
189
- const git = (...args: string[]): string =>
190
- execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim();
191
- git("init", "-b", "main");
192
- writeFileSync(join(repository, "app.ts"), "export const value = 1;\n");
193
- git("add", ".");
194
- git("-c", "user.name=Runtime Gate", "-c", "user.email=runtime@example.invalid", "commit", "-m", "base");
195
- const baseTree = git("rev-parse", "HEAD^{tree}");
196
- writeFileSync(join(repository, "app.ts"), "export const value = 2;\n");
197
- git("add", ".");
198
- git("-c", "user.name=Runtime Gate", "-c", "user.email=runtime@example.invalid", "commit", "-m", "final");
199
- const finalTree = git("rev-parse", "HEAD^{tree}");
200
- const store = ReviewTransactionStore.forRepository(repository, { mutationLockPlatform: qualifiedReviewLockPlatform() });
201
- store.create(createReviewState({
202
- lineageId: "runtime-approved",
203
- mode: REVIEW_MODE.ORDINARY,
204
- snapshot: testSnapshot({
205
- baseTree,
206
- completeTree: finalTree,
207
- route: REVIEW_ROUTE.STANDARD,
208
- lenses: [REVIEW_LENS.READABILITY],
209
- }),
210
- evidenceHash: "b".repeat(64),
211
- budget: runtimeBudget(),
212
- }), "start-runtime-approved");
213
- for (const [transition, input, idempotencyKey] of [
214
- [REVIEW_TRANSITION.ORDINARY_DISCOVERY, { rows: [] }, "discover"],
215
- [REVIEW_TRANSITION.ORDINARY_EVIDENCE, { deterministicResults: [] }, "evidence"],
216
- [REVIEW_TRANSITION.ORDINARY_FINAL_VERIFICATION, { passed: true }, "verify"],
217
- ] as const) {
218
- store.runReducerOperation({
219
- lineageId: "runtime-approved",
220
- transition,
221
- idempotencyKey,
222
- input,
223
- });
481
+ for (const mode of modes) {
482
+ const handlers = new Map<string, ToolCallHandler>();
483
+ const pi = {
484
+ on(name: string, handler: ToolCallHandler) {
485
+ handlers.set(name, handler);
486
+ },
487
+ events: { emit() {} },
488
+ registerCommand() {},
489
+ registerTool() {},
490
+ } as unknown as ExtensionAPI;
491
+ createGentleAiExtension({ nativeReviewCli: mode.nativeReviewCli as never })(pi);
492
+ const toolCall = handlers.get("tool_call");
493
+ assert.equal(typeof toolCall, "function", mode.label);
494
+ const ctx = {
495
+ cwd: process.cwd(),
496
+ hasUI: true,
497
+ ui: { confirm: async () => true },
498
+ } as ExtensionContext;
499
+
500
+ for (const command of commands) {
501
+ const result = await toolCall!({ toolName: "bash", input: { command } }, ctx);
502
+ assert.equal(result, undefined, `${mode.label}: ${command}`);
503
+ }
224
504
  }
225
- return {
226
- repository,
227
- finalTree,
228
- receipt: createReceiptForState(store.read("runtime-approved")),
229
- };
230
- }
505
+ });
231
506
 
232
- test("runtime lifecycle gates reject fabricated metadata while compound and wrapper forms fail closed", async (t) => {
507
+ test("guarded command confirmation emits a generic correlated permission lifecycle", async () => {
233
508
  type ToolCallHandler = (
234
509
  event: { toolName: string; input: unknown },
235
510
  ctx: ExtensionContext,
236
511
  ) => Promise<ToolCallEventResult | undefined>;
512
+ type PermissionEvent = {
513
+ channel: string;
514
+ data: {
515
+ requestId: string;
516
+ state: "waiting" | "approved" | "denied";
517
+ source: "tool_call";
518
+ message: string;
519
+ toolName: "bash";
520
+ };
521
+ };
522
+ type HerdrBlockedEvent = {
523
+ channel: "herdr:blocked";
524
+ data: { active: boolean; label?: string };
525
+ };
526
+ type EmittedEvent = PermissionEvent | HerdrBlockedEvent;
237
527
  const handlers = new Map<string, ToolCallHandler>();
528
+ const emitted: EmittedEvent[] = [];
529
+ const sequence: string[] = [];
530
+ let confirm!: () => Promise<boolean>;
238
531
  const pi = {
239
532
  on(name: string, handler: ToolCallHandler) {
240
533
  handlers.set(name, handler);
241
534
  },
535
+ events: {
536
+ emit(channel: string, data: EmittedEvent["data"]) {
537
+ sequence.push(
538
+ channel === "herdr:blocked"
539
+ ? `herdr:${"active" in data && data.active ? "active" : "inactive"}`
540
+ : `event:${data.state}`,
541
+ );
542
+ emitted.push({ channel, data } as EmittedEvent);
543
+ },
544
+ },
242
545
  registerCommand() {},
243
546
  registerTool() {},
244
547
  } as unknown as ExtensionAPI;
245
548
  createGentleAiExtension({ nativeReviewCli: null })(pi);
246
549
  const toolCall = handlers.get("tool_call");
247
550
  assert.equal(typeof toolCall, "function");
248
- const authority = runtimeAuthority(t);
249
- const ctx = {
250
- cwd: authority.repository,
251
- hasUI: false,
252
- } as ExtensionContext;
551
+ const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-permission-request-"));
552
+ try {
553
+ const ctx = {
554
+ cwd,
555
+ hasUI: true,
556
+ ui: {
557
+ confirm: async () => {
558
+ sequence.push("confirm");
559
+ return confirm();
560
+ },
561
+ },
562
+ } as ExtensionContext;
563
+ let resolveConfirmation!: (approved: boolean) => void;
564
+ confirm = () => new Promise<boolean>((resolve) => { resolveConfirmation = resolve; });
565
+ const denied = toolCall!({
566
+ toolName: "bash",
567
+ input: { command: "git rebase main --secret-command-content" },
568
+ }, ctx);
253
569
 
254
- const fabricated = await toolCall!(
255
- {
570
+ await Promise.resolve();
571
+ assert.equal(emitted[0].channel, "pi-permission-system:permission-request");
572
+ assert.equal(emitted[0].data.state, "waiting");
573
+ assert.deepEqual(emitted[1], {
574
+ channel: "herdr:blocked",
575
+ data: { active: true, label: "Guarded command confirmation" },
576
+ });
577
+ assert.deepEqual(sequence, ["event:waiting", "herdr:active", "confirm"]);
578
+ const deniedRequestId = emitted[0].data.requestId;
579
+ assert.match(deniedRequestId, /^[0-9a-f-]{36}$/);
580
+ assert.deepEqual(emitted[0].data, {
581
+ requestId: deniedRequestId,
582
+ state: "waiting",
583
+ source: "tool_call",
584
+ message: "Gentle AI safety policy requires confirmation for this tool call.",
256
585
  toolName: "bash",
257
- input: {
258
- command: "git commit -m bounded",
259
- reviewGate: {
260
- receipt: authority.receipt,
261
- target: {
262
- kind: GATE_TARGET_KIND.INTENDED_COMMIT,
263
- intended_commit_tree: authority.finalTree,
264
- },
265
- idempotencyKey: "runtime-commit",
266
- scopeBudget: runtimeBudget(),
267
- },
586
+ });
587
+ assert.equal(Object.keys(emitted[0].data).includes("command"), false);
588
+ assert.equal(Object.keys(emitted[0].data).includes("preview"), false);
589
+ assert.doesNotMatch(JSON.stringify(emitted), /secret-command-content|git rebase/);
590
+
591
+ resolveConfirmation(false);
592
+ assert.deepEqual(await denied, {
593
+ block: true,
594
+ reason: "Gentle AI safety policy blocked the command because it was not confirmed.",
595
+ });
596
+ assert.deepEqual(emitted[2], {
597
+ channel: "pi-permission-system:permission-request",
598
+ data: {
599
+ requestId: deniedRequestId,
600
+ state: "denied",
601
+ source: "tool_call",
602
+ message: "Gentle AI safety policy requires confirmation for this tool call.",
603
+ toolName: "bash",
604
+ },
605
+ });
606
+ assert.deepEqual(emitted[3], {
607
+ channel: "herdr:blocked",
608
+ data: { active: false },
609
+ });
610
+ assert.deepEqual(sequence, ["event:waiting", "herdr:active", "confirm", "event:denied", "herdr:inactive"]);
611
+
612
+ emitted.length = 0;
613
+ sequence.length = 0;
614
+ confirm = async () => true;
615
+ assert.equal(await toolCall!({ toolName: "bash", input: { command: "git rebase main" } }, ctx), undefined);
616
+ assert.equal(emitted.length, 4);
617
+ assert.equal(emitted[0].data.state, "waiting");
618
+ assert.deepEqual(emitted[1], {
619
+ channel: "herdr:blocked",
620
+ data: { active: true, label: "Guarded command confirmation" },
621
+ });
622
+ assert.equal(emitted[2].data.state, "approved");
623
+ assert.equal(emitted[0].data.requestId, emitted[2].data.requestId);
624
+ assert.notEqual(emitted[0].data.requestId, deniedRequestId);
625
+ assert.deepEqual(emitted[3], {
626
+ channel: "herdr:blocked",
627
+ data: { active: false },
628
+ });
629
+ assert.deepEqual(sequence, ["event:waiting", "herdr:active", "confirm", "event:approved", "herdr:inactive"]);
630
+
631
+ emitted.length = 0;
632
+ sequence.length = 0;
633
+ const confirmationError = new Error("confirmation unavailable");
634
+ confirm = async () => { throw confirmationError; };
635
+ await assert.rejects(
636
+ toolCall!({ toolName: "bash", input: { command: "git rebase main" } }, ctx),
637
+ (error) => error === confirmationError,
638
+ );
639
+ assert.equal(emitted.length, 4);
640
+ assert.equal(emitted[0].data.state, "waiting");
641
+ assert.deepEqual(emitted[1], {
642
+ channel: "herdr:blocked",
643
+ data: { active: true, label: "Guarded command confirmation" },
644
+ });
645
+ assert.equal(emitted[2].data.state, "denied");
646
+ assert.equal(emitted[0].data.requestId, emitted[2].data.requestId);
647
+ assert.deepEqual(emitted[3], {
648
+ channel: "herdr:blocked",
649
+ data: { active: false },
650
+ });
651
+ assert.deepEqual(sequence, ["event:waiting", "herdr:active", "confirm", "event:denied", "herdr:inactive"]);
652
+ } finally {
653
+ rmSync(cwd, { recursive: true, force: true });
654
+ }
655
+ });
656
+
657
+ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extension instance", async () => {
658
+ type ToolCallHandler = (
659
+ event: { toolName: string; input: unknown },
660
+ ctx: ExtensionContext,
661
+ ) => Promise<ToolCallEventResult | undefined>;
662
+ type EmittedEvent = {
663
+ channel: string;
664
+ data: {
665
+ requestId?: string;
666
+ state?: "waiting" | "approved" | "denied";
667
+ active?: boolean;
668
+ label?: string;
669
+ };
670
+ };
671
+ const createHarness = () => {
672
+ const handlers = new Map<string, ToolCallHandler>();
673
+ const emitted: EmittedEvent[] = [];
674
+ const confirmations: Array<(approved: boolean) => void> = [];
675
+ const pi = {
676
+ on(name: string, handler: ToolCallHandler) {
677
+ handlers.set(name, handler);
678
+ },
679
+ events: { emit(channel: string, data: EmittedEvent["data"]) { emitted.push({ channel, data }); } },
680
+ registerCommand() {},
681
+ registerTool() {},
682
+ } as unknown as ExtensionAPI;
683
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
684
+ return { handlers, emitted, confirmations };
685
+ };
686
+ const first = createHarness();
687
+ const second = createHarness();
688
+ const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-permission-concurrent-"));
689
+ try {
690
+ const context = (confirmations: Array<(approved: boolean) => void>) => ({
691
+ cwd,
692
+ hasUI: true,
693
+ ui: {
694
+ confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
695
+ },
696
+ } as ExtensionContext);
697
+ const firstRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main" } }, context(first.confirmations));
698
+ const secondRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --another-command" } }, context(first.confirmations));
699
+ await Promise.resolve();
700
+ assert.deepEqual(first.emitted.map(({ channel, data }) => ({ channel, state: data.state, active: data.active })), [
701
+ { channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
702
+ { channel: "herdr:blocked", state: undefined, active: true },
703
+ { channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
704
+ ]);
705
+ assert.equal(first.confirmations.length, 2);
706
+ const waitingEvents = first.emitted.filter(({ channel, data }) => channel === "pi-permission-system:permission-request" && data.state === "waiting");
707
+ assert.notEqual(waitingEvents[0]?.data.requestId, waitingEvents[1]?.data.requestId);
708
+
709
+ first.confirmations[0]!(false);
710
+ assert.deepEqual(await firstRequest, {
711
+ block: true,
712
+ reason: "Gentle AI safety policy blocked the command because it was not confirmed.",
713
+ });
714
+ assert.deepEqual(first.emitted.map(({ channel, data }) => ({ channel, state: data.state, active: data.active })), [
715
+ { channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
716
+ { channel: "herdr:blocked", state: undefined, active: true },
717
+ { channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
718
+ { channel: "pi-permission-system:permission-request", state: "denied", active: undefined },
719
+ ]);
720
+
721
+ const independentRequest = second.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --independent-command" } }, context(second.confirmations));
722
+ await Promise.resolve();
723
+ assert.equal(second.emitted.filter(({ channel }) => channel === "herdr:blocked").length, 1);
724
+ assert.equal(second.emitted.find(({ channel }) => channel === "herdr:blocked")?.data.active, true);
725
+ assert.equal(second.confirmations.length, 1);
726
+
727
+ first.confirmations[1]!(true);
728
+ assert.equal(await secondRequest, undefined);
729
+ assert.equal(first.emitted.filter(({ channel, data }) => channel === "herdr:blocked" && data.active === false).length, 1);
730
+ second.confirmations[0]!(true);
731
+ assert.equal(await independentRequest, undefined);
732
+ assert.deepEqual(second.emitted.map(({ channel, data }) => ({ channel, state: data.state, active: data.active })), [
733
+ { channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
734
+ { channel: "herdr:blocked", state: undefined, active: true },
735
+ { channel: "pi-permission-system:permission-request", state: "approved", active: undefined },
736
+ { channel: "herdr:blocked", state: undefined, active: false },
737
+ ]);
738
+ } finally {
739
+ rmSync(cwd, { recursive: true, force: true });
740
+ }
741
+ });
742
+
743
+
744
+ test("RPIV questionnaire blockers emit only a private, balanced Herdr projection", () => {
745
+ type HerdrBlockedEvent = { active: boolean; label?: string };
746
+ const eventHandlers = new Map<string, (data: unknown) => void>();
747
+ const published: Array<{ channel: string; data: unknown }> = [];
748
+ const herdrEvents: HerdrBlockedEvent[] = [];
749
+ const pi = {
750
+ on() {},
751
+ events: {
752
+ emit(channel: string, data: unknown) {
753
+ published.push({ channel, data });
754
+ if (channel === "herdr:blocked") herdrEvents.push(data as HerdrBlockedEvent);
755
+ const handler = eventHandlers.get(channel);
756
+ if (handler) handler(data);
757
+ },
758
+ on(channel: string, handler: (data: unknown) => void) {
759
+ eventHandlers.set(channel, handler);
760
+ return () => eventHandlers.delete(channel);
268
761
  },
269
762
  },
270
- ctx,
763
+ registerCommand() {},
764
+ registerTool() {},
765
+ } as unknown as ExtensionAPI;
766
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
767
+ assert.equal(eventHandlers.size, 2);
768
+ assert.equal(eventHandlers.has("gentle-pi:ask-user-choice:blocked"), true);
769
+ assert.equal(eventHandlers.has("rpiv:ask-user:blocked"), true);
770
+
771
+ const source = {
772
+ active: true,
773
+ question: "private questionnaire text",
774
+ answer: "private questionnaire answer",
775
+ path: "/private/questionnaire-path",
776
+ command: "private questionnaire command",
777
+ arbitrary: { nested: "private questionnaire field" },
778
+ };
779
+ pi.events.emit("rpiv:ask-user:blocked", source);
780
+ assert.strictEqual(published[0]?.data, source, "the RPIV event remains the source event");
781
+ assert.deepEqual(herdrEvents, [{ active: true, label: "Questionnaire awaiting input" }]);
782
+ assert.doesNotMatch(JSON.stringify(herdrEvents), /private questionnaire|questionnaire-path/i);
783
+
784
+ pi.events.emit("rpiv:ask-user:blocked", { active: true, duplicate: true });
785
+ pi.events.emit("rpiv:ask-user:blocked", { active: "true" });
786
+ pi.events.emit("rpiv:ask-user:blocked", { active: null });
787
+ pi.events.emit("rpiv:ask-user:blocked", []);
788
+ pi.events.emit("rpiv:ask-user:blocked", null);
789
+ pi.events.emit("rpiv:ask-user:other", { active: false });
790
+ assert.deepEqual(herdrEvents, [{ active: true, label: "Questionnaire awaiting input" }]);
791
+
792
+ pi.events.emit("rpiv:ask-user:blocked", { active: false });
793
+ pi.events.emit("rpiv:ask-user:blocked", { active: false, duplicate: true });
794
+ pi.events.emit("rpiv:ask-user:blocked", { active: true });
795
+ pi.events.emit("rpiv:ask-user:blocked", { active: false });
796
+ assert.deepEqual(herdrEvents, [
797
+ { active: true, label: "Questionnaire awaiting input" },
798
+ { active: false },
799
+ { active: true, label: "Questionnaire awaiting input" },
800
+ { active: false },
801
+ ]);
802
+ });
803
+
804
+ test("Herdr coordinates guarded confirmations and RPIV labels without inactive relabel pulses", async () => {
805
+ type ToolCallHandler = (
806
+ event: { toolName: string; input: unknown },
807
+ ctx: ExtensionContext,
808
+ ) => Promise<ToolCallEventResult | undefined>;
809
+ type HerdrBlockedEvent = { active: boolean; label?: string };
810
+ const createHarness = () => {
811
+ const handlers = new Map<string, ToolCallHandler>();
812
+ const eventHandlers = new Map<string, (data: unknown) => void>();
813
+ const herdrEvents: HerdrBlockedEvent[] = [];
814
+ const confirmations: Array<(approved: boolean) => void> = [];
815
+ const pi = {
816
+ on(name: string, handler: ToolCallHandler) {
817
+ handlers.set(name, handler);
818
+ },
819
+ events: {
820
+ emit(channel: string, data: unknown) {
821
+ if (channel === "herdr:blocked") herdrEvents.push(data as HerdrBlockedEvent);
822
+ const handler = eventHandlers.get(channel);
823
+ if (handler) handler(data);
824
+ },
825
+ on(channel: string, handler: (data: unknown) => void) {
826
+ eventHandlers.set(channel, handler);
827
+ return () => eventHandlers.delete(channel);
828
+ },
829
+ },
830
+ registerCommand() {},
831
+ registerTool() {},
832
+ } as unknown as ExtensionAPI;
833
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
834
+ const context = {
835
+ cwd: process.cwd(),
836
+ hasUI: true,
837
+ ui: {
838
+ confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
839
+ },
840
+ } as ExtensionContext;
841
+ return { confirmations, context, herdrEvents, pi, toolCall: handlers.get("tool_call")! };
842
+ };
843
+
844
+ const guardedFirst = createHarness();
845
+ const guardedRequest = guardedFirst.toolCall(
846
+ { toolName: "bash", input: { command: "git rebase main" } },
847
+ guardedFirst.context,
271
848
  );
272
- assert.equal(fabricated?.block, true);
273
- assert.match(fabricated?.reason ?? "", /registered review controller authorization/i);
849
+ await Promise.resolve();
850
+ guardedFirst.pi.events.emit("rpiv:ask-user:blocked", { active: true });
851
+ guardedFirst.confirmations[0]!(true);
852
+ assert.equal(await guardedRequest, undefined);
853
+ assert.deepEqual(guardedFirst.herdrEvents, [
854
+ { active: true, label: "Guarded command confirmation" },
855
+ { active: true, label: "Questionnaire awaiting input" },
856
+ ]);
857
+ guardedFirst.pi.events.emit("rpiv:ask-user:blocked", { active: false });
858
+ assert.deepEqual(guardedFirst.herdrEvents, [
859
+ { active: true, label: "Guarded command confirmation" },
860
+ { active: true, label: "Questionnaire awaiting input" },
861
+ { active: false },
862
+ ]);
274
863
 
275
- const lifecycle = await toolCall!(
276
- { toolName: "bash", input: { command: "git commit -m bounded" } },
277
- ctx,
864
+ const questionnaireFirst = createHarness();
865
+ questionnaireFirst.pi.events.emit("rpiv:ask-user:blocked", { active: true });
866
+ const questionnaireRequest = questionnaireFirst.toolCall(
867
+ { toolName: "bash", input: { command: "git rebase main" } },
868
+ questionnaireFirst.context,
278
869
  );
279
- assert.equal(lifecycle?.block, true);
280
- assert.match(lifecycle?.reason ?? "", /approved receipt.*exact typed command target/i);
281
- for (const command of [
282
- "git status && git commit -m compound",
283
- "env SAFE=1 git commit -m wrapped",
284
- "command git commit -m wrapped",
285
- "sh -c 'git commit -m wrapped'",
286
- "git \\\n commit -m continued",
287
- `git -c safe.long=${"x".repeat(8_192)} commit -m long-direct`,
288
- `sh -c 'git -c safe.long=${"x".repeat(8_192)} commit -m long-wrapped'`,
289
- ]) {
290
- const wrapped = await toolCall!({ toolName: "bash", input: { command } }, ctx);
291
- assert.equal(wrapped?.block, true, command);
292
- assert.match(wrapped?.reason ?? "", /compound or wrapped lifecycle command.*fail closed/i);
293
- }
870
+ await Promise.resolve();
871
+ questionnaireFirst.pi.events.emit("rpiv:ask-user:blocked", { active: false });
872
+ questionnaireFirst.confirmations[0]!(false);
873
+ await questionnaireRequest;
874
+ assert.deepEqual(questionnaireFirst.herdrEvents, [
875
+ { active: true, label: "Questionnaire awaiting input" },
876
+ { active: true, label: "Guarded command confirmation" },
877
+ { active: false },
878
+ ]);
879
+ });
880
+
881
+ test("closed choice blockers retain the visible choice label through guarded-confirmation overlap", async () => {
882
+ type ToolCallHandler = (
883
+ event: { toolName: string; input: unknown },
884
+ ctx: ExtensionContext,
885
+ ) => Promise<ToolCallEventResult | undefined>;
886
+ type HerdrBlockedEvent = { active: boolean; label?: string };
887
+ const handlers = new Map<string, ToolCallHandler>();
888
+ const eventHandlers = new Map<string, (data: unknown) => void>();
889
+ const herdrEvents: HerdrBlockedEvent[] = [];
890
+ const choiceEvents: Array<{ active: boolean }> = [];
891
+ const confirmations: Array<(approved: boolean) => void> = [];
892
+ const pi = {
893
+ on(name: string, handler: ToolCallHandler) {
894
+ handlers.set(name, handler);
895
+ },
896
+ events: {
897
+ emit(channel: string, data: unknown) {
898
+ if (channel === "herdr:blocked") herdrEvents.push(data as HerdrBlockedEvent);
899
+ if (channel === "gentle-pi:ask-user-choice:blocked") choiceEvents.push(data as { active: boolean });
900
+ eventHandlers.get(channel)?.(data);
901
+ },
902
+ on(channel: string, handler: (data: unknown) => void) {
903
+ eventHandlers.set(channel, handler);
904
+ return () => eventHandlers.delete(channel);
905
+ },
906
+ },
907
+ registerCommand() {},
908
+ registerTool() {},
909
+ } as unknown as ExtensionAPI;
910
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
911
+ assert.equal(eventHandlers.has("gentle-pi:ask-user-choice:blocked"), true);
294
912
 
295
- const destructive = await toolCall!(
296
- { toolName: "bash", input: { command: "git push --force origin main" } },
297
- ctx,
913
+ pi.events.emit("gentle-pi:ask-user-choice:blocked", { active: true });
914
+ assert.deepEqual(choiceEvents, [{ active: true }]);
915
+ assert.deepEqual(herdrEvents, [{ active: true, label: "Choice awaiting input" }]);
916
+
917
+ const guardedRequest = handlers.get("tool_call")!(
918
+ { toolName: "bash", input: { command: "git rebase main" } },
919
+ {
920
+ cwd: process.cwd(),
921
+ hasUI: true,
922
+ ui: {
923
+ confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
924
+ },
925
+ } as ExtensionContext,
298
926
  );
299
- assert.equal(destructive?.block, true);
300
- assert.match(destructive?.reason ?? "", /safety policy blocked a destructive shell command/i);
301
- assert.doesNotMatch(destructive?.reason ?? "", /approved receipt/i);
927
+ await Promise.resolve();
928
+ assert.equal(confirmations.length, 1);
929
+ assert.deepEqual(herdrEvents, [{ active: true, label: "Choice awaiting input" }]);
930
+
931
+ pi.events.emit("gentle-pi:ask-user-choice:blocked", { active: false });
932
+ assert.deepEqual(choiceEvents, [{ active: true }, { active: false }]);
933
+ assert.deepEqual(herdrEvents, [
934
+ { active: true, label: "Choice awaiting input" },
935
+ { active: true, label: "Guarded command confirmation" },
936
+ ]);
937
+ assert.equal(herdrEvents.some((event) => event.active === false), false);
938
+
939
+ confirmations[0]!(true);
940
+ assert.equal(await guardedRequest, undefined);
941
+ assert.deepEqual(herdrEvents, [
942
+ { active: true, label: "Choice awaiting input" },
943
+ { active: true, label: "Guarded command confirmation" },
944
+ { active: false },
945
+ ]);
946
+ });
947
+
948
+ test("permission lifecycle is inactive for unguarded and headless commands", async () => {
949
+ type ToolCallHandler = (
950
+ event: { toolName: string; input: unknown },
951
+ ctx: ExtensionContext,
952
+ ) => Promise<ToolCallEventResult | undefined>;
953
+ const handlers = new Map<string, ToolCallHandler>();
954
+ const emitted: unknown[] = [];
955
+ let confirmations = 0;
956
+ const pi = {
957
+ on(name: string, handler: ToolCallHandler) {
958
+ handlers.set(name, handler);
959
+ },
960
+ events: { emit(_channel: string, data: unknown) { emitted.push(data); } },
961
+ registerCommand() {},
962
+ registerTool() {},
963
+ } as unknown as ExtensionAPI;
964
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
965
+ const toolCall = handlers.get("tool_call");
966
+ assert.equal(typeof toolCall, "function");
967
+ const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-permission-headless-"));
968
+ try {
969
+ const confirm = async () => {
970
+ confirmations += 1;
971
+ return true;
972
+ };
973
+ assert.equal(await toolCall!({ toolName: "bash", input: { command: "echo safe --secret-command-content" } }, {
974
+ cwd,
975
+ hasUI: false,
976
+ ui: { confirm },
977
+ } as ExtensionContext), undefined);
978
+ assert.deepEqual(await toolCall!({ toolName: "bash", input: { command: "git rebase main" } }, {
979
+ cwd,
980
+ hasUI: false,
981
+ ui: { confirm },
982
+ } as ExtensionContext), {
983
+ block: true,
984
+ reason: "Gentle AI safety policy requires interactive confirmation before this command.",
985
+ });
986
+ assert.equal(confirmations, 0);
987
+ assert.deepEqual(emitted, []);
988
+ } finally {
989
+ rmSync(cwd, { recursive: true, force: true });
990
+ }
302
991
  });