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.
- package/README.md +76 -79
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +56 -249
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +20 -40
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +2349 -3785
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/agent-home.ts +8 -0
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +387 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +324 -96
- package/lib/review-integration-v2.ts +868 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +179 -68
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +337 -890
- package/runtime/review-integration-v2.mjs +816 -255
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +23 -23
- package/skills/_shared/review-ledger-contract.md +14 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/issue-creation/SKILL.md +53 -93
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +821 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +45 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +152 -36
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-contract-prompt.test.ts +139 -0
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +328 -91
- package/tests/review-host-relay.test.ts +436 -14
- package/tests/review-integration-v2-forward.test.ts +623 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +362 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +366 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /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
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
58
|
-
const
|
|
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
|
|
71
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
226
|
-
repository,
|
|
227
|
-
finalTree,
|
|
228
|
-
receipt: createReceiptForState(store.read("runtime-approved")),
|
|
229
|
-
};
|
|
230
|
-
}
|
|
506
|
+
});
|
|
231
507
|
|
|
232
|
-
test("
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
assert.
|
|
301
|
-
assert.
|
|
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
|
});
|