gentle-pi 2.7.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. package/tests/openspec-guardrails.test.ts +0 -71
@@ -1,10 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
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";
3
+ import { readFileSync } from "node:fs";
8
4
  import { childArguments } from "../lib/agents-runner.ts";
9
5
  import { resolveResearchCapabilities, researchAgent, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts";
10
6
  import type { AgentDefinition } from "../lib/agents-config.ts";
@@ -19,7 +15,7 @@ test("approved active external tools reach the actual child CLI allowlist", () =
19
15
  const pi = inventory(["read", "write", "fetch_content", "web_search", "source_check", "get_search_content", "bash", "mcp"]);
20
16
  const result = researchAgent(agent, pi, both);
21
17
  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");
18
+ assert.equal(args[args.indexOf("--tools") + 1], "fetch_content,web_search,source_check,get_search_content,subagent_parent_message");
23
19
  assert.equal(result.capabilities.documentation.status, "available");
24
20
  assert.equal(result.capabilities["open-web"].status, "available");
25
21
  });
@@ -31,19 +27,19 @@ test("class-specific grants remain exact when both classes are explicitly select
31
27
  assert.match(renderResearchCapabilities(caps), /documentation: available; tools=\["fetch_content"\]/);
32
28
  assert.deepEqual(resolveResearchCapabilities(inventory(["web_search"])).documentation.tools, []);
33
29
  assert.deepEqual(resolveResearchCapabilities(inventory(["web_search"]))["open-web"].tools, ["web_search"]);
34
- assert.deepEqual(researchAgent(agent, inventory(names), both).agent.tools, agent.tools);
30
+ assert.deepEqual(researchAgent(agent, inventory(names), both).agent.tools, agent.tools.filter(name => !["read", "write"].includes(name)));
35
31
  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/);
32
+ assert.match(instructions, /Report grants per source class exactly as observed/);
37
33
  assert.match(instructions, /never copy the child tool union into each class/);
38
34
  assert.match(instructions, /research_selection/);
39
35
  assert.match(instructions, /sourceInfo\.path/);
40
36
  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/);
37
+ assert.match(instructions, /output-only/);
38
+ assert.doesNotMatch(instructions, /revision_count/);
39
+ assert.match(instructions, /parent owns.*persistence/);
40
+
45
41
  });
46
- test("open-web requires all four canonical tools, each active and unrestricted", () => {
42
+ test("open-web exposes available authorized tools without a completeness gate", () => {
47
43
  const required = ["web_search", "source_check", "fetch_content", "get_search_content"];
48
44
  for (const missing of required) {
49
45
  const remaining = required.filter(name => name !== missing);
@@ -53,7 +49,7 @@ test("open-web requires all four canonical tools, each active and unrestricted",
53
49
  resolveResearchCapabilities(inventory(required), remaining),
54
50
  researchAgent(agent, inventory(remaining), both).capabilities,
55
51
  ]) {
56
- assert.equal(caps["open-web"].status, "blocked", `${missing} must deny open-web`);
52
+ assert.equal(caps["open-web"].status, "available", `${missing} must not deny other tools`);
57
53
  assert.match(caps["open-web"].reason, new RegExp(missing));
58
54
  assert.equal(caps.documentation.status, missing === "fetch_content" ? "blocked" : "available");
59
55
  }
@@ -63,14 +59,14 @@ test("restrictions, inactive tools and unknown tools never become grants", () =>
63
59
  const pi = inventory(["fetch_content", "web_search", "mcp", "mcp__context7", "bash"]);
64
60
  const caps = resolveResearchCapabilities(pi, ["web_search"]);
65
61
  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"]);
62
+ assert.equal(caps["open-web"].status, "available");
63
+ assert.deepEqual(researchAgent({ ...agent, tools: ["read", "write"] } as never, pi).agent.tools, []);
68
64
  assert.equal(resolveResearchCapabilities(inventory(["mcp", "mcp__context7"])).documentation.status, "blocked");
69
65
  const inactive = { ...pi, getActiveTools: () => [] };
70
66
  assert.equal(resolveResearchCapabilities(inactive).documentation.status, "blocked");
71
67
  });
72
68
  test("documentation can run independently of unavailable open-web search", () => {
73
- const caps = resolveResearchCapabilities(inventory(["fetch_content"]));
69
+ const caps = researchAgent(agent, inventory(["fetch_content"]), documentation).capabilities;
74
70
  assert.equal(caps.documentation.status, "available");
75
71
  assert.equal(caps["open-web"].status, "blocked");
76
72
  assert.match(renderResearchCapabilities(caps), /official/);
@@ -84,15 +80,15 @@ test("SDK-only tools and unavailable inventory fail closed", () => {
84
80
 
85
81
  test("selected documentation never inherits available open-web routes or arbitrary extensions", () => {
86
82
  const result = researchAgent(agent, inventory(agent.tools), documentation);
87
- assert.deepEqual(result.agent.tools, ["read", "write", "fetch_content"]);
83
+ assert.deepEqual(result.agent.tools, ["fetch_content"]);
88
84
  assert.deepEqual(result.extensionPaths, ["/installed/web.ts"]);
89
85
  assert.equal(result.capabilities["open-web"].status, "blocked");
90
86
  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"]);
87
+ assert.deepEqual(researchAgent(agent, inventory(agent.tools), selection).agent.tools, []);
92
88
  }
93
89
  });
94
90
 
95
- test("selected grants refuse missing, inactive, SDK, restricted and unknown routes without removing local authorization", () => {
91
+ test("selected grants refuse missing, inactive, SDK, restricted and unknown routes while research remains output-only", () => {
96
92
  const local = ["read", "grep", "find", "edit", "write", "mem_search", "mem_get_observation", "mem_save"];
97
93
  const definition = { ...agent, tools: [...local, ...both["open-web"].tools, "bash", "mcp"] };
98
94
  for (const missing of both["open-web"].tools) {
@@ -104,153 +100,15 @@ test("selected grants refuse missing, inactive, SDK, restricted and unknown rout
104
100
  [full, definition.tools.filter(name => name !== missing)],
105
101
  ] as const) {
106
102
  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");
103
+ assert.deepEqual(result.agent.tools, both["open-web"].tools.filter(name => name !== missing), missing);
104
+ assert.equal(result.capabilities["open-web"].status, "available");
109
105
  assert.deepEqual(result.capabilities["open-web"].tools, both["open-web"].tools.filter(name => name !== missing));
110
106
  }
111
107
  }
112
108
  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);
109
+ assert.deepEqual(researchAgent(definition, inventory(definition.tools), selection).agent.tools, []);
114
110
  }
115
111
  const allowed = researchAgent(definition, inventory(definition.tools), { "open-web": both["open-web"] });
116
- assert.deepEqual(allowed.agent.tools, [...local, ...both["open-web"].tools]);
112
+ assert.deepEqual(allowed.agent.tools, both["open-web"].tools);
117
113
  assert.deepEqual(allowed.capabilities.documentation.tools, []);
118
114
  });
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
- });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs";
3
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
@@ -92,12 +92,14 @@ test("selected SDD change snapshots at task construction and reaches child start
92
92
  const concurrent = spawned[1]![spawned[1]!.indexOf("--gentle-sdd-change") + 1]!;
93
93
  assert.deepEqual(JSON.parse(serialized), { changeName: "alpha", workspaceRoot: root, phase: "apply" });
94
94
  assert.deepEqual(JSON.parse(concurrent), { changeName: "beta", workspaceRoot: root, phase: "apply" });
95
- const startup = __testing.resolveSddChangeStartup(serialized, root, "sdd-apply");
95
+ const authority = { ...commandStatus(root), nextRecommended: "apply", blockedReasons: [],
96
+ dependencies: { ...commandStatus(root).dependencies, apply: "ready" } };
97
+ const startup = await nativeStartup(serialized, root, "sdd-apply", { sddStatus: async () => authority });
96
98
  assert.equal(startup.status.changeName, "alpha");
97
- assert.equal(startup.status.nextRecommended, "sdd-propose");
99
+ assert.equal(startup.status.nextRecommended, "apply");
98
100
  });
99
101
 
100
- test("selected native v2 archive authority is injected whole and never falls back to the local resolver", async (t) => {
102
+ test("selected native v2 archive authority is injected whole without a competing local projection", async (t) => {
101
103
  const root = workspace(t);
102
104
  const serialized = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "archive" });
103
105
  const nativeAuthority = {
@@ -114,31 +116,16 @@ test("selected native v2 archive authority is injected whole and never falls bac
114
116
  nextRecommended: "archive",
115
117
  };
116
118
  const nativeCalls: unknown[] = [];
117
- let localResolverCalls = 0;
118
- const startup = await (__testing as unknown as {
119
- resolveSelectedNativeSddChangeStartup(
120
- serialized: unknown,
121
- cwd: string,
122
- agentName: string,
123
- native: { sddStatus?: (request: unknown) => Promise<unknown> },
124
- localResolver: () => unknown,
125
- ): Promise<{ selection: { changeName: string; workspaceRoot: string; phase: string }; status: unknown }>;
126
- }).resolveSelectedNativeSddChangeStartup(serialized, root, "sdd-archive", {
119
+ const startup = await nativeStartup(serialized, root, "sdd-archive", {
127
120
  sddStatus: async (request) => { nativeCalls.push(request); return nativeAuthority; },
128
- }, () => {
129
- localResolverCalls += 1;
130
- // Simulates local v1's missing sync-report.md result: it must never
131
- // overlay the native archive-ready authority.
132
- return { dependencies: { verify: "all_done", sync: "blocked", archive: "blocked" } };
133
121
  });
134
122
 
135
123
  assert.deepEqual(startup.selection, { changeName: "alpha", workspaceRoot: root, phase: "archive" });
136
124
  assert.equal(startup.status, nativeAuthority, "the validated native status object is injected without a local overlay");
137
125
  assert.deepEqual(nativeCalls, [{ changeName: "alpha", workspaceRoot: root }]);
138
- assert.equal(localResolverCalls, 0);
139
126
  });
140
127
 
141
- test("selected native v2 failures fail closed without consulting the local resolver", async (t) => {
128
+ test("selected native v2 failures fail closed without local status reconstruction", async (t) => {
142
129
  const root = workspace(t);
143
130
  const serialized = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "archive" });
144
131
  const valid = {
@@ -163,66 +150,17 @@ test("selected native v2 failures fail closed without consulting the local resol
163
150
  { sddStatus: async () => ({ ...valid, blockedReasons: "invalid" }) },
164
151
  ];
165
152
  for (const native of failures) {
166
- let localResolverCalls = 0;
167
- await assert.rejects(
168
- () => (__testing as unknown as {
169
- resolveSelectedNativeSddChangeStartup(
170
- serialized: unknown, cwd: string, agentName: string,
171
- native: { sddStatus?: () => Promise<unknown> }, localResolver: () => unknown,
172
- ): Promise<unknown>;
173
- }).resolveSelectedNativeSddChangeStartup(serialized, root, "sdd-archive", native, () => { localResolverCalls += 1; return {}; }),
174
- /SDD selection native status/i,
175
- );
176
- assert.equal(localResolverCalls, 0);
153
+ await assert.rejects(() => nativeStartup(serialized, root, "sdd-archive", native), /SDD selection native status/i);
177
154
  }
155
+ await assert.rejects(() => nativeStartup(serialized, root, "sdd-archive", { sddStatus: async () => valid }), /native status.*blocks|cannot execute/i);
178
156
 
179
- await assert.rejects(
180
- () => (__testing as unknown as {
181
- resolveSelectedNativeSddChangeStartup(
182
- serialized: unknown, cwd: string, agentName: string,
183
- native: { sddStatus?: () => Promise<unknown> }, localResolver: () => unknown,
184
- ): Promise<unknown>;
185
- }).resolveSelectedNativeSddChangeStartup(serialized, root, "sdd-archive", { sddStatus: async () => valid }, () => {
186
- throw new Error("local resolver must not run");
187
- }),
188
- /native status.*blocks|cannot execute/i,
189
- );
190
-
191
- const syncSerialized = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "sync" });
192
- const localStatus = __testing.resolveSddChangeStartup(syncSerialized, root, "sdd-sync").status;
193
- const syncNativeCalls: unknown[] = [];
194
- const syncLocalCalls: unknown[] = [];
195
- const sync = await (__testing as unknown as {
196
- resolveSelectedNativeSddChangeStartup(
197
- serialized: unknown, cwd: string, agentName: string,
198
- native: { sddStatus?: (request: unknown) => Promise<unknown> }, localResolver: (options: unknown) => unknown,
199
- ): Promise<{ status: unknown }>;
200
- }).resolveSelectedNativeSddChangeStartup(syncSerialized, root, "sdd-sync", {
201
- sddStatus: async (request) => { syncNativeCalls.push(request); throw new Error("native must not run"); },
202
- }, (options) => {
203
- syncLocalCalls.push(options);
204
- return localStatus;
205
- });
206
- assert.equal(sync.status, localStatus, "selected sync injects the exact local status");
207
- assert.deepEqual(syncLocalCalls, [{ cwd: root, workspaceRoot: root, changeName: "alpha", includeInstructions: true }]);
208
- assert.deepEqual(syncNativeCalls, []);
209
-
210
- await assert.rejects(
211
- () => (__testing as unknown as {
212
- resolveSelectedNativeSddChangeStartup(
213
- serialized: unknown, cwd: string, agentName: string,
214
- native: { sddStatus?: () => Promise<unknown> }, localResolver: () => typeof localStatus,
215
- ): Promise<unknown>;
216
- }).resolveSelectedNativeSddChangeStartup(syncSerialized, root, "sdd-sync", undefined, () => ({ ...localStatus, changeName: "beta" })),
217
- /mismatched status/i,
218
- );
219
157
  });
220
158
 
221
159
  function nativeStartup(
222
160
  serialized: unknown,
223
161
  cwd: string,
224
162
  agentName: string,
225
- native: { sddStatus: (request: unknown) => Promise<unknown> },
163
+ native: { sddStatus?: (request: unknown) => Promise<unknown> },
226
164
  ) {
227
165
  return (__testing as unknown as {
228
166
  resolveSelectedNativeSddChangeStartup(
@@ -275,24 +213,24 @@ test("a native verify evidence-refresh route starts under its own blocker while
275
213
  }
276
214
  });
277
215
 
278
- test("a throwing SDD selection flag reader fails closed without resolving an unselected status", (t) => {
216
+ test("a throwing SDD selection flag reader fails closed without resolving an unselected status", async (t) => {
279
217
  const root = workspace(t);
280
218
  assert.equal(__testing.readSddChangeFlag({ getFlag: () => false } as never), undefined);
281
219
  const selection = __testing.readSddChangeFlag({
282
220
  getFlag() { throw new Error("flag reader failed"); },
283
221
  } as never);
284
222
  let resolverCalls = 0;
285
- assert.throws(
286
- () => __testing.resolveSddChangeStartup(selection, root, "sdd-apply", () => {
223
+ await assert.rejects(
224
+ () => nativeStartup(selection, root, "sdd-apply", { sddStatus: async () => {
287
225
  resolverCalls += 1;
288
226
  throw new Error("status resolver must not run");
289
- }),
227
+ } }),
290
228
  /SDD selection must be a JSON string/i,
291
229
  );
292
230
  assert.equal(resolverCalls, 0);
293
231
  });
294
232
 
295
- test("selected SDD startup fails closed for malformed identity, root, phase, symlink, and resolver errors", (t) => {
233
+ test("selected SDD startup fails closed for malformed identity, root, phase, symlink, and resolver errors", async (t) => {
296
234
  const root = workspace(t);
297
235
  const selected = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "apply" });
298
236
  const outside = mkdtempSync(join(tmpdir(), "gentle-pi-sdd-selection-outside-"));
@@ -308,11 +246,11 @@ test("selected SDD startup fails closed for malformed identity, root, phase, sym
308
246
  JSON.stringify({ changeName: "alpha", workspaceRoot: join(root, "wrong"), phase: "apply" }),
309
247
  JSON.stringify({ changeName: "alpha", workspaceRoot: escaped, phase: "apply" }),
310
248
  ]) {
311
- assert.throws(() => __testing.resolveSddChangeStartup(value, root, "sdd-apply"), /SDD selection/i);
249
+ await assert.rejects(() => nativeStartup(value, root, "sdd-apply", {}), /SDD selection/i);
312
250
  }
313
- assert.throws(() => __testing.resolveSddChangeStartup(selected, root, "sdd-verify"), /phase/i);
314
- assert.throws(
315
- () => __testing.resolveSddChangeStartup(selected, root, "sdd-apply", () => { throw new Error("resolver failed"); }),
251
+ await assert.rejects(() => nativeStartup(selected, root, "sdd-verify", {}), /phase/i);
252
+ await assert.rejects(
253
+ () => nativeStartup(selected, root, "sdd-apply", { sddStatus: async () => { throw new Error("resolver failed"); } }),
316
254
  /resolver failed/i,
317
255
  );
318
256
  });
@@ -395,14 +333,10 @@ test("before_agent_start resolves the unnamed packaged executor and renders nati
395
333
  for (const phase of ["apply", "verify", "remediate", "archive"] as const) {
396
334
  const phasePrompt = readFileSync(new URL(`../assets/agents/sdd-${phase}.md`, import.meta.url), "utf8").replace(/^---\n[\s\S]*?\n---\n/, "");
397
335
  serialized = undefined;
398
- nativeReply = { ...status, nextRecommended: phase, ...(phase === "remediate" ? { remediationState: { required: true, complete: false, failedEvidenceRevision: `sha256:${"a".repeat(64)}` } } : {}) };
336
+ nativeReply = { ...status, nextRecommended: phase, dependencies: { ...status.dependencies, ...(phase === "remediate" ? {} : { [phase]: "ready" }) }, ...(phase === "remediate" ? { remediationState: { required: true, complete: false, failedEvidenceRevision: `sha256:${"a".repeat(64)}` } } : {}) };
399
337
  assert.doesNotMatch((await hooks.get("before_agent_start")!({ systemPrompt: phasePrompt }, ctx)).systemPrompt, /SDD selection blocked:/);
400
338
  assert.equal((await callTool({ toolName: "read", input: {} }, ctx))?.block, undefined, `${phase} accepts its matching native recommendation`);
401
339
  }
402
- const syncPrompt = readFileSync(new URL("../assets/agents/sdd-sync.md", import.meta.url), "utf8").replace(/^---\n[\s\S]*?\n---\n/, "");
403
- calls.length = 0; nativeReply = { ...status, changeName: null };
404
- assert.doesNotMatch((await hooks.get("before_agent_start")!({ systemPrompt: syncPrompt }, ctx)).systemPrompt, /SDD selection blocked:/);
405
- assert.deepEqual(calls, [], "manual sync remains local and never consults native discovery");
406
340
  serialized = JSON.stringify(selection);
407
341
  for (const blockedReply of [
408
342
  { ...status, dependencies: { ...status.dependencies, apply: "blocked" }, blockedReasons: ["missing native prerequisite"] },
@@ -426,8 +360,16 @@ test("producer v2 preservation: selected status is read-only and retains all sev
426
360
  assert.equal(first, second);
427
361
  const status = decodeNativeSddStatusV2(JSON.parse(first), { changeName: "alpha", workspaceRoot: root });
428
362
  assert.deepEqual(Object.keys(status.dependencies).sort(), ["apply", "archive", "design", "proposal", "specs", "tasks", "verify"]);
429
- assert.deepEqual(Object.keys(status.phaseInstructions!).sort(), ["apply", "archive", "remediate", "verify"]);
363
+ assert.deepEqual(Object.keys(status.phaseInstructions!).sort(), "remediate" in status.phaseInstructions!
364
+ ? ["apply", "archive", "remediate", "verify"] : ["apply", "archive", "verify"]);
430
365
  assert.equal(status.nextRecommended, "propose");
366
+ if (!("remediate" in status.phaseInstructions!)) {
367
+ assert.throws(() => execFileSync(process.env.SDD_TEST_PRODUCER!, ["sdd-verify-validate"], {
368
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
369
+ }), (error: unknown) => error instanceof Error && "stderr" in error && /unknown command/.test(String(error.stderr)));
370
+ const verifier = readFileSync(new URL("../assets/agents/sdd-verify.md", import.meta.url), "utf8");
371
+ assert.doesNotMatch(verifier, /sdd-verify-validate|gentle-ai\.verify-result\/v1/);
372
+ }
431
373
  const verbs: string[] = [];
432
374
  const adapter = createNodeExecFileAdapter();
433
375
  const native = new NativeReviewCliV216(async (request) => { verbs.push(request.arguments[0]!); return adapter(request); }, process.env.SDD_TEST_PRODUCER!);
@@ -473,6 +415,156 @@ function commandStatus(root: string) {
473
415
  };
474
416
  }
475
417
 
418
+ test("classical native instructions reach selected apply, verify and archive startup unchanged", async (t) => {
419
+ const root = workspace(t);
420
+ for (const phase of ["apply", "verify", "archive"] as const) {
421
+ const legacy = commandStatus(root);
422
+ const { remediate: _legacyOnly, ...phaseInstructions } = legacy.phaseInstructions;
423
+ const status = { ...legacy, phaseInstructions, blockedReasons: [], nextRecommended: phase,
424
+ dependencies: { ...legacy.dependencies, [phase]: "ready" } };
425
+ const calls: unknown[] = [];
426
+ const result = await nativeStartup(JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase }), root, `sdd-${phase}`, {
427
+ sddStatus: async (request) => { calls.push(request); return status; },
428
+ });
429
+ assert.equal(result.status, status, "transport preserves the provider's original object");
430
+ assert.equal("remediate" in result.status.phaseInstructions!, false, "never synthesize retired instructions");
431
+ assert.deepEqual(calls, [{ changeName: "alpha", workspaceRoot: root }]);
432
+ }
433
+ });
434
+
435
+ test("native instruction compatibility does not admit incomplete or foreign phase records", (t) => {
436
+ const root = workspace(t), legacy = commandStatus(root);
437
+ const { remediate: _legacyOnly, ...classical } = legacy.phaseInstructions;
438
+ const request = { changeName: "alpha", workspaceRoot: root };
439
+ assert.equal(decodeNativeSddStatusV2(legacy, request), legacy, "pinned producer remains supported");
440
+ for (const phaseInstructions of [
441
+ { ...classical, verify: undefined }, { ...classical, remediate: undefined },
442
+ { ...classical, remediate: "not an instruction list" }, { ...classical, sync: [] },
443
+ { ...classical, unknown: [] },
444
+ ]) assert.throws(() => decodeNativeSddStatusV2({ ...legacy, phaseInstructions }, request));
445
+ assert.throws(() => decodeNativeSddStatusV2({ ...legacy, phaseInstructions: classical,
446
+ nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: `sha256:${"a".repeat(64)}` },
447
+ }, request), /remediation/i);
448
+ });
449
+
450
+ test("producer instructions reach actual selected-child startup without provider replacement", { skip: !process.env.SDD_TEST_PRODUCER }, async (t) => {
451
+ const root = workspace(t), change = join(root, "openspec/changes/alpha");
452
+ rmSync(join(root, "openspec/changes/beta"), { recursive: true });
453
+ execFileSync("git", ["init", "--quiet", root]);
454
+ mkdirSync(join(change, "specs/feature"), { recursive: true });
455
+ for (const [path, content] of Object.entries({ "proposal.md": "# Proposal\n", "design.md": "# Design\n",
456
+ "specs/feature/spec.md": "# Spec\n", "tasks.md": "- [ ] 1.1 Implement\n" })) writeFileSync(join(change, path), content);
457
+ const invoked: string[] = [];
458
+ const adapter = createNodeExecFileAdapter();
459
+ const native = new NativeReviewCliV216(async (request) => {
460
+ invoked.push(request.arguments[0]!);
461
+ return adapter(request);
462
+ }, process.env.SDD_TEST_PRODUCER!);
463
+ t.after(() => assert.ok(invoked.every((verb) => verb === "sdd-status"), "registered startup never invokes attempt acquire/settle"));
464
+ const status = await native.sddStatus!({ workspaceRoot: root, changeName: "alpha" });
465
+ assert.equal(status.nextRecommended, "apply");
466
+ let selection: string | undefined = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "apply" });
467
+ const hooks = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<{ systemPrompt: string }>>();
468
+ const pi = { on(name: string, hook: never) { hooks.set(name, hook); }, events: { emit() {} },
469
+ registerCommand() {}, registerTool() {}, getFlag: () => selection, getActiveTools: () => [],
470
+ } as unknown as ExtensionAPI;
471
+ const ctx = { cwd: root, hasUI: false, sessionManager: { getSessionId: () => root } } as unknown as ExtensionContext;
472
+ await ensureSddPreflight(ctx, { pi, installAssets: () => ({ agents: 0, chains: 0, support: 0, skipped: 0 }) });
473
+ createGentleAiExtension({ nativeReviewCli: native, processEnv: {},
474
+ resolveTelemetryTriggerBinary: () => { throw new Error("no telemetry in producer test"); },
475
+ })(pi);
476
+ const result = await hooks.get("before_agent_start")!({ systemPrompt: "SDD apply executor" }, ctx);
477
+ assert.doesNotMatch(result.systemPrompt, /SDD selection blocked:/);
478
+ assert.ok(result.systemPrompt.includes(JSON.stringify(status, null, 2)));
479
+ assert.match(result.systemPrompt, /### apply instructions/);
480
+ writeFileSync(join(change, "tasks.md"), "- [x] 1.1 Implement\n");
481
+ const completed = await native.sddStatus!({ workspaceRoot: root, changeName: "alpha" });
482
+ // The current pin requests verify; the classical producer requests archive.
483
+ // This slice consumes either producer, without changing its chosen route.
484
+ const next = "remediate" in completed.phaseInstructions! ? "verify" : "archive";
485
+ assert.equal(completed.nextRecommended, next);
486
+ selection = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: next });
487
+ const completion = await hooks.get("before_agent_start")!({ systemPrompt: `SDD ${next} executor` }, ctx);
488
+ assert.doesNotMatch(completion.systemPrompt, /SDD selection blocked:/);
489
+ assert.ok(completion.systemPrompt.includes(JSON.stringify(completed, null, 2)));
490
+ for (const selected of [true, false]) {
491
+ for (const phase of new Set([next, "verify"])) {
492
+ selection = selected ? JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase }) : undefined;
493
+ const result = await hooks.get("before_agent_start")!({ systemPrompt: `SDD ${phase} executor` }, ctx);
494
+ assert.doesNotMatch(result.systemPrompt, /SDD selection blocked:/);
495
+ assert.ok(result.systemPrompt.includes(JSON.stringify(completed, null, 2)));
496
+ }
497
+ }
498
+ writeFileSync(join(change, "verify-report.md"), "# Verification\nPASS\n");
499
+ const practical = await native.sddStatus!({ workspaceRoot: root, changeName: "alpha" });
500
+ assert.equal(practical.nextRecommended, next, "plain PASS cannot bypass a legacy provider's emitted evidence requirements");
501
+ if (next === "verify") assert.ok(practical.blockedReasons.some((reason) => reason.includes("verification evidence")));
502
+ for (const selected of [true, false]) {
503
+ selection = selected ? JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: next }) : undefined;
504
+ const result = await hooks.get("before_agent_start")!({ systemPrompt: `SDD ${next} executor` }, ctx);
505
+ assert.doesNotMatch(result.systemPrompt, /SDD selection blocked:/);
506
+ assert.ok(result.systemPrompt.includes(JSON.stringify(practical, null, 2)), "forward exact current provider instructions, including legacy evidence requirements");
507
+ }
508
+ writeFileSync(join(change, "tasks.md"), "- [ ] 1.1 Implement\n");
509
+ const partial = await native.sddStatus!({ workspaceRoot: root, changeName: "alpha" });
510
+ selection = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "verify" });
511
+ const partialVerify = await hooks.get("before_agent_start")!({ systemPrompt: "SDD verify executor" }, ctx);
512
+ if (partial.dependencies.verify === "ready") {
513
+ assert.doesNotMatch(partialVerify.systemPrompt, /SDD selection blocked:/);
514
+ assert.ok(partialVerify.systemPrompt.includes(JSON.stringify(partial, null, 2)));
515
+ } else {
516
+ assert.match(partialVerify.systemPrompt, /SDD selection blocked:/, "do not override the pinned provider's verification readiness");
517
+ }
518
+ for (const selected of [true, false]) {
519
+ selection = selected ? JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "archive" }) : undefined;
520
+ assert.match((await hooks.get("before_agent_start")!({ systemPrompt: "SDD archive executor" }, ctx)).systemPrompt, /SDD selection blocked:/);
521
+ }
522
+ const outside = join(root, "..", "ungranted-source.ts");
523
+ writeFileSync(join(change, "tasks.md"), `- [ ] Edit \`${outside}\`\n`);
524
+ const denied = await native.sddStatus!({ workspaceRoot: root, changeName: "alpha" });
525
+ assert.ok(denied.blockedReasons.some((reason) => reason.includes("edit_authority_missing")));
526
+ assert.deepEqual(denied.actionContext.allowedEditRoots, [root]);
527
+ selection = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "archive" });
528
+ assert.match((await hooks.get("before_agent_start")!({ systemPrompt: "SDD archive executor" }, ctx)).systemPrompt, /SDD selection blocked:/);
529
+
530
+ // Optional local integration proof uses the real Engram CLI, but never the
531
+ // user's data directory. No in-memory status substitute proves persistence.
532
+ if (process.env.SDD_TEST_ENGRAM) {
533
+ const originalData = process.env.ENGRAM_DATA_DIR;
534
+ const originalProject = process.env.ENGRAM_PROJECT;
535
+ try {
536
+ process.env.ENGRAM_PROJECT = "pi-sdd-parity-fixture";
537
+ writeFileSync(join(root, "openspec/config.yaml"), "sdd:\n artifact_store: engram\n");
538
+ rmSync(join(root, "openspec/changes"), { recursive: true });
539
+ for (const complete of [false, true]) {
540
+ await t.test(`persisted Engram complete=${complete}`, async () => {
541
+ process.env.ENGRAM_DATA_DIR = join(root, `memory-${complete}`);
542
+ for (const [kind, content] of Object.entries({ proposal: "# Proposal", spec: "# Spec", design: "# Design",
543
+ tasks: complete ? "- [x] 1.1 Implement" : "- [ ] 1.1 Implement" })) {
544
+ execFileSync(process.env.SDD_TEST_ENGRAM!, ["save", `sdd/alpha/${kind}`, content, "--project", "pi-sdd-parity-fixture", "--scope", "project"], { cwd: root, stdio: "pipe" });
545
+ }
546
+ const memory = await native.sddStatus!({ workspaceRoot: root, changeName: "alpha" });
547
+ assert.equal(memory.artifactStore, "engram");
548
+ assert.equal(memory.nextRecommended, complete ? next : "apply");
549
+ for (const selected of [true, false]) {
550
+ selection = selected ? JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: memory.nextRecommended }) : undefined;
551
+ const result = await hooks.get("before_agent_start")!({ systemPrompt: `SDD ${memory.nextRecommended} executor` }, ctx);
552
+ assert.doesNotMatch(result.systemPrompt, /SDD selection blocked:/);
553
+ assert.ok(result.systemPrompt.includes(JSON.stringify(memory, null, 2)));
554
+ }
555
+ if (!complete) {
556
+ selection = JSON.stringify({ changeName: "alpha", workspaceRoot: root, phase: "archive" });
557
+ assert.match((await hooks.get("before_agent_start")!({ systemPrompt: "SDD archive executor" }, ctx)).systemPrompt, /SDD selection blocked:/);
558
+ }
559
+ });
560
+ }
561
+ } finally {
562
+ if (originalData === undefined) delete process.env.ENGRAM_DATA_DIR; else process.env.ENGRAM_DATA_DIR = originalData;
563
+ if (originalProject === undefined) delete process.env.ENGRAM_PROJECT; else process.env.ENGRAM_PROJECT = originalProject;
564
+ }
565
+ }
566
+ });
567
+
476
568
  test("status command renders native facts without confirmation, mutation, or phase launch", async (t) => {
477
569
  const root = workspace(t);
478
570
  const status = commandStatus(root);