gentle-pi 2.3.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 +30 -4
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +2 -1
- package/assets/orchestrator.md +6 -10
- 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/review-integration.md +1 -1
- package/extensions/gentle-ai.ts +530 -71
- package/lib/agent-home.ts +8 -0
- package/lib/native-review-cli.ts +15 -0
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +34 -5
- package/lib/sdd-preflight.ts +2 -2
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +15 -0
- package/runtime/review-integration-v2.mjs +34 -5
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +14 -13
- package/skills/_shared/review-ledger-contract.md +6 -0
- package/skills/issue-creation/SKILL.md +53 -93
- 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 +1 -1
- package/tests/gentle-ai-installer.test.ts +46 -46
- package/tests/gentle-ai.test.ts +3 -2
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-review-capability-contract.test.ts +18 -1
- package/tests/orchestrator-budget.test.ts +2 -2
- package/tests/orchestrator-rdd-ownership.test.ts +1 -1
- package/tests/package-manifest.test.ts +123 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-contract-prompt.test.ts +139 -0
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +211 -9
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/runtime-harness.mjs +1 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /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
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
9
|
+
import type { ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
10
|
+
|
|
11
|
+
// gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent finished an
|
|
12
|
+
// implementation and reported completion without ever entering the review
|
|
13
|
+
// preflight. These tests cover the read-only, idempotent `agent_end` nudge
|
|
14
|
+
// that reminds the agent to call gentle_review before reporting completion,
|
|
15
|
+
// without ever starting a review or answering consent itself.
|
|
16
|
+
//
|
|
17
|
+
// gentle-pi#568: `session_start` records the target identity STATUS reports
|
|
18
|
+
// at session start as a baseline, so `agent_end` skips a candidate that
|
|
19
|
+
// already existed before this session touched the worktree (the user's own
|
|
20
|
+
// pre-session work, not this session's output). These tests point
|
|
21
|
+
// `GENTLE_PI_AGENT_HOME` and the session `cwd` at fresh temp directories so
|
|
22
|
+
// `session_start`'s real SDD asset install and model config sweep never
|
|
23
|
+
// touch this machine's actual home directory.
|
|
24
|
+
|
|
25
|
+
type AnyHandler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
26
|
+
type SentMessage = { message: Record<string, unknown>; options: Record<string, unknown> };
|
|
27
|
+
|
|
28
|
+
function harness(nativeReviewCli: NativeReviewCli | null): {
|
|
29
|
+
handlers: Map<string, AnyHandler>;
|
|
30
|
+
sent: SentMessage[];
|
|
31
|
+
} {
|
|
32
|
+
const handlers = new Map<string, AnyHandler>();
|
|
33
|
+
const sent: SentMessage[] = [];
|
|
34
|
+
const pi = {
|
|
35
|
+
on(name: string, handler: AnyHandler) {
|
|
36
|
+
handlers.set(name, handler);
|
|
37
|
+
},
|
|
38
|
+
events: { emit() {} },
|
|
39
|
+
registerCommand() {},
|
|
40
|
+
registerTool() {},
|
|
41
|
+
sendMessage(message: Record<string, unknown>, options: Record<string, unknown> = {}) {
|
|
42
|
+
sent.push({ message, options });
|
|
43
|
+
},
|
|
44
|
+
} as unknown as ExtensionAPI;
|
|
45
|
+
createGentleAiExtension({ nativeReviewCli })(pi);
|
|
46
|
+
return { handlers, sent };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ctx(sessionId: string, hasUI = true, cwd = process.cwd()): ExtensionContext {
|
|
50
|
+
return {
|
|
51
|
+
cwd,
|
|
52
|
+
hasUI,
|
|
53
|
+
ui: { notify() {} },
|
|
54
|
+
sessionManager: { getSessionId: () => sessionId },
|
|
55
|
+
} as unknown as ExtensionContext;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function withSessionStartEnv<T>(callback: (cwd: string) => Promise<T>): Promise<T> {
|
|
59
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
60
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
61
|
+
process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-agent-home-"));
|
|
62
|
+
// Isolates both the model-config sweep and the dev-binary registration
|
|
63
|
+
// lookup from this machine's real ~/.pi/gentle-ai, so `session_start`'s
|
|
64
|
+
// unrelated notifications never leak into these assertions.
|
|
65
|
+
process.env.GENTLE_PI_CONFIG_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-config-home-"));
|
|
66
|
+
try {
|
|
67
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-cwd-"));
|
|
68
|
+
return await callback(cwd);
|
|
69
|
+
} finally {
|
|
70
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
71
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
72
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
73
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onMode(effective: "on" | "off"): NativeReviewCli["reviewMode"] {
|
|
78
|
+
return async () => ({
|
|
79
|
+
operation: "status",
|
|
80
|
+
scope: "clone",
|
|
81
|
+
status: { global: "", cloneLocal: effective === "on" ? "on" : "", effective, source: "clone_local" },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function executeStartStatus(targetIdentity: string): ReviewStatusV3 {
|
|
86
|
+
return {
|
|
87
|
+
applicability: "unrelated",
|
|
88
|
+
action: "start",
|
|
89
|
+
targetIdentity,
|
|
90
|
+
nextTransition: { kind: "execute", execute: { operation: "review.start", arguments: [] } },
|
|
91
|
+
} as unknown as ReviewStatusV3;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function collectStatus(targetIdentity: string): ReviewStatusV3 {
|
|
95
|
+
return {
|
|
96
|
+
applicability: "unrelated",
|
|
97
|
+
action: "start",
|
|
98
|
+
targetIdentity,
|
|
99
|
+
nextTransition: { kind: "collect", collect: { inputs: [] } },
|
|
100
|
+
} as unknown as ReviewStatusV3;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stopStatus(targetIdentity: string): ReviewStatusV3 {
|
|
104
|
+
return {
|
|
105
|
+
applicability: "unrelated",
|
|
106
|
+
action: "start",
|
|
107
|
+
targetIdentity,
|
|
108
|
+
nextTransition: { kind: "stop" },
|
|
109
|
+
} as unknown as ReviewStatusV3;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const agentEndEvent = { type: "agent_end", messages: [] };
|
|
113
|
+
|
|
114
|
+
test("agent_end performs no STATUS call and sends nothing when RDD is off", async () => {
|
|
115
|
+
const statusRequests: unknown[] = [];
|
|
116
|
+
const native = {
|
|
117
|
+
reviewMode: onMode("off"),
|
|
118
|
+
targetStatus: async (request: unknown) => {
|
|
119
|
+
statusRequests.push(request);
|
|
120
|
+
throw new Error("targetStatus must not be called when RDD is off");
|
|
121
|
+
},
|
|
122
|
+
} as unknown as NativeReviewCli;
|
|
123
|
+
const { handlers, sent } = harness(native);
|
|
124
|
+
const agentEnd = handlers.get("agent_end");
|
|
125
|
+
assert.equal(typeof agentEnd, "function");
|
|
126
|
+
await agentEnd!(agentEndEvent, ctx("agent-end-rdd-off"));
|
|
127
|
+
assert.deepEqual(statusRequests, []);
|
|
128
|
+
assert.deepEqual(sent, []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("agent_end nudges exactly once when RDD is on and STATUS offers review.start", async () => {
|
|
132
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
133
|
+
const statusRequests: Array<{ agent?: string }> = [];
|
|
134
|
+
const native = {
|
|
135
|
+
reviewMode: onMode("on"),
|
|
136
|
+
targetStatus: async (request: { agent?: string }) => {
|
|
137
|
+
statusRequests.push(request);
|
|
138
|
+
return executeStartStatus(targetIdentity);
|
|
139
|
+
},
|
|
140
|
+
} as unknown as NativeReviewCli;
|
|
141
|
+
const { handlers, sent } = harness(native);
|
|
142
|
+
const agentEnd = handlers.get("agent_end");
|
|
143
|
+
await agentEnd!(agentEndEvent, ctx("agent-end-execute"));
|
|
144
|
+
|
|
145
|
+
assert.equal(sent.length, 1);
|
|
146
|
+
const [entry] = sent;
|
|
147
|
+
assert.equal(entry?.message.customType, "gentle-pi.review-preflight");
|
|
148
|
+
const content = String(entry?.message.content);
|
|
149
|
+
assert.match(content, /gentle_review/);
|
|
150
|
+
assert.ok(content.includes(targetIdentity), "message must name the target identity");
|
|
151
|
+
assert.equal(entry?.options.triggerTurn, true);
|
|
152
|
+
assert.equal(statusRequests[0]?.agent, "pi");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("agent_end nudges once per target identity and again for a fresh identity", async () => {
|
|
156
|
+
let targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
157
|
+
const native = {
|
|
158
|
+
reviewMode: onMode("on"),
|
|
159
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
160
|
+
} as unknown as NativeReviewCli;
|
|
161
|
+
const { handlers, sent } = harness(native);
|
|
162
|
+
const agentEnd = handlers.get("agent_end");
|
|
163
|
+
const session = ctx("agent-end-repeat");
|
|
164
|
+
|
|
165
|
+
await agentEnd!(agentEndEvent, session);
|
|
166
|
+
await agentEnd!(agentEndEvent, session);
|
|
167
|
+
assert.equal(sent.length, 1, "the same target identity nudges only once");
|
|
168
|
+
|
|
169
|
+
targetIdentity = `sha256:${"e".repeat(64)}`;
|
|
170
|
+
await agentEnd!(agentEndEvent, session);
|
|
171
|
+
assert.equal(sent.length, 2, "a different target identity nudges again");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("agent_end sends nothing when STATUS offers collect or stop", async () => {
|
|
175
|
+
for (const [label, status] of [
|
|
176
|
+
["collect", collectStatus(`sha256:${"c".repeat(64)}`)],
|
|
177
|
+
["stop", stopStatus(`sha256:${"d".repeat(64)}`)],
|
|
178
|
+
] as const) {
|
|
179
|
+
const native = {
|
|
180
|
+
reviewMode: onMode("on"),
|
|
181
|
+
targetStatus: async () => status,
|
|
182
|
+
} as unknown as NativeReviewCli;
|
|
183
|
+
const { handlers, sent } = harness(native);
|
|
184
|
+
const agentEnd = handlers.get("agent_end");
|
|
185
|
+
await agentEnd!(agentEndEvent, ctx(`agent-end-${label}`));
|
|
186
|
+
assert.deepEqual(sent, [], label);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("agent_end skips headless sessions before any native call", async () => {
|
|
191
|
+
const native = {
|
|
192
|
+
reviewMode: async () => {
|
|
193
|
+
throw new Error("reviewMode must not be called when hasUI is false");
|
|
194
|
+
},
|
|
195
|
+
targetStatus: async () => {
|
|
196
|
+
throw new Error("targetStatus must not be called when hasUI is false");
|
|
197
|
+
},
|
|
198
|
+
} as unknown as NativeReviewCli;
|
|
199
|
+
const { handlers, sent } = harness(native);
|
|
200
|
+
const agentEnd = handlers.get("agent_end");
|
|
201
|
+
await agentEnd!(agentEndEvent, ctx("agent-end-no-ui", false));
|
|
202
|
+
assert.deepEqual(sent, []);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("agent_end pairs a named agent's start with its own end, then still nudges for the primary loop's end", async () => {
|
|
206
|
+
const targetIdentity = `sha256:${"f".repeat(64)}`;
|
|
207
|
+
const native = {
|
|
208
|
+
reviewMode: onMode("on"),
|
|
209
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
210
|
+
} as unknown as NativeReviewCli;
|
|
211
|
+
const { handlers, sent } = harness(native);
|
|
212
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
213
|
+
const agentEnd = handlers.get("agent_end");
|
|
214
|
+
assert.equal(typeof beforeAgentStart, "function");
|
|
215
|
+
const session = ctx("agent-end-subagent");
|
|
216
|
+
|
|
217
|
+
await beforeAgentStart!({ agentName: "review-readability", systemPrompt: "" }, session);
|
|
218
|
+
await agentEnd!(agentEndEvent, session);
|
|
219
|
+
assert.deepEqual(sent, [], "the subagent's own loop end is suppressed");
|
|
220
|
+
|
|
221
|
+
await agentEnd!(agentEndEvent, session);
|
|
222
|
+
assert.equal(sent.length, 1, "the primary loop's end still nudges once the subagent's end is paired off");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("agent_end resets the subagent depth when a fresh primary loop starts", async () => {
|
|
226
|
+
const targetIdentity = `sha256:${"1".repeat(64)}`;
|
|
227
|
+
const native = {
|
|
228
|
+
reviewMode: onMode("on"),
|
|
229
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
230
|
+
} as unknown as NativeReviewCli;
|
|
231
|
+
const { handlers, sent } = harness(native);
|
|
232
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
233
|
+
const agentEnd = handlers.get("agent_end");
|
|
234
|
+
const session = ctx("agent-end-subagent-reset");
|
|
235
|
+
|
|
236
|
+
await beforeAgentStart!({ agentName: "review-readability", systemPrompt: "" }, session);
|
|
237
|
+
await beforeAgentStart!({ systemPrompt: "" }, session);
|
|
238
|
+
await agentEnd!(agentEndEvent, session);
|
|
239
|
+
|
|
240
|
+
assert.equal(sent.length, 1, "a fresh primary-loop start resets the depth so its own end nudges");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("agent_end handler exists but sends nothing when nativeReviewCli is null", async () => {
|
|
244
|
+
const { handlers, sent } = harness(null);
|
|
245
|
+
const agentEnd = handlers.get("agent_end");
|
|
246
|
+
assert.equal(typeof agentEnd, "function");
|
|
247
|
+
await agentEnd!(agentEndEvent, ctx("agent-end-null-cli"));
|
|
248
|
+
assert.deepEqual(sent, []);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("agent_end sends nothing and does not throw when target STATUS rejects", async () => {
|
|
252
|
+
const native = {
|
|
253
|
+
reviewMode: onMode("on"),
|
|
254
|
+
targetStatus: async () => {
|
|
255
|
+
throw new Error("native status unavailable");
|
|
256
|
+
},
|
|
257
|
+
} as unknown as NativeReviewCli;
|
|
258
|
+
const { handlers, sent } = harness(native);
|
|
259
|
+
const agentEnd = handlers.get("agent_end");
|
|
260
|
+
await assert.doesNotReject(async () => agentEnd!(agentEndEvent, ctx("agent-end-status-throws")));
|
|
261
|
+
assert.deepEqual(sent, []);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("session_shutdown clears the nudged-target set for its session key", async () => {
|
|
265
|
+
const targetIdentity = `sha256:${"9".repeat(64)}`;
|
|
266
|
+
const native = {
|
|
267
|
+
reviewMode: onMode("on"),
|
|
268
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
269
|
+
} as unknown as NativeReviewCli;
|
|
270
|
+
const { handlers, sent } = harness(native);
|
|
271
|
+
const agentEnd = handlers.get("agent_end");
|
|
272
|
+
const shutdown = handlers.get("session_shutdown");
|
|
273
|
+
assert.equal(typeof shutdown, "function");
|
|
274
|
+
const session = ctx("agent-end-shutdown");
|
|
275
|
+
|
|
276
|
+
await agentEnd!(agentEndEvent, session);
|
|
277
|
+
assert.equal(sent.length, 1);
|
|
278
|
+
|
|
279
|
+
await shutdown!({}, session);
|
|
280
|
+
await agentEnd!(agentEndEvent, session);
|
|
281
|
+
assert.equal(sent.length, 2, "shutdown clears the nudged set so the same identity nudges again");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("session_start records the baseline target identity when RDD is on", async () => {
|
|
285
|
+
const targetIdentity = `sha256:${"2".repeat(64)}`;
|
|
286
|
+
const statusRequests: Array<{ agent?: string }> = [];
|
|
287
|
+
const native = {
|
|
288
|
+
reviewMode: onMode("on"),
|
|
289
|
+
targetStatus: async (request: { agent?: string }) => {
|
|
290
|
+
statusRequests.push(request);
|
|
291
|
+
return executeStartStatus(targetIdentity);
|
|
292
|
+
},
|
|
293
|
+
} as unknown as NativeReviewCli;
|
|
294
|
+
await withSessionStartEnv(async (cwd) => {
|
|
295
|
+
const { handlers, sent } = harness(native);
|
|
296
|
+
const sessionStart = handlers.get("session_start");
|
|
297
|
+
assert.equal(typeof sessionStart, "function");
|
|
298
|
+
const notifications: Array<{ message: string; severity: string }> = [];
|
|
299
|
+
const session = {
|
|
300
|
+
...ctx("session-baseline-record", true, cwd),
|
|
301
|
+
ui: { notify: (message: string, severity: string) => notifications.push({ message, severity }) },
|
|
302
|
+
};
|
|
303
|
+
await sessionStart!({}, session);
|
|
304
|
+
assert.equal(statusRequests[0]?.agent, "pi");
|
|
305
|
+
assert.deepEqual(sent, []);
|
|
306
|
+
assert.deepEqual(notifications, []);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("agent_end skips the candidate that matches the session's recorded baseline, then nudges for a new one", async () => {
|
|
311
|
+
let targetIdentity = `sha256:${"3".repeat(64)}`;
|
|
312
|
+
const native = {
|
|
313
|
+
reviewMode: onMode("on"),
|
|
314
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
315
|
+
} as unknown as NativeReviewCli;
|
|
316
|
+
await withSessionStartEnv(async (cwd) => {
|
|
317
|
+
const { handlers, sent } = harness(native);
|
|
318
|
+
const sessionStart = handlers.get("session_start");
|
|
319
|
+
const agentEnd = handlers.get("agent_end");
|
|
320
|
+
const session = ctx("session-baseline-skip", true, cwd);
|
|
321
|
+
|
|
322
|
+
await sessionStart!({}, session);
|
|
323
|
+
await agentEnd!(agentEndEvent, session);
|
|
324
|
+
assert.deepEqual(sent, [], "the baseline candidate predates the session and is not nudged");
|
|
325
|
+
|
|
326
|
+
targetIdentity = `sha256:${"4".repeat(64)}`;
|
|
327
|
+
await agentEnd!(agentEndEvent, session);
|
|
328
|
+
assert.equal(sent.length, 1, "a candidate identity different from the baseline still nudges");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("session_start with RDD off records no baseline, so agent_end still reminds once", async () => {
|
|
333
|
+
const targetIdentity = `sha256:${"5".repeat(64)}`;
|
|
334
|
+
let mode: "on" | "off" = "off";
|
|
335
|
+
const statusRequests: unknown[] = [];
|
|
336
|
+
const native = {
|
|
337
|
+
reviewMode: async () => ({
|
|
338
|
+
operation: "status",
|
|
339
|
+
scope: "clone",
|
|
340
|
+
status: { global: "", cloneLocal: mode === "on" ? "on" : "", effective: mode, source: "clone_local" },
|
|
341
|
+
}),
|
|
342
|
+
targetStatus: async (request: unknown) => {
|
|
343
|
+
statusRequests.push(request);
|
|
344
|
+
if (mode === "off") throw new Error("targetStatus must not be called when RDD is off");
|
|
345
|
+
return executeStartStatus(targetIdentity);
|
|
346
|
+
},
|
|
347
|
+
} as unknown as NativeReviewCli;
|
|
348
|
+
await withSessionStartEnv(async (cwd) => {
|
|
349
|
+
const { handlers, sent } = harness(native);
|
|
350
|
+
const sessionStart = handlers.get("session_start");
|
|
351
|
+
const agentEnd = handlers.get("agent_end");
|
|
352
|
+
const session = ctx("session-baseline-rdd-off", true, cwd);
|
|
353
|
+
|
|
354
|
+
await sessionStart!({}, session);
|
|
355
|
+
assert.deepEqual(statusRequests, []);
|
|
356
|
+
|
|
357
|
+
mode = "on";
|
|
358
|
+
await agentEnd!(agentEndEvent, session);
|
|
359
|
+
assert.equal(sent.length, 1, "no baseline was recorded while RDD was off, so the first dirty candidate still reminds");
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("session_start whose targetStatus rejects records no baseline, so agent_end still reminds once", async () => {
|
|
364
|
+
const targetIdentity = `sha256:${"6".repeat(64)}`;
|
|
365
|
+
let shouldThrow = true;
|
|
366
|
+
const native = {
|
|
367
|
+
reviewMode: onMode("on"),
|
|
368
|
+
targetStatus: async () => {
|
|
369
|
+
if (shouldThrow) throw new Error("native status unavailable at session start");
|
|
370
|
+
return executeStartStatus(targetIdentity);
|
|
371
|
+
},
|
|
372
|
+
} as unknown as NativeReviewCli;
|
|
373
|
+
await withSessionStartEnv(async (cwd) => {
|
|
374
|
+
const { handlers, sent } = harness(native);
|
|
375
|
+
const sessionStart = handlers.get("session_start");
|
|
376
|
+
const agentEnd = handlers.get("agent_end");
|
|
377
|
+
const session = ctx("session-baseline-throws", true, cwd);
|
|
378
|
+
|
|
379
|
+
await assert.doesNotReject(async () => sessionStart!({}, session));
|
|
380
|
+
|
|
381
|
+
shouldThrow = false;
|
|
382
|
+
await agentEnd!(agentEndEvent, session);
|
|
383
|
+
assert.equal(sent.length, 1, "STATUS threw at session_start, so no baseline was recorded and the candidate still reminds");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("session_shutdown clears the recorded baseline so the same identity reminds again", async () => {
|
|
388
|
+
const targetIdentity = `sha256:${"7".repeat(64)}`;
|
|
389
|
+
const native = {
|
|
390
|
+
reviewMode: onMode("on"),
|
|
391
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
392
|
+
} as unknown as NativeReviewCli;
|
|
393
|
+
await withSessionStartEnv(async (cwd) => {
|
|
394
|
+
const { handlers, sent } = harness(native);
|
|
395
|
+
const sessionStart = handlers.get("session_start");
|
|
396
|
+
const agentEnd = handlers.get("agent_end");
|
|
397
|
+
const shutdown = handlers.get("session_shutdown");
|
|
398
|
+
const session = ctx("session-baseline-shutdown", true, cwd);
|
|
399
|
+
|
|
400
|
+
await sessionStart!({}, session);
|
|
401
|
+
await agentEnd!(agentEndEvent, session);
|
|
402
|
+
assert.deepEqual(sent, [], "the baseline candidate is skipped");
|
|
403
|
+
|
|
404
|
+
await shutdown!({}, session);
|
|
405
|
+
await agentEnd!(agentEndEvent, session);
|
|
406
|
+
assert.equal(sent.length, 1, "shutdown cleared the baseline, so the same identity reminds again in the next session");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
10
|
+
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
11
|
+
|
|
12
|
+
// gentle-pi#560 / gentle-ai#4056, #4057: since 2026-08-01 Gentle AI stopped
|
|
13
|
+
// writing a runtime-specific review execution contract into Pi's generated
|
|
14
|
+
// APPEND_SYSTEM composition. This package now injects the mirrored provider
|
|
15
|
+
// contract bundle's own `orchestration/pi.md` text at session start instead.
|
|
16
|
+
// These tests exercise the real committed mirror (contracts/review-provider-contract-mirror/)
|
|
17
|
+
// rather than a fake one: the mirror IS the package under test.
|
|
18
|
+
|
|
19
|
+
type BeforeAgentStartResult = { systemPrompt: string };
|
|
20
|
+
type BeforeAgentStartHandler = (event: unknown, ctx: ExtensionContext) => Promise<BeforeAgentStartResult>;
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = join(import.meta.dirname, "..");
|
|
23
|
+
const MIRROR_LOCK_PATH = join(REPO_ROOT, "contracts", "review-provider-contract-mirror", "provider-contract.lock.json");
|
|
24
|
+
|
|
25
|
+
function mirroredPiOrchestrationText(): string {
|
|
26
|
+
const lock = JSON.parse(readFileSync(MIRROR_LOCK_PATH, "utf8")) as { contract_semver: string };
|
|
27
|
+
return readFileSync(
|
|
28
|
+
join(REPO_ROOT, "contracts", "review-provider-contract-mirror", `v${lock.contract_semver}`, "bundle", "orchestration", "pi.md"),
|
|
29
|
+
"utf8",
|
|
30
|
+
).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function harness(nativeReviewCli: NativeReviewCli | null): { beforeAgentStart: BeforeAgentStartHandler } {
|
|
34
|
+
const handlers = new Map<string, BeforeAgentStartHandler>();
|
|
35
|
+
const pi = {
|
|
36
|
+
on(name: string, handler: BeforeAgentStartHandler) {
|
|
37
|
+
handlers.set(name, handler);
|
|
38
|
+
},
|
|
39
|
+
events: { emit() {} },
|
|
40
|
+
registerCommand() {},
|
|
41
|
+
registerTool() {},
|
|
42
|
+
} as unknown as ExtensionAPI;
|
|
43
|
+
createGentleAiExtension({ nativeReviewCli })(pi);
|
|
44
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
45
|
+
assert.equal(typeof beforeAgentStart, "function");
|
|
46
|
+
return { beforeAgentStart: beforeAgentStart as BeforeAgentStartHandler };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ctx(overrides: Record<string, unknown> = {}): ExtensionContext {
|
|
50
|
+
return {
|
|
51
|
+
cwd: process.cwd(),
|
|
52
|
+
hasUI: true,
|
|
53
|
+
ui: { notify() {} },
|
|
54
|
+
sessionManager: { getSessionId: () => "review-contract-prompt-session" },
|
|
55
|
+
...overrides,
|
|
56
|
+
} as unknown as ExtensionContext;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const primaryEvent = { systemPrompt: "base" };
|
|
60
|
+
|
|
61
|
+
test("before_agent_start injects the mirrored review execution contract for the primary session", async () => {
|
|
62
|
+
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
63
|
+
const result = await beforeAgentStart(primaryEvent, ctx());
|
|
64
|
+
const expected = mirroredPiOrchestrationText();
|
|
65
|
+
assert.match(result.systemPrompt, /## Gentle AI review execution contract \(mirrored provider bundle 1\.2\.0\)/);
|
|
66
|
+
assert.ok(result.systemPrompt.includes(expected), "the mirrored orchestration/pi.md text must appear verbatim");
|
|
67
|
+
assert.match(result.systemPrompt, /call `gentle_review` with {"operation":"inspect"}/);
|
|
68
|
+
assert.match(result.systemPrompt, /call `gentle_review` with operation `status`, the exact retained `lineageId`, and `workspaceRoot`/);
|
|
69
|
+
assert.match(result.systemPrompt, /Use `gentle_review_capture` for one current returned slot or `gentle_review_capture_group` for the complete current reviewer group/);
|
|
70
|
+
assert.match(result.systemPrompt, /An approved capture awaits acknowledgement; it is not burned\. On `approved`, use bound facade STATUS to obtain or replay the exact provider-issued `acknowledge-approved` continuation, then execute it unchanged\. Only its successful returned envelope burns authority; do not issue STATUS after that burn\./);
|
|
71
|
+
let previousLifecycleIndex = result.systemPrompt.indexOf("## Gentle AI review execution contract");
|
|
72
|
+
for (const marker of [
|
|
73
|
+
'call `gentle_review` with {"operation":"inspect"}',
|
|
74
|
+
"2. **Freeze once.**",
|
|
75
|
+
"call `gentle_review` with operation `status`",
|
|
76
|
+
"Use `gentle_review_capture` for one current returned slot",
|
|
77
|
+
"5. **Acknowledge exactly.**",
|
|
78
|
+
]) {
|
|
79
|
+
const markerIndex = result.systemPrompt.indexOf(marker, previousLifecycleIndex + 1);
|
|
80
|
+
assert.ok(markerIndex > previousLifecycleIndex, `${marker} must follow the previous lifecycle step`);
|
|
81
|
+
previousLifecycleIndex = markerIndex;
|
|
82
|
+
}
|
|
83
|
+
assert.doesNotMatch(result.systemPrompt, /authority is already burned/);
|
|
84
|
+
assert.doesNotMatch(result.systemPrompt, /gentle-ai review status\b.*--agent pi/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("before_agent_start does not inject the review execution contract for a named agent session", async () => {
|
|
88
|
+
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
89
|
+
const result = await beforeAgentStart({ agentName: "review-readability", systemPrompt: "base" }, ctx());
|
|
90
|
+
assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("before_agent_start does not inject the review execution contract for an SDD executor session", async () => {
|
|
94
|
+
const { beforeAgentStart } = harness({} as NativeReviewCli);
|
|
95
|
+
const result = await beforeAgentStart({ systemPrompt: "SDD apply executor body" }, ctx());
|
|
96
|
+
assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("before_agent_start injects nothing when nativeReviewCli is null", async () => {
|
|
100
|
+
const { beforeAgentStart } = harness(null);
|
|
101
|
+
const result = await beforeAgentStart(primaryEvent, ctx());
|
|
102
|
+
assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// gentle-ai R1/R3: a tampered mirrored orchestration/pi.md must never be spliced into the system prompt.
|
|
106
|
+
function tempMirror(text: string, entrySha256: string): string {
|
|
107
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-mirror-"));
|
|
108
|
+
const bundleDir = join(root, "v9.9.9", "bundle", "orchestration");
|
|
109
|
+
mkdirSync(bundleDir, { recursive: true });
|
|
110
|
+
writeFileSync(join(bundleDir, "pi.md"), text, "utf8");
|
|
111
|
+
const lock = JSON.stringify({ contract_semver: "9.9.9", entries: { "orchestration/pi.md": entrySha256 } });
|
|
112
|
+
writeFileSync(join(root, "provider-contract.lock.json"), lock, "utf8");
|
|
113
|
+
return root;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test("rejects a tampered mirror, accepts a matching one, and warns once", async () => {
|
|
117
|
+
const tampered = tempMirror("tampered contract text", "0".repeat(64));
|
|
118
|
+
const matchingText = "matching contract text";
|
|
119
|
+
const matching = tempMirror(matchingText, createHash("sha256").update(Buffer.from(matchingText, "utf8")).digest("hex"));
|
|
120
|
+
try {
|
|
121
|
+
// Cache-bust: isolate this module's fragment cache from the real-mirror tests above.
|
|
122
|
+
const cacheBustedUrl = `${pathToFileURL(join(import.meta.dirname, "..", "extensions", "gentle-ai.ts")).href}?tamper=${Math.random()}`;
|
|
123
|
+
type Testing = {
|
|
124
|
+
readMirroredReviewContractFragment: (mirrorRoot?: string) => string | null;
|
|
125
|
+
loadReviewContractPromptFragment: (ctx: ExtensionContext, mirrorRoot?: string) => string | null;
|
|
126
|
+
};
|
|
127
|
+
const fresh = (await import(cacheBustedUrl)) as { __testing: Testing };
|
|
128
|
+
assert.equal(fresh.__testing.readMirroredReviewContractFragment(tampered), null);
|
|
129
|
+
assert.ok(fresh.__testing.readMirroredReviewContractFragment(matching)?.includes(matchingText));
|
|
130
|
+
let notifyCount = 0;
|
|
131
|
+
const spyCtx = { hasUI: true, ui: { notify: () => { notifyCount += 1; } } } as unknown as ExtensionContext;
|
|
132
|
+
fresh.__testing.loadReviewContractPromptFragment(spyCtx, tampered);
|
|
133
|
+
fresh.__testing.loadReviewContractPromptFragment(spyCtx, tampered);
|
|
134
|
+
assert.equal(notifyCount, 1);
|
|
135
|
+
} finally {
|
|
136
|
+
rmSync(tampered, { recursive: true, force: true });
|
|
137
|
+
rmSync(matching, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
@@ -421,7 +421,7 @@ test("shipped controller fails closed while static prompts defer RDD lifecycle o
|
|
|
421
421
|
assert.match(toolContract, /ambiguous START output.*target-scoped native status.*declared action.*ambiguous gentle_review_capture.*never replays/is);
|
|
422
422
|
assert.doesNotMatch(toolContract, /START throws.*lineage does not exist/is);
|
|
423
423
|
|
|
424
|
-
const boundary = "
|
|
424
|
+
const boundary = "This package injects the mirrored provider-bundle review execution contract into this session's system prompt at start; Gentle AI writes nothing into the Pi system prompt, and this package owns everything else here. Absent that mirrored contract, this package invents no lifecycle instructions.";
|
|
425
425
|
const core = readFileSync("assets/orchestrator.md", "utf8");
|
|
426
426
|
assert.match(core, new RegExp(boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
427
427
|
|