gentle-pi 2.6.4 → 3.0.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 +25 -7
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +41 -19
- package/docs/readme-reference.md +175 -36
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +289 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +123 -97
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +74 -113
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +19 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +88 -0
- package/lib/session-changes.ts +147 -0
- package/lib/shell-bar.ts +24 -10
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-changes.ts +5 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +18 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +321 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +883 -356
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +338 -205
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-capability-contract.test.ts +15 -1
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +119 -31
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +86 -0
- package/tests/session-changes-shell.test.ts +38 -0
- package/tests/session-changes.test.ts +114 -0
- package/tests/shell-bar.test.ts +35 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
|
@@ -154,7 +154,7 @@ if (!childRole) test("installed AI producer → fixed Pi extensions → managed
|
|
|
154
154
|
},
|
|
155
155
|
}, { askUser: async () => ({ cancelled: true }) });
|
|
156
156
|
try {
|
|
157
|
-
const task = runner.run({ agent: { ...actor(phase), ...(phase === "research" ? { tools: ["read", "write", "fetch_content"] } : {}) }, prompt: "Controlled boundary probe only; no implementation or acceptance.", label: undefined, context: phase === "research" ? JSON.stringify(status) : undefined, mode: "task", cwd, parentSessionId: "uptake", model: undefined, thinking: undefined, sessionDir: join(directory, "sessions"), resumeSessionPath: undefined, env: { PATH: process.env.PATH, HOME: home, PI_CODING_AGENT_DIR: home, GENTLE_PI_AGENT_HOME: home, PI_OFFLINE: "1", UPTAKE_CHILD: "1", UPTAKE_RUN: directory, UPTAKE_SCRIPT: scriptPath, GENTLE_PI_GENTLE_AI_DEV_BINARY: binary, ...(phase === "research" ? { [RESEARCH_CHILD_TOOLS_ENV]: JSON.stringify(["read", "write", "fetch_content"]) } : {}), ...options.env }, extensionPaths: [...fixed, ...(options.absentTool ? [] : [docExtension])], researchSelection: selection,
|
|
157
|
+
const task = runner.run({ agent: { ...actor(phase), ...(phase === "research" ? { tools: ["read", "write", "fetch_content"] } : {}) }, prompt: "Controlled boundary probe only; no implementation or acceptance.", label: undefined, context: phase === "research" ? JSON.stringify(status) : undefined, mode: "task", cwd, parentSessionId: "uptake", model: undefined, thinking: undefined, sessionDir: join(directory, "sessions"), resumeSessionPath: undefined, env: { PATH: process.env.PATH, HOME: home, PI_CODING_AGENT_DIR: home, GENTLE_PI_AGENT_HOME: home, PI_OFFLINE: "1", UPTAKE_CHILD: "1", UPTAKE_RUN: directory, UPTAKE_SCRIPT: scriptPath, GENTLE_PI_GENTLE_AI_DEV_BINARY: binary, ...(phase === "research" ? { [RESEARCH_CHILD_TOOLS_ENV]: JSON.stringify(["read", "write", "fetch_content"]) } : {}), ...options.env }, extensionPaths: [...fixed, ...(options.absentTool ? [] : [docExtension])], researchSelection: selection, sddRemediation: options.remediation, ...(phase === "research" ? {} : { sddChange: { phase, changeName: "uptake", workspaceRoot: cwd, ...options.selection } }) });
|
|
158
158
|
const result = await runner.waitFor(task.id);
|
|
159
159
|
assert.equal(launches.length, 1);
|
|
160
160
|
assert.deepEqual(launches[0].args.flatMap((arg, i, args) => arg === "--extension" ? [args[i + 1]] : []), [...fixed, ...(options.absentTool ? [] : [docExtension])]);
|
|
@@ -173,31 +173,21 @@ if (!childRole) test("installed AI producer → fixed Pi extensions → managed
|
|
|
173
173
|
assert.equal(ended(observed, "read").length, 1); assert.equal(ended(observed, "read")[0].isError, false);
|
|
174
174
|
assert.match(JSON.stringify(ended(observed, "read")[0].result), /Native uptake controlled fixture/);
|
|
175
175
|
});
|
|
176
|
-
const path = join(change, "research.md")
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
if (!absentTool) { artifact.locators[0].revision = 2; artifact.locators[0].digest = digest(next); }
|
|
182
|
-
const observed = await run(`research-${absentTool}`, "research", [[["fetch_content", {}]], [["read", { path }]], [["write", { path, content }]], [["read", { path }]], [["write", { path: join(cwd, "forbidden"), content }]]], { artifact, absentTool });
|
|
176
|
+
const path = join(change, "research.md");
|
|
177
|
+
for (const absentTool of [true, false]) await t.test(`output-only research provisioning absent=${absentTool} preserves parent ownership`, async () => {
|
|
178
|
+
const content = "# Partial research\nExternal questions remain open.\n";
|
|
179
|
+
const before = existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
180
|
+
const observed = await run(`research-${absentTool}`, "research", [[["fetch_content", {}]], [["read", { path }]], [["write", { path, content }]]], { absentTool });
|
|
183
181
|
assert.equal(observed.result.status, "completed", JSON.stringify(observed.result));
|
|
184
|
-
assert.ok(JSON.stringify(observed.input).includes("gentle-ai.sdd-status"));
|
|
185
182
|
assert.equal(ended(observed, "fetch_content")[0].isError, absentTool);
|
|
186
183
|
assert.equal(existsSync(join(observed.directory, "fetch-calls")), !absentTool);
|
|
184
|
+
for (const tool of ["read", "write"]) assert.equal(ended(observed, tool)[0].isError, true);
|
|
185
|
+
assert.equal(existsSync(path) ? readFileSync(path, "utf8") : undefined, before);
|
|
186
|
+
// Scripted parent persistence/readback, not autonomous model proof.
|
|
187
|
+
writeFileSync(path, content);
|
|
187
188
|
assert.equal(readFileSync(path, "utf8"), content);
|
|
188
|
-
assert.equal(existsSync(join(cwd, "forbidden")), false);
|
|
189
|
-
assert.equal(ended(observed, "write")[1].isError, true);
|
|
190
|
-
assert.match(JSON.stringify(ended(observed, "read").at(-1).result), /Readback identity matched/);
|
|
191
189
|
if (!absentTool) assert.equal(json(join(observed.directory, "inventory.json")).find(tool => tool.name === "fetch_content").sourceInfo.path, docExtension);
|
|
192
190
|
});
|
|
193
|
-
for (const fault of ["stale", "worktree"]) await t.test(`research ${fault} refuses before mutation`, async () => {
|
|
194
|
-
const bytes = readFileSync(path, "utf8");
|
|
195
|
-
const scope = { ...artifact, worktree: fault === "worktree" ? root : cwd, locators: [{ ...artifact.locators[0], revision: 3, digest: fault === "stale" ? "0".repeat(64) : digest(bytes) }] };
|
|
196
|
-
const observed = await run(`research-${fault}`, "research", [[["read", { path }]], [["write", { path, content: '{"revision":4}' }]]], { artifact: scope });
|
|
197
|
-
assert.equal(ended(observed, "read")[0].isError, true);
|
|
198
|
-
assert.equal(ended(observed, "write")[0].isError, true);
|
|
199
|
-
assert.equal(readFileSync(path, "utf8"), bytes);
|
|
200
|
-
});
|
|
201
191
|
|
|
202
192
|
await t.test("wrong native worktree refuses actual child tool work", async () => {
|
|
203
193
|
const observed = await run("wrong-worktree", "apply", [[["read", { path: join(change, "proposal.md") }]]], { selection: { workspaceRoot: root } });
|
|
@@ -211,7 +201,7 @@ if (!childRole) test("installed AI producer → fixed Pi extensions → managed
|
|
|
211
201
|
rmSync(definition.filePath);
|
|
212
202
|
let acquires = 0;
|
|
213
203
|
try {
|
|
214
|
-
await assert.rejects(admitManagedRemediation({ agent: definition, cwd, sddChange: { phase: "remediate", changeName: "uptake", workspaceRoot: cwd, failedEvidenceRevision: `sha256:${"a".repeat(64)}` } } as unknown as TaskRequest, {}, { ...native, sddAttemptAcquire: async () => { acquires++; throw new Error("Must not acquire"); } } as unknown as import("../lib/native-review-cli.ts").NativeReviewCli
|
|
204
|
+
await assert.rejects(admitManagedRemediation({ agent: definition, cwd, sddChange: { phase: "remediate", changeName: "uptake", workspaceRoot: cwd, failedEvidenceRevision: `sha256:${"a".repeat(64)}` } } as unknown as TaskRequest, {}, { ...native, sddAttemptAcquire: async () => { acquires++; throw new Error("Must not acquire"); } } as unknown as import("../lib/native-review-cli.ts").NativeReviewCli), /unsupported/i);
|
|
215
205
|
assert.equal(acquires, 0);
|
|
216
206
|
} finally { writeFileSync(definition.filePath, bytes); }
|
|
217
207
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
test("ordinary SDD instructions keep native selection and TDD without attempt launch governance", () => {
|
|
6
|
+
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
|
7
|
+
const workflow = read("assets/sdd-orchestrator-workflow.md");
|
|
8
|
+
const status = read("assets/support/sdd-status-contract.md");
|
|
9
|
+
for (const text of [workflow, status, read("assets/agents/sdd-remediate.md")]) {
|
|
10
|
+
assert.doesNotMatch(text, /sdd-attempt (acquire|settle)|Native Runtime Attempt Authority|compact acquire\/settle/);
|
|
11
|
+
}
|
|
12
|
+
assert.match(workflow, /Strict TDD/);
|
|
13
|
+
assert.match(status, /native.*authoritative|authoritative.*native/i);
|
|
14
|
+
assert.match(status, /edit.*grant|grant.*edit/i);
|
|
15
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
|
6
|
+
|
|
7
|
+
test("integrated organic continuity and selected SDD keep separate persistence ownership", () => {
|
|
8
|
+
const memory = read("assets/orchestrator-memory.md");
|
|
9
|
+
assert.match(memory, /one feature document, not a separate plan file or topic/);
|
|
10
|
+
assert.match(memory, /odd\/tasks\/<feature-name>\.md/);
|
|
11
|
+
assert.match(memory, /odd\/<feature-name>\/tasks/);
|
|
12
|
+
assert.match(memory, /Research is output-only/);
|
|
13
|
+
assert.match(memory, /Except for output-only `sdd-research`/);
|
|
14
|
+
assert.match(memory, /do not switch the selected store/);
|
|
15
|
+
const delegation = read("assets/orchestrator-delegation.md");
|
|
16
|
+
assert.match(delegation, /organic work, not explicitly selected SDD/);
|
|
17
|
+
assert.match(delegation, /do not create a specialized agent or invoke `sdd-research`/);
|
|
18
|
+
assert.match(read("assets/agents/sdd-research.md"), /Do not read local artifacts/);
|
|
19
|
+
assert.match(read("assets/sdd-orchestrator-workflow.md"), /Optional research takes precedence/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("integrated TDD guidance preserves native SDD completion without retired consumers", () => {
|
|
23
|
+
const wrapper = read("extensions/gentle-ai.ts"), tasks = read("assets/agents/sdd-tasks.md");
|
|
24
|
+
assert.match(wrapper, /Use configured TDD mode, source, and exact runner/);
|
|
25
|
+
assert.doesNotMatch(wrapper + tasks, /If tests exist(?:, use strict TDD| or strict TDD)/);
|
|
26
|
+
assert.match(tasks, /Only when configured strict TDD is active/);
|
|
27
|
+
assert.match(read("assets/agents/sdd-apply.md"), /native task progress and authorized scope/);
|
|
28
|
+
assert.match(read("assets/agents/sdd-verify.md"), /Archive admission follows fresh native status and real permissions/);
|
|
29
|
+
assert.match(read("assets/chains/sdd-full.chain.md"), /apply -> archive/);
|
|
30
|
+
for (const path of ["assets/agents/sdd-sync.md", "lib/openspec-guardrails.ts"]) {
|
|
31
|
+
assert.equal(existsSync(new URL(`../${path}`, import.meta.url)), false, path);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import gentleAgents from "../extensions/gentle-agents.ts";
|
|
10
|
+
|
|
11
|
+
interface HookEvent {
|
|
12
|
+
systemPrompt?: string;
|
|
13
|
+
toolName?: string;
|
|
14
|
+
toolCallId?: string;
|
|
15
|
+
input?: Record<string, unknown>;
|
|
16
|
+
content?: Array<{ type: "text"; text: string }>;
|
|
17
|
+
isError?: boolean;
|
|
18
|
+
}
|
|
19
|
+
interface HookResult { block?: boolean; reason?: string; isError?: boolean; systemPrompt?: string }
|
|
20
|
+
type Hook = (event: HookEvent, ctx: { cwd: string }) => HookResult | undefined;
|
|
21
|
+
|
|
22
|
+
function child(cwd: string, artifact?: unknown, selection?: unknown) {
|
|
23
|
+
const hooks = new Map<string, Hook>();
|
|
24
|
+
let active = ["read", "grep", "find", "write", "edit", "mem_save", "mem_search", "mem_get_observation", "web_search", "subagent_parent_message"];
|
|
25
|
+
let source = "/installed/search.ts";
|
|
26
|
+
const pi = { on: (name: string, hook: Hook) => hooks.set(name, hook),
|
|
27
|
+
getActiveTools: () => active,
|
|
28
|
+
getAllTools: () => active.map(name => ({ name, sourceInfo: { source: "extension", path: source } })),
|
|
29
|
+
appendEntry: () => assert.fail("research must not create a second persistence checkpoint"),
|
|
30
|
+
};
|
|
31
|
+
gentleAgents(pi as unknown as ExtensionAPI, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active),
|
|
32
|
+
GENTLE_PI_RESEARCH_SELECTION: JSON.stringify(selection ?? null), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(artifact ?? null) });
|
|
33
|
+
// No physical session file or pre-existing artifact is necessary.
|
|
34
|
+
const ctx = { cwd };
|
|
35
|
+
hooks.get("before_agent_start")!({ systemPrompt: "Research a question" }, ctx);
|
|
36
|
+
return {
|
|
37
|
+
call: (toolName: string, input: Record<string, unknown> = {}) => hooks.get("tool_call")!({ toolName, input, toolCallId: "call" }, ctx),
|
|
38
|
+
result: (toolName: string, input: Record<string, unknown>, content: string, isError = false) => {
|
|
39
|
+
const event = { toolName, input, toolCallId: "call", content: [{ type: "text" as const, text: content }], isError };
|
|
40
|
+
return { ...event, ...hooks.get("tool_result")?.(event, ctx) };
|
|
41
|
+
},
|
|
42
|
+
deactivate: (name: string) => { active = active.filter(tool => tool !== name); },
|
|
43
|
+
replaceSource: () => { source = "/different/search.ts"; },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function workspace(t: test.TestContext) {
|
|
48
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-optional-research-")));
|
|
49
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
50
|
+
return cwd;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("optional research is output-only even with historical artifact identity", t => {
|
|
54
|
+
const cwd = workspace(t);
|
|
55
|
+
const path = join(cwd, "openspec/changes/demo/research.md"), content = '{"revision":1,"outcome":"partial"}';
|
|
56
|
+
mkdirSync(join(cwd, "openspec/changes/demo"), { recursive: true });
|
|
57
|
+
writeFileSync(path, content);
|
|
58
|
+
const h = child(cwd, { store: "openspec", worktree: cwd, changeName: "demo", retainedIntent: "Research question", locators: [{ artifact: "research", path, revision: 1, digest: createHash("sha256").update(content).digest("hex") }] });
|
|
59
|
+
for (const tool of ["read", "grep", "find", "write", "edit", "mem_save", "mem_search", "mem_get_observation", "bash"]) {
|
|
60
|
+
assert.equal(h.call(tool, { path, content: "overwrite", id: 1 })?.block, true, tool);
|
|
61
|
+
}
|
|
62
|
+
assert.equal(readFileSync(path, "utf8"), content);
|
|
63
|
+
assert.equal(h.call("subagent_parent_message", { message: "Partial findings; external sources unavailable." }), undefined);
|
|
64
|
+
assert.equal(h.result("web_search", {}, "Partial search results").isError, false);
|
|
65
|
+
assert.equal(h.result("web_search", {}, "Permission denied", true).isError, true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("optional research uses individually authorized external tools but never invents grants", t => {
|
|
69
|
+
const cwd = workspace(t);
|
|
70
|
+
const selection = { "open-web": { tools: ["web_search"], extensions: { web_search: "/installed/search.ts" } } };
|
|
71
|
+
const h = child(cwd, undefined, selection);
|
|
72
|
+
assert.equal(h.call("web_search", { query: "official reference" }), undefined);
|
|
73
|
+
assert.equal(h.call("fetch_content", { url: "https://example.com" })?.block, true);
|
|
74
|
+
assert.equal(h.call("mcp", {})?.block, true);
|
|
75
|
+
h.replaceSource();
|
|
76
|
+
assert.equal(h.call("web_search", {})?.block, true, "changed extension provenance revokes the route");
|
|
77
|
+
const inactive = child(cwd, undefined, selection); inactive.deactivate("web_search");
|
|
78
|
+
assert.equal(inactive.call("web_search", {})?.block, true);
|
|
79
|
+
assert.equal(child(cwd).call("web_search", {})?.block, true, "availability alone is not authorization");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (process.env.SDD_TEST_ENGRAM) test("research parent can persist first partial artifact through actual isolated Engram", t => {
|
|
83
|
+
const cwd = workspace(t), project = "research-fixture", binary = process.env.SDD_TEST_ENGRAM!;
|
|
84
|
+
const env = { ...process.env, ENGRAM_DATA_DIR: join(cwd, "memory"), ENGRAM_PROJECT: project };
|
|
85
|
+
// Establish a known test project, not a research artifact or session checkpoint.
|
|
86
|
+
execFileSync(binary, ["save", "Fixture context", "Isolated project context", "--project", project], { cwd, env, stdio: "pipe" });
|
|
87
|
+
const invoke = (name: string, args: Record<string, unknown>) => {
|
|
88
|
+
const messages = [
|
|
89
|
+
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "research-integration", version: "1" } } },
|
|
90
|
+
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
|
91
|
+
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name, arguments: args } },
|
|
92
|
+
];
|
|
93
|
+
const raw = execFileSync(binary, ["mcp", "--project", project], { cwd, env, encoding: "utf8", input: messages.map(value => JSON.stringify(value)).join("\n") + "\n", timeout: 15_000 });
|
|
94
|
+
const response = raw.trim().split("\n").map(line => JSON.parse(line)).find(value => value.id === 2);
|
|
95
|
+
assert.ok(response?.result && !response.error);
|
|
96
|
+
assert.notEqual(response.result.isError, true);
|
|
97
|
+
return response.result.content.map((part: { text: string }) => part.text).join("");
|
|
98
|
+
};
|
|
99
|
+
const engram = { project, topic_key: "sdd/demo/research" };
|
|
100
|
+
// The parent owns persistence; research child hooks never perform or approve it.
|
|
101
|
+
const input = { ...engram, title: "Partial documentation research", content: "External sources unavailable; product question remains unanswered.", capture_prompt: false };
|
|
102
|
+
const saved = invoke("mem_save", input);
|
|
103
|
+
const id = JSON.parse(saved).id; assert.ok(Number.isSafeInteger(id));
|
|
104
|
+
const readback = invoke("mem_get_observation", { id });
|
|
105
|
+
assert.ok(JSON.parse(readback).result.includes(input.content));
|
|
106
|
+
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
test("optional research handoff keeps first file persistence and readback parent-owned", t => {
|
|
111
|
+
const cwd = workspace(t), path = join(cwd, "openspec/changes/demo/research.md");
|
|
112
|
+
const findings = "# Research\nPartial findings; external sources unavailable.\n";
|
|
113
|
+
assert.equal(child(cwd).call("write", { path, content: findings })?.block, true);
|
|
114
|
+
// This models the parent using its existing file tools, not a child persistence adapter.
|
|
115
|
+
mkdirSync(join(cwd, "openspec/changes/demo"), { recursive: true });
|
|
116
|
+
writeFileSync(path, findings);
|
|
117
|
+
assert.equal(readFileSync(path, "utf8"), findings);
|
|
118
|
+
const workflow = readFileSync(new URL("../assets/sdd-orchestrator-workflow.md", import.meta.url), "utf8");
|
|
119
|
+
const gate = workflow.slice(workflow.indexOf("## Automatic Mode Gatekeeper"));
|
|
120
|
+
assert.ok(gate.indexOf("Optional research takes precedence") < gate.indexOf("**Contract conformance:**"));
|
|
121
|
+
assert.match(gate, /accept honest partial, unavailable, or inline findings without requiring an artifact or retry/);
|
|
122
|
+
assert.match(workflow, /parent owns local reads, product choices, and any authorized persistence\/readback/);
|
|
123
|
+
assert.match(workflow, /First artifacts and in-memory sessions require no prior identity or checkpoint/);
|
|
124
|
+
});
|
|
@@ -30,7 +30,7 @@ for (const [index, path] of paths.entries()) {
|
|
|
30
30
|
"stop for ambiguous change selection, unresolved session preflight, or unsafe action context",
|
|
31
31
|
"prove planned writes are within the authoritative workspace or allowed edit roots",
|
|
32
32
|
"workspace-planning without allowed edit roots remains read-only",
|
|
33
|
-
"Planning does not bypass the init guard,
|
|
33
|
+
"Planning does not bypass the init guard, optional research guidance, or phase approval requirements",
|
|
34
34
|
"never infer a route from prose",
|
|
35
35
|
"Keep genuine blockers in `blockedReasons` and non-blocking diagnostics in `notes`, never in `nextRecommended`",
|
|
36
36
|
"report them without discarding them to enable a route",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
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 { isSddPreflightTrigger, sddPreflightDiskPath } from "../lib/sdd-preflight.ts";
|
|
10
|
+
|
|
11
|
+
// gentle-pi#1001: the natural-language SDD `input` hook called the parent-only
|
|
12
|
+
// preflight resolver for every matching prompt, RPC children included.
|
|
13
|
+
// `ensureSddPreflight` rejects RPC by design, the hook caught that rejection and
|
|
14
|
+
// answered `handled`, and Pi's `AgentSession.prompt()` accepts handled input and
|
|
15
|
+
// returns before `before_agent_start` and the model loop. A delegated child
|
|
16
|
+
// therefore ACKed a prompt it never ran: no agent_start, zero turns, zero tool
|
|
17
|
+
// calls, and a stall that only surfaced as the runner's inactivity timeout.
|
|
18
|
+
//
|
|
19
|
+
// An RPC child consumes the parent-rendered preflight block transported in its
|
|
20
|
+
// task context. It must never originate preflight, and the interceptor must
|
|
21
|
+
// never consume a delegated prompt on its behalf.
|
|
22
|
+
|
|
23
|
+
type InputResult = { action: "continue" | "handled" };
|
|
24
|
+
type InputHandler = (event: { text?: unknown }, ctx: ExtensionContext) => Promise<InputResult>;
|
|
25
|
+
|
|
26
|
+
const DELEGATED_SDD_TASK = "Implement the accepted SDD change.";
|
|
27
|
+
|
|
28
|
+
function inputHook(): InputHandler {
|
|
29
|
+
const handlers = new Map<string, InputHandler>();
|
|
30
|
+
const pi = {
|
|
31
|
+
on(name: string, handler: InputHandler) {
|
|
32
|
+
handlers.set(name, handler);
|
|
33
|
+
},
|
|
34
|
+
events: { emit() {} },
|
|
35
|
+
registerCommand() {},
|
|
36
|
+
registerTool() {},
|
|
37
|
+
getActiveTools: () => [],
|
|
38
|
+
} as unknown as ExtensionAPI;
|
|
39
|
+
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
40
|
+
const input = handlers.get("input");
|
|
41
|
+
assert.equal(typeof input, "function", "the extension must register an input hook");
|
|
42
|
+
return input as InputHandler;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ctx(overrides: Record<string, unknown>): ExtensionContext {
|
|
46
|
+
return {
|
|
47
|
+
cwd: process.cwd(),
|
|
48
|
+
hasUI: true,
|
|
49
|
+
ui: { notify() {} },
|
|
50
|
+
sessionManager: { getSessionId: () => "sdd-preflight-rpc-input" },
|
|
51
|
+
...overrides,
|
|
52
|
+
} as unknown as ExtensionContext;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test("the delegated task text reaches the preflight interceptor", () => {
|
|
56
|
+
// Guards the premise of the tests below: if the classifier stops matching
|
|
57
|
+
// this text they would pass for the wrong reason.
|
|
58
|
+
assert.equal(isSddPreflightTrigger(DELEGATED_SDD_TASK), true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("an RPC child's matching prompt is not consumed by the preflight interceptor", async () => {
|
|
62
|
+
const input = inputHook();
|
|
63
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-rpc-input-"));
|
|
64
|
+
const notifications: string[] = [];
|
|
65
|
+
try {
|
|
66
|
+
const result = await input(
|
|
67
|
+
{ text: DELEGATED_SDD_TASK },
|
|
68
|
+
ctx({
|
|
69
|
+
cwd,
|
|
70
|
+
mode: "rpc",
|
|
71
|
+
sessionManager: { getSessionId: () => "sdd-preflight-rpc-child" },
|
|
72
|
+
ui: { notify: (message: string) => notifications.push(message) },
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
assert.deepEqual(result, { action: "continue" }, "a delegated prompt must reach the agent");
|
|
76
|
+
assert.deepEqual(notifications, [], "an RPC child must not originate preflight at all");
|
|
77
|
+
assert.equal(existsSync(sddPreflightDiskPath(cwd)), false, "an RPC child must not persist defaults");
|
|
78
|
+
} finally {
|
|
79
|
+
await rm(cwd, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("an RPC child's ordinary prompt still continues", async () => {
|
|
84
|
+
const input = inputHook();
|
|
85
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-rpc-input-control-"));
|
|
86
|
+
try {
|
|
87
|
+
const result = await input(
|
|
88
|
+
{ text: "Implement a small Rust selection type." },
|
|
89
|
+
ctx({ cwd, mode: "rpc", sessionManager: { getSessionId: () => "sdd-preflight-rpc-control" } }),
|
|
90
|
+
);
|
|
91
|
+
assert.deepEqual(result, { action: "continue" });
|
|
92
|
+
assert.equal(existsSync(sddPreflightDiskPath(cwd)), false);
|
|
93
|
+
} finally {
|
|
94
|
+
await rm(cwd, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("an interactive parent still resolves preflight for a matching prompt", async () => {
|
|
99
|
+
const input = inputHook();
|
|
100
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-parent-input-"));
|
|
101
|
+
const agentHome = await mkdtemp(join(tmpdir(), "gentle-pi-parent-agent-home-"));
|
|
102
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
103
|
+
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
104
|
+
try {
|
|
105
|
+
const result = await input(
|
|
106
|
+
{ text: DELEGATED_SDD_TASK },
|
|
107
|
+
ctx({
|
|
108
|
+
cwd,
|
|
109
|
+
hasUI: false,
|
|
110
|
+
sessionManager: { getSessionId: () => "sdd-preflight-interactive-parent" },
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
assert.deepEqual(result, { action: "continue" });
|
|
114
|
+
assert.equal(
|
|
115
|
+
existsSync(sddPreflightDiskPath(cwd)),
|
|
116
|
+
true,
|
|
117
|
+
"the parent remains the only actor that resolves and persists preflight",
|
|
118
|
+
);
|
|
119
|
+
} finally {
|
|
120
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
121
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
122
|
+
await rm(cwd, { recursive: true, force: true });
|
|
123
|
+
await rm(agentHome, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
@@ -496,7 +496,7 @@ test("a persisted canonical 'hybrid' artifact store loads unchanged", async () =
|
|
|
496
496
|
test("the shared shipped SDD inventory includes every executor, including remediation", () => {
|
|
497
497
|
assert.deepEqual(SHIPPED_SDD_AGENT_NAMES, [
|
|
498
498
|
"sdd-init", "sdd-onboard", "sdd-explore", "sdd-research", "sdd-proposal", "sdd-spec", "sdd-design",
|
|
499
|
-
"sdd-tasks", "sdd-status", "sdd-apply", "sdd-verify", "sdd-
|
|
499
|
+
"sdd-tasks", "sdd-status", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-remediate",
|
|
500
500
|
]);
|
|
501
501
|
});
|
|
502
502
|
|