gentle-pi 2.1.2 → 2.3.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 (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync } from "node:fs";
6
- import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
6
+ import { chmod, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { dirname, join } from "node:path";
9
9
  import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
@@ -11,7 +11,6 @@ import { matchesKey } from "@earendil-works/pi-tui";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { stripAnsi } from "../lib/terminal-theme.ts";
13
13
  import { domainHashV1 } from "../lib/review-canonical.ts";
14
- import { NativeReviewCliV216, NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError } from "../lib/native-review-cli.ts";
15
14
 
16
15
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
17
16
  const EXTENSIONS = [
@@ -32,18 +31,23 @@ const EXPECTED_BANNER_COMMANDS = [
32
31
  const EXPECTED_COMMANDS = [
33
32
  "gentle:install-sdd",
34
33
  "gentle:sdd-preflight",
35
- "sdd-status",
36
- "sdd-continue",
34
+ "gentle-sdd-status",
35
+ "gentle-sdd-continue",
37
36
  "gentle:models",
38
37
  "gentle:persona",
39
38
  "gentle:status",
40
39
  "gentle:doctor",
41
- "sdd-init",
40
+ "gentle-sdd-init",
42
41
  "skill-registry:refresh",
43
42
  ...EXPECTED_BANNER_COMMANDS,
44
43
  ];
45
44
 
46
45
  const FORBIDDEN_COMPAT_COMMANDS = [
46
+ // The SDD entry points carry the gentle- prefix so they read identically in
47
+ // Claude Code and Pi. The bare names are retired without an alias.
48
+ "sdd-init",
49
+ "sdd-continue",
50
+ "sdd-status",
47
51
  "gentle-ai:install-sdd",
48
52
  "gentle-ai:sdd-preflight",
49
53
  "gentle-ai:sdd-status",
@@ -65,10 +69,28 @@ function createPi() {
65
69
  const commands = new Map();
66
70
  const flags = new Map();
67
71
  const tools = new Map();
72
+ const eventHandlers = new Map();
73
+ const emittedEvents = [];
68
74
  const flagValues = new Map([["no-skill-registry", true]]);
75
+ const events = {
76
+ emit(channel, data) {
77
+ emittedEvents.push({ channel, data });
78
+ for (const handler of eventHandlers.get(channel) ?? []) handler(data);
79
+ },
80
+ on(channel, handler) {
81
+ const handlers = eventHandlers.get(channel) ?? new Set();
82
+ handlers.add(handler);
83
+ eventHandlers.set(channel, handlers);
84
+ return () => {
85
+ handlers.delete(handler);
86
+ if (handlers.size === 0) eventHandlers.delete(channel);
87
+ };
88
+ },
89
+ };
69
90
  let activeTools = ["read", "bash", "edit", "write"];
70
91
 
71
92
  const pi = {
93
+ events,
72
94
  on(name, handler) {
73
95
  const list = hooks.get(name) ?? [];
74
96
  list.push(handler);
@@ -109,7 +131,7 @@ function createPi() {
109
131
  },
110
132
  };
111
133
 
112
- return { pi, hooks, commands, flags, tools };
134
+ return { pi, hooks, commands, flags, tools, emittedEvents };
113
135
  }
114
136
 
115
137
  function createUi() {
@@ -181,6 +203,15 @@ async function tempWorkspace() {
181
203
  return mkdtemp(join(tmpdir(), "gentle-pi-runtime-"));
182
204
  }
183
205
 
206
+ function restoreWorkspaceWritePermissions(cwd) {
207
+ if (process.platform === "win32") return;
208
+ try {
209
+ execFileSync("chmod", ["-R", "u+w", cwd], { stdio: "ignore" });
210
+ } catch {
211
+ // A prior candidate-view cleanup may already have removed the workspace.
212
+ }
213
+ }
214
+
184
215
  async function loadExtensions(pi) {
185
216
  for (const [index, rel] of EXTENSIONS.entries()) {
186
217
  const mod = await import(`${pathToFileURL(join(ROOT, rel)).href}?runtime-harness=${index}`);
@@ -198,9 +229,122 @@ async function run() {
198
229
  process.env.GENTLE_PI_TEST_ASSETS_DIR = ambientTestAssetsDir;
199
230
  const globalModelsPath = join(globalConfigHome, "models.json");
200
231
  const globalSubagentsPath = join(globalAgentHome, "subagents.json");
201
- const { pi, hooks, commands, flags, tools } = createPi();
232
+ const { pi, hooks, commands, flags, tools, emittedEvents } = createPi();
202
233
  await loadExtensions(pi);
203
234
 
235
+ // gentle-pi#404: a collect binding that returns the native last-event
236
+ // closure must terminate after one capture. It must not re-enter a public
237
+ // lifecycle mutation or synthesize a follow-up transition.
238
+ {
239
+ const lineageId = "runtime-last-event";
240
+ const sha = `sha256:${"a".repeat(64)}`;
241
+ const tree = "b".repeat(40);
242
+ const repositoryContext = `rctx1_${"c".repeat(64)}`;
243
+ const calls = [];
244
+ const arguments_ = [
245
+ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
246
+ { name: "expected-revision", value: sha, token: `--expected-revision=${sha}` },
247
+ { name: "target", value: sha, token: `--target=${sha}` },
248
+ { name: "repository-context", value: repositoryContext, token: `--repository-context=${repositoryContext}` },
249
+ { name: "lens", value: "review-risk", token: "--lens=review-risk" },
250
+ { name: "order", value: "0", token: "--order=0" },
251
+ { name: "subject-hash", value: sha, token: `--subject-hash=${sha}` },
252
+ ];
253
+ const input = {
254
+ name: "correction_plan",
255
+ schema: "gentle-ai.review-correction-plan/v1",
256
+ captureOperation: "review.capture-correction-plan",
257
+ arguments: arguments_,
258
+ submission: {
259
+ operationToken: "capture-correction-plan",
260
+ argumentTokens: [...arguments_.map((argument) => argument.token), "--correction-lines={{value}}"],
261
+ values: [{ slot: "correction_lines", domain: "positive_integer", substitutionLocation: 7, minimum: 1, maximum: 1 }],
262
+ },
263
+ };
264
+ const status = {
265
+ contract: "gentle-ai.review-integration/v2",
266
+ applicability: "current_target",
267
+ authority: { version: "compact-v2", lineageId, state: "correction_required", generation: 1, revision: sha },
268
+ receipt: { status: "expected_missing" },
269
+ action: "stop",
270
+ replayability: "not_replayable",
271
+ targetIdentity: sha,
272
+ projection: {
273
+ schema: "gentle-ai.review-candidate-projection/v1",
274
+ kind: "current-changes",
275
+ projection: "workspace",
276
+ baseTree: tree,
277
+ initialReviewTree: tree,
278
+ currentCandidateTree: tree,
279
+ pathsDigest: sha,
280
+ paths: ["app.ts"],
281
+ intendedUntracked: [],
282
+ intendedUntrackedProof: sha,
283
+ initialSnapshotIdentity: sha,
284
+ currentSnapshotIdentity: sha,
285
+ },
286
+ repair: { schema: "gentle-ai.review-authority-repair-assessment/v1", status: "unsupported", counts: { lineages: 0, compactLineages: 0, legacyLineages: 0, events: 0, bytes: 0, eligibleCandidates: 0, unsupportedLineages: 0, conflicts: 0 }, supportedOperations: ["review/complete-fix", "review/validate-fix"], authorizationSchema: "gentle-ai.review-repair-authorization/v1" },
287
+ candidates: [],
288
+ nextTransition: { kind: "collect", reasonCode: "correction_plan_required", collect: { inputs: [input] } },
289
+ raw: { schema: "gentle-ai.review-integration.status/v5" },
290
+ };
291
+ const nativeReviewCli = {
292
+ async targetStatus(request) {
293
+ calls.push({ operation: "status", request });
294
+ return status;
295
+ },
296
+ async captureCorrectionPlan(request) {
297
+ calls.push({ operation: "capture-correction-plan", request });
298
+ return {
299
+ schema: "gentle-ai.review-last-event-closure/v1",
300
+ operation: "review.capture-correction-plan",
301
+ lineageId,
302
+ state: "correction_required",
303
+ targetIdentity: sha,
304
+ requestHash: sha,
305
+ correctionLines: 1,
306
+ storeRevision: sha,
307
+ };
308
+ },
309
+ };
310
+ const lastEventPi = createPi();
311
+ const { createGentleAiExtension } = await import(pathToFileURL(join(ROOT, "extensions/gentle-ai.ts")).href);
312
+ createGentleAiExtension({ nativeReviewCli })(lastEventPi.pi);
313
+ const controller = lastEventPi.tools.get("gentle_review");
314
+ const capture = lastEventPi.tools.get("gentle_review_capture");
315
+ assert.ok(controller, "runtime must register the public status controller");
316
+ assert.ok(capture, "runtime must register the one-slot capture tool");
317
+ assert.equal(controller.parameters.properties.operation.enum.includes("finalize"), false);
318
+ assert.equal(controller.parameters.properties.operation.enum.includes("validate"), false);
319
+
320
+ const publicStatus = await controller.execute(
321
+ "runtime-status",
322
+ { operation: "status", lineageId },
323
+ undefined,
324
+ undefined,
325
+ createCtx(ROOT, false, lineageId),
326
+ );
327
+ const collectBindings = publicStatus.details.collectBindings;
328
+ assert.equal(publicStatus.details.status, "blocked");
329
+ assert.equal(collectBindings.length, 1);
330
+
331
+ const captured = await capture.execute(
332
+ "runtime-capture",
333
+ { lineageId, collectBinding: collectBindings[0].collectBinding, correctionLines: 1 },
334
+ undefined,
335
+ undefined,
336
+ createCtx(ROOT, false, lineageId),
337
+ );
338
+ assert.equal(captured.details.status, "closed");
339
+ assert.equal(captured.details.outcome, "native-last-event-closure");
340
+ assert.equal(captured.details.closure.operation, "review.capture-correction-plan");
341
+ assert.deepEqual(
342
+ calls.map(({ operation }) => operation),
343
+ ["status", "status", "capture-correction-plan"],
344
+ "last-event closure must make no follow-up lifecycle mutation",
345
+ );
346
+ }
347
+
204
348
  for (const name of EXPECTED_COMMANDS) {
205
349
  assert.ok(commands.has(name), `missing command ${name}`);
206
350
  }
@@ -217,6 +361,7 @@ async function run() {
217
361
  assert.ok(tools.has(toolName), `missing quiet built-in tool renderer ${toolName}`);
218
362
  }
219
363
  assert.ok(tools.has("gentle_review"), "missing registered bounded review controller tool");
364
+ assert.ok(tools.has("gentle_review_scope"), "missing registered bounded review scope tool");
220
365
  assert.deepEqual(
221
366
  tools.get("gentle_review").parameters.properties.operation.enum.filter((operation) => operation.includes("supersession") || operation === "supersede" || operation === "reconcile-authority"),
222
367
  ["reconcile-authority"],
@@ -262,10 +407,13 @@ async function run() {
262
407
  assert.doesNotMatch(promptResult.systemPrompt, /default\s*\|\s*sonnet\s*\|\s*Non-SDD general delegation/);
263
408
  assert.match(promptResult.systemPrompt, /openspec\/config\.yaml.*not session preflight/s);
264
409
  assert.match(promptResult.systemPrompt, /Do not mark SDD preflight complete/);
265
- assert.match(
266
- promptResult.systemPrompt,
267
- new RegExp(`${ROOT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*assets.*sdd-orchestrator-workflow\\.md`),
410
+ assert.ok(
411
+ promptResult.systemPrompt.includes(
412
+ `Package assets root: \`${join(ROOT, "assets")}\`. Lazy asset paths below are relative to this root.`,
413
+ ),
414
+ "parent prompt must declare the one absolute root for relative lazy asset paths",
268
415
  );
416
+ assert.match(promptResult.systemPrompt, /`sdd-orchestrator-workflow\.md`/);
269
417
  assert.doesNotMatch(
270
418
  promptResult.systemPrompt,
271
419
  new RegExp(ambientTestAssetsDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
@@ -339,10 +487,10 @@ async function run() {
339
487
  assert.match(applyPromptResult.systemPrompt, /"changeName": "status-demo"/);
340
488
  assert.match(applyPromptResult.systemPrompt, /### apply instructions/);
341
489
  const statusCtx = createCtx(promptCwd, true);
342
- await commands.get("sdd-status").handler("status-demo --json", statusCtx);
490
+ await commands.get("gentle-sdd-status").handler("status-demo --json", statusCtx);
343
491
  assert.match(statusCtx.ui.notifications.at(-1).message, /"schemaName": "gentle-pi\.sdd-status"/);
344
492
  const continueCtx = createCtx(promptCwd, true);
345
- await commands.get("sdd-continue").handler("status-demo", continueCtx);
493
+ await commands.get("gentle-sdd-continue").handler("status-demo", continueCtx);
346
494
  assert.match(continueCtx.ui.notifications.at(-1).message, /Native SDD Dispatcher/);
347
495
  assert.match(continueCtx.ui.notifications.at(-1).message, /nextPhase: sdd-apply/);
348
496
  const { execFileSync } = await import("node:child_process");
@@ -354,7 +502,7 @@ async function run() {
354
502
  '{"schema":"gentle-ai.recovery-required/v1","change_name":"status-demo"}',
355
503
  );
356
504
  const blockedContinueCtx = createCtx(promptCwd, true);
357
- await commands.get("sdd-continue").handler("status-demo", blockedContinueCtx);
505
+ await commands.get("gentle-sdd-continue").handler("status-demo", blockedContinueCtx);
358
506
  assert.doesNotMatch(blockedContinueCtx.ui.notifications.at(-1).message, /resolve-review:/);
359
507
  assert.match(blockedContinueCtx.ui.notifications.at(-1).message, /nextPhase: sdd-apply/);
360
508
  } finally {
@@ -367,12 +515,11 @@ async function run() {
367
515
  const ghPrCwd = await tempWorkspace();
368
516
  try {
369
517
  execFileSync("git", ["init"], { cwd: ghPrCwd, stdio: "ignore" });
370
- const receiptGate = await toolHook(
518
+ const deliveryResult = await toolHook(
371
519
  { toolName: "bash", input: { command: "gh pr create --draft" } },
372
- createCtx(ghPrCwd, true, "receipt-gate-session"),
520
+ createCtx(ghPrCwd, true, "ordinary-delivery-session"),
373
521
  );
374
- assert.equal(receiptGate.block, true);
375
- assert.match(receiptGate.reason, /exactly derive.*--base/i);
522
+ assert.equal(deliveryResult, undefined, "ordinary delivery policy, not review authority, governs pull-request creation");
376
523
  } finally {
377
524
  await rm(ghPrCwd, { recursive: true, force: true });
378
525
  }
@@ -385,6 +532,110 @@ async function run() {
385
532
  assert.equal(missingReviewView.block, true);
386
533
  assert.match(missingReviewView.reason, /candidate view/i);
387
534
  assert.equal(reviewDispatch.task, "review", "blocked review dispatch must not mutate child input");
535
+
536
+ for (const [agent, label, task] of [
537
+ ["gentle-ai-worker", "missing", "Implement the requested change."],
538
+ ["gentle-ai-worker", "absolute", "## Allowed edit surfaces\n/tmp/outside.ts"],
539
+ ["gentle-ai-worker", "Windows absolute", "## Allowed edit surfaces\nC:\\outside.ts"],
540
+ ["gentle-ai-worker", "prose instead of paths", "## Allowed edit surfaces\nThe parent will determine the paths."],
541
+ ["gentle-ai-worker", "repository root", "## Allowed edit surfaces\n."],
542
+ ["gentle-ai-worker", "bare repository root", "## Allowed edit surfaces\n./"],
543
+ ["gentle-ai-worker", "normalized bare repository root", "## Allowed edit surfaces\n.//"],
544
+ ["gentle-ai-worker", "equivalent normalized bare repository root", "## Allowed edit surfaces\n././/"],
545
+ ["worker", "generic writer missing", "Implement the requested change."],
546
+ ]) {
547
+ const writerDispatch = { agent, task, mode: "task" };
548
+ const writerResult = await toolHook(
549
+ { toolName: "subagent_run", input: writerDispatch },
550
+ createCtx(toolCwd),
551
+ );
552
+ assert.equal(writerResult?.block, true, `${label} writer scope must be blocked before dispatch`);
553
+ assert.match(writerResult?.reason ?? "", /derive|map/i);
554
+ assert.match(writerResult?.reason ?? "", /relaunch/i);
555
+ assert.match(writerResult?.reason ?? "", /do not ask.*human.*paths or globs/i);
556
+ assert.equal(writerDispatch.task, task, "writer guard must not mutate child input");
557
+ }
558
+
559
+ const scopedWriterDispatch = {
560
+ agent: "gentle-ai-worker",
561
+ task: "Implement the requested change.\n\n## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs",
562
+ mode: "task",
563
+ };
564
+ assert.equal(
565
+ await toolHook({ toolName: "subagent_run", input: scopedWriterDispatch }, createCtx(toolCwd)),
566
+ undefined,
567
+ "a writer may dispatch with narrow task-scoped repository-relative paths",
568
+ );
569
+ assert.equal(
570
+ await toolHook(
571
+ {
572
+ toolName: "subagent_run",
573
+ input: {
574
+ agent: "worker",
575
+ task: "Implement the requested change.",
576
+ context: "## Allowed edit surfaces\n- assets/orchestrator.md",
577
+ mode: "task",
578
+ },
579
+ },
580
+ createCtx(toolCwd),
581
+ ),
582
+ undefined,
583
+ "a writer may dispatch when context carries narrow task-scoped repository-relative paths",
584
+ );
585
+ for (const [label, input] of [
586
+ [
587
+ "valid scope followed by a repository-root scope",
588
+ {
589
+ agent: "gentle-ai-worker",
590
+ task: "## Allowed edit surfaces\nextensions/gentle-ai.ts\n\n## Allowed edit surfaces\n.",
591
+ mode: "task",
592
+ },
593
+ ],
594
+ [
595
+ "valid task scope plus invalid context scope",
596
+ {
597
+ agent: "gentle-ai-worker",
598
+ task: "## Allowed edit surfaces\nextensions/gentle-ai.ts",
599
+ context: "## Allowed edit surfaces\n.",
600
+ mode: "task",
601
+ },
602
+ ],
603
+ [
604
+ "conflicting valid task and context scopes",
605
+ {
606
+ agent: "gentle-ai-worker",
607
+ task: "## Allowed edit surfaces\nextensions/gentle-ai.ts",
608
+ context: "## Allowed edit surfaces\ntests/runtime-harness.mjs",
609
+ mode: "task",
610
+ },
611
+ ],
612
+ ]) {
613
+ const writerResult = await toolHook({ toolName: "subagent_run", input }, createCtx(toolCwd));
614
+ assert.equal(writerResult?.block, true, `${label} must block writer dispatch`);
615
+ }
616
+ assert.equal(
617
+ await toolHook(
618
+ {
619
+ toolName: "subagent_run",
620
+ input: {
621
+ agent: "gentle-ai-worker",
622
+ task: "## Allowed edit surfaces\nextensions/gentle-ai.ts\ntests/runtime-harness.mjs\n\n## Allowed edit surfaces\n- `tests/runtime-harness.mjs`\n- `extensions/gentle-ai.ts`",
623
+ mode: "task",
624
+ },
625
+ },
626
+ createCtx(toolCwd),
627
+ ),
628
+ undefined,
629
+ "a writer may dispatch when multiple allowed edit surface sections are compatible",
630
+ );
631
+ assert.equal(
632
+ await toolHook(
633
+ { toolName: "subagent_run", input: { agent: "scout", task: "Map the repository.", mode: "task" } },
634
+ createCtx(toolCwd),
635
+ ),
636
+ undefined,
637
+ "non-writer subagents must remain unaffected",
638
+ );
388
639
  const sensitiveRead = await toolHook({ toolName: "read", input: { path: join(toolCwd, ".env.local") } }, createCtx(toolCwd));
389
640
  assert.equal(sensitiveRead.block, true);
390
641
  assert.match(sensitiveRead.reason, /sensitive path/);
@@ -400,20 +651,74 @@ async function run() {
400
651
  );
401
652
  assert.equal(needsConfirm.block, true);
402
653
  assert.match(needsConfirm.reason, /not confirmed/);
654
+ assert.deepEqual(
655
+ emittedEvents.map(({ channel, data }) => ({
656
+ channel,
657
+ state: data.state,
658
+ active: data.active,
659
+ label: data.label,
660
+ })),
661
+ [
662
+ {
663
+ channel: "pi-permission-system:permission-request",
664
+ state: "waiting",
665
+ active: undefined,
666
+ label: undefined,
667
+ },
668
+ {
669
+ channel: "herdr:blocked",
670
+ state: undefined,
671
+ active: true,
672
+ label: "Guarded command confirmation",
673
+ },
674
+ {
675
+ channel: "pi-permission-system:permission-request",
676
+ state: "denied",
677
+ active: undefined,
678
+ label: undefined,
679
+ },
680
+ {
681
+ channel: "herdr:blocked",
682
+ state: undefined,
683
+ active: false,
684
+ label: undefined,
685
+ },
686
+ ],
687
+ "guarded confirmation emits both lifecycle channels in order",
688
+ );
689
+ assert.equal(emittedEvents[0].data.requestId, emittedEvents[2].data.requestId);
690
+ emittedEvents.length = 0;
691
+ pi.events.emit("rpiv:ask-user:blocked", {
692
+ active: true,
693
+ question: "private runtime questionnaire",
694
+ answer: "private runtime answer",
695
+ path: "/private/runtime-path",
696
+ command: "private runtime command",
697
+ });
698
+ pi.events.emit("rpiv:ask-user:blocked", { active: true, duplicate: true });
699
+ pi.events.emit("rpiv:ask-user:blocked", { active: "true" });
700
+ pi.events.emit("rpiv:ask-user:other", { active: false });
701
+ pi.events.emit("rpiv:ask-user:blocked", { active: false });
702
+ const rpivHerdrEvents = emittedEvents.filter(({ channel }) => channel === "herdr:blocked");
703
+ assert.deepEqual(rpivHerdrEvents, [
704
+ { channel: "herdr:blocked", data: { active: true, label: "Questionnaire awaiting input" } },
705
+ { channel: "herdr:blocked", data: { active: false } },
706
+ ]);
707
+ assert.doesNotMatch(JSON.stringify(rpivHerdrEvents), /private runtime/i);
708
+ emittedEvents.length = 0;
403
709
  assert.equal(
404
710
  dangerousReviewCtx.ui.notifications.length,
405
711
  0,
406
- "receipt validation must not launch or announce review actors",
712
+ "dangerous-command confirmation must not launch or announce review actors",
407
713
  );
408
714
  const commitCwd = await tempWorkspace();
409
715
  try {
410
716
  execFileSync("git", ["init"], { cwd: commitCwd, stdio: "ignore" });
411
- const commitGate = await toolHook(
717
+ const deliveryResult = await toolHook(
412
718
  { toolName: "bash", input: { command: "git commit -m bounded tracked.txt" } },
413
719
  createCtx(commitCwd),
414
720
  );
415
- assert.equal(commitGate.block, true);
416
- assert.match(commitGate.reason, /exactly derive/i);
721
+ assert.equal(deliveryResult, undefined, "ordinary delivery policy, not review authority, governs commits");
417
722
  } finally {
418
723
  await rm(commitCwd, { recursive: true, force: true });
419
724
  }
@@ -477,131 +782,13 @@ async function run() {
477
782
  /Controller-owned review lineage/,
478
783
  "a failed-closed dispatch must never inject a substituted candidate view into the lens sub-agent's task",
479
784
  );
785
+ await chmod(view.root, 0o700);
480
786
  registry.cleanup(view.token);
481
787
  } finally {
788
+ restoreWorkspaceWritePermissions(candidateDriftCwd);
482
789
  await rm(candidateDriftCwd, { recursive: true, force: true });
483
790
  }
484
791
 
485
- // Corrective v2 lifecycle settling test: the real controller must call the
486
- // native evidence surface before it accepts a targeted-validation document.
487
- // This is intentionally controller-level rather than a pure resolver test;
488
- // the ordered call trace proves production wiring and the fail-closed gate.
489
- const correctionCwd = await tempWorkspace();
490
- try {
491
- const { createGentleAiExtension } = await import(pathToFileURL(join(ROOT, "extensions/gentle-ai.ts")).href);
492
- const { CandidateViewRegistry } = await import(pathToFileURL(join(ROOT, "lib/review-candidate-view.ts")).href);
493
- gitSync(correctionCwd, "init", "-b", "main");
494
- await writeFile(join(correctionCwd, "app.ts"), "export const value = 1;\n");
495
- gitSync(correctionCwd, "add", "app.ts");
496
- gitSync(correctionCwd, "-c", "user.name=Runtime Harness", "-c", "user.email=runtime-harness@example.invalid", "commit", "-m", "base");
497
- await writeFile(join(correctionCwd, "app.ts"), "export const value = 2;\n");
498
- const frozen = new CandidateViewRegistry().create({ contributorRoot: correctionCwd });
499
- const sha = (digit) => `sha256:${digit.repeat(64)}`;
500
- const repair = { schema: "gentle-ai.review-authority-repair-assessment/v1", status: "unsupported", counts: { lineages: 0, compactLineages: 0, legacyLineages: 0, events: 0, bytes: 0, eligibleCandidates: 0, unsupportedLineages: 0, conflicts: 0 }, supportedOperations: ["review/complete-fix", "review/validate-fix"], authorizationSchema: "gentle-ai.review-repair-authorization/v1" };
501
- const projection = { schema: "gentle-ai.review-integration.projection/v1", kind: "current-changes", projection: "workspace", baseTree: frozen.baseTree, initialReviewTree: frozen.candidateTree, currentCandidateTree: frozen.candidateTree, pathsDigest: sha("a"), paths: frozen.paths, intendedUntracked: [], intendedUntrackedProof: sha("b"), initialSnapshotIdentity: sha("c"), currentSnapshotIdentity: sha("c") };
502
- const status = { contract: "gentle-ai.review-integration/v2", applicability: "current_target", authority: { version: "compact-v2", lineageId: "runtime-correction", state: "correction_required", generation: 1, revision: sha("d") }, receipt: { status: "expected_missing" }, action: "finalize", replayability: "not_replayable", frozen: { tier: "medium", originalChangedLines: 1, correctionBudget: 1 }, targetIdentity: sha("e"), projection, repair, candidates: [], nextTransition: { kind: "collect", reasonCode: "verification_evidence_required", collect: { inputs: [{ name: "verification_evidence", schema: "gentle-ai.review-verification-evidence/v2", captureOperation: "review.capture-evidence", arguments: [{ name: "lineage", value: "runtime-correction" }] }] } }, raw: {} };
503
- const validationRequest = { schema: "gentle-ai.review-targeted-validation-request/v1", requestHash: sha("f"), lineageId: "runtime-correction", expectedRevision: sha("d"), targetIdentity: sha("e"), fixFindingIds: [], projection: "workspace", correctionCandidateTree: frozen.candidateTree, correctionTargetIdentity: sha("e"), correctionPaths: frozen.paths, correctionPathsDigest: sha("a") };
504
- const afterCapture = { ...status, authority: { ...status.authority, state: "validating" }, validationRequest, nextTransition: { kind: "collect", reasonCode: "targeted_validation_required", collect: { inputs: [{ name: "targeted_validation", schema: validationRequest.schema, captureOperation: "external.run_targeted_validation", arguments: [{ name: "lineage", value: "runtime-correction" }], validationRequest }] } } };
505
- frozen.cleanup();
506
- const calls = [];
507
- let statuses = 0;
508
- const nativeReviewCli = {
509
- async targetStatus() { calls.push("status"); statuses += 1; return statuses === 1 ? status : afterCapture; },
510
- async captureEvidence(request) { calls.push("capture-evidence"); return { schema: "gentle-ai.review-verification-evidence/v2", version: 2, lineageId: "runtime-correction", authorityRevision: sha("d"), targetIdentity: sha("e"), candidateTree: frozen.candidateTree, pathsDigest: sha("a"), paths: frozen.paths, ledgerIds: [], rawPayloadSha256: sha("1"), rawPayloadBytes: request.evidenceDocument.length, outcome: "passed", recordDigest: sha("2") }; },
511
- async finalize() { calls.push("finalize"); return { lineageId: "runtime-correction", state: "approved", action: "approved", storeRevision: sha("3") }; },
512
- async start() { throw new Error("not expected"); }, async validate() { throw new Error("not expected"); }, async bindSdd() { throw new Error("not expected"); }, async sddStatus() { return { ready: false }; }, async reviewStatus() { throw new Error("not expected"); },
513
- };
514
- const correctionPi = createPi();
515
- createGentleAiExtension({ nativeReviewCli, candidateViews: new CandidateViewRegistry() })(correctionPi.pi);
516
- const controller = correctionPi.tools.get("gentle_review");
517
- const response = await controller.execute("runtime-correction", { operation: "finalize", lineageId: "runtime-correction", input: JSON.stringify({ final_evidence: "focused verification passed", final_verification_outcome: "passed", validation: { request_hash: "f".repeat(64), correction_ids: [], original_criteria: { passed: true, evidence: ["acceptance passes"] }, correction_regression: { passed: true, evidence: ["regression passes"] }, fix_caused_findings: [], follow_ups: [] } }) }, undefined, undefined, createCtx(correctionCwd));
518
- assert.deepEqual(calls, ["status", "capture-evidence", "status", "finalize"]);
519
- assert.equal(response.details.result.state, "approved");
520
- } finally {
521
- await rm(correctionCwd, { recursive: true, force: true });
522
- }
523
-
524
- // Task 11.2 (migrate-review-integration-v2): an unimplemented
525
- // next_transition.execute.operation (e.g. a future "dispose-result") must
526
- // raise a typed, named unsupported-transition-operation refusal and stop —
527
- // never fall through to a generic schema-incompatible error, and never
528
- // synthesize an invocation for it. Drives the real negotiated STATUS decode
529
- // path (NativeReviewCliV216.targetStatus) against a stubbed adapter, so no
530
- // live binary needs to advertise the unimplemented operation to prove this.
531
- {
532
- const capabilitiesFixturePath = join(
533
- ROOT, "contracts", "review-integration", "v2", "fixtures", "capabilities.fixture.json",
534
- );
535
- const capabilitiesFixture = JSON.parse(await readFile(capabilitiesFixturePath, "utf8"));
536
- const executableDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705";
537
- const capabilitiesBody = {
538
- ...capabilitiesFixture,
539
- package: { ...capabilitiesFixture.package, version: "2.2.2" },
540
- };
541
- const statusBody = {
542
- schema: "gentle-ai.review-integration.status/v3",
543
- contract: "gentle-ai.review-integration/v2",
544
- operation: "review.status",
545
- applicability: "unrelated",
546
- receipt: { status: "not_applicable" },
547
- action: "start",
548
- replayability: "not_replayable",
549
- target_identity: `sha256:${"a".repeat(64)}`,
550
- repair: {
551
- schema: "gentle-ai.review-authority-repair-assessment/v1",
552
- status: "unsupported",
553
- counts: { lineages: 0, compact_lineages: 0, legacy_lineages: 0, events: 0, bytes: 0, eligible_candidates: 0, unsupported_lineages: 0, conflicts: 0 },
554
- supported_operations: ["review/complete-fix", "review/validate-fix"],
555
- authorization_schema: "gentle-ai.review-repair-authorization/v1",
556
- },
557
- projection: {
558
- schema: "gentle-ai.review-integration.projection/v1",
559
- kind: "current-changes",
560
- projection: "workspace",
561
- base_tree: "b".repeat(40),
562
- initial_review_tree: "b".repeat(40),
563
- current_candidate_tree: "b".repeat(40),
564
- paths_digest: `sha256:${"a".repeat(64)}`,
565
- paths: [],
566
- intended_untracked: [],
567
- intended_untracked_proof: `sha256:${"a".repeat(64)}`,
568
- initial_snapshot_identity: `sha256:${"a".repeat(64)}`,
569
- current_snapshot_identity: `sha256:${"a".repeat(64)}`,
570
- },
571
- candidates: [],
572
- next_transition: {
573
- kind: "execute",
574
- reason_code: "disposable_result_pending",
575
- execute: {
576
- operation: "review.dispose-result",
577
- arguments: [],
578
- preconditions: [],
579
- binding: { target_identity: `sha256:${"a".repeat(64)}` },
580
- },
581
- },
582
- };
583
- const calls = [];
584
- const adapter = async (request) => {
585
- calls.push(request.arguments);
586
- const body = calls.length === 1 ? capabilitiesBody : statusBody;
587
- return { stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
588
- };
589
- const client = new NativeReviewCliV216(
590
- adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024,
591
- async () => undefined, () => executableDigest,
592
- );
593
- let thrown;
594
- try {
595
- await client.targetStatus({ cwd: "/repo" });
596
- } catch (error) {
597
- thrown = error;
598
- }
599
- assert.ok(thrown instanceof NativeReviewCliError, "an unimplemented next_transition.execute.operation must raise a typed NativeReviewCliError");
600
- assert.equal(thrown.code, NATIVE_REVIEW_ERROR_CODE.UNSUPPORTED_TRANSITION_OPERATION);
601
- assert.match(thrown.message, /review\.dispose-result/, "the refusal must name the specific unimplemented operation");
602
- assert.equal(calls.length, 2, "capabilities negotiation plus one STATUS call — never a third call synthesizing the unimplemented operation");
603
- }
604
-
605
792
  const bannerCwd = await tempWorkspace();
606
793
  try {
607
794
  const ctx = createCtx(bannerCwd, true);
@@ -624,6 +811,76 @@ async function run() {
624
811
  await rm(join(globalConfigHome, "banner.json"), { force: true });
625
812
  }
626
813
 
814
+ // issue-301: cancelling the color picker must be a no-op — no write,
815
+ // no notify, and the previously saved color must survive byte/semantically
816
+ // unchanged. Covers both entry points: /gentle:banner-color picker
817
+ // (cancelled), and /gentle:banner -> Color row -> nested picker (cancelled).
818
+ // Also covers an invalid non-empty argument, which must still open the
819
+ // picker and treat its cancellation as a no-op.
820
+ const cancelPickerCwd = await tempWorkspace();
821
+ try {
822
+ const bannerConfigPath = join(globalConfigHome, "banner.json");
823
+ const seeded = {
824
+ showRose: false,
825
+ showTextLogo: false,
826
+ color: "green",
827
+ };
828
+ const seededJson = `${JSON.stringify(seeded, null, 2)}\n`;
829
+ await writeFile(bannerConfigPath, seededJson, "utf8");
830
+
831
+ const cancelCtx = createCtx(cancelPickerCwd, true);
832
+
833
+ // (a) /gentle:banner-color picker cancelled: seeded color unchanged, no notify.
834
+ cancelCtx.ui.notifications.length = 0;
835
+ cancelCtx.ui.selections.length = 0;
836
+ cancelCtx.ui.select = async (label, options) => {
837
+ cancelCtx.ui.selections.push({ label, options });
838
+ return undefined;
839
+ };
840
+ await commands.get("gentle:banner-color").handler("", cancelCtx);
841
+ let afterCancel = await readFile(bannerConfigPath, "utf8");
842
+ assert.equal(afterCancel, seededJson, "banner-color cancel must not rewrite banner.json");
843
+ assert.equal(cancelCtx.ui.selections.length, 1, "banner-color cancel must open the picker once");
844
+ assert.equal(cancelCtx.ui.notifications.length, 0, "banner-color cancel must not notify");
845
+
846
+ // (d) invalid non-empty /gentle:banner-color input still opens picker;
847
+ // cancelling it is a no-op.
848
+ cancelCtx.ui.notifications.length = 0;
849
+ cancelCtx.ui.selections.length = 0;
850
+ await commands.get("gentle:banner-color").handler("purple", cancelCtx);
851
+ afterCancel = await readFile(bannerConfigPath, "utf8");
852
+ assert.equal(afterCancel, seededJson, "banner-color invalid+cancel must not rewrite banner.json");
853
+ assert.equal(cancelCtx.ui.selections.length, 1, "invalid banner-color arg must still open the picker");
854
+ assert.equal(cancelCtx.ui.notifications.length, 0, "banner-color invalid+cancel must not notify");
855
+
856
+ // (b) /gentle:banner selects the Color row, then the nested picker is
857
+ // cancelled: seeded color unchanged, no notify. The outer select
858
+ // returns the Color row; the nested select returns undefined.
859
+ cancelCtx.ui.notifications.length = 0;
860
+ cancelCtx.ui.selections.length = 0;
861
+ let selectCall = 0;
862
+ cancelCtx.ui.select = async (label, options) => {
863
+ cancelCtx.ui.selections.push({ label, options });
864
+ selectCall += 1;
865
+ // First call: outer "Startup banner" menu -> pick the Color row.
866
+ // Second call: nested color picker -> cancel (undefined).
867
+ return selectCall === 1 ? options[options.length - 1] : undefined;
868
+ };
869
+ await commands.get("gentle:banner").handler("", cancelCtx);
870
+ afterCancel = await readFile(bannerConfigPath, "utf8");
871
+ assert.equal(afterCancel, seededJson, "banner Color-row cancel must not rewrite banner.json");
872
+ assert.equal(cancelCtx.ui.selections.length, 2, "banner Color-row flow must open outer then nested picker");
873
+ assert.equal(cancelCtx.ui.notifications.length, 0, "banner Color-row cancel must not notify");
874
+
875
+ // Sanity: the seeded config round-trips through normalization unchanged,
876
+ // proving the byte equality above is semantic, not a test artifact.
877
+ const reparsed = JSON.parse(await readFile(bannerConfigPath, "utf8"));
878
+ assert.deepEqual(reparsed, seeded, "seeded non-default color must round-trip semantically");
879
+ } finally {
880
+ await rm(cancelPickerCwd, { recursive: true, force: true });
881
+ await rm(join(globalConfigHome, "banner.json"), { force: true });
882
+ }
883
+
627
884
  const noUiCwd = await tempWorkspace();
628
885
  try {
629
886
  for (const handler of hooks.get("session_start")) {
@@ -640,22 +897,22 @@ async function run() {
640
897
  "session_start must not install project-local SDD chains",
641
898
  );
642
899
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
900
+ // gentle-pi#311 P5: the Pi-authored adversarial role agents are retired;
901
+ // installation must not (re)create them.
643
902
  const installedRefuterPath = join(globalAgentHome, "agents", "review-refuter.md");
644
- assert.equal(existsSync(installedRefuterPath), true);
645
- assert.deepEqual(
646
- readAgentDefinition(await readFile(installedRefuterPath, "utf8")),
647
- { name: "review-refuter", tools: ["read", "grep", "find"] },
648
- "isolated package installation must activate only the refuter inspection tools",
903
+ assert.equal(existsSync(installedRefuterPath), false, "the retired review-refuter agent must not be installed");
904
+ assert.equal(
905
+ existsSync(join(globalAgentHome, "agents", "review-validator.md")),
906
+ false,
907
+ "the retired review-validator agent must not be installed",
649
908
  );
650
- const installedRefuterSource = await readFile(installedRefuterPath, "utf8");
651
- assert.match(
652
- installedRefuterSource,
653
- /^tools:\n {2}- "\*": false$/m,
654
- "installed refuter must lead its tool map with the deny-all rule",
909
+ const installedExplorePath = join(globalAgentHome, "agents", "gentle-ai-explore.md");
910
+ assert.equal(existsSync(installedExplorePath), true);
911
+ assert.deepEqual(
912
+ readAgentDefinition(await readFile(installedExplorePath, "utf8")),
913
+ { name: "gentle-ai-explore", tools: ["read", "grep", "find", "codegraph"] },
914
+ "isolated package installation must activate only the explorer inspection tools",
655
915
  );
656
- assert.match(installedRefuterSource, /complete inferential-severe frozen-row list once/);
657
- assert.match(installedRefuterSource, /refuted \| corroborated \| inconclusive/);
658
- assert.doesNotMatch(installedRefuterSource, /general, correctness, impact\/exploitability, or reproducibility/);
659
916
  const installedRiskSource = await readFile(
660
917
  join(globalAgentHome, "agents", "review-risk.md"),
661
918
  "utf8",
@@ -685,6 +942,13 @@ async function run() {
685
942
  ].join("\n");
686
943
  await writeFile(installedRefuterPath, samePathUserRefuter);
687
944
  delete managedAssetsManifest.assets["agents/review-refuter.md"];
945
+ // Retirement sweep: a hash-proven package-managed copy of a retired
946
+ // asset is deleted on refresh; the user-authored same-path refuter
947
+ // above must survive because its hash proves nothing.
948
+ const retiredManagedValidatorPath = join(globalAgentHome, "agents", "review-validator.md");
949
+ const retiredManagedValidator = "stale managed validator\n";
950
+ await writeFile(retiredManagedValidatorPath, retiredManagedValidator);
951
+ managedAssetsManifest.assets["agents/review-validator.md"] = sha256(retiredManagedValidator);
688
952
  await mkdir(join(globalAgentHome, "subagents"), { recursive: true });
689
953
  const userRefuterOverride = join(globalAgentHome, "subagents", "review-refuter.md");
690
954
  await writeFile(userRefuterOverride, "user refuter override must stay\n");
@@ -738,6 +1002,24 @@ async function run() {
738
1002
  "user refuter override must stay\n",
739
1003
  "package refresh must not rewrite or certify an explicit user refuter",
740
1004
  );
1005
+ assert.equal(
1006
+ existsSync(retiredManagedValidatorPath),
1007
+ false,
1008
+ "session refresh must delete a hash-proven package-managed copy of a retired asset",
1009
+ );
1010
+ const refreshedManagedAssets = JSON.parse(
1011
+ await readFile(managedAssetsManifestPath, "utf8"),
1012
+ );
1013
+ assert.equal(
1014
+ refreshedManagedAssets.assets["agents/review-validator.md"],
1015
+ undefined,
1016
+ "a retired asset must lose package-managed ownership",
1017
+ );
1018
+ assert.equal(
1019
+ refreshedManagedAssets.assets["agents/review-refuter.md"],
1020
+ undefined,
1021
+ "a user-authored same-path retired asset must stay unowned",
1022
+ );
741
1023
  } finally {
742
1024
  await rm(noUiCwd, { recursive: true, force: true });
743
1025
  }
@@ -811,17 +1093,13 @@ async function run() {
811
1093
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
812
1094
  assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
813
1095
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
814
- assert.equal(ctx.ui.selections.length, 3);
815
- assert.equal(ctx.ui.selections[0].label, "SDD execution mode");
816
- assert.equal(ctx.ui.selections[1].label, "SDD artifact store");
817
- assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
818
- assert.equal(ctx.ui.selections[2].label, "SDD PR chaining");
819
- assert.match(ctx.ui.notifications.at(-1).message, /Preference source: user prompt/);
1096
+ assert.equal(ctx.ui.selections.length, 0, "automatic SDD triggers must not render confirmation-only selectors");
1097
+ assert.match(ctx.ui.notifications.at(-1).message, /Preference source: canonical default or persisted preference/);
820
1098
  assert.deepEqual(
821
1099
  await inputHook({ text: "please use sdd for this change", source: "interactive" }, ctx),
822
1100
  { action: "continue" },
823
1101
  );
824
- assert.equal(ctx.ui.selections.length, 3, "natural SDD trigger should reuse session choices");
1102
+ assert.equal(ctx.ui.selections.length, 0, "natural SDD triggers reuse preferences without prompts");
825
1103
  assert.deepEqual(
826
1104
  await inputHook({ text: "/sdd", source: "interactive" }, ctx),
827
1105
  { action: "continue" },
@@ -834,7 +1112,7 @@ async function run() {
834
1112
  await inputHook({ text: "/sdd:plan", source: "interactive" }, ctx),
835
1113
  { action: "continue" },
836
1114
  );
837
- assert.equal(ctx.ui.selections.length, 3);
1115
+ assert.equal(ctx.ui.selections.length, 0, "slash SDD triggers use automatic defaults without prompts");
838
1116
 
839
1117
  assert.deepEqual(
840
1118
  await inputHook({ text: "/sdd-plan this change", source: "interactive" }, ctx),
@@ -862,19 +1140,18 @@ async function run() {
862
1140
  "global SDD model routing must be materialized in agent frontmatter, not project settings overrides",
863
1141
  );
864
1142
  }
865
- assert.equal(ctx.ui.selections.length, 3);
866
- assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
867
- assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
1143
+ assert.equal(ctx.ui.selections.length, 0, "automatic SDD routing must not render confirmation-only selectors");
1144
+ assert.match(ctx.ui.notifications.at(-1).message, /Preference source: canonical default or persisted preference/);
868
1145
  await commands.get("gentle:status").handler("", ctx);
869
1146
  assert.match(ctx.ui.notifications.at(-1).message, /Global SDD assets stale: 0 file\(s\)/);
870
1147
  assert.doesNotMatch(ctx.ui.notifications.at(-1).message, /install-sdd --force/);
871
1148
 
872
1149
  await inputHook({ text: "/sdd-plan another change", source: "interactive" }, ctx);
873
- assert.equal(ctx.ui.selections.length, 3, "preflight should run only once per session");
1150
+ assert.equal(ctx.ui.selections.length, 0, "automatic preflight should remain prompt-free for the session");
874
1151
  const promptHook = hooks.get("before_agent_start")[0];
875
1152
  const promptResult = await promptHook({ systemPrompt: "base" }, ctx);
876
1153
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
877
- assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
1154
+ assert.match(promptResult.systemPrompt, /Execution mode: auto/);
878
1155
  const workerPromptResult = await promptHook(
879
1156
  { agentName: "worker", systemPrompt: "worker base" },
880
1157
  ctx,
@@ -889,7 +1166,7 @@ async function run() {
889
1166
  await rm(globalModelsPath, { force: true });
890
1167
  }
891
1168
 
892
- for (const [index, text] of ["/sdd", "/sdd plan", "/sdd:plan", "/sdd-plan this change"].entries()) {
1169
+ for (const [index, text] of ["/sdd", "/sdd plan", "/sdd:plan", "/sdd-plan this change", "/gentle-sdd-continue", "/gentle-sdd-status fix-rose --json", "/gentle-sdd-init"].entries()) {
893
1170
  const slashSddCwd = await tempWorkspace();
894
1171
  try {
895
1172
  const ctx = createCtx(slashSddCwd, true, `slash-sdd-session-${index}`);
@@ -899,7 +1176,7 @@ async function run() {
899
1176
  });
900
1177
  assert.equal(existsSync(join(slashSddCwd, ".pi", "agents", "sdd-apply.md")), false);
901
1178
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
902
- assert.equal(ctx.ui.selections.length, 3, `${text} should run canonical preflight`);
1179
+ assert.equal(ctx.ui.selections.length, 0, `${text} should use automatic preflight without selectors`);
903
1180
  } finally {
904
1181
  await rm(slashSddCwd, { recursive: true, force: true });
905
1182
  }
@@ -911,9 +1188,11 @@ async function run() {
911
1188
  await commands.get("gentle:sdd-preflight").handler("", ctx);
912
1189
  assert.equal(existsSync(join(commandSddCwd, ".pi", "agents", "sdd-apply.md")), false);
913
1190
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
914
- assert.equal(ctx.ui.selections.length, 3);
1191
+ assert.equal(ctx.ui.selections.length, 2, "explicit preflight prompts intentional choice fields");
1192
+ assert.equal(ctx.ui.selections.some(({ label }) => label === "SDD artifact store"), false, "one-option artifact store must be elided");
1193
+ assert.match(ctx.ui.notifications.at(-1).message, /Preference source: explicit session choice/);
915
1194
  await commands.get("gentle:sdd-preflight").handler("", ctx);
916
- assert.equal(ctx.ui.selections.length, 3, "manual preflight command should reuse session choices");
1195
+ assert.equal(ctx.ui.selections.length, 4, "explicit preflight remains an intentional re-prompt");
917
1196
  } finally {
918
1197
  await rm(commandSddCwd, { recursive: true, force: true });
919
1198
  }
@@ -932,7 +1211,7 @@ async function run() {
932
1211
  assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "chains", "sdd-full.chain.md")), false);
933
1212
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
934
1213
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
935
- assert.equal(ctx.ui.selections.length, 3);
1214
+ assert.equal(ctx.ui.selections.length, 0, "automatic SDD-agent startup must not render selectors");
936
1215
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
937
1216
  assert.doesNotMatch(
938
1217
  promptResult.systemPrompt,
@@ -953,7 +1232,7 @@ async function run() {
953
1232
  },
954
1233
  ctx,
955
1234
  );
956
- assert.equal(ctx.ui.selections.length, 3, "SDD agent guard should reuse session choices");
1235
+ assert.equal(ctx.ui.selections.length, 0, "SDD-agent startup should reuse automatic preferences");
957
1236
  assert.doesNotMatch(
958
1237
  reusedPromptResult.systemPrompt,
959
1238
  /el Gentleman Identity and Harness/,
@@ -975,7 +1254,7 @@ async function run() {
975
1254
  ctx,
976
1255
  );
977
1256
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
978
- assert.match(promptResult.systemPrompt, /No interactive UI was available/);
1257
+ assert.match(promptResult.systemPrompt, /canonical defaults or persisted choices/);
979
1258
  assert.equal(ctx.ui.selections.length, 0);
980
1259
  assert.equal(existsSync(join(noUiSddAgentCwd, ".pi", "agents", "sdd-apply.md")), false);
981
1260
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
@@ -1001,18 +1280,94 @@ async function run() {
1001
1280
  pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
1002
1281
  const ctx = createCtx(engramSddCwd, true, "engram-session");
1003
1282
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1004
- assert.deepEqual(ctx.ui.selections[1].options, ["openspec", "engram", "both"]);
1283
+ assert.deepEqual(ctx.ui.selections[1].options, ["openspec", "engram", "hybrid"]);
1005
1284
  } finally {
1006
1285
  pi.setActiveTools(["read", "bash", "edit", "write"]);
1007
1286
  await rm(engramSddCwd, { recursive: true, force: true });
1008
1287
  }
1009
1288
 
1289
+ // Issue #64: selecting engram as the artifact store must not cause
1290
+ // /gentle-sdd-init to create openspec/ or openspec/config.yaml.
1291
+ const engramSddInitCwd = await tempWorkspace();
1292
+ try {
1293
+ pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
1294
+ const ctx = createCtx(engramSddInitCwd, true, "engram-sdd-init-session");
1295
+ ctx.ui.select = async (label, options) => {
1296
+ if (label === "SDD artifact store") return "engram";
1297
+ return options[0];
1298
+ };
1299
+ await commands.get("gentle:sdd-preflight").handler("", ctx);
1300
+ await commands.get("gentle-sdd-init").handler("", ctx);
1301
+ assert.equal(
1302
+ existsSync(join(engramSddInitCwd, "openspec")),
1303
+ false,
1304
+ "/gentle-sdd-init must not create openspec/ when artifactStore is engram",
1305
+ );
1306
+ assert.equal(
1307
+ existsSync(join(engramSddInitCwd, "openspec", "config.yaml")),
1308
+ false,
1309
+ "/gentle-sdd-init must not write openspec/config.yaml when artifactStore is engram",
1310
+ );
1311
+ assert.doesNotMatch(
1312
+ ctx.ui.notifications.at(-1).message,
1313
+ /Wrote openspec\/config\.yaml/,
1314
+ "/gentle-sdd-init must not announce openspec/config.yaml when artifactStore is engram",
1315
+ );
1316
+ assert.match(
1317
+ ctx.ui.notifications.at(-1).message,
1318
+ /SDD initialized for engram:/,
1319
+ );
1320
+ assert.equal(ctx.ui.notifications.at(-1).level, "info");
1321
+ } finally {
1322
+ pi.setActiveTools(["read", "bash", "edit", "write"]);
1323
+ await rm(engramSddInitCwd, { recursive: true, force: true });
1324
+ }
1325
+
1326
+ // Issue #64 counterpart: the engram skip must stay narrow. Selecting both
1327
+ // still has to create the full openspec/ scaffold and write config.yaml,
1328
+ // so an over-broad skip is caught here instead of in the field.
1329
+ const bothSddInitCwd = await tempWorkspace();
1330
+ try {
1331
+ pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
1332
+ const ctx = createCtx(bothSddInitCwd, true, "both-sdd-init-session");
1333
+ ctx.ui.select = async (label, options) => {
1334
+ if (label === "SDD artifact store") return "hybrid";
1335
+ return options[0];
1336
+ };
1337
+ await commands.get("gentle:sdd-preflight").handler("", ctx);
1338
+ await commands.get("gentle-sdd-init").handler("", ctx);
1339
+ assert.equal(
1340
+ existsSync(join(bothSddInitCwd, "openspec", "specs")),
1341
+ true,
1342
+ "/gentle-sdd-init must create openspec/specs when artifactStore is both",
1343
+ );
1344
+ assert.equal(
1345
+ existsSync(join(bothSddInitCwd, "openspec", "changes", "archive")),
1346
+ true,
1347
+ "/gentle-sdd-init must create openspec/changes/archive when artifactStore is both",
1348
+ );
1349
+ assert.equal(
1350
+ existsSync(join(bothSddInitCwd, "openspec", "config.yaml")),
1351
+ true,
1352
+ "/gentle-sdd-init must write openspec/config.yaml when artifactStore is both",
1353
+ );
1354
+ assert.match(
1355
+ ctx.ui.notifications.at(-1).message,
1356
+ /Wrote openspec\/config\.yaml/,
1357
+ "/gentle-sdd-init must announce openspec/config.yaml when artifactStore is both",
1358
+ );
1359
+ assert.equal(ctx.ui.notifications.at(-1).level, "info");
1360
+ } finally {
1361
+ pi.setActiveTools(["read", "bash", "edit", "write"]);
1362
+ await rm(bothSddInitCwd, { recursive: true, force: true });
1363
+ }
1364
+
1010
1365
  const directEngramToolCwd = await tempWorkspace();
1011
1366
  try {
1012
1367
  pi.setActiveTools(["read", "bash", "edit", "write", "engram_mem_save"]);
1013
1368
  const ctx = createCtx(directEngramToolCwd, true, "direct-engram-session");
1014
1369
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1015
- assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
1370
+ assert.equal(ctx.ui.selections.some(({ label }) => label === "SDD artifact store"), false, "unrecognized Engram capability must elide the artifact selector");
1016
1371
  } finally {
1017
1372
  pi.setActiveTools(["read", "bash", "edit", "write"]);
1018
1373
  await rm(directEngramToolCwd, { recursive: true, force: true });
@@ -1062,7 +1417,7 @@ async function run() {
1062
1417
  const sddCwd = await tempWorkspace();
1063
1418
  try {
1064
1419
  const ctx = createCtx(sddCwd, true);
1065
- await commands.get("sdd-init").handler("", ctx);
1420
+ await commands.get("gentle-sdd-init").handler("", ctx);
1066
1421
  assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), false);
1067
1422
  assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
1068
1423
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
@@ -1070,12 +1425,12 @@ async function run() {
1070
1425
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
1071
1426
  assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
1072
1427
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
1073
- assert.equal(ctx.ui.selections.length, 3);
1428
+ assert.equal(ctx.ui.selections.length, 0, "sdd-init uses automatic preflight defaults");
1074
1429
  assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
1075
1430
  assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
1076
1431
 
1077
1432
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1078
- assert.equal(ctx.ui.selections.length, 3, "/sdd-init preflight should be reused by later manual preflight");
1433
+ assert.equal(ctx.ui.selections.length, 2, "explicit preflight prompts after automatic sdd-init");
1079
1434
  } finally {
1080
1435
  await rm(sddCwd, { recursive: true, force: true });
1081
1436
  }
@@ -1089,7 +1444,7 @@ async function run() {
1089
1444
  );
1090
1445
  await writeFile(globalModelsPath, "{ invalid json");
1091
1446
  const ctx = createCtx(invalidSddInitCwd, true, "invalid-sdd-init-session");
1092
- await commands.get("sdd-init").handler("", ctx);
1447
+ await commands.get("gentle-sdd-init").handler("", ctx);
1093
1448
  assert.equal(ctx.ui.notifications[0].level, "warning");
1094
1449
  assert.match(ctx.ui.notifications[0].message, /Model routing skipped:/);
1095
1450
  assert.match(ctx.ui.notifications[0].message, /models\.json/);
@@ -1699,6 +2054,44 @@ async function run() {
1699
2054
  const restoredAgent = await readFile(join(modelsCwd, ".pi", "agents", "sdd-apply.md"), "utf8");
1700
2055
  assert.match(restoredAgent, /model: restore\/provider/);
1701
2056
  assert.match(restoredAgent, /thinking: high/);
2057
+
2058
+ // issue #286: `thinking: "max"` must survive save normalization and
2059
+ // reach both subagents.json (effort) and agent frontmatter (thinking).
2060
+ ctx.ui.custom = () =>
2061
+ Promise.resolve({
2062
+ type: "save",
2063
+ config: { "sdd-apply": { model: "openai/gpt-5", thinking: "max" } },
2064
+ });
2065
+ await commands.get("gentle:models").handler("", ctx);
2066
+ const maxSavedConfig = JSON.parse(await readFile(globalModelsPath, "utf8"));
2067
+ assert.equal(maxSavedConfig["sdd-apply"].thinking, "max");
2068
+ const maxSubagents = JSON.parse(
2069
+ await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"),
2070
+ );
2071
+ assert.equal(maxSubagents.model_profiles["sdd-apply"].effort, "max");
2072
+ const maxApplyAgent = await readFile(
2073
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
2074
+ "utf8",
2075
+ );
2076
+ assert.match(maxApplyAgent, /thinking: max/);
2077
+
2078
+ // issue #286: effort picker must offer `max` after `xhigh` and save it.
2079
+ let maxPickerCalls = 0;
2080
+ ctx.ui.custom = (factory) =>
2081
+ new Promise((resolve) => {
2082
+ maxPickerCalls += 1;
2083
+ const panel = factory(null, null, null, resolve);
2084
+ if (maxPickerCalls === 1) {
2085
+ panel.handleInput(kittyE); // open effort picker (set-all row)
2086
+ for (let i = 0; i < 7; i++) panel.handleInput("j"); // max
2087
+ panel.handleInput("\r");
2088
+ panel.handleInput("\u0013"); // ctrl+s saves the draft
2089
+ return;
2090
+ }
2091
+ });
2092
+ await commands.get("gentle:models").handler("", ctx);
2093
+ const pickerMaxConfig = JSON.parse(await readFile(globalModelsPath, "utf8"));
2094
+ assert.equal(pickerMaxConfig["sdd-apply"].thinking, "max");
1702
2095
  } finally {
1703
2096
  await rm(modelsCwd, { recursive: true, force: true });
1704
2097
  await rm(globalModelsPath, { force: true });