gentle-pi 2.7.0 → 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 +24 -6
- 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 +14 -4
- package/docs/readme-reference.md +171 -32
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- 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 +67 -111
- 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 +10 -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 +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -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 +9 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +0 -1
- 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 +307 -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 +849 -356
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- 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 +115 -27
- 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 +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { applyDeltaSpec, parseRequirementBlocks } from "../lib/openspec-deltas.ts";
|
|
5
|
+
|
|
6
|
+
const archive = readFileSync(new URL("../assets/agents/sdd-archive.md", import.meta.url), "utf8");
|
|
7
|
+
|
|
8
|
+
// These checks exercise the shipped instruction contract, not an autonomous executor.
|
|
9
|
+
test("archive reconciles prior composition per operation before applying remaining deltas", () => {
|
|
10
|
+
assert.match(archive, /### Resume prior composition/);
|
|
11
|
+
assert.match(archive, /Classify each operation as already applied, pending, or unresolved/);
|
|
12
|
+
assert.match(archive, /existing change-specific artifacts.*history/);
|
|
13
|
+
assert.match(archive, /PASS.*absence alone.*not proof/);
|
|
14
|
+
assert.match(archive, /REMOVED.*absent.*same requirement.*removed by this change/);
|
|
15
|
+
assert.match(archive, /ADDED.*MODIFIED.*full current requirement block.*intended delta result/);
|
|
16
|
+
assert.match(archive, /Apply only pending operations/);
|
|
17
|
+
assert.match(archive, /unresolved.*before any canonical write or archive move/);
|
|
18
|
+
assert.match(archive, /Do not create a new report schema.*hash inventory/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("archive reconciliation preserves safety and backend closure boundaries", () => {
|
|
22
|
+
assert.match(archive, /collision.*order.*still apply/);
|
|
23
|
+
assert.match(archive, /Fail or block.*REMOVED target is missing without corroborating history/);
|
|
24
|
+
assert.match(archive, /differing current content.*unresolved/);
|
|
25
|
+
assert.match(archive, /re-read the persisted tasks artifact/i);
|
|
26
|
+
assert.match(archive, /resolved symlink targets/);
|
|
27
|
+
assert.match(archive, /Block rather than overwrite an existing archive destination/);
|
|
28
|
+
assert.match(archive, /Engram.*do not create or require.*canonical/);
|
|
29
|
+
assert.match(archive, /already-applied.*pending.*unresolved.*supporting artifact/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const original = `# Domain
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
### Requirement: Retained
|
|
37
|
+
|
|
38
|
+
Keep the original behavior.
|
|
39
|
+
|
|
40
|
+
### Requirement: Removed
|
|
41
|
+
|
|
42
|
+
Retire this behavior.
|
|
43
|
+
`;
|
|
44
|
+
const removal = "## REMOVED Requirements\n\n### Requirement: Removed\n\nRetired by this change.\n";
|
|
45
|
+
const modification = "## MODIFIED Requirements\n\n### Requirement: Retained\n\nKeep the revised behavior.\n";
|
|
46
|
+
const addition = "## ADDED Requirements\n\n### Requirement: Added\n\nAdd this behavior.\n";
|
|
47
|
+
|
|
48
|
+
// Existing helpers deliberately reject replay; they do not infer historical ownership.
|
|
49
|
+
// Explicit pending inputs below demonstrate composition after instruction-level reconciliation.
|
|
50
|
+
test("an already-applied removal is preserved without reapplying it", () => {
|
|
51
|
+
const synced = applyDeltaSpec(original, removal);
|
|
52
|
+
assert.throws(() => applyDeltaSpec(synced, removal), /Missing canonical requirement.*REMOVED/);
|
|
53
|
+
const unchanged = applyDeltaSpec(synced, "");
|
|
54
|
+
assert.equal(unchanged, synced);
|
|
55
|
+
assert.deepEqual(parseRequirementBlocks(unchanged).map((block) => block.name), ["Retained"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("unapplied missing removal cannot become a successful helper operation", () => {
|
|
59
|
+
const neverExisted = removal.replaceAll("Removed", "Never existed");
|
|
60
|
+
assert.throws(() => applyDeltaSpec(original, neverExisted), /Missing canonical requirement.*Never existed/);
|
|
61
|
+
assert.match(original, /### Requirement: Removed/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("mixed prior removal and pending modification/addition compose without duplicating prior effects", () => {
|
|
65
|
+
const partiallySynced = applyDeltaSpec(original, removal);
|
|
66
|
+
const resumed = applyDeltaSpec(partiallySynced, `${modification}\n${addition}`);
|
|
67
|
+
const uninterrupted = applyDeltaSpec(original, `${removal}\n${modification}\n${addition}`);
|
|
68
|
+
assert.equal(resumed, uninterrupted);
|
|
69
|
+
assert.deepEqual(parseRequirementBlocks(resumed).map((block) => block.name), ["Retained", "Added"]);
|
|
70
|
+
assert.throws(() => applyDeltaSpec(resumed, addition), /Cannot add existing canonical requirement/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("canonical content comparison distinguishes matching effects from later drift", () => {
|
|
74
|
+
const synced = applyDeltaSpec(original, modification);
|
|
75
|
+
const intended = parseRequirementBlocks(modification)[0].content;
|
|
76
|
+
assert.equal(parseRequirementBlocks(synced)[0].content, intended);
|
|
77
|
+
const drifted = synced.replace("Keep the revised behavior.", "A later change owns this behavior.");
|
|
78
|
+
assert.notEqual(parseRequirementBlocks(drifted)[0].content, intended);
|
|
79
|
+
// The helper is not a drift/consent authority: the executor must stop before calling it.
|
|
80
|
+
assert.match(drifted, /A later change owns this behavior/);
|
|
81
|
+
assert.match(applyDeltaSpec(drifted, modification), /Keep the revised behavior/);
|
|
82
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
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 { ensureSddPreflight } from "../lib/sdd-preflight.ts";
|
|
10
|
+
|
|
11
|
+
type Hook = (event: unknown, ctx: ExtensionContext) => Promise<{ systemPrompt: string }>;
|
|
12
|
+
|
|
13
|
+
for (const store of ["openspec", "engram", "hybrid"] as const) {
|
|
14
|
+
for (const selected of [true, false]) {
|
|
15
|
+
test(`classical startup preserves native archive and admits optional verify (store=${store}, selected=${selected})`, async (t) => {
|
|
16
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "pi-classical-")));
|
|
17
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
18
|
+
const status = {
|
|
19
|
+
schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: store,
|
|
20
|
+
planningHome: { mode: "repo-local", path: store === "openspec" ? join(root, "openspec") : "engram:sdd" }, changeRoot: store === "openspec" ? join(root, "openspec/changes/alpha") : null,
|
|
21
|
+
actionContext: { mode: "repo-local", workspaceRoot: root, allowedEditRoots: [root] },
|
|
22
|
+
dependencies: { proposal: "all_done", specs: "all_done", design: "all_done", tasks: "all_done", apply: "all_done", verify: "ready", archive: "ready" },
|
|
23
|
+
phaseInstructions: { apply: [], verify: ["Optional functional verification."], archive: ["Compose applicable specs and archive." ] },
|
|
24
|
+
blockedReasons: [] as string[], nextRecommended: "archive",
|
|
25
|
+
};
|
|
26
|
+
let phase = "verify";
|
|
27
|
+
let reply: unknown = status;
|
|
28
|
+
const hooks = new Map<string, Hook>();
|
|
29
|
+
const pi = {
|
|
30
|
+
on(name: string, hook: Hook) { hooks.set(name, hook); }, events: { emit() {} }, registerCommand() {}, registerTool() {},
|
|
31
|
+
getFlag: () => selected ? JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase }) : undefined,
|
|
32
|
+
getActiveTools: () => [],
|
|
33
|
+
} as unknown as ExtensionAPI;
|
|
34
|
+
const ctx = { cwd: root, hasUI: false, sessionManager: { getSessionId: () => root } } as unknown as ExtensionContext;
|
|
35
|
+
await ensureSddPreflight(ctx, { pi, installAssets: () => ({ agents: 0, chains: 0, support: 0, skipped: 0 }) });
|
|
36
|
+
createGentleAiExtension({ nativeReviewCli: { sddStatus: async () => reply } as unknown as NativeReviewCli, processEnv: {} })(pi);
|
|
37
|
+
const start = () => hooks.get("before_agent_start")!({ agentName: `sdd-${phase}`, systemPrompt: `SDD ${phase} executor` }, ctx);
|
|
38
|
+
for (const next of ["verify", "archive"]) {
|
|
39
|
+
phase = next;
|
|
40
|
+
await t.test(`explicit ${next}`, async () => {
|
|
41
|
+
const result = await start();
|
|
42
|
+
assert.doesNotMatch(result.systemPrompt, /SDD selection blocked:/);
|
|
43
|
+
assert.ok(result.systemPrompt.includes(JSON.stringify(status, null, 2)), "never rewrite the native recommended action");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
phase = "verify";
|
|
47
|
+
reply = { ...status, nextRecommended: "apply", dependencies: { ...status.dependencies, apply: "ready" } };
|
|
48
|
+
await t.test("partial verification", async () => {
|
|
49
|
+
assert.doesNotMatch((await start()).systemPrompt, /SDD selection blocked:/, "explicit partial verification preserves native apply selection");
|
|
50
|
+
});
|
|
51
|
+
reply = { ...status, dependencies: { ...status.dependencies, verify: "blocked" } };
|
|
52
|
+
assert.match((await start()).systemPrompt, /SDD selection blocked:/);
|
|
53
|
+
phase = "archive";
|
|
54
|
+
for (const blocked of [
|
|
55
|
+
{ ...status, nextRecommended: "apply", dependencies: { ...status.dependencies, apply: "ready", archive: "blocked" } },
|
|
56
|
+
{ ...status, blockedReasons: ["edit authority missing"] },
|
|
57
|
+
{ ...status, dependencies: { ...status.dependencies, archive: "blocked" } },
|
|
58
|
+
{ ...status, nextRecommended: "verify" },
|
|
59
|
+
]) {
|
|
60
|
+
reply = blocked;
|
|
61
|
+
assert.match((await start()).systemPrompt, /SDD selection blocked:/);
|
|
62
|
+
}
|
|
63
|
+
phase = "sync";
|
|
64
|
+
reply = status;
|
|
65
|
+
await t.test("stale sync is mechanically blocked", async () => {
|
|
66
|
+
assert.match((await start()).systemPrompt, /SDD selection blocked:/, "stale sync executors must not bypass native authority");
|
|
67
|
+
const callTool = hooks.get("tool_call")! as unknown as (event: unknown, ctx: ExtensionContext) => Promise<{ block?: boolean }>;
|
|
68
|
+
for (const toolName of ["write", "edit", "bash"]) {
|
|
69
|
+
assert.equal((await callTool({ toolName, input: { path: "unsafe.ts", command: "echo unsafe" } }, ctx))?.block, true);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -20,9 +20,25 @@ for (const path of guidancePaths) {
|
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
test("workflow preserves explicit continuation and
|
|
23
|
+
test("workflow preserves explicit continuation and native-only classical completion", () => {
|
|
24
24
|
const workflow = readFileSync(new URL("../assets/sdd-orchestrator-workflow.md", import.meta.url), "utf8");
|
|
25
25
|
assert.match(workflow, /only.*sdd-continue|sdd-continue.*only/i);
|
|
26
|
-
assert.
|
|
26
|
+
assert.doesNotMatch(workflow, /manual sdd-sync|local resolver/i);
|
|
27
|
+
assert.match(workflow, /verification is optional/);
|
|
27
28
|
assert.doesNotMatch(workflow, /sdd-(?:apply|verify|archive).*local|local.*sdd-(?:apply|verify|archive)/i);
|
|
28
29
|
});
|
|
30
|
+
|
|
31
|
+
test("archive owns composition without mandatory verification or a standalone sync receipt", () => {
|
|
32
|
+
const archive = readFileSync(new URL("../assets/agents/sdd-archive.md", import.meta.url), "utf8");
|
|
33
|
+
const full = readFileSync(new URL("../assets/chains/sdd-full.chain.md", import.meta.url), "utf8");
|
|
34
|
+
assert.doesNotMatch(full, /^## sdd-(verify|sync)$/m);
|
|
35
|
+
assert.match(full, /apply -> archive/);
|
|
36
|
+
assert.match(archive, /missing optional report is not a blocker/);
|
|
37
|
+
assert.match(archive, /no separate sync phase or successful sync-report artifact is required/);
|
|
38
|
+
for (const guard of ["Final Task Completion Gate", "allowed edit roots", "resolved symlink targets", "existing archive destination",
|
|
39
|
+
"ADDED Requirements", "MODIFIED Requirements", "REMOVED Requirements", "RENAMED Requirements",
|
|
40
|
+
"explicit composition/archive order", "explicit approval for the destructive sync", "dependsOn", "rules.sync",
|
|
41
|
+
"archive-report", "observation-ID traceability", "Preserve every canonical requirement not mentioned by the delta"]) {
|
|
42
|
+
assert.ok(archive.includes(guard), `archive retains ${guard}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
@@ -1,40 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { parseRemediationPlan,
|
|
5
|
-
import { NATIVE_REVIEW_ERROR_CODE, NATIVE_REVIEW_OPERATION, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
6
|
-
import { remediationUnresolved } from "../lib/agents-history.ts";
|
|
7
|
-
import type { TaskRecord } from "../lib/agents-protocol.ts";
|
|
4
|
+
import { parseRemediationPlan, type RemediationPlan, type RemediationScope } from "../lib/agents-runner.ts";
|
|
8
5
|
|
|
9
6
|
const testCwd = process.cwd();
|
|
10
|
-
const human = { hasUI: true, ui: { confirm: async () => true } } as unknown as Pick<ExtensionContext, "hasUI" | "ui">;
|
|
11
7
|
const shellScope = (cwd: string, commands: string[]): RemediationScope => ({ cwd, commands, editPaths: [], allowedEditRoots: [cwd] });
|
|
12
|
-
const revision = `sha256:${"a".repeat(64)}`;
|
|
13
8
|
const plan: RemediationPlan = { cwd: testCwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this correction changes only static assets." }, rollback: { boundary: "Revert the changed asset and paired test", command: "git diff --check" } };
|
|
14
|
-
|
|
15
|
-
function observed(s: RemediationObservations, command: string, id: string, exitCode: number | null = 0, overrides: Record<string, unknown> = {}) {
|
|
16
|
-
observeRemediationTool(s, { type: "tool_execution_start", toolName: "bash", toolCallId: id, args: { command } });
|
|
17
|
-
observeRemediationTool(s, { type: "tool_execution_end", toolName: "bash", toolCallId: id, isError: false, result: { content: [{ type: "text", text: "concrete command output" }], details: { remediationCommand: { toolCallId: id, command, cwd: testCwd, exitCode, ...overrides } } } });
|
|
18
|
-
}
|
|
19
|
-
test("remediation evidence requires every exact planned command and rollback observation", () => {
|
|
20
|
-
const s = state();
|
|
21
|
-
assert.equal(remediationEvidence(s), undefined);
|
|
22
|
-
observed(s, "pnpm test", "a");
|
|
23
|
-
assert.equal(remediationEvidence(s), undefined);
|
|
24
|
-
observed(s, "git diff --check", "b");
|
|
25
|
-
const evidence = remediationEvidence(s);
|
|
26
|
-
assert.equal(evidence.failed_evidence_revision, revision);
|
|
27
|
-
assert.equal(evidence.commands[0].exit_code, 0);
|
|
28
|
-
assert.match(evidence.commands[0].result, /sha256:/);
|
|
29
|
-
assert.equal(evidence.runtime_harness.status, "not_applicable");
|
|
30
|
-
assert.equal(evidence.rollback.boundary, plan.rollback.boundary);
|
|
31
|
-
});
|
|
32
|
-
for (const fault of ["nonzero", "null", "cwd", "id", "truncated", "unmatched"]) test(`remediation refuses ${fault} without parsing prose`, () => {
|
|
33
|
-
const s = state();
|
|
34
|
-
observed(s, fault === "unmatched" ? "echo passed" : "pnpm test", "a", fault === "nonzero" ? 1 : fault === "null" ? null : 0, fault === "cwd" ? { cwd: "/other" } : fault === "id" ? { toolCallId: "wrong" } : fault === "truncated" ? { truncated: true } : {});
|
|
35
|
-
observed(s, "git diff --check", "b");
|
|
36
|
-
assert.equal(remediationEvidence(s), undefined);
|
|
37
|
-
});
|
|
9
|
+
|
|
38
10
|
test("remediation plan is bounded and cannot select another working directory", () => {
|
|
39
11
|
assert.throws(() => parseRemediationPlan({ ...plan, cwd: "/other" }, testCwd), /plan/);
|
|
40
12
|
assert.throws(() => parseRemediationPlan({ ...plan, commands: [] }, testCwd), /plan/);
|
|
@@ -71,86 +43,6 @@ test("remediation owner and packaged actor are installed through existing owners
|
|
|
71
43
|
assert.match(readFileSync("assets/agents/sdd-remediate.md", "utf8"), /SDD remediate executor/);
|
|
72
44
|
});
|
|
73
45
|
|
|
74
|
-
test("native remediation admission refuses unsupported owner before acquire", async () => {
|
|
75
|
-
const { admitManagedRemediation } = await import("../extensions/gentle-agents.ts");
|
|
76
|
-
let acquires = 0;
|
|
77
|
-
await assert.rejects(admitManagedRemediation({ agent: { name: "sdd-remediate", filePath: "/absent" }, sddChange: { changeName: "fix", workspaceRoot: testCwd, phase: "remediate", failedEvidenceRevision: revision }, cwd: testCwd } as unknown as TaskRequest, {}, { sddAttemptAcquire: async () => { acquires++; } } as unknown as NativeReviewCli, async () => {}), /unsupported/i);
|
|
78
|
-
assert.equal(acquires, 0);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
test("admitted remediation retains exact settlement before one lost-reply replay", async () => {
|
|
83
|
-
const { admitManagedRemediation } = await import("../extensions/gentle-agents.ts");
|
|
84
|
-
const { resolve } = await import("node:path");
|
|
85
|
-
const { readFileSync } = await import("node:fs");
|
|
86
|
-
const path = resolve("assets/agents/sdd-remediate.md");
|
|
87
|
-
const { parseAgentDefinition } = await import("../lib/agents-config.ts");
|
|
88
|
-
const agent = parseAgentDefinition(readFileSync(path, "utf8"), path, "global");
|
|
89
|
-
assert.ok("instructions" in agent);
|
|
90
|
-
const saved = [], calls = [];
|
|
91
|
-
const request = { agent, sddChange: { changeName: "fix", workspaceRoot: testCwd, phase: "remediate", failedEvidenceRevision: revision }, cwd: testCwd } as unknown as TaskRequest;
|
|
92
|
-
const native = { sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, artifactStore: "openspec", planningHome: { mode: "repo-local", path: `${testCwd}/openspec` }, changeRoot: `${testCwd}/openspec/changes/fix`, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: [], nextRecommended: "remediate", changeName: "fix", actionContext: { mode: "repo-local", workspaceRoot: testCwd, allowedEditRoots: [testCwd] }, remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }), sddAttemptAcquire: async input => { calls.push(["acquire", input]); return { state: "proceed", token: "opaque-admitted" }; }, sddAttemptSettle: async input => { assert.equal(saved.at(-1).sddRemediation.settle.requestId, input.requestId); calls.push(["settle", structuredClone(input)]); if (calls.length === 2) throw new Error("lost reply"); return { state: "complete" as const }; } } as unknown as NativeReviewCli;
|
|
93
|
-
const admitted = await admitManagedRemediation(request, { plan, attempt: { requestId: "a", workUnit: "fix", evidenceGoal: "Observed correction", maxAttempts: 1, maxChangedLines: 200 } }, native, async task => { saved.push(structuredClone(task)); }, human, { id: "prepared", cwd: testCwd, agent: "sdd-remediate", status: "queued" } as unknown as TaskRecord);
|
|
94
|
-
const task = { id: "task", cwd: testCwd, status: "completed", sddRemediation: admitted.sddRemediation } as unknown as TaskRecord & { sddRemediation: typeof admitted.sddRemediation };
|
|
95
|
-
observed(task.sddRemediation, "pnpm test", "one"); observed(task.sddRemediation, "git diff --check", "two");
|
|
96
|
-
await admitted.finalizeRemediation(task, { spawned: true, exited: true, cleanupConfirmed: true });
|
|
97
|
-
assert.equal(calls.filter(([verb]) => verb === "acquire").length, 1);
|
|
98
|
-
assert.equal(calls.filter(([verb]) => verb === "settle").length, 2);
|
|
99
|
-
assert.deepEqual(calls[1], calls[2]);
|
|
100
|
-
assert.equal(calls[1][1].outcome, "passed");
|
|
101
|
-
assert.equal(calls[1][1].remediatesEvidenceRevision, revision);
|
|
102
|
-
assert.equal(JSON.parse(calls[1][1].remediationEvidence).failed_evidence_revision, revision);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
async function admissionFixture(overrides = {}, persist: (task: TaskRecord) => Promise<void> = async () => {}, agentPatch = {}, attemptPatch = {}, context: Pick<ExtensionContext, "hasUI" | "ui"> = human) {
|
|
107
|
-
const { admitManagedRemediation } = await import("../extensions/gentle-agents.ts");
|
|
108
|
-
const { parseAgentDefinition } = await import("../lib/agents-config.ts");
|
|
109
|
-
const { readFileSync } = await import("node:fs");
|
|
110
|
-
const { resolve } = await import("node:path");
|
|
111
|
-
const path = resolve("assets/agents/sdd-remediate.md");
|
|
112
|
-
const request = { agent: { ...parseAgentDefinition(readFileSync(path, "utf8"), path, "global"), ...agentPatch }, cwd: testCwd, sddChange: { changeName: "fix", workspaceRoot: testCwd, phase: "remediate", failedEvidenceRevision: revision } } as unknown as TaskRequest;
|
|
113
|
-
const calls = [];
|
|
114
|
-
const native = { sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, artifactStore: "openspec", planningHome: { mode: "repo-local", path: `${testCwd}/openspec` }, changeRoot: `${testCwd}/openspec/changes/fix`, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: [], nextRecommended: "remediate", changeName: "fix", actionContext: { mode: "repo-local", workspaceRoot: testCwd, allowedEditRoots: [testCwd] }, remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }), sddAttemptAcquire: async input => { calls.push(["acquire", input]); return { state: "proceed", token: "opaque" }; }, sddAttemptSettle: async input => { calls.push(["settle", input]); return { state: "proceed" as const }; }, ...overrides } as unknown as NativeReviewCli;
|
|
115
|
-
const admitted = await admitManagedRemediation(request, { plan, attempt: { requestId: "a", workUnit: "fix", evidenceGoal: "Observed correction", untrackedScope: "select", expectedUntrackedInventory: revision, intendedUntracked: ["new file.ts"], ...attemptPatch } }, native, persist, context, { id: "prepared", cwd: testCwd, agent: "sdd-remediate", status: "queued" } as unknown as TaskRecord);
|
|
116
|
-
return { admitted, calls, task: { id: "one", status: "failed", sddRemediation: admitted.sddRemediation } as unknown as TaskRecord & { sddRemediation: typeof admitted.sddRemediation } };
|
|
117
|
-
}
|
|
118
|
-
for (const state of ["blocked", "complete"]) test(`native ${state} refuses without settlement`, async () => {
|
|
119
|
-
let settles = 0;
|
|
120
|
-
await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => ({ state }), sddAttemptSettle: async () => { settles++; } }), new RegExp(state));
|
|
121
|
-
assert.equal(settles, 0);
|
|
122
|
-
});
|
|
123
|
-
for (const status of ["failed", "cancelled", "timed_out"] as const) test(`terminal ${status} settles without passing evidence and retains exact untracked scope`, async () => {
|
|
124
|
-
const { admitted, calls, task } = await admissionFixture(); task.status = status;
|
|
125
|
-
await admitted.finalizeRemediation(task, { spawned: true, exited: true, cleanupConfirmed: true });
|
|
126
|
-
const payload = calls[1][1];
|
|
127
|
-
assert.equal(payload.outcome, status === "failed" ? "failed" : "interrupted");
|
|
128
|
-
assert.equal(payload.remediationEvidence, undefined);
|
|
129
|
-
assert.equal(typeof payload.evidenceRevision, status === "failed" ? "string" : "undefined");
|
|
130
|
-
assert.deepEqual(payload.intendedUntracked, calls[0][1].intendedUntracked);
|
|
131
|
-
assert.equal(payload.expectedUntrackedInventory, revision);
|
|
132
|
-
});
|
|
133
|
-
test("failure to retain settlement never mutates native; two lost replies retain uncertainty", async () => {
|
|
134
|
-
const failed = await admissionFixture({}, async task => { if (task.sddRemediation.settle) throw new Error("disk failure"); });
|
|
135
|
-
await assert.rejects(failed.admitted.finalizeRemediation(failed.task, { spawned: false, exited: false, cleanupConfirmed: true }), /disk failure/);
|
|
136
|
-
assert.equal(failed.calls.length, 1);
|
|
137
|
-
let replies = 0;
|
|
138
|
-
const lost = await admissionFixture({ sddAttemptSettle: async () => { replies++; throw new Error("lost"); } });
|
|
139
|
-
await lost.admitted.finalizeRemediation(lost.task, { spawned: false, exited: false, cleanupConfirmed: true });
|
|
140
|
-
assert.equal(replies, 2); assert.equal(lost.task.sddRemediation.settlementUncertain, true);
|
|
141
|
-
assert.equal(lost.task.sddRemediation.settle.outcome, "interrupted");
|
|
142
|
-
});
|
|
143
|
-
test("runtime harness requires its own observed command; malformed content cannot pass", () => {
|
|
144
|
-
const s = state(); s.plan.runtimeHarness = { command: "pnpm run harness" };
|
|
145
|
-
observed(s, "pnpm test", "test"); observed(s, "git diff --check", "rollback");
|
|
146
|
-
assert.equal(remediationEvidence(s), undefined);
|
|
147
|
-
observed(s, "pnpm run harness", "harness"); assert.equal(remediationEvidence(s).runtime_harness.status, "passed");
|
|
148
|
-
const malformed = state();
|
|
149
|
-
observeRemediationTool(malformed, { type: "tool_execution_start", toolName: "bash", toolCallId: "a", args: { command: "pnpm test" } });
|
|
150
|
-
observeRemediationTool(malformed, { type: "tool_execution_end", toolName: "bash", toolCallId: "a", isError: false, result: { content: [null], details: { remediationCommand: { toolCallId: "a", command: "pnpm test", cwd: testCwd, exitCode: 0 } } } });
|
|
151
|
-
observed(malformed, "git diff --check", "b"); assert.equal(remediationEvidence(malformed), undefined);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
46
|
|
|
155
47
|
test("stock local shell wrapper observes real exit zero and preserves cancellation", async () => {
|
|
156
48
|
const { remediationBash } = await import("../extensions/gentle-agents.ts");
|
|
@@ -170,88 +62,6 @@ test("stock local shell wrapper observes real exit zero and preserves cancellati
|
|
|
170
62
|
});
|
|
171
63
|
|
|
172
64
|
|
|
173
|
-
test("nonzero observed exits remain concrete failure evidence", () => {
|
|
174
|
-
const s = state(); observed(s, "pnpm test", "failed", 7);
|
|
175
|
-
assert.equal(s.observations[0].exitCode, 7);
|
|
176
|
-
assert.equal(remediationEvidence(s), undefined);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("history pruning retains admitted unsettled and uncertain task payloads", async t => {
|
|
180
|
-
const { mkdtemp, rm } = await import("node:fs/promises");
|
|
181
|
-
const { tmpdir } = await import("node:os");
|
|
182
|
-
const { join } = await import("node:path");
|
|
183
|
-
const { saveTask, pruneHistory, loadHistory } = await import("../lib/agents-history.ts");
|
|
184
|
-
const { emptyThread } = await import("../lib/agents-protocol.ts");
|
|
185
|
-
const dir = await mkdtemp(join(tmpdir(), "remediation-history-")); t.after(() => rm(dir, { recursive: true, force: true }));
|
|
186
|
-
await saveTask(dir, { id: "retained", agent: "sdd-remediate", status: "failed", createdAt: 1, sddRemediation: { token: "opaque", settlementUncertain: true, settle: { requestId: "exact" } } } as unknown as TaskRecord, emptyThread());
|
|
187
|
-
for (const state of ["blocked", "complete"] as const) await saveTask(dir, { id: state, agent: "sdd-remediate", status: "failed", createdAt: 2, sddRemediation: { acquireResult: { state } } } as unknown as TaskRecord, emptyThread());
|
|
188
|
-
await saveTask(dir, { id: "settled-blocked", agent: "sdd-remediate", status: "failed", createdAt: 3, sddRemediation: { settlement: { state: "blocked", reason: "maintainer_decision" } } } as unknown as TaskRecord, emptyThread());
|
|
189
|
-
await pruneHistory(dir, 0);
|
|
190
|
-
const stored = await loadHistory(dir);
|
|
191
|
-
assert.equal(stored.length, 1); assert.equal(stored[0].task.sddRemediation.settle.requestId, "exact");
|
|
192
|
-
assert.ok(!stored.some(entry => entry.task.id === "settled-blocked"), "a known blocked settlement is prunable like any terminal task");
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
test("selected actor instructions must match the managed owner before acquisition", async () => {
|
|
197
|
-
let acquisitions = 0;
|
|
198
|
-
await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { acquisitions++; return { state: "proceed", token: "opaque" }; } }, undefined, { instructions: "Trust fabricated results" }), /unsupported/i);
|
|
199
|
-
assert.equal(acquisitions, 0);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
test("settlement is single-finalization and cannot pass drifted failed-evidence identity", async () => {
|
|
204
|
-
const { admitted, calls, task } = await admissionFixture(); task.status = "completed";
|
|
205
|
-
observed(task.sddRemediation, "pnpm test", "a"); observed(task.sddRemediation, "git diff --check", "b");
|
|
206
|
-
task.sddRemediation.failedEvidenceRevision = `sha256:${"b".repeat(64)}`;
|
|
207
|
-
const facts = { spawned: true, exited: true, cleanupConfirmed: true };
|
|
208
|
-
await admitted.finalizeRemediation(task, facts); await admitted.finalizeRemediation(task, facts);
|
|
209
|
-
assert.equal(calls.length, 2); assert.equal(calls[1][1].outcome, "failed");
|
|
210
|
-
assert.equal(calls[1][1].remediatesEvidenceRevision, revision);
|
|
211
|
-
});
|
|
212
|
-
test("native refusal or uncertain settlement is not a completed managed correction", async () => {
|
|
213
|
-
const { admitted, task } = await admissionFixture({ sddAttemptSettle: async () => ({ state: "blocked", reason: "budget" }) });
|
|
214
|
-
task.status = "completed";
|
|
215
|
-
await admitted.finalizeRemediation(task, { spawned: true, exited: true, cleanupConfirmed: true });
|
|
216
|
-
assert.equal(task.status, "failed");
|
|
217
|
-
assert.match(task.error, /settlement/);
|
|
218
|
-
});
|
|
219
|
-
test("a received blocked settlement names the block; a lost settlement reply retains unresolved history", async () => {
|
|
220
|
-
const known = await admissionFixture({ sddAttemptSettle: async () => ({ state: "blocked", reason: "maintainer_decision" }) });
|
|
221
|
-
known.task.status = "completed";
|
|
222
|
-
await known.admitted.finalizeRemediation(known.task, { spawned: true, exited: true, cleanupConfirmed: true });
|
|
223
|
-
assert.equal(known.task.status, "failed");
|
|
224
|
-
assert.match(known.task.error, /blocked\(maintainer_decision\)/);
|
|
225
|
-
assert.doesNotMatch(known.task.error, /unresolved/);
|
|
226
|
-
assert.equal(remediationUnresolved(known.task), false);
|
|
227
|
-
|
|
228
|
-
const unspecified = await admissionFixture({ sddAttemptSettle: async () => ({ state: "blocked" }) });
|
|
229
|
-
unspecified.task.status = "completed";
|
|
230
|
-
await unspecified.admitted.finalizeRemediation(unspecified.task, { spawned: true, exited: true, cleanupConfirmed: true });
|
|
231
|
-
assert.equal(unspecified.task.status, "failed");
|
|
232
|
-
assert.match(unspecified.task.error, /blocked\(unspecified\)/, "a settlement without a reason is distinguishable from one whose reason is literally blocked");
|
|
233
|
-
assert.doesNotMatch(unspecified.task.error, /unresolved/);
|
|
234
|
-
|
|
235
|
-
let attempts = 0;
|
|
236
|
-
const uncertain = await admissionFixture({ sddAttemptSettle: async () => { attempts++; throw new Error("lost reply"); } });
|
|
237
|
-
uncertain.task.status = "completed";
|
|
238
|
-
await uncertain.admitted.finalizeRemediation(uncertain.task, { spawned: true, exited: true, cleanupConfirmed: true });
|
|
239
|
-
assert.equal(attempts, 2);
|
|
240
|
-
assert.equal(uncertain.task.status, "failed");
|
|
241
|
-
assert.match(uncertain.task.error, /unresolved/);
|
|
242
|
-
assert.equal(remediationUnresolved(uncertain.task), true);
|
|
243
|
-
});
|
|
244
|
-
test("remediationUnresolved treats a received settlement as terminal, regardless of state, unless uncertain", () => {
|
|
245
|
-
// A terminal acquire result keeps the final branch false, so the token and
|
|
246
|
-
// actor-claim assertions below discriminate on those fields alone.
|
|
247
|
-
const base = { acquire: {}, acquireResult: { state: "complete" } } as unknown as TaskRecord["sddRemediation"];
|
|
248
|
-
assert.equal(remediationUnresolved({ sddRemediation: { ...base } } as unknown as TaskRecord), false);
|
|
249
|
-
assert.equal(remediationUnresolved({ sddRemediation: { ...base, settlement: { state: "blocked", reason: "maintainer_decision" } } } as unknown as TaskRecord), false);
|
|
250
|
-
assert.equal(remediationUnresolved({ sddRemediation: { ...base, settlement: { state: "blocked", reason: "maintainer_decision" }, settlementUncertain: true } } as unknown as TaskRecord), true);
|
|
251
|
-
assert.equal(remediationUnresolved({ sddRemediation: { ...base, actorClaimed: true } } as unknown as TaskRecord), true);
|
|
252
|
-
assert.equal(remediationUnresolved({ sddRemediation: { ...base, token: "retained" } } as unknown as TaskRecord), true);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
65
|
|
|
256
66
|
test("remediation actor preserves separately authorized memory artifact tools", async () => {
|
|
257
67
|
const { parseAgentDefinition } = await import("../lib/agents-config.ts");
|
|
@@ -262,25 +72,6 @@ test("remediation actor preserves separately authorized memory artifact tools",
|
|
|
262
72
|
});
|
|
263
73
|
|
|
264
74
|
|
|
265
|
-
test("managed parent admission continues an explicitly retained token without a new acquire", async () => {
|
|
266
|
-
const { calls } = await admissionFixture({}, undefined, {}, { token: "retained-parent-admission" });
|
|
267
|
-
assert.equal(calls.length, 1);
|
|
268
|
-
assert.equal(calls[0][1].token, "retained-parent-admission");
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
test("R3-duplicate-command-evidence requires a distinct execution for every indexed slot", () => {
|
|
273
|
-
const s = state(); s.plan.commands = ["pnpm test", "pnpm test"];
|
|
274
|
-
s.plan.runtimeHarness = { command: "pnpm test" }; s.plan.rollback.command = "pnpm test";
|
|
275
|
-
for (let index = 0; index < 4; index++) {
|
|
276
|
-
observed(s, "pnpm test", `slot-${index}`);
|
|
277
|
-
assert.equal(remediationEvidence(s) === undefined, index < 3);
|
|
278
|
-
}
|
|
279
|
-
assert.deepEqual(s.observations.map(item => item.slot), [0, 1, 2, 3]);
|
|
280
|
-
observed(s, "pnpm test", "slot-0");
|
|
281
|
-
assert.equal(remediationEvidence(s), undefined, "reused call identity cannot produce evidence");
|
|
282
|
-
});
|
|
283
|
-
|
|
284
75
|
|
|
285
76
|
test("R1 confirms exact canonical paths and commands; data, denial and symlinks grant nothing", async t => {
|
|
286
77
|
const { confirmRemediationScope, remediationToolAllowed } = await import("../extensions/gentle-agents.ts");
|
|
@@ -303,32 +94,7 @@ test("R1 confirms exact canonical paths and commands; data, denial and symlinks
|
|
|
303
94
|
});
|
|
304
95
|
|
|
305
96
|
|
|
306
|
-
test("R3/R4 acquire request is durable before mutation and token before actor admission", async () => {
|
|
307
|
-
const records = [], calls = [];
|
|
308
|
-
const { admitted } = await admissionFixture({ sddAttemptAcquire: async request => {
|
|
309
|
-
assert.deepEqual(records.at(-1).sddRemediation.acquire, request);
|
|
310
|
-
assert.equal(records.at(-1).sddRemediation.token, undefined);
|
|
311
|
-
calls.push(structuredClone(request)); if (calls.length === 1) throw new Error("lost acquire reply");
|
|
312
|
-
return { state: "proceed", token: "native-returned" };
|
|
313
|
-
} }, async task => { records.push(structuredClone(task)); });
|
|
314
|
-
assert.equal(calls.length, 2); assert.deepEqual(calls[0], calls[1]);
|
|
315
|
-
assert.equal(records.at(-1).sddRemediation.token, "native-returned");
|
|
316
|
-
assert.equal(admitted.sddRemediation.token, "native-returned");
|
|
317
|
-
});
|
|
318
|
-
test("R3/R4 acquire persistence failure refuses before native mutation", async () => {
|
|
319
|
-
let mutations = 0;
|
|
320
|
-
await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { mutations++; return { state: "proceed", token: "unsafe" }; } }, async () => { throw new Error("disk full"); }), /disk full/);
|
|
321
|
-
assert.equal(mutations, 0);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
97
|
|
|
325
|
-
test("R1 denial/headless/cancellation gives zero acquisition and durable mutation", async () => {
|
|
326
|
-
for (const context of [{ hasUI: false }, { hasUI: true, ui: { confirm: async () => false } }, { hasUI: true, ui: { confirm: async () => undefined } }, { hasUI: true, ui: { confirm: async () => { throw new Error("cancelled"); } } }]) {
|
|
327
|
-
let acquisitions = 0, persistence = 0;
|
|
328
|
-
await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { acquisitions++; } }, async () => { persistence++; }, {}, {}, context as unknown as Pick<ExtensionContext, "hasUI" | "ui">));
|
|
329
|
-
assert.equal(acquisitions, 0); assert.equal(persistence, 0);
|
|
330
|
-
}
|
|
331
|
-
});
|
|
332
98
|
test("R1 child invokes only the exact confirmed command/cwd/count with distinct call IDs", async () => {
|
|
333
99
|
const { remediationBash } = await import("../extensions/gentle-agents.ts");
|
|
334
100
|
let executions = 0;
|
|
@@ -341,103 +107,49 @@ test("R1 child invokes only the exact confirmed command/cwd/count with distinct
|
|
|
341
107
|
await execute("two", "pnpm test"); await assert.rejects(execute("three", "pnpm test"), /authorization/);
|
|
342
108
|
assert.equal(executions, 2);
|
|
343
109
|
});
|
|
344
|
-
test("R3/R4 known non-mutating acquire failures retain retryable blocked history while ambiguity remains unresolved", async () => {
|
|
345
|
-
for (const error of [new TypeError("invalid acquire request"), new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, NATIVE_REVIEW_OPERATION.SDD_ATTEMPT, true, false, "native launch unavailable")]) {
|
|
346
|
-
let calls = 0; const retained = [];
|
|
347
|
-
await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { calls++; throw error; } }, async task => { retained.push(structuredClone(task)); }), error);
|
|
348
|
-
assert.equal(calls, 1);
|
|
349
|
-
assert.deepEqual(retained.at(-1).sddRemediation.acquireResult, { state: "blocked" });
|
|
350
|
-
assert.equal(remediationUnresolved(retained.at(-1)), false);
|
|
351
|
-
}
|
|
352
|
-
let calls = 0; const retained = [];
|
|
353
|
-
await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { calls++; throw new Error("lost reply"); } }, async task => { retained.push(structuredClone(task)); }), /Unknown acquire/);
|
|
354
|
-
assert.equal(calls, 2); assert.equal(retained.at(-1).sddRemediation.acquireUncertain, true);
|
|
355
|
-
assert.equal(remediationUnresolved(retained.at(-1)), true);
|
|
356
|
-
assert.deepEqual(retained[0].sddRemediation.acquire, retained.at(-1).sddRemediation.acquire);
|
|
357
|
-
calls = 0;
|
|
358
|
-
await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { calls++; return { state: "proceed", token: "returned" }; } }, async task => { if (task.sddRemediation.token) throw new Error("token disk failure"); }), /token disk failure/);
|
|
359
|
-
assert.equal(calls, 1);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
test("uncertain acquire reconciliation terminalizes blocked/complete exact replays", async () => {
|
|
363
|
-
const { reconcileManagedRemediation } = await import("../extensions/gentle-agents.ts");
|
|
364
|
-
for (const nativeState of ["blocked", "complete"] as const) {
|
|
365
|
-
const acquire = { workspaceRoot: testCwd, changeName: "fix", requestId: `reconcile-${nativeState}`, workUnit: "fix", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision };
|
|
366
|
-
const task = { id: nativeState, agent: "sdd-remediate", cwd: testCwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as unknown as TaskRecord;
|
|
367
|
-
const calls = [], saved = [];
|
|
368
|
-
const result = await reconcileManagedRemediation(task, { sddAttemptAcquire: async input => { calls.push(structuredClone(input)); return { state: nativeState }; } } as unknown as NativeReviewCli, async current => { saved.push(structuredClone(current)); });
|
|
369
|
-
assert.deepEqual(calls, [acquire]);
|
|
370
|
-
assert.equal(result.acquireState, nativeState);
|
|
371
|
-
assert.equal(task.sddRemediation.acquireUncertain, undefined);
|
|
372
|
-
assert.deepEqual(task.sddRemediation.acquireResult, { state: nativeState });
|
|
373
|
-
assert.equal(remediationUnresolved(task), false);
|
|
374
|
-
assert.ok(saved.length >= 1);
|
|
375
|
-
}
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
test("recovered proceed is durably settled as interrupted without actor replay", async () => {
|
|
379
|
-
const { reconcileManagedRemediation } = await import("../extensions/gentle-agents.ts");
|
|
380
|
-
const acquire = { workspaceRoot: testCwd, changeName: "fix", requestId: "reconcile-proceed", workUnit: "fix", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision, untrackedScope: "exclude" as const };
|
|
381
|
-
const task = { id: "proceed", agent: "sdd-remediate", cwd: testCwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as unknown as TaskRecord;
|
|
382
|
-
const saved = [], calls = [];
|
|
383
|
-
const result = await reconcileManagedRemediation(task, {
|
|
384
|
-
sddAttemptAcquire: async input => { calls.push(["acquire", structuredClone(input)]); return { state: "proceed", token: "secret-token" }; },
|
|
385
|
-
sddAttemptSettle: async input => {
|
|
386
|
-
calls.push(["settle", structuredClone(input)]);
|
|
387
|
-
assert.deepEqual(saved.at(-1).sddRemediation.settle, input, "settle intent is durable before mutation");
|
|
388
|
-
return { state: "complete" };
|
|
389
|
-
},
|
|
390
|
-
} as unknown as NativeReviewCli, async current => { saved.push(structuredClone(current)); });
|
|
391
|
-
assert.equal(result.acquireState, "proceed"); assert.equal(result.settlementState, "complete");
|
|
392
|
-
assert.deepEqual(calls[0], ["acquire", acquire]);
|
|
393
|
-
assert.equal(calls[1][1].outcome, "interrupted");
|
|
394
|
-
assert.equal(calls[1][1].token, "secret-token");
|
|
395
|
-
assert.match(calls[1][1].requestId, /^reconcile-[a-f0-9]{32}$/);
|
|
396
|
-
assert.equal(calls[1][1].remediatesEvidenceRevision, revision);
|
|
397
|
-
assert.equal(calls[1][1].untrackedScope, "exclude");
|
|
398
|
-
assert.ok(saved.filter(snapshot => snapshot.sddRemediation.token).every(snapshot => snapshot.sddRemediation.settle), "no durable checkpoint may retain recovered authority without its compensating settle request");
|
|
399
|
-
assert.equal(task.sddRemediation.actorClaimed, undefined);
|
|
400
|
-
assert.equal(task.sddRemediation.acquireUncertain, undefined);
|
|
401
|
-
assert.equal(task.sddRemediation.settlementUncertain, undefined);
|
|
402
|
-
assert.equal(remediationUnresolved(task), false);
|
|
403
|
-
assert.equal(JSON.stringify(result).includes("secret-token"), false);
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
test("reconciliation retries only exact pending mutations and preserves uncertainty", async () => {
|
|
407
|
-
const { reconcileManagedRemediation } = await import("../extensions/gentle-agents.ts");
|
|
408
|
-
const acquire = { workspaceRoot: testCwd, changeName: "fix", requestId: "reconcile-lost", workUnit: "fix", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision };
|
|
409
|
-
const acquireTask = { id: "lost-acquire", agent: "sdd-remediate", cwd: testCwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as unknown as TaskRecord;
|
|
410
|
-
let acquireCalls = 0;
|
|
411
|
-
await assert.rejects(reconcileManagedRemediation(acquireTask, { sddAttemptAcquire: async input => { acquireCalls++; assert.deepEqual(input, acquire); throw new Error("lost reply"); } } as unknown as NativeReviewCli, async () => {}), /acquire.*unresolved/i);
|
|
412
|
-
assert.equal(acquireCalls, 2); assert.equal(acquireTask.sddRemediation.acquireUncertain, true);
|
|
413
|
-
|
|
414
|
-
const settle = { workspaceRoot: testCwd, changeName: "fix", token: "retained", requestId: "settle-exact", outcome: "interrupted" as const, diagnosis: "Interrupted", harnessDisposition: "invalidated" as const, cleanupEvidence: "None", processEvidence: "None", remediatesEvidenceRevision: revision };
|
|
415
|
-
const settleTask = { id: "lost-settle", agent: "sdd-remediate", cwd: testCwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireResult: { state: "proceed", token: "retained" }, token: "retained", settle, settlementUncertain: true } } as unknown as TaskRecord;
|
|
416
|
-
const settleCalls = [];
|
|
417
|
-
const result = await reconcileManagedRemediation(settleTask, { sddAttemptSettle: async input => { settleCalls.push(structuredClone(input)); return { state: "blocked", reason: "already_closed" }; } } as unknown as NativeReviewCli, async () => {});
|
|
418
|
-
assert.deepEqual(settleCalls, [settle]); assert.equal(result.settlementState, "blocked");
|
|
419
|
-
assert.equal(settleTask.sddRemediation.settlementUncertain, undefined);
|
|
420
|
-
assert.equal(remediationUnresolved(settleTask), false);
|
|
421
|
-
|
|
422
|
-
const ambiguousSettle = { id: "ambiguous-settle", agent: "sdd-remediate", cwd: testCwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireResult: { state: "proceed", token: "retained" }, token: "retained", settle, settlementUncertain: true } } as unknown as TaskRecord;
|
|
423
|
-
let ambiguousCalls = 0; const ambiguousSaved = [];
|
|
424
|
-
await assert.rejects(reconcileManagedRemediation(ambiguousSettle, { sddAttemptSettle: async input => { ambiguousCalls++; assert.deepEqual(input, settle); throw new Error("lost settle reply"); } } as unknown as NativeReviewCli, async current => { ambiguousSaved.push(structuredClone(current)); }), /settlement.*unresolved/i);
|
|
425
|
-
assert.equal(ambiguousCalls, 2);
|
|
426
|
-
assert.equal(ambiguousSettle.sddRemediation.settlementUncertain, true);
|
|
427
|
-
assert.deepEqual(ambiguousSaved.at(-1).sddRemediation.settle, settle);
|
|
428
|
-
assert.equal(remediationUnresolved(ambiguousSettle), true);
|
|
429
|
-
|
|
430
|
-
const actorTask = { id: "actor", agent: "sdd-remediate", cwd: testCwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true, actorClaimed: true } } as unknown as TaskRecord;
|
|
431
|
-
await assert.rejects(reconcileManagedRemediation(actorTask, {} as NativeReviewCli, async () => {}), /actor.*uncertain/i);
|
|
432
|
-
const mismatchedSettle = structuredClone(settle); mismatchedSettle.changeName = "other";
|
|
433
|
-
const corruptTask = { id: "corrupt", agent: "sdd-remediate", cwd: testCwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireResult: { state: "proceed", token: "retained" }, token: "retained", settle: mismatchedSettle, settlementUncertain: true } } as unknown as TaskRecord;
|
|
434
|
-
await assert.rejects(reconcileManagedRemediation(corruptTask, { sddAttemptSettle: async () => { throw new Error("must not call"); } } as unknown as NativeReviewCli, async () => {}), /does not match/);
|
|
435
|
-
});
|
|
436
|
-
|
|
437
110
|
|
|
438
111
|
test("remediation actor explains one-launch human authority and indexed command evidence", async () => {
|
|
439
112
|
const { readFileSync } = await import("node:fs");
|
|
440
113
|
const text = readFileSync("assets/agents/sdd-remediate.md", "utf8");
|
|
441
114
|
assert.match(text, /fresh host UI confirmation/); assert.match(text, /each repeated command.*separate execution/);
|
|
442
|
-
assert.match(text, /
|
|
115
|
+
assert.match(text, /Inspect prior task\/artifact history/);
|
|
116
|
+
assert.match(text, /No attempt-ledger command is required/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
// Runs only in the explicit producer verification lane; no default-suite skip is added.
|
|
121
|
+
if (process.env.SDD_TEST_PRODUCER) test("real native selection admits only its own remediation route without attempt subprocesses", async (t) => {
|
|
122
|
+
const { execFileSync } = await import("node:child_process");
|
|
123
|
+
const { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, realpathSync } = await import("node:fs");
|
|
124
|
+
const { tmpdir } = await import("node:os");
|
|
125
|
+
const { join, resolve } = await import("node:path");
|
|
126
|
+
const { NativeReviewCliV216, createNodeExecFileAdapter } = await import("../lib/native-review-cli.ts");
|
|
127
|
+
const { admitManagedRemediation } = await import("../extensions/gentle-agents.ts");
|
|
128
|
+
const { parseAgentDefinition } = await import("../lib/agents-config.ts");
|
|
129
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "no-attempt-producer-")));
|
|
130
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
131
|
+
execFileSync("git", ["init", "--quiet", cwd]);
|
|
132
|
+
const change = join(cwd, "openspec/changes/fix");
|
|
133
|
+
mkdirSync(join(change, "specs/example"), { recursive: true });
|
|
134
|
+
for (const [path, bytes] of Object.entries({ "proposal.md": "# Proposal\n", "design.md": "# Design\n", "specs/example/spec.md": "# Spec\n", "tasks.md": "- [x] Fixture complete\n" })) writeFileSync(join(change, path), bytes);
|
|
135
|
+
const revision = `sha256:${"a".repeat(64)}`;
|
|
136
|
+
writeFileSync(join(change, "verify-report.md"), ["```yaml", "schema: gentle-ai.verify-result/v1", `evidence_revision: ${revision}`, "verdict: fail", "blockers: 1", "critical_findings: 0", "requirements: 1/1", "scenarios: 1/1", "test_command: false", "test_exit_code: 1", `test_output_hash: sha256:${"b".repeat(64)}`, "build_command: true", "build_exit_code: 0", `build_output_hash: sha256:${"b".repeat(64)}`, "```"].join("\n"));
|
|
137
|
+
const invocations: string[] = [], adapter = createNodeExecFileAdapter();
|
|
138
|
+
const native = new NativeReviewCliV216(async (request) => { invocations.push(request.arguments[0]!); return adapter(request); }, process.env.SDD_TEST_PRODUCER!);
|
|
139
|
+
const status = await native.sddStatus({ workspaceRoot: cwd, changeName: "fix" });
|
|
140
|
+
const path = resolve("assets/agents/sdd-remediate.md"), agent = parseAgentDefinition(readFileSync(path, "utf8"), path, "global");
|
|
141
|
+
assert.ok("instructions" in agent);
|
|
142
|
+
let confirmations = 0;
|
|
143
|
+
const context = { hasUI: true, ui: { confirm: async () => { confirmations++; return true; } } } as unknown as Pick<ExtensionContext, "hasUI" | "ui">;
|
|
144
|
+
const request = { agent, cwd, sddChange: { changeName: "fix", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision } } as import("../lib/agents-runner.ts").TaskRequest;
|
|
145
|
+
const admission = admitManagedRemediation(request, { plan: { ...plan, cwd } }, native, context);
|
|
146
|
+
if (status.nextRecommended === "remediate") {
|
|
147
|
+
const admitted = await admission;
|
|
148
|
+
assert.equal(admitted.sddRemediation?.failedEvidenceRevision, revision);
|
|
149
|
+
assert.equal(confirmations, 1);
|
|
150
|
+
} else {
|
|
151
|
+
await assert.rejects(admission, /Stale remediation selection/);
|
|
152
|
+
assert.equal(confirmations, 0, "a classical provider is not assigned a fabricated remediation route");
|
|
153
|
+
}
|
|
154
|
+
assert.deepEqual(invocations, ["sdd-status", "sdd-status"]);
|
|
443
155
|
});
|