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,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
const paths = [
|
|
6
|
+
"assets/sdd-orchestrator-workflow.md",
|
|
7
|
+
"assets/support/sdd-status-contract.md",
|
|
8
|
+
];
|
|
9
|
+
const documents = paths.map((path) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"));
|
|
10
|
+
|
|
11
|
+
function routingContract(document: string): string {
|
|
12
|
+
const section = document.match(/## Bounded Planning Routing\n([\s\S]*?)(?=\n## |$)/);
|
|
13
|
+
assert.ok(section, "explicit bounded planning routing contract is required");
|
|
14
|
+
return section[1].trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const [index, path] of paths.entries()) {
|
|
18
|
+
test(`${path}: native planning tokens are the only automatic routes`, () => {
|
|
19
|
+
const contract = routingContract(documents[index]);
|
|
20
|
+
for (const [token, phase] of [["propose", "sdd-proposal"], ["spec", "sdd-spec"], ["design", "sdd-design"], ["tasks", "sdd-tasks"]]) {
|
|
21
|
+
assert.ok(contract.includes(`| \`${token}\` | \`${phase}\` |`));
|
|
22
|
+
}
|
|
23
|
+
assert.match(contract, /only automatic planning routes/i);
|
|
24
|
+
assert.doesNotMatch(contract, /^\| `sdd-(?:propose|spec|design|tasks)`/m);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test(`${path}: planning preserves native blocker and edit-root guards`, () => {
|
|
28
|
+
const contract = routingContract(documents[index]);
|
|
29
|
+
for (const guard of [
|
|
30
|
+
"stop for ambiguous change selection, unresolved session preflight, or unsafe action context",
|
|
31
|
+
"prove planned writes are within the authoritative workspace or allowed edit roots",
|
|
32
|
+
"workspace-planning without allowed edit roots remains read-only",
|
|
33
|
+
"Planning does not bypass the init guard, pre-proposal gate, or phase approval requirements",
|
|
34
|
+
"never infer a route from prose",
|
|
35
|
+
"Keep genuine blockers in `blockedReasons` and non-blocking diagnostics in `notes`, never in `nextRecommended`",
|
|
36
|
+
"report them without discarding them to enable a route",
|
|
37
|
+
]) {
|
|
38
|
+
assert.ok(contract.includes(guard), `missing guard: ${guard}`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("workflow and support contract agree on bounded planning routing", () => {
|
|
44
|
+
assert.equal(routingContract(documents[0]), routingContract(documents[1]));
|
|
45
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import {
|
|
4
5
|
existsSync,
|
|
@@ -12,11 +13,19 @@ import { mkdtemp } from "node:fs/promises";
|
|
|
12
13
|
import { tmpdir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import test from "node:test";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
15
17
|
import {
|
|
16
18
|
collectSddPreflightPreferences,
|
|
19
|
+
ensureSddPreflight,
|
|
20
|
+
getSddPreflightPreferences,
|
|
17
21
|
DEFAULT_SDD_PREFLIGHT,
|
|
22
|
+
installPackageAssets,
|
|
18
23
|
installSddAssets,
|
|
19
24
|
isSddPreflightTrigger,
|
|
25
|
+
isParentConfirmedSddPreflightContext,
|
|
26
|
+
renderSddPreflightPrompt,
|
|
27
|
+
SHIPPED_SDD_AGENT_NAMES,
|
|
28
|
+
updatePackageManagedSddAgentOwnership,
|
|
20
29
|
readSddPreflightFromDisk,
|
|
21
30
|
sddPreflightDiskPath,
|
|
22
31
|
writeSddPreflightToDisk,
|
|
@@ -42,17 +51,219 @@ function preflightContext(cwd: string, hasUI: boolean, calls: string[] = [], ans
|
|
|
42
51
|
function writeRawPreflight(cwd: string, chainedPrStrategy: string, prompted = true): string {
|
|
43
52
|
const path = sddPreflightDiskPath(cwd); mkdirSync(join(cwd, ".pi", "gentle-ai"), { recursive: true }); writeFileSync(path, JSON.stringify({ executionMode: "auto", artifactStore: "openspec", chainedPrStrategy, reviewBudgetLines: 400, engramAvailable: false, prompted })); return path;
|
|
44
53
|
}
|
|
45
|
-
test("production callers distinguish
|
|
54
|
+
test("production callers distinguish first-session confirmation from explicit field editing", () => {
|
|
46
55
|
const root = join(import.meta.dirname, ".."), gentleAi = readFileSync(join(root, "extensions", "gentle-ai.ts"), "utf8"), sddInit = readFileSync(join(root, "extensions", "sdd-init.ts"), "utf8");
|
|
47
|
-
assert.match(gentleAi, /function runSddPreflight\(\s*ctx: ExtensionContext,\s*promptFields: readonly SddPreflightField\[\] = \[\]\s*\)/s); assert.match(gentleAi, /if \(isSddAgent && !getSddPreflightPreferences\(ctx\)\) \{\s*await runSddPreflight\(ctx\);/s); assert.match(gentleAi, /applyModelConfig: async \(\) => applySavedModelConfig\(ctx\)\s*\},\s*\{\s*promptFields\s*\}\s*\);/s); assert.
|
|
56
|
+
assert.match(gentleAi, /function runSddPreflight\(\s*ctx: ExtensionContext,\s*promptFields: readonly SddPreflightField\[\] = \[\]\s*\)/s); assert.match(gentleAi, /if \(isSddAgent && !getSddPreflightPreferences\(ctx\) && ctx\.mode !== "rpc"\) \{\s*await runSddPreflight\(ctx\);/s); assert.match(gentleAi, /applyModelConfig: async \(\) => applySavedModelConfig\(ctx\)\s*\},\s*\{\s*promptFields\s*\}\s*\);/s); assert.ok(gentleAi.includes('await runSddPreflight(ctx, args.trim() === "--edit" ? SDD_PREFLIGHT_FIELDS : []);')); assert.match(sddInit, /applyModelConfig: \(\) => applySavedModelConfig\(ctx\)\s*\},\s*\{\s*promptFields: \[\]\s*\}\s*\);/s);
|
|
48
57
|
});
|
|
49
58
|
test("capability-constrained artifact selector elision", async () => {
|
|
50
59
|
const calls: string[] = [], prefs = await collectSddPreflightPreferences(preflightContext(await workspace(), true, calls), false, { promptFields: ["artifactStore"] });
|
|
51
60
|
assert.deepEqual(prefs, DEFAULT_SDD_PREFLIGHT); assert.deepEqual(calls, []);
|
|
52
61
|
});
|
|
53
|
-
test("headless
|
|
54
|
-
const calls: string[] = [], ui = await collectSddPreflightPreferences(preflightContext(await workspace(), true, calls), false), headless = await collectSddPreflightPreferences(preflightContext(await workspace(), false), false);
|
|
55
|
-
assert.deepEqual(
|
|
62
|
+
test("headless defaults stay silent while UI confirms defaults", async () => {
|
|
63
|
+
const calls: string[] = [], ui = await collectSddPreflightPreferences(preflightContext(await workspace(), true, calls, { "Confirm SDD session preflight": "Confirm" }), false), headless = await collectSddPreflightPreferences(preflightContext(await workspace(), false), false);
|
|
64
|
+
assert.deepEqual(headless, DEFAULT_SDD_PREFLIGHT);
|
|
65
|
+
assert.deepEqual(ui, { ...headless, prompted: true }); assert.equal(calls.length, 1);
|
|
66
|
+
});
|
|
67
|
+
test("disk preferences are suggestions and resolved choices are reused only in session", async () => {
|
|
68
|
+
const cwd = await workspace(), calls: string[] = [];
|
|
69
|
+
writeSddPreflightToDisk(cwd, SAMPLE_PREFS);
|
|
70
|
+
const ctx = preflightContext(cwd, true, calls, { "Confirm SDD session preflight": "Confirm" });
|
|
71
|
+
assert.equal(getSddPreflightPreferences(ctx), undefined);
|
|
72
|
+
const callbacks = { pi: { getActiveTools: () => ["mem_save"] } as never, installAssets: () => ({ agents: 0, chains: 0, support: 0, skipped: 0 }) };
|
|
73
|
+
await ensureSddPreflight(ctx, callbacks);
|
|
74
|
+
await ensureSddPreflight(ctx, callbacks);
|
|
75
|
+
assert.equal(calls.length, 1);
|
|
76
|
+
const next = { ...ctx, sessionManager: { getSessionId: () => `${cwd}-new` } } as typeof ctx;
|
|
77
|
+
assert.equal(getSddPreflightPreferences(next), undefined);
|
|
78
|
+
await ensureSddPreflight(next, callbacks);
|
|
79
|
+
assert.equal(calls.length, 2);
|
|
80
|
+
});
|
|
81
|
+
test("no-callback preflight fallback installs only SDD-owned assets", async () => {
|
|
82
|
+
const cwd = await workspace();
|
|
83
|
+
const agentHome = await workspace();
|
|
84
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
85
|
+
try {
|
|
86
|
+
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
87
|
+
await ensureSddPreflight(
|
|
88
|
+
preflightContext(cwd, false),
|
|
89
|
+
{ pi: { getActiveTools: () => [] } as never },
|
|
90
|
+
);
|
|
91
|
+
assert.equal(existsSync(join(agentHome, "agents", "sdd-apply.md")), true);
|
|
92
|
+
assert.equal(existsSync(join(agentHome, "agents", "gentle-ai-worker.md")), false);
|
|
93
|
+
assert.equal(existsSync(join(agentHome, "agents", "review-risk.md")), false);
|
|
94
|
+
} finally {
|
|
95
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
96
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
97
|
+
rmSync(agentHome, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
async function waitForFile(path: string, timeoutMs = 2_000): Promise<void> {
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
while (!existsSync(path)) {
|
|
104
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${path}`);
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function spawnOwnerInstall(agentHome: string, owner: "delegation" | "review", holdLockMs = 0) {
|
|
110
|
+
const moduleUrl = pathToFileURL(join(import.meta.dirname, "..", "lib", "sdd-preflight.ts")).href;
|
|
111
|
+
const script = `import { installPackageAssets } from ${JSON.stringify(moduleUrl)}; installPackageAssets(process.env.GENTLE_PI_AGENT_HOME, false, [process.env.GENTLE_PI_TEST_ASSET_OWNER], { holdLockMs: Number(process.env.GENTLE_PI_TEST_HOLD_LOCK_MS) });`;
|
|
112
|
+
const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script], {
|
|
113
|
+
env: {
|
|
114
|
+
...process.env,
|
|
115
|
+
GENTLE_PI_AGENT_HOME: agentHome,
|
|
116
|
+
GENTLE_PI_TEST_ASSET_OWNER: owner,
|
|
117
|
+
GENTLE_PI_TEST_HOLD_LOCK_MS: String(holdLockMs),
|
|
118
|
+
},
|
|
119
|
+
stdio: "inherit",
|
|
120
|
+
});
|
|
121
|
+
let exited = false;
|
|
122
|
+
const completion = new Promise<void>((resolve, reject) => {
|
|
123
|
+
child.once("error", reject);
|
|
124
|
+
child.once("exit", (code) => {
|
|
125
|
+
exited = true;
|
|
126
|
+
code === 0 ? resolve() : reject(new Error(`asset installer exited ${code}`));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
return { completion, hasExited: () => exited };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test("managed asset replacements use exclusive same-directory temporary files", () => {
|
|
133
|
+
const source = readFileSync(join(import.meta.dirname, "..", "lib", "sdd-preflight.ts"), "utf8");
|
|
134
|
+
assert.match(source, /function replaceManagedAssetFileAtomically\([\s\S]*?flag: "wx"/);
|
|
135
|
+
assert.match(source, /renameSync\(temporaryPath, path\)/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("managed ownership update waits for installer lock and atomically updates the file and manifest", async () => {
|
|
139
|
+
const agentHome = await workspace();
|
|
140
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
141
|
+
try {
|
|
142
|
+
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
143
|
+
installPackageAssets(agentHome, false, ["sdd"]);
|
|
144
|
+
const target = join(agentHome, "agents", "sdd-apply.md");
|
|
145
|
+
const previous = readFileSync(target, "utf8");
|
|
146
|
+
const next = `${previous}\nmanaged routing update\n`;
|
|
147
|
+
const held = spawnOwnerInstall(agentHome, "delegation", 400);
|
|
148
|
+
await waitForFile(join(agentHome, "gentle-ai", "managed-assets.lock"));
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
assert.equal(updatePackageManagedSddAgentOwnership(target, previous, next), true);
|
|
151
|
+
assert.ok(Date.now() - startedAt >= 250, "ownership update must not bypass an active installer lock");
|
|
152
|
+
await held.completion;
|
|
153
|
+
assert.equal(readFileSync(target, "utf8"), next, "the managed file must be written under the installer lock");
|
|
154
|
+
const manifest = JSON.parse(readFileSync(join(agentHome, "gentle-ai", "managed-assets.json"), "utf8")) as { assets: Record<string, string> };
|
|
155
|
+
assert.ok(manifest.assets["agents/gentle-ai-worker.md"], "delegation ownership must survive the routed SDD update");
|
|
156
|
+
assert.equal(manifest.assets["agents/sdd-apply.md"], createHash("sha256").update(next).digest("hex"));
|
|
157
|
+
} finally {
|
|
158
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
159
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
160
|
+
rmSync(agentHome, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("managed ownership update exposes lock timeout without writing a partial routed file", async () => {
|
|
165
|
+
const agentHome = await workspace();
|
|
166
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
167
|
+
try {
|
|
168
|
+
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
169
|
+
installPackageAssets(agentHome, false, ["sdd"]);
|
|
170
|
+
const target = join(agentHome, "agents", "sdd-apply.md");
|
|
171
|
+
const previous = readFileSync(target, "utf8");
|
|
172
|
+
const next = `${previous}\nmanaged routing update\n`;
|
|
173
|
+
const manifestPath = join(agentHome, "gentle-ai", "managed-assets.json");
|
|
174
|
+
const manifestBefore = readFileSync(manifestPath, "utf8");
|
|
175
|
+
writeFileSync(
|
|
176
|
+
join(agentHome, "gentle-ai", "managed-assets.lock"),
|
|
177
|
+
JSON.stringify({ schemaVersion: 1, token: "foreign", pid: process.pid, createdAtMs: Date.now() }),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
assert.throws(
|
|
181
|
+
() => updatePackageManagedSddAgentOwnership(target, previous, next, { timeoutMs: 0 }),
|
|
182
|
+
/Timed out acquiring managed-assets lock file/i,
|
|
183
|
+
);
|
|
184
|
+
assert.equal(readFileSync(target, "utf8"), previous, "a timed-out managed update must not write the agent file");
|
|
185
|
+
assert.equal(readFileSync(manifestPath, "utf8"), manifestBefore, "a timed-out managed update must not write the manifest");
|
|
186
|
+
} finally {
|
|
187
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
188
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
189
|
+
rmSync(agentHome, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("cross-process owner installations preserve both managed manifest entries", async () => {
|
|
194
|
+
const agentHome = await workspace();
|
|
195
|
+
const lockPath = join(agentHome, "gentle-ai", "managed-assets.lock");
|
|
196
|
+
try {
|
|
197
|
+
const delegation = spawnOwnerInstall(agentHome, "delegation", 500);
|
|
198
|
+
await waitForFile(lockPath);
|
|
199
|
+
const review = spawnOwnerInstall(agentHome, "review");
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
201
|
+
assert.equal(review.hasExited(), false, "the second owner must remain blocked while the first owner holds the lock");
|
|
202
|
+
await Promise.all([delegation.completion, review.completion]);
|
|
203
|
+
const assets = (JSON.parse(readFileSync(join(agentHome, "gentle-ai", "managed-assets.json"), "utf8")) as { assets: Record<string, string> }).assets;
|
|
204
|
+
assert.deepEqual(
|
|
205
|
+
Object.keys(assets).filter((key) => key.startsWith("agents/gentle-ai-")).sort(),
|
|
206
|
+
["agents/gentle-ai-explore.md", "agents/gentle-ai-verify.md", "agents/gentle-ai-worker.md"],
|
|
207
|
+
);
|
|
208
|
+
assert.deepEqual(
|
|
209
|
+
Object.keys(assets).filter((key) => key === "chains/4r-review.chain.md" || key.startsWith("agents/jd-") || key.startsWith("agents/review-")).sort(),
|
|
210
|
+
["agents/jd-fix-agent.md", "agents/jd-judge-a.md", "agents/jd-judge-b.md", "agents/review-readability.md", "agents/review-reliability.md", "agents/review-resilience.md", "agents/review-risk.md", "chains/4r-review.chain.md"],
|
|
211
|
+
);
|
|
212
|
+
assert.equal(existsSync(lockPath), false, "the completed lock owner must release its lock file");
|
|
213
|
+
} finally {
|
|
214
|
+
rmSync(agentHome, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("installer preserves foreign, malformed, and unsafe lock paths", async () => {
|
|
219
|
+
const agentHome = await workspace();
|
|
220
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
221
|
+
const lockPath = join(agentHome, "gentle-ai", "managed-assets.lock");
|
|
222
|
+
try {
|
|
223
|
+
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
224
|
+
mkdirSync(join(agentHome, "gentle-ai"), { recursive: true });
|
|
225
|
+
for (const contents of ["", "not-json\n", JSON.stringify({ schemaVersion: 1, token: "foreign", pid: process.pid, createdAtMs: Date.now() })]) {
|
|
226
|
+
writeFileSync(lockPath, contents);
|
|
227
|
+
assert.throws(() => installPackageAssets(agentHome, false, ["delegation"], { timeoutMs: 0 }), /Timed out acquiring managed-assets lock file .*verify no installer is active/i);
|
|
228
|
+
assert.equal(readFileSync(lockPath, "utf8"), contents, "a regular lock file must remain untouched without this caller's token");
|
|
229
|
+
rmSync(lockPath, { force: true });
|
|
230
|
+
}
|
|
231
|
+
mkdirSync(lockPath);
|
|
232
|
+
assert.throws(() => installPackageAssets(agentHome, false, ["review"], { timeoutMs: 0 }), /lock path is unsafe/);
|
|
233
|
+
assert.equal(existsSync(lockPath), true, "an unsafe non-regular lock path must remain untouched");
|
|
234
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
235
|
+
installPackageAssets(agentHome, false, ["review"]);
|
|
236
|
+
assert.equal(existsSync(lockPath), false, "an installer must release only its own completed lock file");
|
|
237
|
+
} finally {
|
|
238
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
239
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
240
|
+
rmSync(agentHome, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("RPC children cannot promote or persist headless defaults", async () => {
|
|
245
|
+
const cwd = await workspace();
|
|
246
|
+
const calls: string[] = [];
|
|
247
|
+
const ctx = { ...preflightContext(cwd, true, calls), mode: "rpc" as const };
|
|
248
|
+
assert.deepEqual(await collectSddPreflightPreferences(ctx, false), DEFAULT_SDD_PREFLIGHT);
|
|
249
|
+
await assert.rejects(
|
|
250
|
+
ensureSddPreflight(ctx, { pi: { getActiveTools: () => [] } as never }),
|
|
251
|
+
/RPC child cannot originate or persist defaults/i,
|
|
252
|
+
);
|
|
253
|
+
assert.equal(existsSync(sddPreflightDiskPath(cwd)), false);
|
|
254
|
+
assert.deepEqual(calls, []);
|
|
255
|
+
});
|
|
256
|
+
test("explicit field editing cannot open RPC or no-UI dialogs", async () => {
|
|
257
|
+
for (const hasUI of [true, false]) {
|
|
258
|
+
const calls: string[] = [];
|
|
259
|
+
const ctx = { ...preflightContext(await workspace(), hasUI, calls), mode: "rpc" as const };
|
|
260
|
+
const prefs = await collectSddPreflightPreferences(ctx, false, { promptFields: ["executionMode", "artifactStore", "chainedPrStrategy", "reviewBudgetLines"] });
|
|
261
|
+
assert.deepEqual(prefs, DEFAULT_SDD_PREFLIGHT);
|
|
262
|
+
assert.deepEqual(calls, []);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
test("cancelled confirmation cannot become current-session consent", async () => {
|
|
266
|
+
await assert.rejects(collectSddPreflightPreferences(preflightContext(await workspace(), true), false), /cancelled/i);
|
|
56
267
|
});
|
|
57
268
|
test("explicit UI selections override defaults when a field is genuinely unresolved", async () => {
|
|
58
269
|
const calls: string[] = [], prefs = await collectSddPreflightPreferences(preflightContext(await workspace(), true, calls, { "SDD execution mode": "interactive", "SDD artifact store": "engram", "SDD delivery strategy": "auto-chain", "SDD review budget lines": "700" }), true, { persisted: DEFAULT_SDD_PREFLIGHT, promptFields: ["executionMode", "artifactStore", "chainedPrStrategy", "reviewBudgetLines"] });
|
|
@@ -97,11 +308,11 @@ test("readSddPreflightFromDisk returns persisted prefs after write", async () =>
|
|
|
97
308
|
assert.deepEqual(loaded, SAMPLE_PREFS);
|
|
98
309
|
});
|
|
99
310
|
|
|
100
|
-
test("persisted preferences
|
|
311
|
+
test("persisted preferences require fresh session confirmation", async () => {
|
|
101
312
|
const cwd = await workspace(); writeSddPreflightToDisk(cwd, SAMPLE_PREFS);
|
|
102
313
|
const loaded = readSddPreflightFromDisk(cwd); assert.ok(loaded);
|
|
103
|
-
const calls: string[] = [], reused = await collectSddPreflightPreferences(preflightContext(cwd, true, calls), loaded!.engramAvailable, { persisted: loaded });
|
|
104
|
-
assert.deepEqual(reused, loaded); assert.
|
|
314
|
+
const calls: string[] = [], reused = await collectSddPreflightPreferences(preflightContext(cwd, true, calls, { "Confirm SDD session preflight": "Confirm" }), loaded!.engramAvailable, { persisted: loaded });
|
|
315
|
+
assert.deepEqual(reused, loaded); assert.equal(calls.length, 1);
|
|
105
316
|
});
|
|
106
317
|
|
|
107
318
|
test("readSddPreflightFromDisk returns undefined for corrupt JSON", async () => {
|
|
@@ -282,6 +493,39 @@ test("a persisted canonical 'hybrid' artifact store loads unchanged", async () =
|
|
|
282
493
|
assert.equal(loaded?.artifactStore, "hybrid");
|
|
283
494
|
});
|
|
284
495
|
|
|
496
|
+
test("the shared shipped SDD inventory includes every executor, including remediation", () => {
|
|
497
|
+
assert.deepEqual(SHIPPED_SDD_AGENT_NAMES, [
|
|
498
|
+
"sdd-init", "sdd-onboard", "sdd-explore", "sdd-research", "sdd-proposal", "sdd-spec", "sdd-design",
|
|
499
|
+
"sdd-tasks", "sdd-status", "sdd-apply", "sdd-verify", "sdd-sync", "sdd-archive", "sdd-remediate",
|
|
500
|
+
]);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("only a structurally valid parent-rendered preflight block can reach an SDD child", () => {
|
|
504
|
+
const block = renderSddPreflightPrompt({ ...DEFAULT_SDD_PREFLIGHT, prompted: true });
|
|
505
|
+
assert.equal(isParentConfirmedSddPreflightContext(block), true);
|
|
506
|
+
assert.equal(isParentConfirmedSddPreflightContext(block.replace("Review budget: 400", "Review budget: 0")), false);
|
|
507
|
+
assert.equal(isParentConfirmedSddPreflightContext("## SDD Session Preflight\ncaller-authored defaults"), false);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("affirmative natural-language SDD requests trigger preflight without matching a finite phrase list", () => {
|
|
511
|
+
for (const text of [
|
|
512
|
+
"quiero hacer un proyecto con SDD",
|
|
513
|
+
"I want to build this with SDD",
|
|
514
|
+
"por favor usemos SDD para este cambio",
|
|
515
|
+
]) {
|
|
516
|
+
assert.equal(isSddPreflightTrigger(text), true, text);
|
|
517
|
+
}
|
|
518
|
+
for (const text of [
|
|
519
|
+
"Should we use SDD?",
|
|
520
|
+
"no quiero usar SDD por ahora",
|
|
521
|
+
"no necesito usar SDD",
|
|
522
|
+
"I don't want to use SDD",
|
|
523
|
+
"I use SDD sometimes",
|
|
524
|
+
]) {
|
|
525
|
+
assert.equal(isSddPreflightTrigger(text), false, text);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
285
529
|
test("slash SDD preflight trigger accepts the gentle-sdd command prefix", () => {
|
|
286
530
|
for (const text of ["/gentle-sdd-init", "/gentle-sdd-continue", "/gentle-sdd-status fix-rose --json", "/sdd", "/sdd:plan", "/sdd-plan this change"]) {
|
|
287
531
|
assert.equal(isSddPreflightTrigger(text), true, text);
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { parseResearchArtifactIntent, researchArtifactCall, researchArtifactReadback, type ResearchArtifactIntent } from "../lib/sdd-research-capabilities.ts";
|
|
8
|
+
import { childArguments } from "../lib/agents-runner.ts";
|
|
9
|
+
import { resolveResearchCapabilities, researchAgent, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts";
|
|
10
|
+
import type { AgentDefinition } from "../lib/agents-config.ts";
|
|
11
|
+
|
|
12
|
+
const inventory = (names: string[]) => ({ getActiveTools: () => names, getAllTools: () => names.map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) });
|
|
13
|
+
const grant = (tools: string[]) => ({ tools, extensions: Object.fromEntries(tools.map(name => [name, "/installed/web.ts"])) });
|
|
14
|
+
const documentation = { documentation: grant(["fetch_content"]) };
|
|
15
|
+
const both = { ...documentation, "open-web": grant(["web_search", "source_check", "fetch_content", "get_search_content"]) };
|
|
16
|
+
const agent: AgentDefinition = { name: "sdd-research", description: "Research", filePath: "/agents/sdd-research.md", scope: "global", tools: ["read", "write", "fetch_content", "web_search", "source_check", "get_search_content"], instructions: "Research", model: undefined, thinking: undefined, mode: undefined };
|
|
17
|
+
|
|
18
|
+
test("approved active external tools reach the actual child CLI allowlist", () => {
|
|
19
|
+
const pi = inventory(["read", "write", "fetch_content", "web_search", "source_check", "get_search_content", "bash", "mcp"]);
|
|
20
|
+
const result = researchAgent(agent, pi, both);
|
|
21
|
+
const args = childArguments({ agent: result.agent, sessionDir: "sessions" } as never);
|
|
22
|
+
assert.equal(args[args.indexOf("--tools") + 1], "read,write,fetch_content,web_search,source_check,get_search_content,subagent_parent_message");
|
|
23
|
+
assert.equal(result.capabilities.documentation.status, "available");
|
|
24
|
+
assert.equal(result.capabilities["open-web"].status, "available");
|
|
25
|
+
});
|
|
26
|
+
test("class-specific grants remain exact when both classes are explicitly selected", () => {
|
|
27
|
+
const names = ["web_search", "source_check", "fetch_content", "get_search_content"];
|
|
28
|
+
const caps = resolveResearchCapabilities(inventory([...names, "unknown"]));
|
|
29
|
+
assert.deepEqual(caps.documentation.tools, ["fetch_content"]);
|
|
30
|
+
assert.deepEqual(caps["open-web"].tools, names);
|
|
31
|
+
assert.match(renderResearchCapabilities(caps), /documentation: available; tools=\["fetch_content"\]/);
|
|
32
|
+
assert.deepEqual(resolveResearchCapabilities(inventory(["web_search"])).documentation.tools, []);
|
|
33
|
+
assert.deepEqual(resolveResearchCapabilities(inventory(["web_search"]))["open-web"].tools, ["web_search"]);
|
|
34
|
+
assert.deepEqual(researchAgent(agent, inventory(names), both).agent.tools, agent.tools);
|
|
35
|
+
const instructions = readFileSync(new URL("../assets/agents/sdd-research.md", import.meta.url), "utf8");
|
|
36
|
+
assert.match(instructions, /Persist grants per source class exactly as observed/);
|
|
37
|
+
assert.match(instructions, /never copy the child tool union into each class/);
|
|
38
|
+
assert.match(instructions, /research_selection/);
|
|
39
|
+
assert.match(instructions, /sourceInfo\.path/);
|
|
40
|
+
assert.match(instructions, /--extension/);
|
|
41
|
+
assert.match(instructions, /research_artifact/);
|
|
42
|
+
assert.match(instructions, /revision_count/);
|
|
43
|
+
assert.match(instructions, /full bounded write/);
|
|
44
|
+
assert.match(instructions, /identical store.*worktree/);
|
|
45
|
+
});
|
|
46
|
+
test("open-web requires all four canonical tools, each active and unrestricted", () => {
|
|
47
|
+
const required = ["web_search", "source_check", "fetch_content", "get_search_content"];
|
|
48
|
+
for (const missing of required) {
|
|
49
|
+
const remaining = required.filter(name => name !== missing);
|
|
50
|
+
for (const caps of [
|
|
51
|
+
resolveResearchCapabilities(inventory(remaining)),
|
|
52
|
+
resolveResearchCapabilities({ ...inventory(required), getActiveTools: () => remaining }),
|
|
53
|
+
resolveResearchCapabilities(inventory(required), remaining),
|
|
54
|
+
researchAgent(agent, inventory(remaining), both).capabilities,
|
|
55
|
+
]) {
|
|
56
|
+
assert.equal(caps["open-web"].status, "blocked", `${missing} must deny open-web`);
|
|
57
|
+
assert.match(caps["open-web"].reason, new RegExp(missing));
|
|
58
|
+
assert.equal(caps.documentation.status, missing === "fetch_content" ? "blocked" : "available");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
test("restrictions, inactive tools and unknown tools never become grants", () => {
|
|
63
|
+
const pi = inventory(["fetch_content", "web_search", "mcp", "mcp__context7", "bash"]);
|
|
64
|
+
const caps = resolveResearchCapabilities(pi, ["web_search"]);
|
|
65
|
+
assert.equal(caps.documentation.status, "blocked");
|
|
66
|
+
assert.equal(caps["open-web"].status, "blocked");
|
|
67
|
+
assert.deepEqual(researchAgent({ ...agent, tools: ["read", "write"] } as never, pi).agent.tools, ["read", "write"]);
|
|
68
|
+
assert.equal(resolveResearchCapabilities(inventory(["mcp", "mcp__context7"])).documentation.status, "blocked");
|
|
69
|
+
const inactive = { ...pi, getActiveTools: () => [] };
|
|
70
|
+
assert.equal(resolveResearchCapabilities(inactive).documentation.status, "blocked");
|
|
71
|
+
});
|
|
72
|
+
test("documentation can run independently of unavailable open-web search", () => {
|
|
73
|
+
const caps = resolveResearchCapabilities(inventory(["fetch_content"]));
|
|
74
|
+
assert.equal(caps.documentation.status, "available");
|
|
75
|
+
assert.equal(caps["open-web"].status, "blocked");
|
|
76
|
+
assert.match(renderResearchCapabilities(caps), /official/);
|
|
77
|
+
assert.match(renderResearchCapabilities(caps), /not evidence/);
|
|
78
|
+
});
|
|
79
|
+
test("SDK-only tools and unavailable inventory fail closed", () => {
|
|
80
|
+
const pi = { getActiveTools: () => ["fetch_content"], getAllTools: () => [{ name: "fetch_content", sourceInfo: { source: "sdk" } }] };
|
|
81
|
+
assert.equal(resolveResearchCapabilities(pi).documentation.status, "blocked");
|
|
82
|
+
assert.equal(resolveResearchCapabilities({}).documentation.status, "blocked");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("selected documentation never inherits available open-web routes or arbitrary extensions", () => {
|
|
86
|
+
const result = researchAgent(agent, inventory(agent.tools), documentation);
|
|
87
|
+
assert.deepEqual(result.agent.tools, ["read", "write", "fetch_content"]);
|
|
88
|
+
assert.deepEqual(result.extensionPaths, ["/installed/web.ts"]);
|
|
89
|
+
assert.equal(result.capabilities["open-web"].status, "blocked");
|
|
90
|
+
for (const selection of [undefined, {}, { documentation: grant(["fetch_content", "web_search"]) }, { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/other.ts" } } }]) {
|
|
91
|
+
assert.deepEqual(researchAgent(agent, inventory(agent.tools), selection).agent.tools, ["read", "write"]);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("selected grants refuse missing, inactive, SDK, restricted and unknown routes without removing local authorization", () => {
|
|
96
|
+
const local = ["read", "grep", "find", "edit", "write", "mem_search", "mem_get_observation", "mem_save"];
|
|
97
|
+
const definition = { ...agent, tools: [...local, ...both["open-web"].tools, "bash", "mcp"] };
|
|
98
|
+
for (const missing of both["open-web"].tools) {
|
|
99
|
+
const full = inventory(definition.tools);
|
|
100
|
+
for (const [pi, tools] of [
|
|
101
|
+
[inventory(definition.tools.filter(name => name !== missing)), definition.tools],
|
|
102
|
+
[{ ...full, getActiveTools: () => definition.tools.filter(name => name !== missing) }, definition.tools],
|
|
103
|
+
[{ ...full, getAllTools: () => full.getAllTools().map(tool => tool.name === missing ? { ...tool, sourceInfo: { ...tool.sourceInfo, source: "sdk" } } : tool) }, definition.tools],
|
|
104
|
+
[full, definition.tools.filter(name => name !== missing)],
|
|
105
|
+
] as const) {
|
|
106
|
+
const result = researchAgent({ ...definition, tools: [...tools] }, pi, { "open-web": both["open-web"] });
|
|
107
|
+
assert.deepEqual(result.agent.tools, local, missing);
|
|
108
|
+
assert.equal(result.capabilities["open-web"].status, "blocked");
|
|
109
|
+
assert.deepEqual(result.capabilities["open-web"].tools, both["open-web"].tools.filter(name => name !== missing));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const selection of [{ unknown: grant(["fetch_content"]), ...documentation }, { toString: {}, ...documentation }, { documentation: grant(["mcp"]) }]) {
|
|
113
|
+
assert.deepEqual(researchAgent(definition, inventory(definition.tools), selection).agent.tools, local);
|
|
114
|
+
}
|
|
115
|
+
const allowed = researchAgent(definition, inventory(definition.tools), { "open-web": both["open-web"] });
|
|
116
|
+
assert.deepEqual(allowed.agent.tools, [...local, ...both["open-web"].tools]);
|
|
117
|
+
assert.deepEqual(allowed.capabilities.documentation.tools, []);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const digest = (bytes: string) => createHash("sha256").update(bytes).digest("hex");
|
|
121
|
+
test("artifact intent narrows exact paths and topics without granting tools or replacement scope", () => {
|
|
122
|
+
const cwd = mkdtempSync(join(tmpdir(), "research-bounds-"));
|
|
123
|
+
try {
|
|
124
|
+
const path = join(cwd, "openspec/changes/demo/research.md");
|
|
125
|
+
mkdirSync(join(cwd, "openspec/changes/demo"), { recursive: true });
|
|
126
|
+
const bytes = '{"revision":1,"outcome":"blocked"}';
|
|
127
|
+
writeFileSync(path, bytes);
|
|
128
|
+
const locator = { artifact: "research", revision: 1, digest: digest(bytes), path, engram: { id: 7, project: "pi", topic_key: "sdd/demo/research", revision_count: 2 } };
|
|
129
|
+
const intent: ResearchArtifactIntent = { store: "both", worktree: cwd, changeName: "demo", retainedIntent: "docs requested; fetch missing", locators: [locator] };
|
|
130
|
+
const scope = parseResearchArtifactIntent(intent, cwd);
|
|
131
|
+
for (const name of ["read", "edit", "write", "grep"]) assert.equal(researchArtifactCall(scope, cwd, name, { path }), 0);
|
|
132
|
+
assert.equal(researchArtifactCall(scope, cwd, "mem_get_observation", { id: 7 }), 0);
|
|
133
|
+
assert.equal(researchArtifactCall(scope, cwd, "mem_save", { project: "pi", topic_key: "sdd/demo/research" }), 0);
|
|
134
|
+
assert.equal(researchArtifactCall(scope, cwd, "mem_search", { project: "pi", query: "sdd/demo/research" }), 0);
|
|
135
|
+
for (const [name, input] of [["write", { path: join(cwd, "source.ts") }], ["read", {}], ["find", { path: cwd }], ["mem_get_observation", { id: 8 }], ["mem_search", { project: "pi", query: "sdd/demo/research", all_projects: true }], ["mem_save", { project: "other", topic_key: "sdd/demo/research" }]] as const) {
|
|
136
|
+
assert.throws(() => researchArtifactCall(scope, cwd, name, input), /scope/);
|
|
137
|
+
}
|
|
138
|
+
assert.throws(() => researchArtifactCall(scope, tmpdir(), "write", { path }), /worktree/);
|
|
139
|
+
symlinkSync(path, join(cwd, "alias"));
|
|
140
|
+
assert.throws(() => researchArtifactCall(scope, cwd, "read", { path: join(cwd, "alias") }), /scope/);
|
|
141
|
+
rmSync(path);
|
|
142
|
+
symlinkSync(join(cwd, "missing-target"), path);
|
|
143
|
+
assert.throws(() => researchArtifactCall(scope, cwd, "write", { path }), /ENOENT|scope/);
|
|
144
|
+
rmSync(path);
|
|
145
|
+
writeFileSync(path, bytes);
|
|
146
|
+
for (const patch of [{ worktree: tmpdir() }, { store: "engram" }, { retainedIntent: "replacement" }, { locators: [{ ...locator, digest: "0".repeat(64) }] }]) {
|
|
147
|
+
assert.throws(() => parseResearchArtifactIntent({ ...intent, ...patch }, cwd, scope), /scope|worktree|stale/);
|
|
148
|
+
}
|
|
149
|
+
const none = parseResearchArtifactIntent({ ...intent, store: "none", locators: [] }, cwd);
|
|
150
|
+
assert.throws(() => researchArtifactCall(none, cwd, "write", { path }), /scope/);
|
|
151
|
+
const explore = parseResearchArtifactIntent({ ...intent, store: "openspec", locators: [{ ...locator, artifact: "explore", path: join(cwd, "openspec/changes/demo/explore.md"), engram: undefined }] }, cwd);
|
|
152
|
+
assert.throws(() => researchArtifactCall(explore, cwd, "write", { path: explore.locators[0].path }), /scope/);
|
|
153
|
+
const corrected = { ...intent, locators: [{ ...locator, revision: 2, digest: digest("corrected"), engram: { ...locator.engram, revision_count: 3 } }] };
|
|
154
|
+
assert.equal(parseResearchArtifactIntent(corrected, cwd, scope).retainedIntent, intent.retainedIntent);
|
|
155
|
+
assert.throws(() => parseResearchArtifactIntent({ ...intent, locators: [{ ...locator, path: cwd }] }, cwd), /scope/);
|
|
156
|
+
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("only actual exact OpenSpec bytes and Engram metadata satisfy readback identity", () => {
|
|
160
|
+
const cwd = mkdtempSync(join(tmpdir(), "research-readback-"));
|
|
161
|
+
try {
|
|
162
|
+
const bytes = '{"revision":3,"outcome":"done"}';
|
|
163
|
+
const locator = { artifact: "research", revision: 3, digest: digest(bytes), path: join(cwd, "openspec/changes/demo/research.md"), engram: { id: 7, project: "pi", topic_key: "sdd/demo/research", revision_count: 4 } };
|
|
164
|
+
const scope = parseResearchArtifactIntent({ store: "both", worktree: cwd, changeName: "demo", retainedIntent: "docs", locators: [locator] }, cwd);
|
|
165
|
+
const observation = { ...locator.engram, content: bytes };
|
|
166
|
+
assert.equal(researchArtifactReadback(scope.locators[0], "read", bytes), true);
|
|
167
|
+
assert.equal(researchArtifactReadback(scope.locators[0], "mem_get_observation", observation), true);
|
|
168
|
+
for (const patch of [{ id: 9 }, { project: "other" }, { topic_key: "sdd/other/research" }, { revision_count: 3 }, { revision_count: undefined }, { content: bytes + " " }]) {
|
|
169
|
+
assert.equal(researchArtifactReadback(scope.locators[0], "mem_get_observation", { ...observation, ...patch }), false);
|
|
170
|
+
}
|
|
171
|
+
for (const bad of ["", bytes + " ", '{"revision":2,"outcome":"done"}', { verified: true }, null]) assert.equal(researchArtifactReadback(scope.locators[0], "read", bad), false);
|
|
172
|
+
assert.equal(researchArtifactReadback(scope.locators[0], "mem_search", [observation]), false, "search is discovery, not full readback");
|
|
173
|
+
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
test("R4 research write crash reload requires durable desired identity and actual backend readback", async t => {
|
|
178
|
+
const { default: gentleAgents } = await import("../extensions/gentle-agents.ts");
|
|
179
|
+
const { appendFileSync } = await import("node:fs");
|
|
180
|
+
const cwd = mkdtempSync(join(tmpdir(), "research-crash-")); t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
181
|
+
for (const store of ["openspec", "engram", "both"] as const) {
|
|
182
|
+
const path = join(cwd, "openspec/changes/demo/research.md"), history = join(cwd, `${store}.jsonl`), memory = join(cwd, `${store}-memory.json`);
|
|
183
|
+
mkdirSync(join(cwd, "openspec/changes/demo"), { recursive: true }); writeFileSync(history, "");
|
|
184
|
+
const bytes = '{"revision":1}', next = '{"revision":2}', digest = value => createHash("sha256").update(value).digest("hex");
|
|
185
|
+
const engram = { id: 12, project: "pi", topic_key: "sdd/demo/research", revision_count: 1 };
|
|
186
|
+
writeFileSync(path, bytes); writeFileSync(memory, JSON.stringify({ ...engram, content: bytes }));
|
|
187
|
+
const scope: ResearchArtifactIntent = { store, worktree: cwd, changeName: "demo", retainedIntent: "retain uncertainty", locators: [{ artifact: "research", revision: 1, digest: digest(bytes), ...(store !== "engram" ? { path } : {}), ...(store !== "openspec" ? { engram } : {}) }] };
|
|
188
|
+
const entries = () => readFileSync(history, "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line));
|
|
189
|
+
const start = (durable = true) => {
|
|
190
|
+
const hooks = new Map(), active = ["read", "write", "mem_get_observation", "mem_save"];
|
|
191
|
+
const pi = { on: (name, fn) => hooks.set(name, fn), getAllTools: () => active.map(name => ({ name })), getActiveTools: () => active, appendEntry: (customType, data) => appendFileSync(history, JSON.stringify({ type: "custom", customType, data }) + "\n") };
|
|
192
|
+
const ctx = { cwd, sessionManager: { getEntries: entries, getSessionFile: () => durable ? history : undefined } };
|
|
193
|
+
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
|
|
194
|
+
hooks.get("before_agent_start")({ systemPrompt: "research" }, ctx);
|
|
195
|
+
const call = (toolName, input, toolCallId = "call") => hooks.get("tool_call")({ toolName, input, toolCallId }, ctx);
|
|
196
|
+
const read = toolName => {
|
|
197
|
+
const input = toolName === "read" ? { path } : { id: 12 }; assert.equal(call(toolName, input), undefined);
|
|
198
|
+
return hooks.get("tool_result")({ toolName, input, toolCallId: "call", content: [{ type: "text", text: readFileSync(toolName === "read" ? path : memory, "utf8") }], isError: false }, ctx);
|
|
199
|
+
};
|
|
200
|
+
return { call, read, result: (toolName, input, toolCallId) => hooks.get("tool_result")({ toolName, input, toolCallId, content: [{ type: "text", text: "saved" }], isError: false }, ctx) };
|
|
201
|
+
};
|
|
202
|
+
const unpersisted = start(false); if (store !== "engram") unpersisted.read("read"); if (store !== "openspec") unpersisted.read("mem_get_observation");
|
|
203
|
+
assert.equal(unpersisted.call(store === "engram" ? "mem_save" : "write", store === "engram" ? { project: "pi", topic_key: engram.topic_key, content: next } : { path, content: next })?.block, true, "in-memory session cannot authorize durable mutation");
|
|
204
|
+
writeFileSync(history, "");
|
|
205
|
+
let child = start(); if (store !== "engram") child.read("read"); if (store !== "openspec") child.read("mem_get_observation");
|
|
206
|
+
const local = store !== "engram", tool = local ? "write" : "mem_save", input = local ? { path, content: next } : { project: "pi", topic_key: engram.topic_key, content: next };
|
|
207
|
+
assert.equal(child.call(tool, input, "crash-write"), undefined);
|
|
208
|
+
assert.ok(JSON.stringify(entries()).includes(digest(next)), "desired identity must precede backend mutation");
|
|
209
|
+
if (store === "both") {
|
|
210
|
+
child.result(tool, input, "crash-write");
|
|
211
|
+
const before = readFileSync(history, "utf8");
|
|
212
|
+
for (const content of ['{"revision":3}', '{"revision":2,"different":true}']) {
|
|
213
|
+
assert.equal(child.call("mem_save", { project: "pi", topic_key: engram.topic_key, content }, "diverge")?.block, true);
|
|
214
|
+
assert.equal(readFileSync(history, "utf8"), before, "mismatch refused before checkpoint or mutation");
|
|
215
|
+
}
|
|
216
|
+
assert.equal(child.call("mem_save", { project: "pi", topic_key: engram.topic_key, content: next }, "matching"), undefined);
|
|
217
|
+
}
|
|
218
|
+
if (local) writeFileSync(path, next); else writeFileSync(memory, JSON.stringify({ ...engram, content: next, revision_count: 2 }));
|
|
219
|
+
// Recreate extension solely from retained session bytes; no mutation result delivered.
|
|
220
|
+
child = start(); const readback = child.read(local ? "read" : "mem_get_observation");
|
|
221
|
+
if (store === "both") {
|
|
222
|
+
assert.equal(child.read("mem_get_observation").isError, true, "partial hybrid never converges");
|
|
223
|
+
assert.equal(child.call(tool, { ...input, content: '{"revision":3}' })?.block, true);
|
|
224
|
+
} else {
|
|
225
|
+
assert.match(readback.content.at(-1).text, /all selected stores/);
|
|
226
|
+
child = start(); assert.match(child.read(local ? "read" : "mem_get_observation").content.at(-1).text, /all selected stores/, "accepted recovery survives another reload");
|
|
227
|
+
assert.equal(child.call(tool, { ...input, content: '{"revision":3}' }), undefined);
|
|
228
|
+
child = start(); assert.equal(child.read(local ? "read" : "mem_get_observation").isError, true, "crash before actual mutation refuses stale bytes");
|
|
229
|
+
assert.equal(child.call(tool, { ...input, content: '{"revision":4}' })?.block, true);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
test("R4 corrupted or broadened durable research scope is not a restart grant", async t => {
|
|
236
|
+
const { parseResearchPersistence } = await import("../lib/sdd-research-capabilities.ts");
|
|
237
|
+
const cwd = mkdtempSync(join(tmpdir(), "research-journal-")); t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
238
|
+
const locator = { artifact: "research", revision: 1, digest: "a".repeat(64), engram: { id: 12, project: "pi", topic_key: "sdd/demo/research", revision_count: 1 } };
|
|
239
|
+
const scope: ResearchArtifactIntent = { store: "engram", worktree: cwd, changeName: "demo", retainedIntent: "retain uncertainty", locators: [locator] };
|
|
240
|
+
const snapshot = { version: 1, scope, accepted: {}, writes: { "0:mem_get_observation": { revision: 2, digest: "b".repeat(64) } }, operation: { toolCallId: "write", tool: "mem_save", index: 0 } };
|
|
241
|
+
assert.doesNotThrow(() => parseResearchPersistence(snapshot, scope, cwd));
|
|
242
|
+
const hybrid: ResearchArtifactIntent = { ...scope, store: "both", locators: [{ ...locator, path: join(cwd, "openspec/changes/demo/research.md") }] };
|
|
243
|
+
for (const desired of [{ revision: 3, digest: "b".repeat(64) }, { revision: 2, digest: "c".repeat(64) }]) {
|
|
244
|
+
assert.throws(() => parseResearchPersistence({ ...snapshot, scope: hybrid, writes: { ...snapshot.writes, "0:read": desired } }, hybrid, cwd), /divergent/i);
|
|
245
|
+
}
|
|
246
|
+
assert.doesNotThrow(() => parseResearchPersistence({ ...snapshot, scope: hybrid, writes: { ...snapshot.writes, "0:read": snapshot.writes["0:mem_get_observation"] } }, hybrid, cwd));
|
|
247
|
+
for (const bad of [{ ...snapshot, accepted: "unknown" }, { ...snapshot, writes: { "0:mem_get_observation": null } }, { ...snapshot, accepted: { "0:mem_get_observation": null } }, { ...snapshot, scope: { ...scope, store: "none", locators: [] } }, { ...snapshot, writes: { "0:mem_get_observation": { revision: 1, digest: "a".repeat(64) } } }]) assert.throws(() => parseResearchPersistence(bad, scope, cwd));
|
|
248
|
+
for (const engram of [{ ...locator.engram, id: 13 }, { ...locator.engram, project: "other" }, { ...locator.engram, topic_key: "sdd/other/research" }]) assert.throws(() => parseResearchPersistence(snapshot, { ...scope, locators: [{ ...locator, engram }] }, cwd));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
test("research actor explains crash readback without store substitution or replay", () => {
|
|
253
|
+
const text = readFileSync("assets/agents/sdd-research.md", "utf8");
|
|
254
|
+
assert.match(text, /physical session history/); assert.match(text, /crash.*desired identity/);
|
|
255
|
+
assert.match(text, /checkpoint.*not.*readback/i);
|
|
256
|
+
});
|