gentle-pi 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +224 -6
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import {
|
|
6
|
+
ODD_ROUTING_BLOCK_PREFIX,
|
|
7
|
+
ODD_ROUTING_FIXTURE_RELATIVE,
|
|
8
|
+
ODD_ROUTING_SOURCE_PATH,
|
|
9
|
+
ODD_ROUTING_SOURCE_REPO,
|
|
10
|
+
assertCleanGentleAiCheckout,
|
|
11
|
+
parseOddRoutingFixture,
|
|
12
|
+
renderOddRoutingFixture,
|
|
13
|
+
resolveOddRoutingProvenance,
|
|
14
|
+
sha256Hex,
|
|
15
|
+
} from "../scripts/mirror-odd-routing.mjs";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// ODD routing drift ratchet (gentle-pi#1147 follow-up)
|
|
19
|
+
//
|
|
20
|
+
// gentle-pi hand-mirrors the always-on ODD routing block rendered by gentle-ai
|
|
21
|
+
// `internal/components/agentguidance/routing.go` (RenderRouting). There is no
|
|
22
|
+
// automated sync, so a canonical change leaves the pi mirror stale with nothing
|
|
23
|
+
// catching it. This ratchet vendored-snapshots the canonical block into
|
|
24
|
+
// `fixtures/odd-routing-canonical.md` (regenerated via `npm run
|
|
25
|
+
// mirror:odd-routing`) and fails when a mandatory-delegation clause is dropped
|
|
26
|
+
// either from the canonical fixture or from a pi mirror surface.
|
|
27
|
+
//
|
|
28
|
+
// Regeneration writes ONLY the fixture; this ratchet never auto-rewrites mirror
|
|
29
|
+
// assets, so drift stays visible as a deliberate diff.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const REPO_ROOT = join(import.meta.dirname, "..");
|
|
33
|
+
const FIXTURE_PATH = join(REPO_ROOT, ...ODD_ROUTING_FIXTURE_RELATIVE.split("/"));
|
|
34
|
+
const DELEGATION_PATH = join(REPO_ROOT, "assets", "orchestrator-delegation.md");
|
|
35
|
+
const CORE_PATH = join(REPO_ROOT, "assets", "orchestrator.md");
|
|
36
|
+
const EXTENSION_PATH = join(REPO_ROOT, "extensions", "gentle-ai.ts");
|
|
37
|
+
|
|
38
|
+
const readRepo = (absolutePath: string): string => readFileSync(absolutePath, "utf8");
|
|
39
|
+
|
|
40
|
+
// A mandatory-delegation semantic anchor: the clause the canonical block must
|
|
41
|
+
// still carry, plus the wording each pi mirror surface is expected to carry.
|
|
42
|
+
// A canonical change that drops `canonical` is caught here on regeneration
|
|
43
|
+
// because the fixture no longer carries it.
|
|
44
|
+
interface RoutingAnchor {
|
|
45
|
+
label: string;
|
|
46
|
+
canonical: string;
|
|
47
|
+
mirrors: ReadonlyArray<{ surface: string; includes: string }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DELEGATION = "assets/orchestrator-delegation.md";
|
|
51
|
+
const CORE = "assets/orchestrator.md";
|
|
52
|
+
const EXTENSION = "extensions/gentle-ai.ts";
|
|
53
|
+
|
|
54
|
+
const ANCHORS: readonly RoutingAnchor[] = [
|
|
55
|
+
{
|
|
56
|
+
label: "mandatory delegation triggers heading",
|
|
57
|
+
canonical: "### Mandatory Delegation Triggers",
|
|
58
|
+
mirrors: [
|
|
59
|
+
{ surface: DELEGATION, includes: "#### Mandatory Delegation Triggers" },
|
|
60
|
+
{ surface: CORE, includes: "Mandatory Delegation Triggers" },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
label: "triggers are mandatory, not advisory",
|
|
65
|
+
canonical: "These triggers are mandatory, not advisory.",
|
|
66
|
+
mirrors: [
|
|
67
|
+
{ surface: DELEGATION, includes: "These triggers are mandatory, not advisory." },
|
|
68
|
+
{ surface: EXTENSION, includes: "These triggers are mandatory, not advisory" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: "stop and delegate through the runtime's subagent mechanism",
|
|
73
|
+
canonical: "stop and delegate through the runtime's subagent mechanism before continuing",
|
|
74
|
+
mirrors: [
|
|
75
|
+
{ surface: DELEGATION, includes: "stop and delegate through the runtime's subagent mechanism before continuing" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
label: "executing past a fired trigger is a routing defect",
|
|
80
|
+
canonical: "executing past a fired trigger inline is a routing defect even if the work succeeds",
|
|
81
|
+
mirrors: [
|
|
82
|
+
{ surface: DELEGATION, includes: "executing past a fired trigger inline is a routing defect even if the work succeeds" },
|
|
83
|
+
{ surface: EXTENSION, includes: "executing past a fired trigger inline is a routing defect even if the work succeeds" },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
label: "mapping trigger at 4 or more files",
|
|
88
|
+
canonical: "**Mapping trigger:** when understanding the work requires 4 or more files",
|
|
89
|
+
mirrors: [
|
|
90
|
+
{ surface: DELEGATION, includes: "**Mapping trigger (4-file rule):** when understanding the work requires 4 or more files" },
|
|
91
|
+
{ surface: CORE, includes: "**4-file rule** — 4+ files to understand" },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: "writer trigger at 2 or more non-trivial files",
|
|
96
|
+
canonical: "**Writer trigger:** when implementation touches 2 or more non-trivial files",
|
|
97
|
+
mirrors: [
|
|
98
|
+
{ surface: DELEGATION, includes: "**Writer trigger (Multi-file write rule):** when implementation touches 2 or more non-trivial files" },
|
|
99
|
+
{ surface: CORE, includes: "**Multi-file write rule** — 2+ non-trivial files touched" },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
label: "preparation trigger",
|
|
104
|
+
canonical: "**Preparation trigger:**",
|
|
105
|
+
mirrors: [{ surface: DELEGATION, includes: "**Preparation trigger:**" }],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
label: "long-session backstop",
|
|
109
|
+
canonical: "**Long-session backstop:**",
|
|
110
|
+
mirrors: [
|
|
111
|
+
{ surface: DELEGATION, includes: "**Long-session backstop (Long-session rule):**" },
|
|
112
|
+
{ surface: CORE, includes: "**Long-session rule** — ~20 tool calls, 5 exploratory reads, or 2 non-mechanical edits without delegation" },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
label: "route declaration records the chosen route per task",
|
|
117
|
+
canonical: "**Route declaration:**",
|
|
118
|
+
mirrors: [
|
|
119
|
+
{ surface: DELEGATION, includes: "record the chosen route per task" },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
label: "triggers never select SDD",
|
|
124
|
+
canonical: "These triggers never select SDD and never create SDD artifacts",
|
|
125
|
+
mirrors: [
|
|
126
|
+
{ surface: DELEGATION, includes: "These triggers never select SDD and never create SDD artifacts" },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
label: "ODD step 6 honors its mandatory delegation triggers",
|
|
131
|
+
canonical: "honoring its mandatory delegation triggers",
|
|
132
|
+
mirrors: [{ surface: EXTENSION, includes: "honoring its mandatory delegation triggers" }],
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// Condensed trigger rows that live only in the always-on core prompt; the
|
|
137
|
+
// canonical RenderRouting block does not carry the incident or verification
|
|
138
|
+
// rows, so they are mirror-only and not fixture-derived anchors.
|
|
139
|
+
const CORE_ONLY_TRIGGERS = [
|
|
140
|
+
"**Incident rule** — diagnose wrong cwd/worktree/git/tooling incidents separately",
|
|
141
|
+
"**Verification rule** — executing/delegating verification commands",
|
|
142
|
+
] as const;
|
|
143
|
+
|
|
144
|
+
function fixtureBody(): string {
|
|
145
|
+
assert.ok(
|
|
146
|
+
existsSync(FIXTURE_PATH),
|
|
147
|
+
`missing canonical fixture ${ODD_ROUTING_FIXTURE_RELATIVE}; regenerate it with \`npm run mirror:odd-routing\``,
|
|
148
|
+
);
|
|
149
|
+
return parseOddRoutingFixture(readRepo(FIXTURE_PATH)).body;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// 1 — Fixture provenance readback
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
test("the canonical routing fixture exists with managed provenance (source repo, commit, digest)", () => {
|
|
157
|
+
assert.ok(
|
|
158
|
+
existsSync(FIXTURE_PATH),
|
|
159
|
+
`missing canonical fixture ${ODD_ROUTING_FIXTURE_RELATIVE}; regenerate it with \`npm run mirror:odd-routing\``,
|
|
160
|
+
);
|
|
161
|
+
const { header } = parseOddRoutingFixture(readRepo(FIXTURE_PATH));
|
|
162
|
+
assert.equal(header.source_repo, ODD_ROUTING_SOURCE_REPO);
|
|
163
|
+
assert.equal(header.source_path, ODD_ROUTING_SOURCE_PATH);
|
|
164
|
+
assert.match(header.source_commit ?? "", /^[0-9a-f]{40}$/, "fixture must record the gentle-ai source commit");
|
|
165
|
+
assert.match(header.generated_at ?? "", /^\d{4}-\d{2}-\d{2}T/, "fixture must record a generation date");
|
|
166
|
+
assert.match(header.block_sha256 ?? "", /^[0-9a-f]{64}$/, "fixture must record the block body digest");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("the fixture digest matches its block body byte-for-byte", () => {
|
|
170
|
+
const { header, body } = parseOddRoutingFixture(readRepo(FIXTURE_PATH));
|
|
171
|
+
assert.equal(header.block_sha256, sha256Hex(body), "the fixture body was hand-edited without regenerating its digest");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// 2 — Canonical fixture carries every mandatory anchor
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
test("the canonical fixture carries every mandatory-delegation anchor", () => {
|
|
179
|
+
const body = fixtureBody();
|
|
180
|
+
for (const anchor of ANCHORS) {
|
|
181
|
+
assert.ok(body.includes(anchor.canonical), `canonical fixture dropped anchor: ${anchor.label}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// 3 — Every pi mirror surface carries the mapped anchor
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
test("the pi mirror surfaces carry every mapped mandatory-delegation anchor", () => {
|
|
190
|
+
const surfaces: Record<string, string> = {
|
|
191
|
+
[DELEGATION]: readRepo(DELEGATION_PATH),
|
|
192
|
+
[CORE]: readRepo(CORE_PATH),
|
|
193
|
+
[EXTENSION]: readRepo(EXTENSION_PATH),
|
|
194
|
+
};
|
|
195
|
+
for (const anchor of ANCHORS) {
|
|
196
|
+
for (const mirror of anchor.mirrors) {
|
|
197
|
+
const text = surfaces[mirror.surface];
|
|
198
|
+
assert.ok(text !== undefined, `unknown mirror surface ${mirror.surface}`);
|
|
199
|
+
assert.ok(
|
|
200
|
+
text.includes(mirror.includes),
|
|
201
|
+
`${mirror.surface} is missing the mirror of "${anchor.label}": ${JSON.stringify(mirror.includes)}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("the always-on core prompt carries the condensed incident and verification trigger rows", () => {
|
|
208
|
+
const core = readRepo(CORE_PATH);
|
|
209
|
+
for (const row of CORE_ONLY_TRIGGERS) {
|
|
210
|
+
assert.ok(core.includes(row), `assets/orchestrator.md is missing condensed trigger row: ${JSON.stringify(row)}`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// 4 — Regeneration provenance: idempotent and fail-closed
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
// The fixture header claims provenance from the source commit, so regeneration
|
|
219
|
+
// must be byte-reproducible: the same gentle-ai commit must not produce a
|
|
220
|
+
// date-only diff. `generated_at` therefore tracks the source commit's committer
|
|
221
|
+
// date instead of wall-clock time.
|
|
222
|
+
test("fixture rendering is idempotent and derives generated_at from the source commit", () => {
|
|
223
|
+
const sourceCommit = "e7729359fd9d6cb691ed2a88e8f72b1372f7c92e";
|
|
224
|
+
const committerDate = "2026-09-18T13:49:03+02:00";
|
|
225
|
+
const fakeGit = (args: readonly string[]): string => {
|
|
226
|
+
switch (args[0]) {
|
|
227
|
+
case "status":
|
|
228
|
+
return "";
|
|
229
|
+
case "rev-parse":
|
|
230
|
+
return `${sourceCommit}\n`;
|
|
231
|
+
case "show":
|
|
232
|
+
return `${committerDate}\n`;
|
|
233
|
+
default:
|
|
234
|
+
throw new Error(`unexpected git invocation: ${args.join(" ")}`);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const block = `${ODD_ROUTING_BLOCK_PREFIX}\n\n- a canonical clause\n`;
|
|
238
|
+
const first = renderOddRoutingFixture(block, resolveOddRoutingProvenance("/fake/gentle-ai", fakeGit));
|
|
239
|
+
const second = renderOddRoutingFixture(block, resolveOddRoutingProvenance("/fake/gentle-ai", fakeGit));
|
|
240
|
+
assert.equal(first, second, "rendering the same source commit twice must be byte-identical");
|
|
241
|
+
const { header } = parseOddRoutingFixture(first);
|
|
242
|
+
assert.equal(header.source_commit, sourceCommit);
|
|
243
|
+
assert.equal(
|
|
244
|
+
header.generated_at,
|
|
245
|
+
committerDate,
|
|
246
|
+
"generated_at must track the source commit's committer date, not wall-clock time",
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// A dirty checkout would render uncommitted content under the committed
|
|
251
|
+
// provenance claim, so the mirror fails closed before rendering and names the
|
|
252
|
+
// tracked paths. Untracked files (the odd/tasks notes) must never block.
|
|
253
|
+
test("the mirror fails closed on a dirty source checkout and names the tracked paths", () => {
|
|
254
|
+
const recordedCalls: string[][] = [];
|
|
255
|
+
const cleanGit = (args: readonly string[]): string => {
|
|
256
|
+
recordedCalls.push([...args]);
|
|
257
|
+
switch (args[0]) {
|
|
258
|
+
case "status":
|
|
259
|
+
return "";
|
|
260
|
+
case "rev-parse":
|
|
261
|
+
return `${"a".repeat(40)}\n`;
|
|
262
|
+
case "show":
|
|
263
|
+
return "2026-09-18T13:49:03+02:00\n";
|
|
264
|
+
default:
|
|
265
|
+
throw new Error(`unexpected git invocation: ${args.join(" ")}`);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
resolveOddRoutingProvenance("/fake/gentle-ai", cleanGit);
|
|
269
|
+
assert.deepEqual(
|
|
270
|
+
recordedCalls.find((args) => args[0] === "status"),
|
|
271
|
+
["status", "--porcelain", "--untracked-files=no"],
|
|
272
|
+
"the dirty guard must ignore untracked files",
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
assert.doesNotThrow(() => assertCleanGentleAiCheckout(""));
|
|
276
|
+
const dirtyGit = (args: readonly string[]): string => {
|
|
277
|
+
if (args[0] === "status") {
|
|
278
|
+
return " M internal/components/agentguidance/routing.go\n M scripts/other.mjs\n";
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`unexpected git invocation: ${args.join(" ")}`);
|
|
281
|
+
};
|
|
282
|
+
assert.throws(
|
|
283
|
+
() => resolveOddRoutingProvenance("/fake/gentle-ai", dirtyGit),
|
|
284
|
+
(error: unknown) => {
|
|
285
|
+
assert.ok(error instanceof Error);
|
|
286
|
+
assert.match(
|
|
287
|
+
error.message,
|
|
288
|
+
/gentle-ai checkout is dirty; provenance would be unverifiable: internal\/components\/agentguidance\/routing\.go, scripts\/other\.mjs\. Commit or stash first\./,
|
|
289
|
+
);
|
|
290
|
+
return true;
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
});
|
|
@@ -204,6 +204,63 @@ test("ODD forwards configured TDD without equating test presence with enablement
|
|
|
204
204
|
assert.doesNotMatch(wrapper, /If tests exist, use strict TDD/);
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
+
test("mandatory delegation triggers are behavioral in the lazy canonical port and the always-on ODD step", () => {
|
|
208
|
+
for (const clause of [
|
|
209
|
+
"These triggers are mandatory, not advisory.",
|
|
210
|
+
"stop and delegate through the runtime's subagent mechanism before continuing",
|
|
211
|
+
"executing past a fired trigger inline is a routing defect even if the work succeeds",
|
|
212
|
+
"**Mapping trigger",
|
|
213
|
+
"**Writer trigger",
|
|
214
|
+
"**Preparation trigger:**",
|
|
215
|
+
"**Long-session backstop",
|
|
216
|
+
"pause and delegate the next bounded unit of work",
|
|
217
|
+
"**Route declaration:**",
|
|
218
|
+
"record the chosen route per task",
|
|
219
|
+
"so skipped delegation is observable instead of silent",
|
|
220
|
+
"These triggers never select SDD and never create SDD artifacts",
|
|
221
|
+
]) {
|
|
222
|
+
assert.ok(delegation.includes(clause), `lazy canonical port is missing mandatory delegation clause: ${clause}`);
|
|
223
|
+
}
|
|
224
|
+
assert.ok(
|
|
225
|
+
wrapper.includes("honoring its mandatory delegation triggers"),
|
|
226
|
+
"the always-on ODD step 6 must honor its mandatory delegation triggers",
|
|
227
|
+
);
|
|
228
|
+
assert.ok(
|
|
229
|
+
wrapper.includes("executing past a fired trigger inline is a routing defect"),
|
|
230
|
+
"the always-on ODD protocol must state that skipping a fired trigger is a routing defect",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("core and lazy canonical trigger lists agree in numbering and semantics", () => {
|
|
235
|
+
for (const entry of [
|
|
236
|
+
"1. **4-file rule**",
|
|
237
|
+
"2. **Multi-file write rule**",
|
|
238
|
+
"3. **Incident rule**",
|
|
239
|
+
"4. **Long-session rule**",
|
|
240
|
+
"5. **Verification rule**",
|
|
241
|
+
]) {
|
|
242
|
+
assert.ok(core.includes(entry), `always-on core trigger list is missing: ${entry}`);
|
|
243
|
+
}
|
|
244
|
+
for (const entry of [
|
|
245
|
+
"1. **Mapping trigger (4-file rule):**",
|
|
246
|
+
"2. **Writer trigger (Multi-file write rule):**",
|
|
247
|
+
"3. **Incident rule:**",
|
|
248
|
+
"4. **Long-session backstop (Long-session rule):**",
|
|
249
|
+
"5. **Verification rule**",
|
|
250
|
+
]) {
|
|
251
|
+
assert.ok(delegation.includes(entry), `lazy canonical trigger list is missing: ${entry}`);
|
|
252
|
+
}
|
|
253
|
+
for (const stale of [
|
|
254
|
+
"**Bounded read rule**",
|
|
255
|
+
"**Write rule**",
|
|
256
|
+
"**Context rule**",
|
|
257
|
+
"**Per-action rule**",
|
|
258
|
+
"**Optional SDD rule**",
|
|
259
|
+
]) {
|
|
260
|
+
assert.ok(!delegation.includes(stale), `reconciled canonical list retains stale trigger framing: ${stale}`);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
207
264
|
test("ODD protocol is always-on in the rendered system prompt and runs by default", () => {
|
|
208
265
|
const orderedClauses = [
|
|
209
266
|
"Default workflow: Organic Driven Development (MANDATORY)",
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
9
|
+
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
10
|
+
|
|
11
|
+
type Handler = (event: Record<string, unknown>, ctx: ExtensionContext) => unknown;
|
|
12
|
+
|
|
13
|
+
function harness(processEnv: NodeJS.ProcessEnv = {}, activeTools = ["read", "edit", "write", "subagent_run"], sddRoot?: string): Map<string, Handler> {
|
|
14
|
+
const handlers = new Map<string, Handler>();
|
|
15
|
+
const pi = {
|
|
16
|
+
on(name: string, handler: Handler) {
|
|
17
|
+
handlers.set(name, handler);
|
|
18
|
+
},
|
|
19
|
+
events: { emit() {} },
|
|
20
|
+
registerCommand() {},
|
|
21
|
+
registerTool() {},
|
|
22
|
+
getFlag: () => sddRoot ? JSON.stringify({ changeName: "isolation", workspaceRoot: sddRoot, phase: "apply" }) : undefined,
|
|
23
|
+
getActiveTools: () => activeTools,
|
|
24
|
+
} as unknown as ExtensionAPI;
|
|
25
|
+
createGentleAiExtension({
|
|
26
|
+
nativeReviewCli: sddRoot ? {
|
|
27
|
+
sddStatus: async () => ({
|
|
28
|
+
schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "isolation", artifactStore: "openspec",
|
|
29
|
+
planningHome: { mode: "repo-local", path: join(sddRoot, "openspec") }, changeRoot: join(sddRoot, "openspec", "changes", "isolation"),
|
|
30
|
+
actionContext: { mode: "repo-local", workspaceRoot: sddRoot, allowedEditRoots: [sddRoot] },
|
|
31
|
+
dependencies: { proposal: "all_done", specs: "all_done", design: "all_done", tasks: "all_done", apply: "ready", verify: "blocked", archive: "blocked" },
|
|
32
|
+
phaseInstructions: { apply: [], verify: [], archive: [] }, blockedReasons: [], nextRecommended: "apply",
|
|
33
|
+
}),
|
|
34
|
+
} as unknown as NativeReviewCli : null,
|
|
35
|
+
processEnv,
|
|
36
|
+
resolveTelemetryTriggerBinary: () => "/usr/bin/true",
|
|
37
|
+
telemetryTriggerSpawn: (() => undefined) as never,
|
|
38
|
+
})(pi);
|
|
39
|
+
return handlers;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function successfulMutation(handlers: Map<string, Handler>, ctx: ExtensionContext, toolName: "edit" | "write", path: string): Promise<void> {
|
|
43
|
+
assert.equal(await handlers.get("tool_call")!({ toolName, input: { path } }, ctx), undefined);
|
|
44
|
+
await handlers.get("tool_result")!({ toolName, toolCallId: `${toolName}-${path}`, input: { path }, isError: false }, ctx);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function context(cwd: string): ExtensionContext {
|
|
48
|
+
return {
|
|
49
|
+
cwd,
|
|
50
|
+
hasUI: false,
|
|
51
|
+
mode: "interactive",
|
|
52
|
+
ui: { notify() {} },
|
|
53
|
+
sessionManager: { getSessionId: () => "odd-runtime-delegation-red" },
|
|
54
|
+
} as unknown as ExtensionContext;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("primary ODD runtime blocks the second distinct direct source write before mutation", async () => {
|
|
58
|
+
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-odd-runtime-gate-"));
|
|
59
|
+
try {
|
|
60
|
+
execFileSync("git", ["init", "--quiet"], { cwd });
|
|
61
|
+
mkdirSync(join(cwd, "src"));
|
|
62
|
+
const firstPath = join(cwd, "src", "first.ts");
|
|
63
|
+
const secondPath = join(cwd, "src", "second.ts");
|
|
64
|
+
const handlers = harness();
|
|
65
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
66
|
+
const toolCall = handlers.get("tool_call");
|
|
67
|
+
const toolResult = handlers.get("tool_result");
|
|
68
|
+
assert.equal(typeof beforeAgentStart, "function");
|
|
69
|
+
assert.equal(typeof toolCall, "function");
|
|
70
|
+
assert.equal(typeof toolResult, "function");
|
|
71
|
+
const ctx = context(cwd);
|
|
72
|
+
|
|
73
|
+
await beforeAgentStart!({ systemPrompt: "primary" }, ctx);
|
|
74
|
+
assert.equal(await toolCall!({ toolName: "edit", input: { path: firstPath } }, ctx), undefined);
|
|
75
|
+
await toolResult!({
|
|
76
|
+
toolName: "edit",
|
|
77
|
+
toolCallId: "first-direct-edit",
|
|
78
|
+
input: { path: firstPath },
|
|
79
|
+
isError: false,
|
|
80
|
+
}, ctx);
|
|
81
|
+
|
|
82
|
+
const second = await toolCall!({
|
|
83
|
+
toolName: "write",
|
|
84
|
+
input: { path: secondPath, content: "export const second = true;\n" },
|
|
85
|
+
}, ctx) as { block?: boolean; reason?: string } | undefined;
|
|
86
|
+
assert.equal(second?.block, true, "the second distinct direct source file must be refused before mutation");
|
|
87
|
+
assert.match(second?.reason ?? "", /subagent_run/);
|
|
88
|
+
assert.match(second?.reason ?? "", /gentle-ai-worker/);
|
|
89
|
+
|
|
90
|
+
assert.equal(await toolCall!({ toolName: "edit", input: { path: firstPath } }, ctx), undefined, "same-file edits stay direct");
|
|
91
|
+
assert.equal(await toolCall!({ toolName: "write", input: { path: join(cwd, "odd", "tasks", "feature.md") } }, ctx), undefined, "ODD bookkeeping is excluded");
|
|
92
|
+
|
|
93
|
+
await beforeAgentStart!({ systemPrompt: "fresh primary turn" }, ctx);
|
|
94
|
+
await toolResult!({ toolName: "write", toolCallId: "failed", input: { path: firstPath }, isError: true }, ctx);
|
|
95
|
+
assert.equal(await toolCall!({ toolName: "write", input: { path: secondPath } }, ctx), undefined, "failed calls do not consume the direct-file budget");
|
|
96
|
+
await successfulMutation(handlers, ctx, "write", secondPath);
|
|
97
|
+
assert.equal((await toolCall!({ toolName: "write", input: { path: firstPath } }, ctx) as { block?: boolean })?.block, true);
|
|
98
|
+
|
|
99
|
+
await beforeAgentStart!({ systemPrompt: "next primary turn" }, ctx);
|
|
100
|
+
assert.equal(await toolCall!({ toolName: "write", input: { path: firstPath } }, ctx), undefined, "a primary restart resets ephemeral state");
|
|
101
|
+
|
|
102
|
+
await beforeAgentStart!({ systemPrompt: "bookkeeping turn" }, ctx);
|
|
103
|
+
await successfulMutation(handlers, ctx, "write", join(cwd, "odd", "tasks", "feature.md"));
|
|
104
|
+
assert.equal(await toolCall!({ toolName: "write", input: { path: secondPath } }, ctx), undefined, "successful ODD bookkeeping consumes no budget");
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the refusal stops honestly when delegation is unavailable", async () => {
|
|
111
|
+
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-odd-no-delegation-"));
|
|
112
|
+
try {
|
|
113
|
+
execFileSync("git", ["init", "--quiet"], { cwd });
|
|
114
|
+
const handlers = harness({}, ["edit", "write"]);
|
|
115
|
+
const ctx = context(cwd);
|
|
116
|
+
await handlers.get("before_agent_start")!({ systemPrompt: "primary" }, ctx);
|
|
117
|
+
await successfulMutation(handlers, ctx, "write", join(cwd, "first.ts"));
|
|
118
|
+
const refused = await handlers.get("tool_call")!({ toolName: "write", input: { path: join(cwd, "second.ts") } }, ctx) as { reason?: string };
|
|
119
|
+
assert.match(refused.reason ?? "", /Stop and report that no delegation mechanism is callable/);
|
|
120
|
+
} finally {
|
|
121
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a nested named child cannot erase its primary parent's recorded path", async () => {
|
|
126
|
+
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-odd-nested-child-"));
|
|
127
|
+
try {
|
|
128
|
+
execFileSync("git", ["init", "--quiet"], { cwd });
|
|
129
|
+
const handlers = harness();
|
|
130
|
+
const ctx = context(cwd);
|
|
131
|
+
await handlers.get("before_agent_start")!({ systemPrompt: "primary" }, ctx);
|
|
132
|
+
await successfulMutation(handlers, ctx, "write", join(cwd, "first.ts"));
|
|
133
|
+
await handlers.get("before_agent_start")!({ agentName: "gentle-ai-worker", systemPrompt: "named child" }, ctx);
|
|
134
|
+
await handlers.get("agent_end")!({}, ctx);
|
|
135
|
+
const resumed = await handlers.get("tool_call")!({ toolName: "write", input: { path: join(cwd, "second.ts") } }, ctx) as { block?: boolean } | undefined;
|
|
136
|
+
assert.equal(resumed?.block, true, "a completed child must not disable its resumed parent's gate");
|
|
137
|
+
} finally {
|
|
138
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("canonical absolute targets remain inside a repository reached through a nested symlink cwd", async () => {
|
|
143
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-odd-real-root-")));
|
|
144
|
+
const aliases = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-odd-cwd-alias-")));
|
|
145
|
+
try {
|
|
146
|
+
execFileSync("git", ["init", "--quiet"], { cwd: root });
|
|
147
|
+
mkdirSync(join(root, "workspace", "nested"), { recursive: true });
|
|
148
|
+
const linkedRoot = join(aliases, "repository");
|
|
149
|
+
symlinkSync(root, linkedRoot, "dir");
|
|
150
|
+
const ctx = context(join(linkedRoot, "workspace", "nested"));
|
|
151
|
+
const handlers = harness();
|
|
152
|
+
await handlers.get("before_agent_start")!({ systemPrompt: "primary" }, ctx);
|
|
153
|
+
await successfulMutation(handlers, ctx, "write", join(root, "workspace", "nested", "first.ts"));
|
|
154
|
+
const second = await handlers.get("tool_call")!({ toolName: "write", input: { path: join(root, "workspace", "nested", "second.ts") } }, ctx) as { block?: boolean } | undefined;
|
|
155
|
+
assert.equal(second?.block, true, "canonical absolute targets must not be rebased through the lexical symlink cwd");
|
|
156
|
+
} finally {
|
|
157
|
+
rmSync(aliases, { recursive: true, force: true });
|
|
158
|
+
rmSync(root, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("canonical paths collapse aliases and cannot disguise source as ODD bookkeeping", async (t) => {
|
|
163
|
+
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-odd-canonical-path-"));
|
|
164
|
+
try {
|
|
165
|
+
execFileSync("git", ["init", "--quiet"], { cwd });
|
|
166
|
+
mkdirSync(join(cwd, "src"));
|
|
167
|
+
mkdirSync(join(cwd, "odd", "tasks"), { recursive: true });
|
|
168
|
+
const firstPath = join(cwd, "src", "first.ts");
|
|
169
|
+
const secondPath = join(cwd, "src", "second.ts");
|
|
170
|
+
writeFileSync(firstPath, "");
|
|
171
|
+
writeFileSync(secondPath, "");
|
|
172
|
+
symlinkSync("first.ts", join(cwd, "src", "alias.ts"));
|
|
173
|
+
symlinkSync("../../src/second.ts", join(cwd, "odd", "tasks", "source.ts"));
|
|
174
|
+
const handlers = harness();
|
|
175
|
+
const ctx = context(cwd);
|
|
176
|
+
await t.test("an alias of the recorded file remains the same canonical path", async () => {
|
|
177
|
+
await handlers.get("before_agent_start")!({ systemPrompt: "primary alias turn" }, ctx);
|
|
178
|
+
await successfulMutation(handlers, ctx, "write", firstPath);
|
|
179
|
+
assert.equal(await handlers.get("tool_call")!({ toolName: "edit", input: { path: join(cwd, "src", "alias.ts") } }, ctx), undefined);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await t.test("a bookkeeping symlink to source remains an eligible second file", async () => {
|
|
183
|
+
await handlers.get("before_agent_start")!({ systemPrompt: "primary bookkeeping turn" }, ctx);
|
|
184
|
+
await successfulMutation(handlers, ctx, "write", firstPath);
|
|
185
|
+
const disguised = await handlers.get("tool_call")!({ toolName: "edit", input: { path: join(cwd, "odd", "tasks", "source.ts") } }, ctx) as { block?: boolean } | undefined;
|
|
186
|
+
assert.equal(disguised?.block, true);
|
|
187
|
+
});
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("delegated child actors retain multi-file write authority", async () => {
|
|
194
|
+
for (const [label, processEnv, startEvent] of [
|
|
195
|
+
["named", {}, { agentName: "gentle-ai-worker", systemPrompt: "named child" }],
|
|
196
|
+
["SDD", {}, { agentName: "sdd-apply", systemPrompt: "SDD apply executor" }],
|
|
197
|
+
["RPC", { GENTLE_PI_AGENTS_CHILD: "1" }, { systemPrompt: "owned RPC child" }],
|
|
198
|
+
] as const) {
|
|
199
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), `gentle-pi-odd-${label}-`)));
|
|
200
|
+
try {
|
|
201
|
+
execFileSync("git", ["init", "--quiet"], { cwd });
|
|
202
|
+
mkdirSync(join(cwd, "src"));
|
|
203
|
+
const handlers = harness(processEnv, undefined, label === "SDD" ? cwd : undefined);
|
|
204
|
+
const ctx = context(cwd);
|
|
205
|
+
await handlers.get("before_agent_start")!(startEvent, ctx);
|
|
206
|
+
await successfulMutation(handlers, ctx, "write", join(cwd, "src", "first.ts"));
|
|
207
|
+
await successfulMutation(handlers, ctx, "edit", join(cwd, "src", "second.ts"));
|
|
208
|
+
} finally {
|
|
209
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
@@ -96,12 +96,12 @@ test("always-on parent prompt requires a narrow writer edit surface before launc
|
|
|
96
96
|
assert.match(core, /do not ask the human to author paths or globs/i);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
test("review integration documents the
|
|
99
|
+
test("review integration documents the in-process reviewer completion and Go-owned authority boundary", () => {
|
|
100
100
|
const docs = read("docs/review-integration.md");
|
|
101
101
|
for (const marker of [
|
|
102
|
-
"
|
|
102
|
+
"no child process",
|
|
103
103
|
"exact Go-issued materialize/submission tokens",
|
|
104
|
-
"typed
|
|
104
|
+
"typed reviewer refusal fails closed",
|
|
105
105
|
"Go owns worktree, lineage, candidate freeze, lens selection, correction, validator, approval burn, and review semantics",
|
|
106
106
|
"Delivery commands remain ordinary repository-policy operations.",
|
|
107
107
|
"package has no durable receipt or policy authority",
|
|
@@ -309,20 +309,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
|
|
|
309
309
|
);
|
|
310
310
|
});
|
|
311
311
|
|
|
312
|
-
test("package verification binds the published Gentle AI v3.
|
|
312
|
+
test("package verification binds the published Gentle AI v3.4.0 runtime pin", () => {
|
|
313
313
|
const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
|
|
314
314
|
const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
|
|
315
315
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
316
316
|
|
|
317
|
-
assert.match(installer, /INSTALLER_VERSION = "3\.
|
|
317
|
+
assert.match(installer, /INSTALLER_VERSION = "3\.4\.0"/);
|
|
318
318
|
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
|
|
319
|
-
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:
|
|
319
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:bQOM\+Xa3WN5\+idTagEk15rgEEQQWVhZvq8BPF\/FHtSs="/);
|
|
320
320
|
assert.match(installer, /GOTOOLCHAIN: "local"/);
|
|
321
321
|
assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
|
|
322
322
|
assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
|
|
323
323
|
assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
|
|
324
324
|
assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
|
|
325
|
-
assert.match(verifier, /v3\.
|
|
325
|
+
assert.match(verifier, /v3\.4\.0/);
|
|
326
326
|
});
|
|
327
327
|
|
|
328
328
|
|
|
@@ -1574,9 +1574,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
|
|
|
1574
1574
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1575
1575
|
});
|
|
1576
1576
|
|
|
1577
|
-
test("Gentle Shell v3.
|
|
1577
|
+
test("Gentle Shell v3.3.0 package and runtime stop before publication", () => {
|
|
1578
1578
|
const packageJson = readPackageJson();
|
|
1579
|
-
assert.equal(packageJson.version, "3.
|
|
1579
|
+
assert.equal(packageJson.version, "3.3.0", "the release manifest must remain explicitly pinned to v3.3.0");
|
|
1580
1580
|
assert.equal(
|
|
1581
1581
|
packageJson.scripts?.test,
|
|
1582
1582
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
|