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