gentle-pi 2.4.0 → 2.6.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 +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { AGENT_MODE, type AgentDefinition } from "../lib/agents-config.ts";
|
|
8
|
+
import { AgentRunner, type TaskRequest } from "../lib/agents-runner.ts";
|
|
9
|
+
import { TaskStore } from "../lib/agents-protocol.ts";
|
|
10
|
+
import { createNodeExecFileAdapter, NativeReviewCliV216, decodeNativeSddStatusV2, NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError } from "../lib/native-review-cli.ts";
|
|
11
|
+
import { createGentleAiExtension, __testing } from "../extensions/gentle-ai.ts";
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { NativeReviewCli, NativeSddStatusV2 } from "../lib/native-review-cli.ts";
|
|
14
|
+
import { ensureSddPreflight } from "../lib/sdd-preflight.ts";
|
|
15
|
+
import { fakeChild } from "./agents-fake-child.ts";
|
|
16
|
+
|
|
17
|
+
const applyAgent: AgentDefinition = {
|
|
18
|
+
name: "sdd-apply",
|
|
19
|
+
description: "Apply the selected change.",
|
|
20
|
+
filePath: "/agents/sdd-apply.md",
|
|
21
|
+
scope: "global",
|
|
22
|
+
instructions: "SDD apply executor",
|
|
23
|
+
model: undefined,
|
|
24
|
+
thinking: undefined,
|
|
25
|
+
mode: undefined,
|
|
26
|
+
tools: ["read"],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function request(sddChange: { changeName: string; workspaceRoot: string; phase: "apply" }): TaskRequest {
|
|
30
|
+
return {
|
|
31
|
+
agent: applyAgent,
|
|
32
|
+
prompt: "Apply selected change.",
|
|
33
|
+
label: undefined,
|
|
34
|
+
context: undefined,
|
|
35
|
+
mode: AGENT_MODE.BACKGROUND,
|
|
36
|
+
cwd: sddChange.workspaceRoot,
|
|
37
|
+
parentSessionId: "parent",
|
|
38
|
+
model: undefined,
|
|
39
|
+
thinking: undefined,
|
|
40
|
+
sessionDir: "/sessions",
|
|
41
|
+
resumeSessionPath: undefined,
|
|
42
|
+
env: {},
|
|
43
|
+
sddChange,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
48
|
+
|
|
49
|
+
function workspace(t: test.TestContext): string {
|
|
50
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-sdd-selection-"));
|
|
51
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
52
|
+
mkdirSync(join(root, "openspec", "changes", "alpha"), { recursive: true });
|
|
53
|
+
mkdirSync(join(root, "openspec", "changes", "beta"), { recursive: true });
|
|
54
|
+
return realpathSync(root);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("remediation read tools are limited to canonical relative paths inside the confirmed worktree", async (t) => {
|
|
58
|
+
const { remediationToolAllowed } = await import("../extensions/gentle-agents.ts");
|
|
59
|
+
const cwd = workspace(t), outside = mkdtempSync(join(tmpdir(), "gentle-pi-remediation-outside-"));
|
|
60
|
+
t.after(() => rmSync(outside, { recursive: true, force: true }));
|
|
61
|
+
symlinkSync(outside, join(cwd, "escape"));
|
|
62
|
+
const scope = { cwd, editPaths: [], commands: ["pnpm test"], allowedEditRoots: [cwd] };
|
|
63
|
+
for (const tool of ["read", "grep", "find"]) {
|
|
64
|
+
assert.equal(remediationToolAllowed(scope, cwd, tool, {}), true, `${tool} defaults to cwd`);
|
|
65
|
+
assert.equal(remediationToolAllowed(scope, cwd, tool, { path: "openspec" }), true);
|
|
66
|
+
for (const path of [join(cwd, "openspec"), "../outside", "escape", "", 7]) assert.equal(remediationToolAllowed(scope, cwd, tool, { path }), false, `${tool} denies malformed or escaping paths`);
|
|
67
|
+
}
|
|
68
|
+
assert.equal(remediationToolAllowed(scope, cwd, "write", { path: "openspec" }), false);
|
|
69
|
+
assert.equal(remediationToolAllowed(scope, cwd, "bash", { command: "pnpm test" }), true);
|
|
70
|
+
assert.equal(remediationToolAllowed(scope, cwd, "subagent_parent_message", {}), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("selected SDD change snapshots at task construction and reaches child startup in a multi-change workspace", async (t) => {
|
|
74
|
+
const root = workspace(t);
|
|
75
|
+
const selection = { changeName: "alpha", workspaceRoot: root, phase: "apply" as const };
|
|
76
|
+
const spawned: string[][] = [];
|
|
77
|
+
const runner = new AgentRunner(new TaskStore(), { maxConcurrency: 2, stallTimeoutMs: 1_000 }, {
|
|
78
|
+
spawn: (_command, args) => {
|
|
79
|
+
spawned.push(args);
|
|
80
|
+
return fakeChild().child;
|
|
81
|
+
},
|
|
82
|
+
now: () => 1,
|
|
83
|
+
schedule: () => () => {},
|
|
84
|
+
pi: { command: "pi", args: [] },
|
|
85
|
+
}, { askUser: async () => ({ cancelled: true }) });
|
|
86
|
+
|
|
87
|
+
runner.run(request(selection));
|
|
88
|
+
selection.changeName = "beta";
|
|
89
|
+
runner.run(request({ changeName: "beta", workspaceRoot: root, phase: "apply" }));
|
|
90
|
+
await tick();
|
|
91
|
+
const serialized = spawned[0]![spawned[0]!.indexOf("--gentle-sdd-change") + 1]!;
|
|
92
|
+
const concurrent = spawned[1]![spawned[1]!.indexOf("--gentle-sdd-change") + 1]!;
|
|
93
|
+
assert.deepEqual(JSON.parse(serialized), { changeName: "alpha", workspaceRoot: root, phase: "apply" });
|
|
94
|
+
assert.deepEqual(JSON.parse(concurrent), { changeName: "beta", workspaceRoot: root, phase: "apply" });
|
|
95
|
+
const startup = __testing.resolveSddChangeStartup(serialized, root, "sdd-apply");
|
|
96
|
+
assert.equal(startup.status.changeName, "alpha");
|
|
97
|
+
assert.equal(startup.status.nextRecommended, "sdd-propose");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("selected native v2 archive authority is injected whole and never falls back to the local resolver", async (t) => {
|
|
101
|
+
const root = workspace(t);
|
|
102
|
+
const serialized = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "archive" });
|
|
103
|
+
const nativeAuthority = {
|
|
104
|
+
schemaName: "gentle-ai.sdd-status",
|
|
105
|
+
schemaVersion: 2,
|
|
106
|
+
changeName: "alpha",
|
|
107
|
+
artifactStore: "openspec",
|
|
108
|
+
planningHome: { mode: "repo-local", path: join(root, "openspec") },
|
|
109
|
+
changeRoot: join(root, "openspec/changes/alpha"),
|
|
110
|
+
actionContext: { mode: "repo-local", workspaceRoot: root, allowedEditRoots: [root] },
|
|
111
|
+
dependencies: { proposal: "all_done", specs: "all_done", design: "all_done", tasks: "all_done", apply: "all_done", verify: "all_done", archive: "ready" },
|
|
112
|
+
phaseInstructions: { apply: ["done"], verify: ["done"], remediate: ["failed evidence"], archive: ["archive now"] },
|
|
113
|
+
blockedReasons: [],
|
|
114
|
+
nextRecommended: "archive",
|
|
115
|
+
};
|
|
116
|
+
const nativeCalls: unknown[] = [];
|
|
117
|
+
let localResolverCalls = 0;
|
|
118
|
+
const startup = await (__testing as unknown as {
|
|
119
|
+
resolveSelectedNativeSddChangeStartup(
|
|
120
|
+
serialized: unknown,
|
|
121
|
+
cwd: string,
|
|
122
|
+
agentName: string,
|
|
123
|
+
native: { sddStatus?: (request: unknown) => Promise<unknown> },
|
|
124
|
+
localResolver: () => unknown,
|
|
125
|
+
): Promise<{ selection: { changeName: string; workspaceRoot: string; phase: string }; status: unknown }>;
|
|
126
|
+
}).resolveSelectedNativeSddChangeStartup(serialized, root, "sdd-archive", {
|
|
127
|
+
sddStatus: async (request) => { nativeCalls.push(request); return nativeAuthority; },
|
|
128
|
+
}, () => {
|
|
129
|
+
localResolverCalls += 1;
|
|
130
|
+
// Simulates local v1's missing sync-report.md result: it must never
|
|
131
|
+
// overlay the native archive-ready authority.
|
|
132
|
+
return { dependencies: { verify: "all_done", sync: "blocked", archive: "blocked" } };
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
assert.deepEqual(startup.selection, { changeName: "alpha", workspaceRoot: root, phase: "archive" });
|
|
136
|
+
assert.equal(startup.status, nativeAuthority, "the validated native status object is injected without a local overlay");
|
|
137
|
+
assert.deepEqual(nativeCalls, [{ changeName: "alpha", workspaceRoot: root }]);
|
|
138
|
+
assert.equal(localResolverCalls, 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("selected native v2 failures fail closed without consulting the local resolver", async (t) => {
|
|
142
|
+
const root = workspace(t);
|
|
143
|
+
const serialized = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "archive" });
|
|
144
|
+
const valid = {
|
|
145
|
+
schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec",
|
|
146
|
+
planningHome: { mode: "repo-local", path: join(root, "openspec") }, changeRoot: join(root, "openspec/changes/alpha"),
|
|
147
|
+
actionContext: { mode: "repo-local", workspaceRoot: root, allowedEditRoots: [root] },
|
|
148
|
+
dependencies: { proposal: "all_done", specs: "all_done", design: "all_done", tasks: "all_done", apply: "all_done", verify: "all_done", archive: "blocked" },
|
|
149
|
+
phaseInstructions: { apply: ["done"], verify: ["done"], remediate: ["failed evidence"], archive: ["blocked"] },
|
|
150
|
+
blockedReasons: ["native archive blocker"], nextRecommended: "verify",
|
|
151
|
+
};
|
|
152
|
+
const failures: Array<{ sddStatus?: () => Promise<unknown> }> = [
|
|
153
|
+
{},
|
|
154
|
+
{ sddStatus: async () => { throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, "sdd-status", true, false, "native timeout"); } },
|
|
155
|
+
{ sddStatus: async () => { throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, "sdd-status", true, false, "native nonzero"); } },
|
|
156
|
+
{ sddStatus: async () => { throw new Error("native command threw"); } },
|
|
157
|
+
{ sddStatus: async () => ({}) },
|
|
158
|
+
{ sddStatus: async () => ({ ...valid, schemaVersion: 1 }) },
|
|
159
|
+
{ sddStatus: async () => ({ ...valid, changeName: "other" }) },
|
|
160
|
+
{ sddStatus: async () => ({ ...valid, actionContext: { workspaceRoot: "/other" } }) },
|
|
161
|
+
{ sddStatus: async () => ({ ...valid, dependencies: { apply: "all_done", verify: "all_done" } }) },
|
|
162
|
+
{ sddStatus: async () => ({ ...valid, phaseInstructions: { apply: ["done"], verify: ["done"] } }) },
|
|
163
|
+
{ sddStatus: async () => ({ ...valid, blockedReasons: "invalid" }) },
|
|
164
|
+
];
|
|
165
|
+
for (const native of failures) {
|
|
166
|
+
let localResolverCalls = 0;
|
|
167
|
+
await assert.rejects(
|
|
168
|
+
() => (__testing as unknown as {
|
|
169
|
+
resolveSelectedNativeSddChangeStartup(
|
|
170
|
+
serialized: unknown, cwd: string, agentName: string,
|
|
171
|
+
native: { sddStatus?: () => Promise<unknown> }, localResolver: () => unknown,
|
|
172
|
+
): Promise<unknown>;
|
|
173
|
+
}).resolveSelectedNativeSddChangeStartup(serialized, root, "sdd-archive", native, () => { localResolverCalls += 1; return {}; }),
|
|
174
|
+
/SDD selection native status/i,
|
|
175
|
+
);
|
|
176
|
+
assert.equal(localResolverCalls, 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await assert.rejects(
|
|
180
|
+
() => (__testing as unknown as {
|
|
181
|
+
resolveSelectedNativeSddChangeStartup(
|
|
182
|
+
serialized: unknown, cwd: string, agentName: string,
|
|
183
|
+
native: { sddStatus?: () => Promise<unknown> }, localResolver: () => unknown,
|
|
184
|
+
): Promise<unknown>;
|
|
185
|
+
}).resolveSelectedNativeSddChangeStartup(serialized, root, "sdd-archive", { sddStatus: async () => valid }, () => {
|
|
186
|
+
throw new Error("local resolver must not run");
|
|
187
|
+
}),
|
|
188
|
+
/native status.*blocks|cannot execute/i,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const syncSerialized = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "sync" });
|
|
192
|
+
const localStatus = __testing.resolveSddChangeStartup(syncSerialized, root, "sdd-sync").status;
|
|
193
|
+
const syncNativeCalls: unknown[] = [];
|
|
194
|
+
const syncLocalCalls: unknown[] = [];
|
|
195
|
+
const sync = await (__testing as unknown as {
|
|
196
|
+
resolveSelectedNativeSddChangeStartup(
|
|
197
|
+
serialized: unknown, cwd: string, agentName: string,
|
|
198
|
+
native: { sddStatus?: (request: unknown) => Promise<unknown> }, localResolver: (options: unknown) => unknown,
|
|
199
|
+
): Promise<{ status: unknown }>;
|
|
200
|
+
}).resolveSelectedNativeSddChangeStartup(syncSerialized, root, "sdd-sync", {
|
|
201
|
+
sddStatus: async (request) => { syncNativeCalls.push(request); throw new Error("native must not run"); },
|
|
202
|
+
}, (options) => {
|
|
203
|
+
syncLocalCalls.push(options);
|
|
204
|
+
return localStatus;
|
|
205
|
+
});
|
|
206
|
+
assert.equal(sync.status, localStatus, "selected sync injects the exact local status");
|
|
207
|
+
assert.deepEqual(syncLocalCalls, [{ cwd: root, workspaceRoot: root, changeName: "alpha", includeInstructions: true }]);
|
|
208
|
+
assert.deepEqual(syncNativeCalls, []);
|
|
209
|
+
|
|
210
|
+
await assert.rejects(
|
|
211
|
+
() => (__testing as unknown as {
|
|
212
|
+
resolveSelectedNativeSddChangeStartup(
|
|
213
|
+
serialized: unknown, cwd: string, agentName: string,
|
|
214
|
+
native: { sddStatus?: () => Promise<unknown> }, localResolver: () => typeof localStatus,
|
|
215
|
+
): Promise<unknown>;
|
|
216
|
+
}).resolveSelectedNativeSddChangeStartup(syncSerialized, root, "sdd-sync", undefined, () => ({ ...localStatus, changeName: "beta" })),
|
|
217
|
+
/mismatched status/i,
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a throwing SDD selection flag reader fails closed without resolving an unselected status", (t) => {
|
|
222
|
+
const root = workspace(t);
|
|
223
|
+
assert.equal(__testing.readSddChangeFlag({ getFlag: () => false } as never), undefined);
|
|
224
|
+
const selection = __testing.readSddChangeFlag({
|
|
225
|
+
getFlag() { throw new Error("flag reader failed"); },
|
|
226
|
+
} as never);
|
|
227
|
+
let resolverCalls = 0;
|
|
228
|
+
assert.throws(
|
|
229
|
+
() => __testing.resolveSddChangeStartup(selection, root, "sdd-apply", () => {
|
|
230
|
+
resolverCalls += 1;
|
|
231
|
+
throw new Error("status resolver must not run");
|
|
232
|
+
}),
|
|
233
|
+
/SDD selection must be a JSON string/i,
|
|
234
|
+
);
|
|
235
|
+
assert.equal(resolverCalls, 0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("selected SDD startup fails closed for malformed identity, root, phase, symlink, and resolver errors", (t) => {
|
|
239
|
+
const root = workspace(t);
|
|
240
|
+
const selected = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "apply" });
|
|
241
|
+
const outside = mkdtempSync(join(tmpdir(), "gentle-pi-sdd-selection-outside-"));
|
|
242
|
+
t.after(() => rmSync(outside, { recursive: true, force: true }));
|
|
243
|
+
const escaped = join(root, "escaped-root");
|
|
244
|
+
symlinkSync(outside, escaped);
|
|
245
|
+
|
|
246
|
+
for (const value of [
|
|
247
|
+
undefined,
|
|
248
|
+
null,
|
|
249
|
+
"not-json",
|
|
250
|
+
JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "apply", extra: true }),
|
|
251
|
+
JSON.stringify({ changeName: "alpha", workspaceRoot: join(root, "wrong"), phase: "apply" }),
|
|
252
|
+
JSON.stringify({ changeName: "alpha", workspaceRoot: escaped, phase: "apply" }),
|
|
253
|
+
]) {
|
|
254
|
+
assert.throws(() => __testing.resolveSddChangeStartup(value, root, "sdd-apply"), /SDD selection/i);
|
|
255
|
+
}
|
|
256
|
+
assert.throws(() => __testing.resolveSddChangeStartup(selected, root, "sdd-verify"), /phase/i);
|
|
257
|
+
assert.throws(
|
|
258
|
+
() => __testing.resolveSddChangeStartup(selected, root, "sdd-apply", () => { throw new Error("resolver failed"); }),
|
|
259
|
+
/resolver failed/i,
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Use the packaged executor body without an unsupported agent-name event field.
|
|
264
|
+
test("before_agent_start resolves the unnamed packaged executor and renders native v2 selection", async (t) => {
|
|
265
|
+
const root = workspace(t);
|
|
266
|
+
const systemPrompt = readFileSync(new URL("../assets/agents/sdd-apply.md", import.meta.url), "utf8").replace(/^---\n[\s\S]*?\n---\n/, "");
|
|
267
|
+
const selection = { changeName: "alpha", workspaceRoot: root, phase: "apply" };
|
|
268
|
+
const status = {
|
|
269
|
+
schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec",
|
|
270
|
+
planningHome: { mode: "repo-local", path: join(root, "openspec") }, changeRoot: join(root, "openspec/changes/alpha"),
|
|
271
|
+
actionContext: { mode: "repo-local", workspaceRoot: root, allowedEditRoots: [root] },
|
|
272
|
+
dependencies: { proposal: "all_done", specs: "all_done", design: "all_done", tasks: "all_done", apply: "ready", verify: "blocked", archive: "blocked" },
|
|
273
|
+
phaseInstructions: { apply: ["Read proposal, specs, design, and tasks before editing."], verify: ["Verify implementation."], remediate: ["Bind failed evidence."], archive: ["Archive after verification."] },
|
|
274
|
+
blockedReasons: [], nextRecommended: "apply",
|
|
275
|
+
};
|
|
276
|
+
let serialized: unknown = JSON.stringify(selection);
|
|
277
|
+
let nativeReply: unknown = status;
|
|
278
|
+
const calls: unknown[] = [];
|
|
279
|
+
type Hook = (event: unknown, ctx: ExtensionContext) => Promise<{ systemPrompt: string }>;
|
|
280
|
+
const hooks = new Map<string, Hook>();
|
|
281
|
+
const pi = {
|
|
282
|
+
on(name: string, hook: Hook) { hooks.set(name, hook); },
|
|
283
|
+
events: { emit() {} }, registerCommand() {}, registerTool() {},
|
|
284
|
+
getFlag: () => serialized, getActiveTools: () => [],
|
|
285
|
+
} as unknown as ExtensionAPI;
|
|
286
|
+
const ctx = { cwd: root, hasUI: false, sessionManager: { getSessionId: () => root } } as unknown as ExtensionContext;
|
|
287
|
+
await ensureSddPreflight(ctx, { pi, installAssets: () => ({ agents: 0, chains: 0, support: 0, skipped: 0 }) });
|
|
288
|
+
createGentleAiExtension({
|
|
289
|
+
nativeReviewCli: { sddStatus: async (request: unknown) => { calls.push(request); return nativeReply; } } as unknown as NativeReviewCli,
|
|
290
|
+
processEnv: {},
|
|
291
|
+
resolveTelemetryTriggerBinary: () => { throw new Error("no telemetry in hook tests"); },
|
|
292
|
+
})(pi);
|
|
293
|
+
const result = await hooks.get("before_agent_start")!({ systemPrompt }, ctx);
|
|
294
|
+
assert.doesNotMatch(result.systemPrompt, /SDD selection blocked:/);
|
|
295
|
+
assert.deepEqual(calls, [{ changeName: "alpha", workspaceRoot: root }]);
|
|
296
|
+
assert.match(result.systemPrompt, /### apply instructions/);
|
|
297
|
+
assert.ok(result.systemPrompt.includes(status.phaseInstructions.apply[0]!));
|
|
298
|
+
assert.ok(result.systemPrompt.includes(JSON.stringify(status, null, 2)));
|
|
299
|
+
for (const [name, event] of [
|
|
300
|
+
["contradictory names", { systemPrompt, agentName: "sdd-apply", name: "sdd-verify" }],
|
|
301
|
+
["contradictory named phase", { systemPrompt, agentName: "sdd-verify" }],
|
|
302
|
+
["unknown explicit name", { systemPrompt, agentName: "worker" }],
|
|
303
|
+
["ambiguous executor body", { systemPrompt: `${systemPrompt}\nSDD verify executor` }],
|
|
304
|
+
["unknown executor body", { systemPrompt: "SDD unknown executor" }],
|
|
305
|
+
] as const) {
|
|
306
|
+
await t.test(name, async () => {
|
|
307
|
+
calls.length = 0;
|
|
308
|
+
assert.match((await hooks.get("before_agent_start")!(event, ctx)).systemPrompt, /SDD selection blocked:/);
|
|
309
|
+
assert.deepEqual(calls, []);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
for (const invalid of [null, "not-json", { ...selection, phase: "verify" }, { ...selection, workspaceRoot: "/other" }]) {
|
|
313
|
+
serialized = typeof invalid === "object" && invalid !== null ? JSON.stringify(invalid) : invalid;
|
|
314
|
+
calls.length = 0;
|
|
315
|
+
assert.match((await hooks.get("before_agent_start")!({ systemPrompt }, ctx)).systemPrompt, /SDD selection blocked:/);
|
|
316
|
+
assert.deepEqual(calls, []);
|
|
317
|
+
}
|
|
318
|
+
serialized = JSON.stringify(selection);
|
|
319
|
+
for (const invalid of [{ ...status, phaseInstructions: undefined }, { ...status, nextRecommended: "unknown" }]) {
|
|
320
|
+
nativeReply = invalid;
|
|
321
|
+
assert.match((await hooks.get("before_agent_start")!({ systemPrompt }, ctx)).systemPrompt, /SDD selection blocked:/);
|
|
322
|
+
}
|
|
323
|
+
serialized = undefined;
|
|
324
|
+
nativeReply = status;
|
|
325
|
+
calls.length = 0;
|
|
326
|
+
await hooks.get("before_agent_start")!({ systemPrompt }, ctx);
|
|
327
|
+
assert.deepEqual(calls, [{ changeName: undefined, workspaceRoot: root }], "unselected startup reads native discovery, never local readiness");
|
|
328
|
+
const callTool = hooks.get("tool_call")! as unknown as (event: { toolName: string; input: Record<string, unknown> }, context: ExtensionContext) => Promise<{ block?: boolean } | undefined>;
|
|
329
|
+
const assertStartupBlocked = async (reply: unknown) => {
|
|
330
|
+
serialized = undefined; nativeReply = reply;
|
|
331
|
+
assert.match((await hooks.get("before_agent_start")!({ systemPrompt }, ctx)).systemPrompt, /SDD selection blocked:/);
|
|
332
|
+
for (const [toolName, input] of [["write", { path: "allowed.ts" }], ["edit", { path: "allowed.ts" }], ["bash", { command: "echo unsafe" }]] as const) {
|
|
333
|
+
assert.equal((await callTool({ toolName, input }, ctx))?.block, true, `${toolName} is mechanically blocked after ambiguous native startup`);
|
|
334
|
+
}
|
|
335
|
+
assert.equal(await callTool({ toolName: "subagent_parent_message", input: {} }, ctx), undefined, "parent messaging remains available");
|
|
336
|
+
};
|
|
337
|
+
for (const invalid of [{ ...status, changeName: null }, { ...status, phaseInstructions: undefined }, { ...status, nextRecommended: "unknown" }, { ...status, nextRecommended: "propose" }, { ...status, nextRecommended: "verify" }]) await assertStartupBlocked(invalid);
|
|
338
|
+
for (const phase of ["apply", "verify", "remediate", "archive"] as const) {
|
|
339
|
+
const phasePrompt = readFileSync(new URL(`../assets/agents/sdd-${phase}.md`, import.meta.url), "utf8").replace(/^---\n[\s\S]*?\n---\n/, "");
|
|
340
|
+
serialized = undefined;
|
|
341
|
+
nativeReply = { ...status, nextRecommended: phase, ...(phase === "remediate" ? { remediationState: { required: true, complete: false, failedEvidenceRevision: `sha256:${"a".repeat(64)}` } } : {}) };
|
|
342
|
+
assert.doesNotMatch((await hooks.get("before_agent_start")!({ systemPrompt: phasePrompt }, ctx)).systemPrompt, /SDD selection blocked:/);
|
|
343
|
+
assert.equal((await callTool({ toolName: "read", input: {} }, ctx))?.block, undefined, `${phase} accepts its matching native recommendation`);
|
|
344
|
+
}
|
|
345
|
+
const syncPrompt = readFileSync(new URL("../assets/agents/sdd-sync.md", import.meta.url), "utf8").replace(/^---\n[\s\S]*?\n---\n/, "");
|
|
346
|
+
calls.length = 0; nativeReply = { ...status, changeName: null };
|
|
347
|
+
assert.doesNotMatch((await hooks.get("before_agent_start")!({ systemPrompt: syncPrompt }, ctx)).systemPrompt, /SDD selection blocked:/);
|
|
348
|
+
assert.deepEqual(calls, [], "manual sync remains local and never consults native discovery");
|
|
349
|
+
serialized = JSON.stringify(selection);
|
|
350
|
+
for (const blockedReply of [
|
|
351
|
+
{ ...status, dependencies: { ...status.dependencies, apply: "blocked" }, blockedReasons: ["missing native prerequisite"] },
|
|
352
|
+
{ ...status, nextRecommended: "verify" },
|
|
353
|
+
]) {
|
|
354
|
+
nativeReply = blockedReply;
|
|
355
|
+
const blocked = await hooks.get("before_agent_start")!({ systemPrompt }, ctx);
|
|
356
|
+
assert.match(blocked.systemPrompt, /SDD selection blocked:/);
|
|
357
|
+
assert.equal((await callTool({ toolName: "write", input: { path: "unsafe.ts" } }, ctx))?.block, true);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Opt-in controlled producer proof; never resolves or installs a global binary.
|
|
363
|
+
test("producer v2 preservation: selected status is read-only and retains all seven dependencies", { skip: !process.env.SDD_TEST_PRODUCER }, async (t) => {
|
|
364
|
+
const root = workspace(t);
|
|
365
|
+
execFileSync("git", ["init", "--quiet", root]);
|
|
366
|
+
const args = ["sdd-status", "alpha", "--cwd", root, "--json", "--instructions"];
|
|
367
|
+
const first = execFileSync(process.env.SDD_TEST_PRODUCER!, args, { encoding: "utf8" });
|
|
368
|
+
const second = execFileSync(process.env.SDD_TEST_PRODUCER!, args, { encoding: "utf8" });
|
|
369
|
+
assert.equal(first, second);
|
|
370
|
+
const status = decodeNativeSddStatusV2(JSON.parse(first), { changeName: "alpha", workspaceRoot: root });
|
|
371
|
+
assert.deepEqual(Object.keys(status.dependencies).sort(), ["apply", "archive", "design", "proposal", "specs", "tasks", "verify"]);
|
|
372
|
+
assert.deepEqual(Object.keys(status.phaseInstructions!).sort(), ["apply", "archive", "remediate", "verify"]);
|
|
373
|
+
assert.equal(status.nextRecommended, "propose");
|
|
374
|
+
const verbs: string[] = [];
|
|
375
|
+
const adapter = createNodeExecFileAdapter();
|
|
376
|
+
const native = new NativeReviewCliV216(async (request) => { verbs.push(request.arguments[0]!); return adapter(request); }, process.env.SDD_TEST_PRODUCER!);
|
|
377
|
+
const h = commandHarness(root, status, true, true, native);
|
|
378
|
+
await h.run("status");
|
|
379
|
+
assert.deepEqual(JSON.parse(h.notices[0]!), status);
|
|
380
|
+
assert.deepEqual(verbs, ["sdd-status"]);
|
|
381
|
+
await h.run("continue");
|
|
382
|
+
assert.deepEqual(verbs, ["sdd-status", "sdd-status", "sdd-continue"]);
|
|
383
|
+
assert.equal(JSON.parse(h.notices[1]!).nextRecommended, "propose");
|
|
384
|
+
assert.throws(() => readFileSync(join(root, "openspec/changes/alpha/.gentle-ai-instance")), /ENOENT/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
function commandHarness(root: string, status: unknown, answer?: unknown, hasUI = true, native?: NativeReviewCli) {
|
|
388
|
+
const calls: string[] = [];
|
|
389
|
+
const notices: string[] = [];
|
|
390
|
+
const confirmations: string[] = [];
|
|
391
|
+
const commands = new Map<string, (args: string, ctx: ExtensionContext) => Promise<void>>();
|
|
392
|
+
const pi = {
|
|
393
|
+
on() {}, events: { emit() {} }, registerTool() {},
|
|
394
|
+
registerCommand(name: string, command: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) { commands.set(name, command.handler); },
|
|
395
|
+
sendUserMessage() { calls.push("launch"); }, sendMessage() { calls.push("launch"); },
|
|
396
|
+
} as unknown as ExtensionAPI;
|
|
397
|
+
createGentleAiExtension({ nativeReviewCli: native ?? {
|
|
398
|
+
sddStatus: async () => { calls.push("status"); return status; },
|
|
399
|
+
sddContinue: async () => { calls.push("continue"); return status; },
|
|
400
|
+
} as unknown as NativeReviewCli, processEnv: {} })(pi);
|
|
401
|
+
const ctx = { cwd: root, hasUI, ui: {
|
|
402
|
+
notify: (text: string) => notices.push(text),
|
|
403
|
+
confirm: async (title: string, text: string) => { confirmations.push(`${title}\n${text}`); return answer; },
|
|
404
|
+
} } as unknown as ExtensionContext;
|
|
405
|
+
return { calls, notices, confirmations, ctx, run: (verb: string) => commands.get(`gentle-sdd-${verb}`)!("alpha --json", ctx) };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function commandStatus(root: string) {
|
|
409
|
+
return {
|
|
410
|
+
schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec",
|
|
411
|
+
planningHome: { mode: "repo-local", path: join(root, "openspec") }, changeRoot: join(root, "openspec/changes/alpha"),
|
|
412
|
+
actionContext: { mode: "repo-local", workspaceRoot: root, allowedEditRoots: [root] },
|
|
413
|
+
dependencies: { proposal: "ready", specs: "blocked", design: "blocked", tasks: "blocked", apply: "blocked", verify: "blocked", archive: "blocked" },
|
|
414
|
+
phaseInstructions: { apply: ["misleading prose: apply now"], verify: [], remediate: [], archive: [] },
|
|
415
|
+
blockedReasons: ["prepare only; source roots remain ungranted"], nextRecommended: "propose",
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
test("status command renders native facts without confirmation, mutation, or phase launch", async (t) => {
|
|
420
|
+
const root = workspace(t);
|
|
421
|
+
const status = commandStatus(root);
|
|
422
|
+
const h = commandHarness(root, status, true);
|
|
423
|
+
await h.run("status");
|
|
424
|
+
assert.deepEqual(h.calls, ["status"]);
|
|
425
|
+
assert.deepEqual(JSON.parse(h.notices[0]!), status);
|
|
426
|
+
assert.deepEqual(h.confirmations, []);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("only exact marker confirmation permits continuation, never source authority or launch", async (t) => {
|
|
430
|
+
const root = workspace(t);
|
|
431
|
+
const status = commandStatus(root);
|
|
432
|
+
const h = commandHarness(root, status, true);
|
|
433
|
+
await h.run("continue");
|
|
434
|
+
assert.deepEqual(h.calls, ["status", "continue"]);
|
|
435
|
+
assert.equal(h.confirmations.length, 1);
|
|
436
|
+
assert.ok(h.confirmations[0]!.includes(join(root, "openspec/changes/alpha/.gentle-ai-instance")));
|
|
437
|
+
assert.match(h.confirmations[0]!, /no source.*no persistent/i);
|
|
438
|
+
assert.deepEqual(JSON.parse(h.notices[0]!), status);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("read-only, excluded-marker, cancellation and headless scope suppress every mutation", async (t) => {
|
|
442
|
+
const root = workspace(t);
|
|
443
|
+
for (const [answer, hasUI] of [[false, true], [undefined, true], ["yes", true], [true, false]]) {
|
|
444
|
+
const h = commandHarness(root, commandStatus(root), answer, hasUI as boolean);
|
|
445
|
+
await h.run("continue");
|
|
446
|
+
assert.deepEqual(h.calls, ["status"]);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("malformed recommendation and misleading prose refuse before continuation or phase launch", async (t) => {
|
|
451
|
+
const root = workspace(t);
|
|
452
|
+
for (const nextRecommended of ["sdd-apply", "unknown", null]) {
|
|
453
|
+
const h = commandHarness(root, { ...commandStatus(root), nextRecommended });
|
|
454
|
+
await assert.rejects(() => h.run("continue"), /native SDD|recommendation|enum|expected string/i);
|
|
455
|
+
assert.deepEqual(h.calls, ["status"]);
|
|
456
|
+
assert.deepEqual(h.confirmations, []);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("continuation rejects workspace mismatch and missing UI; marker-only confirmation grants no roots", async (t) => {
|
|
461
|
+
const root = workspace(t);
|
|
462
|
+
const mismatch = commandHarness(root, { ...commandStatus(root), actionContext: { workspaceRoot: "/other" } }, true);
|
|
463
|
+
await assert.rejects(() => mismatch.run("continue"), /workspace/);
|
|
464
|
+
assert.deepEqual(mismatch.calls, ["status"]);
|
|
465
|
+
const missingUI = commandHarness(root, commandStatus(root), true);
|
|
466
|
+
Object.assign(missingUI.ctx, { ui: undefined });
|
|
467
|
+
await missingUI.run("continue");
|
|
468
|
+
assert.deepEqual(missingUI.calls, ["status"]);
|
|
469
|
+
const status = commandStatus(root);
|
|
470
|
+
status.actionContext.allowedEditRoots = [];
|
|
471
|
+
const markerOnly = commandHarness(root, status, true);
|
|
472
|
+
await assert.rejects(() => markerOnly.run("continue"), /allowed edit roots/i);
|
|
473
|
+
assert.deepEqual(markerOnly.calls, ["status"]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("managed apply guidance refuses local reconstruction and preserves native authority", () => {
|
|
477
|
+
const guidance = readFileSync(new URL("../assets/agents/sdd-apply.md", import.meta.url), "utf8");
|
|
478
|
+
assert.doesNotMatch(guidance, /resolve-via-engram|produce the same fields|Proceed with implementation once those artifacts/);
|
|
479
|
+
assert.match(guidance, /native.*v2/i);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("unsafe planning context and marker symlink refuse before mutation", async (t) => {
|
|
483
|
+
const root = workspace(t);
|
|
484
|
+
for (const patch of [{ artifactStore: "engram" }, { actionContext: { mode: "unknown", workspaceRoot: root } }]) {
|
|
485
|
+
const h = commandHarness(root, { ...commandStatus(root), ...patch }, true);
|
|
486
|
+
await assert.rejects(() => h.run("continue"), /native SDD/i);
|
|
487
|
+
assert.deepEqual(h.calls, ["status"]);
|
|
488
|
+
}
|
|
489
|
+
symlinkSync(join(root, "openspec/changes/beta"), join(root, "openspec/changes/alpha/.gentle-ai-instance"));
|
|
490
|
+
const h = commandHarness(root, commandStatus(root), true);
|
|
491
|
+
await assert.rejects(() => h.run("continue"), /marker/i);
|
|
492
|
+
assert.deepEqual(h.calls, ["status"]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
test("typed remediation selection preserves native failed evidence and refuses stale binding", async (t) => {
|
|
497
|
+
const root = workspace(t), revision = `sha256:${"a".repeat(64)}`;
|
|
498
|
+
const selection = { changeName: "alpha", workspaceRoot: root, phase: "remediate", failedEvidenceRevision: revision };
|
|
499
|
+
const fixture: NativeSddStatusV2 = { schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec", planningHome: { mode: "repo-local", path: join(root, "openspec") }, changeRoot: join(root, "openspec/changes/alpha"), actionContext: { mode: "repo-local", workspaceRoot: root, allowedEditRoots: [root] }, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])) as NativeSddStatusV2["dependencies"], phaseInstructions: { apply: [], verify: [], remediate: ["Correct failed evidence"], archive: [] }, blockedReasons: [], nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: revision } };
|
|
500
|
+
const result = await __testing.resolveSelectedNativeSddChangeStartup(JSON.stringify(selection), root, "sdd-remediate", { sddStatus: async () => fixture });
|
|
501
|
+
assert.equal(result.selection.failedEvidenceRevision, revision);
|
|
502
|
+
await assert.rejects(__testing.resolveSelectedNativeSddChangeStartup(JSON.stringify({ ...selection, failedEvidenceRevision: `sha256:${"b".repeat(64)}` }), root, "sdd-remediate", { sddStatus: async () => fixture }), /remediation/);
|
|
503
|
+
assert.throws(() => decodeNativeSddStatusV2({ ...fixture, remediationState: { failedEvidenceRevision: "bad" } }, { workspaceRoot: root, changeName: "alpha" }), /remediation/);
|
|
504
|
+
});
|
package/tests/sdd-status.test.ts
CHANGED
|
@@ -40,6 +40,55 @@ function seedChange(cwd: string, change = "add-auth"): string {
|
|
|
40
40
|
return root;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
test("planning recommendations are executable tokens with separate diagnostics", async () => {
|
|
44
|
+
const cwd = await workspace();
|
|
45
|
+
const root = join(cwd, "openspec", "changes", "planning");
|
|
46
|
+
mkdirSync(root, { recursive: true });
|
|
47
|
+
for (const [route, diagnostic, artifact] of [
|
|
48
|
+
["sdd-propose", "proposal.md is missing.", "proposal.md"],
|
|
49
|
+
["sdd-spec", "domain specs are missing or partial.", "specs/auth/spec.md"],
|
|
50
|
+
["sdd-design", "design.md is missing.", "design.md"],
|
|
51
|
+
["sdd-tasks", "tasks.md is missing.", "tasks.md"],
|
|
52
|
+
] as const) {
|
|
53
|
+
const status = resolveSddStatus({ cwd });
|
|
54
|
+
assert.equal(status.nextRecommended, route);
|
|
55
|
+
assert.ok(status.blockedReasons.includes(diagnostic));
|
|
56
|
+
assert.equal(status.dependencies.apply, "blocked");
|
|
57
|
+
write(join(root, artifact), "# Artifact\n- [ ] Implement\n");
|
|
58
|
+
}
|
|
59
|
+
assert.equal(resolveSddStatus({ cwd }).nextRecommended, "sdd-apply");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("partial planning artifacts route to their repair phase", async () => {
|
|
63
|
+
for (const [artifact, route] of [
|
|
64
|
+
["proposal.md", "sdd-propose"],
|
|
65
|
+
["specs/auth/spec.md", "sdd-spec"],
|
|
66
|
+
["design.md", "sdd-design"],
|
|
67
|
+
["tasks.md", "sdd-tasks"],
|
|
68
|
+
] as const) {
|
|
69
|
+
const cwd = await workspace();
|
|
70
|
+
const root = seedChange(cwd);
|
|
71
|
+
write(join(root, artifact), " \n");
|
|
72
|
+
const status = resolveSddStatus({ cwd });
|
|
73
|
+
assert.equal(status.nextRecommended, route);
|
|
74
|
+
assert.ok(status.blockedReasons.length > 0);
|
|
75
|
+
assert.equal(status.applyState, "blocked");
|
|
76
|
+
assert.match(renderSddDispatcherMarkdown(status), new RegExp(`nextPhase: ${route}`));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("unresolved change selection returns blocked rather than diagnostic prose", async () => {
|
|
81
|
+
const cwd = await workspace();
|
|
82
|
+
mkdirSync(join(cwd, "openspec", "changes"), { recursive: true });
|
|
83
|
+
assert.equal(resolveSddStatus({ cwd }).nextRecommended, "blocked");
|
|
84
|
+
assert.equal(resolveSddStatus({ cwd, changeName: "absent" }).nextRecommended, "blocked");
|
|
85
|
+
seedChange(cwd, "first");
|
|
86
|
+
seedChange(cwd, "second");
|
|
87
|
+
const ambiguous = resolveSddStatus({ cwd });
|
|
88
|
+
assert.equal(ambiguous.nextRecommended, "blocked");
|
|
89
|
+
assert.match(ambiguous.blockedReasons[0], /ambiguous/);
|
|
90
|
+
});
|
|
91
|
+
|
|
43
92
|
test("listActiveOpenSpecChanges excludes archive and sorts active changes", async () => {
|
|
44
93
|
const cwd = await workspace();
|
|
45
94
|
mkdirSync(join(cwd, "openspec", "changes", "b-change"), { recursive: true });
|
|
@@ -229,6 +278,7 @@ test("resolveSddStatus blocks apply when tasks has no checkboxes", async () => {
|
|
|
229
278
|
assert.equal(status.applyState, "blocked");
|
|
230
279
|
assert.equal(status.dependencies.apply, "blocked");
|
|
231
280
|
assert.match(status.blockedReasons.join("\n"), /no implementation task checkboxes/);
|
|
281
|
+
assert.equal(status.nextRecommended, "sdd-tasks");
|
|
232
282
|
});
|
|
233
283
|
|
|
234
284
|
test("resolveSddStatus marks legacy flat specs partial and blocks sync", async () => {
|
|
@@ -242,6 +292,7 @@ test("resolveSddStatus marks legacy flat specs partial and blocks sync", async (
|
|
|
242
292
|
const status = resolveSddStatus({ cwd, changeName: "legacy" });
|
|
243
293
|
|
|
244
294
|
assert.equal(status.artifacts.specs, "partial");
|
|
295
|
+
assert.equal(status.nextRecommended, "sdd-spec");
|
|
245
296
|
assert.match(status.blockedReasons.join("\n"), /Legacy flat spec/);
|
|
246
297
|
assert.equal(status.dependencies.sync, "blocked");
|
|
247
298
|
});
|