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.
Files changed (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,331 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { parseRemediationPlan, remediationEvidence, observeRemediationTool, type RemediationObservations, type RemediationPlan, type RemediationScope, type TaskRequest } from "../lib/agents-runner.ts";
5
+ import { NATIVE_REVIEW_ERROR_CODE, NATIVE_REVIEW_OPERATION, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
6
+ import { remediationUnresolved } from "../lib/agents-history.ts";
7
+ import type { TaskRecord } from "../lib/agents-protocol.ts";
8
+
9
+ const testCwd = process.cwd();
10
+ const human = { hasUI: true, ui: { confirm: async () => true } } as unknown as Pick<ExtensionContext, "hasUI" | "ui">;
11
+ const shellScope = (cwd: string, commands: string[]): RemediationScope => ({ cwd, commands, editPaths: [], allowedEditRoots: [cwd] });
12
+ const revision = `sha256:${"a".repeat(64)}`;
13
+ const plan: RemediationPlan = { cwd: testCwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this correction changes only static assets." }, rollback: { boundary: "Revert the changed asset and paired test", command: "git diff --check" } };
14
+ function state(): RemediationObservations { return { failedEvidenceRevision: revision, plan: parseRemediationPlan(plan, testCwd), observations: [], pending: {}, invalid: false }; }
15
+ function observed(s: RemediationObservations, command: string, id: string, exitCode: number | null = 0, overrides: Record<string, unknown> = {}) {
16
+ observeRemediationTool(s, { type: "tool_execution_start", toolName: "bash", toolCallId: id, args: { command } });
17
+ observeRemediationTool(s, { type: "tool_execution_end", toolName: "bash", toolCallId: id, isError: false, result: { content: [{ type: "text", text: "concrete command output" }], details: { remediationCommand: { toolCallId: id, command, cwd: testCwd, exitCode, ...overrides } } } });
18
+ }
19
+ test("remediation evidence requires every exact planned command and rollback observation", () => {
20
+ const s = state();
21
+ assert.equal(remediationEvidence(s), undefined);
22
+ observed(s, "pnpm test", "a");
23
+ assert.equal(remediationEvidence(s), undefined);
24
+ observed(s, "git diff --check", "b");
25
+ const evidence = remediationEvidence(s);
26
+ assert.equal(evidence.failed_evidence_revision, revision);
27
+ assert.equal(evidence.commands[0].exit_code, 0);
28
+ assert.match(evidence.commands[0].result, /sha256:/);
29
+ assert.equal(evidence.runtime_harness.status, "not_applicable");
30
+ assert.equal(evidence.rollback.boundary, plan.rollback.boundary);
31
+ });
32
+ for (const fault of ["nonzero", "null", "cwd", "id", "truncated", "unmatched"]) test(`remediation refuses ${fault} without parsing prose`, () => {
33
+ const s = state();
34
+ observed(s, fault === "unmatched" ? "echo passed" : "pnpm test", "a", fault === "nonzero" ? 1 : fault === "null" ? null : 0, fault === "cwd" ? { cwd: "/other" } : fault === "id" ? { toolCallId: "wrong" } : fault === "truncated" ? { truncated: true } : {});
35
+ observed(s, "git diff --check", "b");
36
+ assert.equal(remediationEvidence(s), undefined);
37
+ });
38
+ test("remediation plan is bounded and cannot select another working directory", () => {
39
+ assert.throws(() => parseRemediationPlan({ ...plan, cwd: "/other" }, testCwd), /plan/);
40
+ assert.throws(() => parseRemediationPlan({ ...plan, commands: [] }, testCwd), /plan/);
41
+ assert.throws(() => parseRemediationPlan({ ...plan, runtimeHarness: { naReason: "N/A" } }, testCwd), /plan/);
42
+ });
43
+
44
+
45
+ test("remediation shell captures numeric exit, preserves stock errors and executes once", async () => {
46
+ const { remediationBash } = await import("../extensions/gentle-agents.ts");
47
+ for (const exitCode of [0, 7, null]) {
48
+ let calls = 0;
49
+ const shell = remediationBash(testCwd, { exec: async (command, cwd, options) => {
50
+ calls++; assert.equal(command, "pnpm test"); assert.equal(cwd, testCwd);
51
+ options.onData(Buffer.from("real output")); return { exitCode };
52
+ } }, shellScope(testCwd, ["pnpm test"]));
53
+ const run = shell.definition.execute("call", { command: "pnpm test" }, undefined, undefined, undefined);
54
+ if (exitCode === 7) await assert.rejects(run, /code 7/); else await run;
55
+ const patch = shell.result({ toolCallId: "call", details: { fullOutputPath: "/retained", remediationCommand: { exitCode: 99 } } } as unknown as Parameters<typeof shell.result>[0]);
56
+ const details = patch.details as typeof patch.details & { fullOutputPath?: string };
57
+ assert.equal(details.remediationCommand.exitCode, exitCode);
58
+ assert.equal(details.remediationCommand.command, "pnpm test");
59
+ assert.equal(details.remediationCommand.cwd, testCwd);
60
+ assert.equal(details.fullOutputPath, "/retained");
61
+ assert.equal(shell.result({ toolCallId: "call" }), undefined);
62
+ assert.equal(calls, 1);
63
+ }
64
+ });
65
+
66
+
67
+ test("remediation owner and packaged actor are installed through existing ownership", async () => {
68
+ const { getPackageAssetOwner } = await import("../lib/sdd-preflight.ts");
69
+ const { readFileSync } = await import("node:fs");
70
+ assert.equal(getPackageAssetOwner("agents/sdd-remediate.md"), "sdd");
71
+ assert.match(readFileSync("assets/agents/sdd-remediate.md", "utf8"), /SDD remediate executor/);
72
+ });
73
+
74
+ test("native remediation admission refuses unsupported owner before acquire", async () => {
75
+ const { admitManagedRemediation } = await import("../extensions/gentle-agents.ts");
76
+ let acquires = 0;
77
+ await assert.rejects(admitManagedRemediation({ agent: { name: "sdd-remediate", filePath: "/absent" }, sddChange: { changeName: "fix", workspaceRoot: testCwd, phase: "remediate", failedEvidenceRevision: revision }, cwd: testCwd } as unknown as TaskRequest, {}, { sddAttemptAcquire: async () => { acquires++; } } as unknown as NativeReviewCli, async () => {}), /unsupported/i);
78
+ assert.equal(acquires, 0);
79
+ });
80
+
81
+
82
+ test("admitted remediation retains exact settlement before one lost-reply replay", async () => {
83
+ const { admitManagedRemediation } = await import("../extensions/gentle-agents.ts");
84
+ const { resolve } = await import("node:path");
85
+ const { readFileSync } = await import("node:fs");
86
+ const path = resolve("assets/agents/sdd-remediate.md");
87
+ const { parseAgentDefinition } = await import("../lib/agents-config.ts");
88
+ const agent = parseAgentDefinition(readFileSync(path, "utf8"), path, "global");
89
+ assert.ok("instructions" in agent);
90
+ const saved = [], calls = [];
91
+ const request = { agent, sddChange: { changeName: "fix", workspaceRoot: testCwd, phase: "remediate", failedEvidenceRevision: revision }, cwd: testCwd } as unknown as TaskRequest;
92
+ const native = { sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, artifactStore: "openspec", planningHome: { mode: "repo-local", path: `${testCwd}/openspec` }, changeRoot: `${testCwd}/openspec/changes/fix`, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: [], nextRecommended: "remediate", changeName: "fix", actionContext: { mode: "repo-local", workspaceRoot: testCwd, allowedEditRoots: [testCwd] }, remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }), sddAttemptAcquire: async input => { calls.push(["acquire", input]); return { state: "proceed", token: "opaque-admitted" }; }, sddAttemptSettle: async input => { assert.equal(saved.at(-1).sddRemediation.settle.requestId, input.requestId); calls.push(["settle", structuredClone(input)]); if (calls.length === 2) throw new Error("lost reply"); return { state: "complete" as const }; } } as unknown as NativeReviewCli;
93
+ const admitted = await admitManagedRemediation(request, { plan, attempt: { requestId: "a", workUnit: "fix", evidenceGoal: "Observed correction", maxAttempts: 1, maxChangedLines: 200 } }, native, async task => { saved.push(structuredClone(task)); }, human, { id: "prepared", cwd: testCwd, agent: "sdd-remediate", status: "queued" } as unknown as TaskRecord);
94
+ const task = { id: "task", cwd: testCwd, status: "completed", sddRemediation: admitted.sddRemediation } as unknown as TaskRecord & { sddRemediation: typeof admitted.sddRemediation };
95
+ observed(task.sddRemediation, "pnpm test", "one"); observed(task.sddRemediation, "git diff --check", "two");
96
+ await admitted.finalizeRemediation(task, { spawned: true, exited: true, cleanupConfirmed: true });
97
+ assert.equal(calls.filter(([verb]) => verb === "acquire").length, 1);
98
+ assert.equal(calls.filter(([verb]) => verb === "settle").length, 2);
99
+ assert.deepEqual(calls[1], calls[2]);
100
+ assert.equal(calls[1][1].outcome, "passed");
101
+ assert.equal(calls[1][1].remediatesEvidenceRevision, revision);
102
+ assert.equal(JSON.parse(calls[1][1].remediationEvidence).failed_evidence_revision, revision);
103
+ });
104
+
105
+
106
+ async function admissionFixture(overrides = {}, persist: (task: TaskRecord) => Promise<void> = async () => {}, agentPatch = {}, attemptPatch = {}, context: Pick<ExtensionContext, "hasUI" | "ui"> = human) {
107
+ const { admitManagedRemediation } = await import("../extensions/gentle-agents.ts");
108
+ const { parseAgentDefinition } = await import("../lib/agents-config.ts");
109
+ const { readFileSync } = await import("node:fs");
110
+ const { resolve } = await import("node:path");
111
+ const path = resolve("assets/agents/sdd-remediate.md");
112
+ const request = { agent: { ...parseAgentDefinition(readFileSync(path, "utf8"), path, "global"), ...agentPatch }, cwd: testCwd, sddChange: { changeName: "fix", workspaceRoot: testCwd, phase: "remediate", failedEvidenceRevision: revision } } as unknown as TaskRequest;
113
+ const calls = [];
114
+ const native = { sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, artifactStore: "openspec", planningHome: { mode: "repo-local", path: `${testCwd}/openspec` }, changeRoot: `${testCwd}/openspec/changes/fix`, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: [], nextRecommended: "remediate", changeName: "fix", actionContext: { mode: "repo-local", workspaceRoot: testCwd, allowedEditRoots: [testCwd] }, remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }), sddAttemptAcquire: async input => { calls.push(["acquire", input]); return { state: "proceed", token: "opaque" }; }, sddAttemptSettle: async input => { calls.push(["settle", input]); return { state: "proceed" as const }; }, ...overrides } as unknown as NativeReviewCli;
115
+ const admitted = await admitManagedRemediation(request, { plan, attempt: { requestId: "a", workUnit: "fix", evidenceGoal: "Observed correction", untrackedScope: "select", expectedUntrackedInventory: revision, intendedUntracked: ["new file.ts"], ...attemptPatch } }, native, persist, context, { id: "prepared", cwd: testCwd, agent: "sdd-remediate", status: "queued" } as unknown as TaskRecord);
116
+ return { admitted, calls, task: { id: "one", status: "failed", sddRemediation: admitted.sddRemediation } as unknown as TaskRecord & { sddRemediation: typeof admitted.sddRemediation } };
117
+ }
118
+ for (const state of ["blocked", "complete"]) test(`native ${state} refuses without settlement`, async () => {
119
+ let settles = 0;
120
+ await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => ({ state }), sddAttemptSettle: async () => { settles++; } }), new RegExp(state));
121
+ assert.equal(settles, 0);
122
+ });
123
+ for (const status of ["failed", "cancelled", "timed_out"] as const) test(`terminal ${status} settles without passing evidence and retains exact untracked scope`, async () => {
124
+ const { admitted, calls, task } = await admissionFixture(); task.status = status;
125
+ await admitted.finalizeRemediation(task, { spawned: true, exited: true, cleanupConfirmed: true });
126
+ const payload = calls[1][1];
127
+ assert.equal(payload.outcome, status === "failed" ? "failed" : "interrupted");
128
+ assert.equal(payload.remediationEvidence, undefined);
129
+ assert.equal(typeof payload.evidenceRevision, status === "failed" ? "string" : "undefined");
130
+ assert.deepEqual(payload.intendedUntracked, calls[0][1].intendedUntracked);
131
+ assert.equal(payload.expectedUntrackedInventory, revision);
132
+ });
133
+ test("failure to retain settlement never mutates native; two lost replies retain uncertainty", async () => {
134
+ const failed = await admissionFixture({}, async task => { if (task.sddRemediation.settle) throw new Error("disk failure"); });
135
+ await assert.rejects(failed.admitted.finalizeRemediation(failed.task, { spawned: false, exited: false, cleanupConfirmed: true }), /disk failure/);
136
+ assert.equal(failed.calls.length, 1);
137
+ let replies = 0;
138
+ const lost = await admissionFixture({ sddAttemptSettle: async () => { replies++; throw new Error("lost"); } });
139
+ await lost.admitted.finalizeRemediation(lost.task, { spawned: false, exited: false, cleanupConfirmed: true });
140
+ assert.equal(replies, 2); assert.equal(lost.task.sddRemediation.settlementUncertain, true);
141
+ assert.equal(lost.task.sddRemediation.settle.outcome, "interrupted");
142
+ });
143
+ test("runtime harness requires its own observed command; malformed content cannot pass", () => {
144
+ const s = state(); s.plan.runtimeHarness = { command: "pnpm run harness" };
145
+ observed(s, "pnpm test", "test"); observed(s, "git diff --check", "rollback");
146
+ assert.equal(remediationEvidence(s), undefined);
147
+ observed(s, "pnpm run harness", "harness"); assert.equal(remediationEvidence(s).runtime_harness.status, "passed");
148
+ const malformed = state();
149
+ observeRemediationTool(malformed, { type: "tool_execution_start", toolName: "bash", toolCallId: "a", args: { command: "pnpm test" } });
150
+ observeRemediationTool(malformed, { type: "tool_execution_end", toolName: "bash", toolCallId: "a", isError: false, result: { content: [null], details: { remediationCommand: { toolCallId: "a", command: "pnpm test", cwd: testCwd, exitCode: 0 } } } });
151
+ observed(malformed, "git diff --check", "b"); assert.equal(remediationEvidence(malformed), undefined);
152
+ });
153
+
154
+
155
+ test("stock local shell wrapper observes real exit zero and preserves cancellation", async () => {
156
+ const { remediationBash } = await import("../extensions/gentle-agents.ts");
157
+ const { createBashToolDefinition } = await import("@earendil-works/pi-coding-agent");
158
+ const shell = remediationBash(process.cwd(), undefined, shellScope(process.cwd(), ["printf wrapper-proof", "sleep 30"]));
159
+ assert.deepEqual(shell.definition.parameters, createBashToolDefinition(process.cwd()).parameters);
160
+ const result = await shell.definition.execute("real", { command: "printf wrapper-proof" }, undefined, undefined, undefined);
161
+ const [content] = result.content;
162
+ assert.ok(content?.type === "text");
163
+ assert.equal(content.text, "wrapper-proof");
164
+ assert.equal(shell.result({ toolCallId: "real", details: result.details }).details.remediationCommand.exitCode, 0);
165
+ const controller = new AbortController();
166
+ const timeout = setTimeout(() => controller.abort(), 30);
167
+ try { await assert.rejects(shell.definition.execute("cancel", { command: "sleep 30" }, controller.signal, undefined, undefined), /aborted/); }
168
+ finally { clearTimeout(timeout); controller.abort(); }
169
+ assert.equal(shell.result({ toolCallId: "cancel" }), undefined);
170
+ });
171
+
172
+
173
+ test("nonzero observed exits remain concrete failure evidence", () => {
174
+ const s = state(); observed(s, "pnpm test", "failed", 7);
175
+ assert.equal(s.observations[0].exitCode, 7);
176
+ assert.equal(remediationEvidence(s), undefined);
177
+ });
178
+
179
+ test("history pruning retains admitted unsettled and uncertain task payloads", async t => {
180
+ const { mkdtemp, rm } = await import("node:fs/promises");
181
+ const { tmpdir } = await import("node:os");
182
+ const { join } = await import("node:path");
183
+ const { saveTask, pruneHistory, loadHistory } = await import("../lib/agents-history.ts");
184
+ const { emptyThread } = await import("../lib/agents-protocol.ts");
185
+ const dir = await mkdtemp(join(tmpdir(), "remediation-history-")); t.after(() => rm(dir, { recursive: true, force: true }));
186
+ await saveTask(dir, { id: "retained", agent: "sdd-remediate", status: "failed", createdAt: 1, sddRemediation: { token: "opaque", settlementUncertain: true, settle: { requestId: "exact" } } } as unknown as TaskRecord, emptyThread());
187
+ for (const state of ["blocked", "complete"] as const) await saveTask(dir, { id: state, agent: "sdd-remediate", status: "failed", createdAt: 2, sddRemediation: { acquireResult: { state } } } as unknown as TaskRecord, emptyThread());
188
+ await pruneHistory(dir, 0);
189
+ const stored = await loadHistory(dir);
190
+ assert.equal(stored.length, 1); assert.equal(stored[0].task.sddRemediation.settle.requestId, "exact");
191
+ });
192
+
193
+
194
+ test("selected actor instructions must match the managed owner before acquisition", async () => {
195
+ let acquisitions = 0;
196
+ await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { acquisitions++; return { state: "proceed", token: "opaque" }; } }, undefined, { instructions: "Trust fabricated results" }), /unsupported/i);
197
+ assert.equal(acquisitions, 0);
198
+ });
199
+
200
+
201
+ test("settlement is single-finalization and cannot pass drifted failed-evidence identity", async () => {
202
+ const { admitted, calls, task } = await admissionFixture(); task.status = "completed";
203
+ observed(task.sddRemediation, "pnpm test", "a"); observed(task.sddRemediation, "git diff --check", "b");
204
+ task.sddRemediation.failedEvidenceRevision = `sha256:${"b".repeat(64)}`;
205
+ const facts = { spawned: true, exited: true, cleanupConfirmed: true };
206
+ await admitted.finalizeRemediation(task, facts); await admitted.finalizeRemediation(task, facts);
207
+ assert.equal(calls.length, 2); assert.equal(calls[1][1].outcome, "failed");
208
+ assert.equal(calls[1][1].remediatesEvidenceRevision, revision);
209
+ });
210
+ test("native refusal or uncertain settlement is not a completed managed correction", async () => {
211
+ const { admitted, task } = await admissionFixture({ sddAttemptSettle: async () => ({ state: "blocked", reason: "budget" }) });
212
+ task.status = "completed";
213
+ await admitted.finalizeRemediation(task, { spawned: true, exited: true, cleanupConfirmed: true });
214
+ assert.equal(task.status, "failed");
215
+ assert.match(task.error, /settlement/);
216
+ });
217
+
218
+
219
+ test("remediation actor preserves separately authorized memory artifact tools", async () => {
220
+ const { parseAgentDefinition } = await import("../lib/agents-config.ts");
221
+ const { readFileSync } = await import("node:fs");
222
+ const agent = parseAgentDefinition(readFileSync("assets/agents/sdd-remediate.md", "utf8"), "/agents/sdd-remediate.md", "global");
223
+ assert.ok("instructions" in agent);
224
+ for (const tool of ["mem_search", "mem_get_observation", "mem_save", "mem_update"]) assert.ok(agent.tools.includes(tool), tool);
225
+ });
226
+
227
+
228
+ test("managed parent admission continues an explicitly retained token without a new acquire", async () => {
229
+ const { calls } = await admissionFixture({}, undefined, {}, { token: "retained-parent-admission" });
230
+ assert.equal(calls.length, 1);
231
+ assert.equal(calls[0][1].token, "retained-parent-admission");
232
+ });
233
+
234
+
235
+ test("R3-duplicate-command-evidence requires a distinct execution for every indexed slot", () => {
236
+ const s = state(); s.plan.commands = ["pnpm test", "pnpm test"];
237
+ s.plan.runtimeHarness = { command: "pnpm test" }; s.plan.rollback.command = "pnpm test";
238
+ for (let index = 0; index < 4; index++) {
239
+ observed(s, "pnpm test", `slot-${index}`);
240
+ assert.equal(remediationEvidence(s) === undefined, index < 3);
241
+ }
242
+ assert.deepEqual(s.observations.map(item => item.slot), [0, 1, 2, 3]);
243
+ observed(s, "pnpm test", "slot-0");
244
+ assert.equal(remediationEvidence(s), undefined, "reused call identity cannot produce evidence");
245
+ });
246
+
247
+
248
+ test("R1 confirms exact canonical paths and commands; data, denial and symlinks grant nothing", async t => {
249
+ const { confirmRemediationScope, remediationToolAllowed } = await import("../extensions/gentle-agents.ts");
250
+ const { mkdtempSync, writeFileSync, symlinkSync, rmSync } = await import("node:fs");
251
+ const { tmpdir } = await import("node:os"); const { join } = await import("node:path");
252
+ const cwd = mkdtempSync(join(tmpdir(), "remediation-scope-")); t.after(() => rmSync(cwd, { recursive: true, force: true }));
253
+ const target = join(cwd, "allowed.ts"); writeFileSync(target, "original"); symlinkSync(target, join(cwd, "alias.ts"));
254
+ const candidate = { ...plan, cwd, editPaths: [target] }, native = { mode: "repo-local", workspaceRoot: cwd, allowedEditRoots: [cwd] };
255
+ let shown = "", confirmations = 0;
256
+ const ui = { hasUI: true, ui: { confirm: async (_title: string, text: string) => { shown = text; confirmations++; return true; } } } as unknown as Pick<ExtensionContext, "hasUI" | "ui">;
257
+ const scope = await confirmRemediationScope(candidate, native, ui);
258
+ assert.match(shown, /pnpm test/); assert.ok(shown.includes(target)); assert.ok(shown.includes(cwd));
259
+ assert.equal(remediationToolAllowed(scope, cwd, "write", { path: target }), true);
260
+ for (const tool of ["mem_search", "mem_get_observation", "mem_save", "mem_update"]) assert.equal(remediationToolAllowed(scope, cwd, tool, { id: 7, project: "other", content: "outside" }), false);
261
+ assert.equal(remediationToolAllowed(scope, cwd, "write", { path: join(cwd, "other.ts") }), false);
262
+ assert.equal(remediationToolAllowed(scope, cwd, "bash", { command: "pnpm test; touch outside" }), false);
263
+ for (const bad of [{ ...candidate, editPaths: [join(cwd, "alias.ts")] }, { ...candidate, editPaths: [cwd] }, { ...candidate, editPaths: [target, target] }]) await assert.rejects(confirmRemediationScope(bad, native, ui));
264
+ assert.equal(confirmations, 1);
265
+ for (const context of [undefined, { hasUI: false, ui: ui.ui }, { hasUI: true, ui: { confirm: async () => false } }, { hasUI: true, ui: { confirm: async () => undefined } }]) await assert.rejects(confirmRemediationScope(candidate, native, context as unknown as Pick<ExtensionContext, "hasUI" | "ui">), /authorization/);
266
+ });
267
+
268
+
269
+ test("R3/R4 acquire request is durable before mutation and token before actor admission", async () => {
270
+ const records = [], calls = [];
271
+ const { admitted } = await admissionFixture({ sddAttemptAcquire: async request => {
272
+ assert.deepEqual(records.at(-1).sddRemediation.acquire, request);
273
+ assert.equal(records.at(-1).sddRemediation.token, undefined);
274
+ calls.push(structuredClone(request)); if (calls.length === 1) throw new Error("lost acquire reply");
275
+ return { state: "proceed", token: "native-returned" };
276
+ } }, async task => { records.push(structuredClone(task)); });
277
+ assert.equal(calls.length, 2); assert.deepEqual(calls[0], calls[1]);
278
+ assert.equal(records.at(-1).sddRemediation.token, "native-returned");
279
+ assert.equal(admitted.sddRemediation.token, "native-returned");
280
+ });
281
+ test("R3/R4 acquire persistence failure refuses before native mutation", async () => {
282
+ let mutations = 0;
283
+ await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { mutations++; return { state: "proceed", token: "unsafe" }; } }, async () => { throw new Error("disk full"); }), /disk full/);
284
+ assert.equal(mutations, 0);
285
+ });
286
+
287
+
288
+ test("R1 denial/headless/cancellation gives zero acquisition and durable mutation", async () => {
289
+ for (const context of [{ hasUI: false }, { hasUI: true, ui: { confirm: async () => false } }, { hasUI: true, ui: { confirm: async () => undefined } }, { hasUI: true, ui: { confirm: async () => { throw new Error("cancelled"); } } }]) {
290
+ let acquisitions = 0, persistence = 0;
291
+ await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { acquisitions++; } }, async () => { persistence++; }, {}, {}, context as unknown as Pick<ExtensionContext, "hasUI" | "ui">));
292
+ assert.equal(acquisitions, 0); assert.equal(persistence, 0);
293
+ }
294
+ });
295
+ test("R1 child invokes only the exact confirmed command/cwd/count with distinct call IDs", async () => {
296
+ const { remediationBash } = await import("../extensions/gentle-agents.ts");
297
+ let executions = 0;
298
+ const shell = remediationBash(testCwd, { exec: async () => { executions++; return { exitCode: 0 }; } }, shellScope(testCwd, ["pnpm test", "pnpm test"]));
299
+ const execute = (id: string, command: string, cwd = testCwd) => shell.definition.execute(id, { command }, undefined, undefined, cwd === testCwd ? undefined : { cwd } as unknown as ExtensionContext);
300
+ await assert.rejects(execute("outside", "pnpm test; touch outside"), /authorization/);
301
+ await assert.rejects(execute("cwd", "pnpm test", "/other"), /authorization/);
302
+ assert.equal(executions, 0);
303
+ await execute("one", "pnpm test"); await assert.rejects(execute("one", "pnpm test"), /authorization/);
304
+ await execute("two", "pnpm test"); await assert.rejects(execute("three", "pnpm test"), /authorization/);
305
+ assert.equal(executions, 2);
306
+ });
307
+ test("R3/R4 known non-mutating acquire failures retain retryable blocked history while ambiguity remains unresolved", async () => {
308
+ for (const error of [new TypeError("invalid acquire request"), new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, NATIVE_REVIEW_OPERATION.SDD_ATTEMPT, true, false, "native launch unavailable")]) {
309
+ let calls = 0; const retained = [];
310
+ await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { calls++; throw error; } }, async task => { retained.push(structuredClone(task)); }), error);
311
+ assert.equal(calls, 1);
312
+ assert.deepEqual(retained.at(-1).sddRemediation.acquireResult, { state: "blocked" });
313
+ assert.equal(remediationUnresolved(retained.at(-1)), false);
314
+ }
315
+ let calls = 0; const retained = [];
316
+ await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { calls++; throw new Error("lost reply"); } }, async task => { retained.push(structuredClone(task)); }), /Unknown acquire/);
317
+ assert.equal(calls, 2); assert.equal(retained.at(-1).sddRemediation.acquireUncertain, true);
318
+ assert.equal(remediationUnresolved(retained.at(-1)), true);
319
+ assert.deepEqual(retained[0].sddRemediation.acquire, retained.at(-1).sddRemediation.acquire);
320
+ calls = 0;
321
+ await assert.rejects(admissionFixture({ sddAttemptAcquire: async () => { calls++; return { state: "proceed", token: "returned" }; } }, async task => { if (task.sddRemediation.token) throw new Error("token disk failure"); }), /token disk failure/);
322
+ assert.equal(calls, 1);
323
+ });
324
+
325
+
326
+ test("remediation actor explains one-launch human authority and indexed command evidence", async () => {
327
+ const { readFileSync } = await import("node:fs");
328
+ const text = readFileSync("assets/agents/sdd-remediate.md", "utf8");
329
+ assert.match(text, /fresh host UI confirmation/); assert.match(text, /each repeated command.*separate execution/);
330
+ assert.match(text, /reconcile.*without.*another actor/);
331
+ });
@@ -0,0 +1,253 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import test from "node:test";
9
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
10
+ import { AgentRunner, type ChildLike, type TaskRequest } from "../lib/agents-runner.ts";
11
+ import { TaskStore } from "../lib/agents-protocol.ts";
12
+ import { parseAgentDefinition } from "../lib/agents-config.ts";
13
+ import { Type } from "typebox";
14
+ import { RESEARCH_CHILD_TOOLS_ENV } from "../lib/sdd-research-capabilities.ts";
15
+ import { admitManagedRemediation } from "../extensions/gentle-agents.ts";
16
+
17
+ import { NativeReviewCliV216, createNodeExecFileAdapter } from "../lib/native-review-cli.ts";
18
+
19
+ const self = fileURLToPath(import.meta.url);
20
+ const source = dirname(dirname(self));
21
+ const binary = process.env.SDD_TEST_PRODUCER;
22
+ const childRole = process.env.UPTAKE_CHILD === "1";
23
+ const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
24
+ const json = (path: string) => JSON.parse(readFileSync(path, "utf8"));
25
+ const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
26
+
27
+ // The model is a finite local script, not a provider call or an actor verdict.
28
+ // SDK/extension hooks, tools, session persistence, RPC and AgentRunner are real.
29
+ export async function launch(): Promise<void> {
30
+ const sdk = await import("@earendil-works/pi-coding-agent");
31
+ const ai = await import("@earendil-works/pi-ai");
32
+ const args = process.argv.slice(1), value = (flag: string) => args[args.indexOf(flag) + 1];
33
+ const paths = args.flatMap((arg, i) => arg === "--extension" ? [args[i + 1]] : []);
34
+ const home = process.env.GENTLE_PI_AGENT_HOME!;
35
+ const runtimeModels = await sdk.ModelRuntime.create({ credentials: new ai.InMemoryCredentialStore(), modelsPath: join(home, "models.json"), modelsStorePath: join(home, "models-store.json"), allowModelNetwork: false });
36
+ const settings = sdk.SettingsManager.inMemory({ retry: { enabled: false }, compaction: { enabled: false } });
37
+ let turn = 0;
38
+ const script = json(process.env.UPTAKE_SCRIPT!);
39
+ const runtime = await sdk.createAgentSessionRuntime(async ({ cwd, sessionManager, sessionStartEvent }) => {
40
+ const services = await sdk.createAgentSessionServices({ cwd, agentDir: home, modelRuntime: runtimeModels, settingsManager: settings, resourceLoaderOptions: {
41
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
42
+ additionalExtensionPaths: paths, appendSystemPrompt: [value("--append-system-prompt")],
43
+ extensionFactories: [pi => {
44
+ pi.registerProvider("uptake-control", { baseUrl: "http://127.0.0.1:1", apiKey: "offline-control", api: "openai-completions", models: [{ id: "control", name: "control", reasoning: false, input: ["text"], cost, contextWindow: 200000, maxTokens: 1000 }],
45
+ streamSimple: (model, context) => {
46
+ writeFileSync(join(process.env.UPTAKE_RUN!, `input-${turn}.json`), JSON.stringify(context));
47
+ const calls = script[turn++] ?? [];
48
+ const stopReason = calls.length ? "toolUse" as const : "stop" as const;
49
+ const message: AssistantMessage = { role: "assistant", content: calls.length ? calls.map(([name, arguments_], i) => ({ type: "toolCall", id: `${turn}-${i}`, name, arguments: arguments_ })) : [{ type: "text", text: "Controlled script exhausted; not acceptance." }], api: model.api, provider: model.provider, model: model.id, usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost }, stopReason, timestamp: Date.now() };
50
+ const stream = ai.createAssistantMessageEventStream();
51
+ queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "done", reason: stopReason, message }); stream.end(message); });
52
+ return stream;
53
+ },
54
+ });
55
+ }],
56
+ } });
57
+ assert.deepEqual(services.resourceLoader.getExtensions().errors, []);
58
+ if (args.includes("--gentle-sdd-change")) services.resourceLoader.getExtensions().runtime.flagValues.set("gentle-sdd-change", value("--gentle-sdd-change"));
59
+ return { ...(await sdk.createAgentSessionFromServices({ services, sessionManager, sessionStartEvent, model: runtimeModels.getModel("uptake-control", "control"), tools: value("--tools").split(",") })), services, diagnostics: services.diagnostics };
60
+ }, { cwd: process.cwd(), agentDir: home, sessionManager: sdk.SessionManager.create(process.cwd(), value("--session-dir")) });
61
+ await sdk.runRpcMode(runtime);
62
+ }
63
+
64
+ // A local documentation capability with real extension provenance and an observable effect.
65
+ export default function controlledDocumentation(pi) {
66
+ pi.registerTool({ name: "fetch_content", label: "Controlled documentation", description: "Read the isolated documentation fixture only", parameters: Type.Object({}), execute: async () => {
67
+ appendFileSync(join(process.env.UPTAKE_RUN!, "fetch-calls"), "called\n");
68
+ return { content: [{ type: "text", text: "Independent controlled documentation bytes" }], details: {} };
69
+ } });
70
+ pi.on("session_start", (_event, ctx) => {
71
+ writeFileSync(join(process.env.UPTAKE_RUN!, "inventory.json"), JSON.stringify(pi.getAllTools()));
72
+ writeFileSync(join(process.env.UPTAKE_RUN!, "startup.json"), JSON.stringify({ cwd: ctx.cwd, flag: pi.getFlag("gentle-sdd-change"), grant: process.env.GENTLE_PI_SDD_REMEDIATION_PLAN }));
73
+ });
74
+ }
75
+
76
+ if (!childRole) test("installed AI producer → fixed Pi extensions → managed child input and tools", { skip: !binary, timeout: 90000 }, async t => {
77
+ assert.ok(binary && resolve(binary) === binary && existsSync(binary), "Explicit independently built AI binary required");
78
+ const producerIdentity = json(`${binary}.identity.json`);
79
+ assert.equal(producerIdentity.sourceRevision, "01e6fac15df169e8993e02549b9edcbd01abc0ee");
80
+ assert.equal(producerIdentity.sha256, digest(readFileSync(binary)), "Producer bytes must match the independent build record");
81
+ const root = mkdtempSync(join(tmpdir(), "pi-native-uptake-"));
82
+ t.after(() => rmSync(root, { recursive: true, force: true }));
83
+ const cwd = join(root, "workspace"), home = join(root, "profile"), pkg = join(root, "pi");
84
+ const change = join(cwd, "openspec", "changes", "uptake");
85
+ mkdirSync(join(change, "specs", "sample"), { recursive: true });
86
+ mkdirSync(home); mkdirSync(pkg);
87
+ execFileSync("git", ["init", "--quiet", cwd]);
88
+ for (const name of ["assets", "extensions", "lib", "runtime", "contracts", "scripts", "package.json", "tests"]) cpSync(join(source, name), join(pkg, name), { recursive: true });
89
+ symlinkSync(join(source, "node_modules"), join(pkg, "node_modules"), "dir");
90
+ execFileSync(process.execPath, ["scripts/build-runtime-modules.mjs", "--write"], { cwd: pkg });
91
+ const previousHome = process.env.GENTLE_PI_AGENT_HOME;
92
+ process.env.GENTLE_PI_AGENT_HOME = home;
93
+ try {
94
+ const { installPackageAssets } = await import(join(pkg, "lib", "sdd-preflight.ts"));
95
+ installPackageAssets(cwd, false, ["sdd"]);
96
+ } finally {
97
+ if (previousHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME; else process.env.GENTLE_PI_AGENT_HOME = previousHome;
98
+ }
99
+ const manifest = json(join(home, "gentle-ai", "managed-assets.json"));
100
+ const actor = (phase: string) => {
101
+ const path = join(home, "agents", `sdd-${phase}.md`);
102
+ const bytes = readFileSync(path, "utf8");
103
+ assert.equal(digest(bytes), manifest.assets[`agents/sdd-${phase}.md`]);
104
+ assert.equal(bytes, readFileSync(join(pkg, "assets", "agents", `sdd-${phase}.md`), "utf8"));
105
+ const definition = parseAgentDefinition(bytes, path, "global");
106
+ if (!("instructions" in definition)) throw new Error(`Invalid fixture agent: ${phase}`);
107
+ return definition;
108
+ };
109
+ for (const phase of ["apply", "research", "remediate"]) assert.equal(actor(phase).name, `sdd-${phase}`);
110
+ writeFileSync(join(change, "proposal.md"), "Native uptake controlled fixture\n");
111
+ writeFileSync(join(change, "design.md"), "Read-only controlled fixture\n");
112
+ writeFileSync(join(change, "specs", "sample", "spec.md"), "### Requirement: Controlled boundary\n#### Scenario: Read\n");
113
+ writeFileSync(join(change, "tasks.md"), "- [ ] Read controlled fixture\n");
114
+ const native = new NativeReviewCliV216(createNodeExecFileAdapter(), binary);
115
+ const status = await native.sddStatus({ changeName: "uptake", workspaceRoot: cwd });
116
+ assert.equal(status.schemaName, "gentle-ai.sdd-status"); assert.equal(status.schemaVersion, 2);
117
+ assert.equal(status.nextRecommended, "apply");
118
+ await t.test("installed host suppresses continuation for read-only and excluded-marker answers", async () => {
119
+ const { createGentleAiExtension } = await import(join(pkg, "extensions", "gentle-ai.ts"));
120
+ for (const answer of [false, undefined]) {
121
+ const verbs: string[] = [], confirmations: string[] = [], notices: string[] = [];
122
+ const commands = new Map();
123
+ const adapter = createNodeExecFileAdapter();
124
+ const client = new NativeReviewCliV216(request => { verbs.push(request.arguments[0]); return adapter(request); }, binary);
125
+ createGentleAiExtension({ nativeReviewCli: client, processEnv: {} })({ on() {}, events: { emit() {} }, registerTool() {}, registerCommand: (name, command) => commands.set(name, command.handler), sendUserMessage: () => assert.fail("Unexpected launch"), sendMessage: () => assert.fail("Unexpected launch") });
126
+ const context = { cwd, hasUI: true, ui: { notify: text => notices.push(text), confirm: async (_title, text) => { confirmations.push(text); return answer; } } };
127
+ await commands.get("gentle-sdd-status")("uptake --json", context);
128
+ assert.deepEqual(JSON.parse(notices[0]), status);
129
+ assert.equal(confirmations.length, 0);
130
+ await commands.get("gentle-sdd-continue")("uptake --json", context);
131
+ assert.deepEqual(verbs, ["sdd-status", "sdd-status"]);
132
+ assert.equal(confirmations.length, 1);
133
+ assert.ok(confirmations[0].includes(join(change, ".gentle-ai-instance")));
134
+ assert.equal(existsSync(join(change, ".gentle-ai-instance")), false);
135
+ }
136
+ });
137
+ const fixed = [join(pkg, "extensions", "gentle-ai.ts"), join(pkg, "extensions", "gentle-agents.ts")];
138
+ const docExtension = join(pkg, "tests", "sdd-native-managed-uptake.test.ts");
139
+ const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: docExtension } } };
140
+ async function run(id: string, phase: "apply" | "research" | "remediate", script: unknown[], options: Record<string, any> = {}) {
141
+ const directory = join(root, id); mkdirSync(directory);
142
+ const scriptPath = join(directory, "script.json"); writeFileSync(scriptPath, JSON.stringify(script));
143
+ const events: any[] = [], launches: any[] = [];
144
+ const runner = new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 15000 }, {
145
+ pi: { command: process.execPath, args: ["--experimental-strip-types", "--input-type=module", "--eval", `import(${JSON.stringify(docExtension)}).then(m=>m.launch())`, "--"] },
146
+ now: Date.now, schedule: (fn, ms) => { const timer = setTimeout(fn, ms); return () => clearTimeout(timer); },
147
+ spawn: (command, args, spawnOptions): ChildLike => {
148
+ launches.push({ args, cwd: spawnOptions.cwd });
149
+ const child = spawn(command, args, { ...spawnOptions, stdio: ["pipe", "pipe", "pipe"] });
150
+ let buffer = "";
151
+ child.stdout.on("data", data => { buffer += data; const lines = buffer.split("\n"); buffer = lines.pop()!; for (const line of lines) { try { events.push(JSON.parse(line)); } catch { /* Diagnostics are not RPC evidence. */ } } });
152
+ child.stderr.on("data", data => appendFileSync(join(directory, "stderr.log"), data));
153
+ return child as unknown as ChildLike;
154
+ },
155
+ }, { askUser: async () => ({ cancelled: true }) });
156
+ try {
157
+ const task = runner.run({ agent: { ...actor(phase), ...(phase === "research" ? { tools: ["read", "write", "fetch_content"] } : {}) }, prompt: "Controlled boundary probe only; no implementation or acceptance.", label: undefined, context: phase === "research" ? JSON.stringify(status) : undefined, mode: "task", cwd, parentSessionId: "uptake", model: undefined, thinking: undefined, sessionDir: join(directory, "sessions"), resumeSessionPath: undefined, env: { PATH: process.env.PATH, HOME: home, PI_CODING_AGENT_DIR: home, GENTLE_PI_AGENT_HOME: home, PI_OFFLINE: "1", UPTAKE_CHILD: "1", UPTAKE_RUN: directory, UPTAKE_SCRIPT: scriptPath, GENTLE_PI_GENTLE_AI_DEV_BINARY: binary, ...(phase === "research" ? { [RESEARCH_CHILD_TOOLS_ENV]: JSON.stringify(["read", "write", "fetch_content"]) } : {}), ...options.env }, extensionPaths: [...fixed, ...(options.absentTool ? [] : [docExtension])], researchSelection: selection, researchArtifact: options.artifact, sddRemediation: options.remediation, ...(phase === "research" ? {} : { sddChange: { phase, changeName: "uptake", workspaceRoot: cwd, ...options.selection } }) });
158
+ const result = await runner.waitFor(task.id);
159
+ assert.equal(launches.length, 1);
160
+ assert.deepEqual(launches[0].args.flatMap((arg, i, args) => arg === "--extension" ? [args[i + 1]] : []), [...fixed, ...(options.absentTool ? [] : [docExtension])]);
161
+ writeFileSync(join(directory, "events.json"), JSON.stringify(events));
162
+ writeFileSync(join(directory, "launch.json"), JSON.stringify(launches));
163
+ if (process.env.UPTAKE_EVIDENCE_DIR) cpSync(directory, join(process.env.UPTAKE_EVIDENCE_DIR, id), { recursive: true });
164
+ return { result, events, directory, input: existsSync(join(directory, "input-0.json")) ? json(join(directory, "input-0.json")) : undefined };
165
+ } finally { runner.cancelAll(); }
166
+ }
167
+ const ended = (run, name: string) => run.events.filter(event => event.type === "tool_execution_end" && event.toolName === name);
168
+ await t.test("native apply context and installed actor reach actual model/tool input", async () => {
169
+ const observed = await run("apply", "apply", [[["read", { path: join(change, "proposal.md") }]]]);
170
+ assert.equal(observed.result.status, "completed", JSON.stringify(observed.result));
171
+ assert.ok(observed.input.systemPrompt.includes(JSON.stringify(status, null, 2)));
172
+ assert.ok(observed.input.systemPrompt.includes(actor("apply").instructions));
173
+ assert.equal(ended(observed, "read").length, 1); assert.equal(ended(observed, "read")[0].isError, false);
174
+ assert.match(JSON.stringify(ended(observed, "read")[0].result), /Native uptake controlled fixture/);
175
+ });
176
+ const path = join(change, "research.md"), initial = '{"revision":1,"outcome":"blocked"}', next = '{"revision":2,"outcome":"partial","intent":"retain docs request"}';
177
+ const artifact = { store: "openspec", worktree: cwd, changeName: "uptake", retainedIntent: "retain docs request", locators: [{ artifact: "research", path, revision: 1, digest: digest(initial) }] };
178
+ writeFileSync(path, initial);
179
+ for (const absentTool of [true, false]) await t.test(`research provisioning absent=${absentTool} retains bounded denial persistence`, async () => {
180
+ const content = absentTool ? next : '{"revision":3,"outcome":"partial","intent":"retain docs request"}';
181
+ if (!absentTool) { artifact.locators[0].revision = 2; artifact.locators[0].digest = digest(next); }
182
+ const observed = await run(`research-${absentTool}`, "research", [[["fetch_content", {}]], [["read", { path }]], [["write", { path, content }]], [["read", { path }]], [["write", { path: join(cwd, "forbidden"), content }]]], { artifact, absentTool });
183
+ assert.equal(observed.result.status, "completed", JSON.stringify(observed.result));
184
+ assert.ok(JSON.stringify(observed.input).includes("gentle-ai.sdd-status"));
185
+ assert.equal(ended(observed, "fetch_content")[0].isError, absentTool);
186
+ assert.equal(existsSync(join(observed.directory, "fetch-calls")), !absentTool);
187
+ assert.equal(readFileSync(path, "utf8"), content);
188
+ assert.equal(existsSync(join(cwd, "forbidden")), false);
189
+ assert.equal(ended(observed, "write")[1].isError, true);
190
+ assert.match(JSON.stringify(ended(observed, "read").at(-1).result), /Readback identity matched/);
191
+ if (!absentTool) assert.equal(json(join(observed.directory, "inventory.json")).find(tool => tool.name === "fetch_content").sourceInfo.path, docExtension);
192
+ });
193
+ for (const fault of ["stale", "worktree"]) await t.test(`research ${fault} refuses before mutation`, async () => {
194
+ const bytes = readFileSync(path, "utf8");
195
+ const scope = { ...artifact, worktree: fault === "worktree" ? root : cwd, locators: [{ ...artifact.locators[0], revision: 3, digest: fault === "stale" ? "0".repeat(64) : digest(bytes) }] };
196
+ const observed = await run(`research-${fault}`, "research", [[["read", { path }]], [["write", { path, content: '{"revision":4}' }]]], { artifact: scope });
197
+ assert.equal(ended(observed, "read")[0].isError, true);
198
+ assert.equal(ended(observed, "write")[0].isError, true);
199
+ assert.equal(readFileSync(path, "utf8"), bytes);
200
+ });
201
+
202
+ await t.test("wrong native worktree refuses actual child tool work", async () => {
203
+ const observed = await run("wrong-worktree", "apply", [[["read", { path: join(change, "proposal.md") }]]], { selection: { workspaceRoot: root } });
204
+ assert.match(observed.input.systemPrompt, /SDD selection blocked/);
205
+ assert.equal(ended(observed, "read").length, 1);
206
+ assert.equal(ended(observed, "read")[0].isError, true);
207
+ });
208
+ await t.test("missing installed typed remediation asset refuses before native acquire", async () => {
209
+ const definition = actor("remediate");
210
+ const bytes = readFileSync(definition.filePath);
211
+ rmSync(definition.filePath);
212
+ let acquires = 0;
213
+ try {
214
+ await assert.rejects(admitManagedRemediation({ agent: definition, cwd, sddChange: { phase: "remediate", changeName: "uptake", workspaceRoot: cwd, failedEvidenceRevision: `sha256:${"a".repeat(64)}` } } as unknown as TaskRequest, {}, { ...native, sddAttemptAcquire: async () => { acquires++; throw new Error("Must not acquire"); } } as unknown as import("../lib/native-review-cli.ts").NativeReviewCli, async () => {}), /unsupported/i);
215
+ assert.equal(acquires, 0);
216
+ } finally { writeFileSync(definition.filePath, bytes); }
217
+ });
218
+ await t.test("native failed-evidence identity reaches the installed distinct remediation child; stale binding refuses", async () => {
219
+ writeFileSync(join(change, "tasks.md"), "- [x] Controlled fixture complete\n");
220
+ // A declared failing verification fixture, never a passing evidence claim.
221
+ const failed = `sha256:${"a".repeat(64)}`;
222
+ writeFileSync(join(change, "verify-report.md"), ["```yaml", "schema: gentle-ai.verify-result/v1", `evidence_revision: ${failed}`, "verdict: fail", "blockers: 1", "critical_findings: 0", "requirements: 1/1", "scenarios: 1/1", "test_command: false", "test_exit_code: 1", `test_output_hash: sha256:${digest("")}`, "build_command: true", "build_exit_code: 0", `build_output_hash: sha256:${digest("")}`, "```"].join("\n"));
223
+ const remediationStatus = await native.sddStatus({ changeName: "uptake", workspaceRoot: cwd });
224
+ assert.equal(remediationStatus.nextRecommended, "remediate");
225
+ assert.equal(remediationStatus.remediationState.failedEvidenceRevision, failed);
226
+ const plan = { cwd, editPaths: [join(change, "proposal.md")], commands: ["true"], runtimeHarness: { naReason: "No correction harness because this controlled boundary performs no source correction or acceptance." }, rollback: { boundary: "Remove the isolated controlled fixture only", command: "true" } };
227
+ const remediation = { failedEvidenceRevision: failed, plan, scope: { cwd, commands: ["true", "true"], editPaths: [join(change, "proposal.md")], allowedEditRoots: [cwd] }, observations: [], pending: {}, invalid: false };
228
+ const observed = await run("remediate", "remediate", [[["read", { path: join(change, "proposal.md") }]]], { selection: { failedEvidenceRevision: failed }, remediation });
229
+ assert.ok(observed.input.systemPrompt.includes(JSON.stringify(remediationStatus, null, 2)), observed.input.systemPrompt.slice(-9000));
230
+ assert.ok(observed.input.systemPrompt.includes(actor("remediate").instructions));
231
+ assert.equal(ended(observed, "read")[0].isError, false, JSON.stringify({ ended: ended(observed, "read"), startup: json(join(observed.directory, "startup.json")) }));
232
+ const stale = await run("stale-remediation", "remediate", [[["read", { path: join(change, "proposal.md") }]]], { selection: { failedEvidenceRevision: `sha256:${"b".repeat(64)}` }, remediation });
233
+ assert.match(stale.input.systemPrompt, /Stale remediation selection/);
234
+ assert.equal(ended(stale, "read")[0].isError, true);
235
+ });
236
+
237
+ const buildFiles: Record<string, string> = {};
238
+ const inventory = (directory: string) => {
239
+ for (const entry of readdirSync(join(pkg, directory), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
240
+ const name = join(directory, entry.name);
241
+ if (entry.isDirectory()) inventory(name); else if (entry.isFile()) buildFiles[name] = digest(readFileSync(join(pkg, name)));
242
+ }
243
+ };
244
+ for (const directory of ["assets", "extensions", "lib", "runtime", "contracts", "scripts"]) inventory(directory);
245
+ buildFiles["package.json"] = digest(readFileSync(join(pkg, "package.json")));
246
+ const tuple = { aiSource: producerIdentity.sourceRevision, aiBinary: producerIdentity.sha256, piHead: execFileSync("git", ["rev-parse", "HEAD"], { cwd: source, encoding: "utf8" }).trim(), piBuild: digest(JSON.stringify(buildFiles)), buildFiles, manifest, contract: "gentle-ai.sdd-status/v2" };
247
+ if (process.env.UPTAKE_EVIDENCE_DIR) {
248
+ writeFileSync(join(process.env.UPTAKE_EVIDENCE_DIR, "tuple.json"), JSON.stringify(tuple, null, 2));
249
+ cpSync(join(home, "agents"), join(process.env.UPTAKE_EVIDENCE_DIR, "installed-agents"), { recursive: true });
250
+ }
251
+ t.diagnostic(JSON.stringify({ aiSource: tuple.aiSource, aiBinary: tuple.aiBinary, piHead: tuple.piHead, piBuild: tuple.piBuild, contract: tuple.contract }));
252
+
253
+ });