gentle-pi 2.2.0 → 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 (130) hide show
  1. package/README.md +51 -80
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -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,8 +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 { resolveGentleAiBinary } from "../lib/gentle-ai-binary.ts";
15
- import { NativeReviewCliV216, NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError } from "../lib/native-review-cli.ts";
16
14
 
17
15
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
18
16
  const EXTENSIONS = [
@@ -33,18 +31,23 @@ const EXPECTED_BANNER_COMMANDS = [
33
31
  const EXPECTED_COMMANDS = [
34
32
  "gentle:install-sdd",
35
33
  "gentle:sdd-preflight",
36
- "sdd-status",
37
- "sdd-continue",
34
+ "gentle-sdd-status",
35
+ "gentle-sdd-continue",
38
36
  "gentle:models",
39
37
  "gentle:persona",
40
38
  "gentle:status",
41
39
  "gentle:doctor",
42
- "sdd-init",
40
+ "gentle-sdd-init",
43
41
  "skill-registry:refresh",
44
42
  ...EXPECTED_BANNER_COMMANDS,
45
43
  ];
46
44
 
47
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",
48
51
  "gentle-ai:install-sdd",
49
52
  "gentle-ai:sdd-preflight",
50
53
  "gentle-ai:sdd-status",
@@ -66,10 +69,28 @@ function createPi() {
66
69
  const commands = new Map();
67
70
  const flags = new Map();
68
71
  const tools = new Map();
72
+ const eventHandlers = new Map();
73
+ const emittedEvents = [];
69
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
+ };
70
90
  let activeTools = ["read", "bash", "edit", "write"];
71
91
 
72
92
  const pi = {
93
+ events,
73
94
  on(name, handler) {
74
95
  const list = hooks.get(name) ?? [];
75
96
  list.push(handler);
@@ -110,7 +131,7 @@ function createPi() {
110
131
  },
111
132
  };
112
133
 
113
- return { pi, hooks, commands, flags, tools };
134
+ return { pi, hooks, commands, flags, tools, emittedEvents };
114
135
  }
115
136
 
116
137
  function createUi() {
@@ -182,6 +203,15 @@ async function tempWorkspace() {
182
203
  return mkdtemp(join(tmpdir(), "gentle-pi-runtime-"));
183
204
  }
184
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
+
185
215
  async function loadExtensions(pi) {
186
216
  for (const [index, rel] of EXTENSIONS.entries()) {
187
217
  const mod = await import(`${pathToFileURL(join(ROOT, rel)).href}?runtime-harness=${index}`);
@@ -197,28 +227,124 @@ async function run() {
197
227
  process.env.GENTLE_PI_CONFIG_HOME = globalConfigHome;
198
228
  process.env.GENTLE_PI_AGENT_HOME = globalAgentHome;
199
229
  process.env.GENTLE_PI_TEST_ASSETS_DIR = ambientTestAssetsDir;
200
- // The harness already sandboxes its config and agent homes; the review kill
201
- // switch is the one piece of machine state it was still inheriting. Several
202
- // lifecycle-gate assertions below only reach their subject while
203
- // receipt-driven development is on — with it off, gateLifecycleCommand
204
- // returns undefined before deriving any target, which is correct behavior
205
- // but not what those assertions are about.
206
- //
207
- // gentle-ai v2.4.0 made the switch opt-in, so an unconfigured machine now
208
- // resolves to off. Until then the harness passed or failed on whatever the
209
- // operator happened to have set, which made it green locally and red on a
210
- // fresh CI runner from identical bytes. It now owns a sandbox HOME and opts
211
- // in explicitly, the same way a user does, so the gates are what is under
212
- // test rather than the machine.
213
- const reviewHome = await tempWorkspace();
214
- process.env.HOME = reviewHome;
215
- const reviewModeEnable = execFileSync(resolveGentleAiBinary(ROOT, process.platform), ["review", "mode", "enable", "--scope", "global", "--json"], { cwd: ROOT, encoding: "utf8", env: { ...process.env, HOME: reviewHome } });
216
- assert.match(reviewModeEnable, /"effective": "on"/, "the harness sandbox HOME must have receipt-driven development explicitly enabled");
217
230
  const globalModelsPath = join(globalConfigHome, "models.json");
218
231
  const globalSubagentsPath = join(globalAgentHome, "subagents.json");
219
- const { pi, hooks, commands, flags, tools } = createPi();
232
+ const { pi, hooks, commands, flags, tools, emittedEvents } = createPi();
220
233
  await loadExtensions(pi);
221
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
+
222
348
  for (const name of EXPECTED_COMMANDS) {
223
349
  assert.ok(commands.has(name), `missing command ${name}`);
224
350
  }
@@ -281,10 +407,13 @@ async function run() {
281
407
  assert.doesNotMatch(promptResult.systemPrompt, /default\s*\|\s*sonnet\s*\|\s*Non-SDD general delegation/);
282
408
  assert.match(promptResult.systemPrompt, /openspec\/config\.yaml.*not session preflight/s);
283
409
  assert.match(promptResult.systemPrompt, /Do not mark SDD preflight complete/);
284
- assert.match(
285
- promptResult.systemPrompt,
286
- 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",
287
415
  );
416
+ assert.match(promptResult.systemPrompt, /`sdd-orchestrator-workflow\.md`/);
288
417
  assert.doesNotMatch(
289
418
  promptResult.systemPrompt,
290
419
  new RegExp(ambientTestAssetsDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
@@ -358,10 +487,10 @@ async function run() {
358
487
  assert.match(applyPromptResult.systemPrompt, /"changeName": "status-demo"/);
359
488
  assert.match(applyPromptResult.systemPrompt, /### apply instructions/);
360
489
  const statusCtx = createCtx(promptCwd, true);
361
- await commands.get("sdd-status").handler("status-demo --json", statusCtx);
490
+ await commands.get("gentle-sdd-status").handler("status-demo --json", statusCtx);
362
491
  assert.match(statusCtx.ui.notifications.at(-1).message, /"schemaName": "gentle-pi\.sdd-status"/);
363
492
  const continueCtx = createCtx(promptCwd, true);
364
- await commands.get("sdd-continue").handler("status-demo", continueCtx);
493
+ await commands.get("gentle-sdd-continue").handler("status-demo", continueCtx);
365
494
  assert.match(continueCtx.ui.notifications.at(-1).message, /Native SDD Dispatcher/);
366
495
  assert.match(continueCtx.ui.notifications.at(-1).message, /nextPhase: sdd-apply/);
367
496
  const { execFileSync } = await import("node:child_process");
@@ -373,7 +502,7 @@ async function run() {
373
502
  '{"schema":"gentle-ai.recovery-required/v1","change_name":"status-demo"}',
374
503
  );
375
504
  const blockedContinueCtx = createCtx(promptCwd, true);
376
- await commands.get("sdd-continue").handler("status-demo", blockedContinueCtx);
505
+ await commands.get("gentle-sdd-continue").handler("status-demo", blockedContinueCtx);
377
506
  assert.doesNotMatch(blockedContinueCtx.ui.notifications.at(-1).message, /resolve-review:/);
378
507
  assert.match(blockedContinueCtx.ui.notifications.at(-1).message, /nextPhase: sdd-apply/);
379
508
  } finally {
@@ -386,12 +515,11 @@ async function run() {
386
515
  const ghPrCwd = await tempWorkspace();
387
516
  try {
388
517
  execFileSync("git", ["init"], { cwd: ghPrCwd, stdio: "ignore" });
389
- const receiptGate = await toolHook(
518
+ const deliveryResult = await toolHook(
390
519
  { toolName: "bash", input: { command: "gh pr create --draft" } },
391
- createCtx(ghPrCwd, true, "receipt-gate-session"),
520
+ createCtx(ghPrCwd, true, "ordinary-delivery-session"),
392
521
  );
393
- assert.equal(receiptGate.block, true);
394
- assert.match(receiptGate.reason, /exactly derive.*--base/i);
522
+ assert.equal(deliveryResult, undefined, "ordinary delivery policy, not review authority, governs pull-request creation");
395
523
  } finally {
396
524
  await rm(ghPrCwd, { recursive: true, force: true });
397
525
  }
@@ -404,6 +532,110 @@ async function run() {
404
532
  assert.equal(missingReviewView.block, true);
405
533
  assert.match(missingReviewView.reason, /candidate view/i);
406
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
+ );
407
639
  const sensitiveRead = await toolHook({ toolName: "read", input: { path: join(toolCwd, ".env.local") } }, createCtx(toolCwd));
408
640
  assert.equal(sensitiveRead.block, true);
409
641
  assert.match(sensitiveRead.reason, /sensitive path/);
@@ -419,20 +651,74 @@ async function run() {
419
651
  );
420
652
  assert.equal(needsConfirm.block, true);
421
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;
422
709
  assert.equal(
423
710
  dangerousReviewCtx.ui.notifications.length,
424
711
  0,
425
- "receipt validation must not launch or announce review actors",
712
+ "dangerous-command confirmation must not launch or announce review actors",
426
713
  );
427
714
  const commitCwd = await tempWorkspace();
428
715
  try {
429
716
  execFileSync("git", ["init"], { cwd: commitCwd, stdio: "ignore" });
430
- const commitGate = await toolHook(
717
+ const deliveryResult = await toolHook(
431
718
  { toolName: "bash", input: { command: "git commit -m bounded tracked.txt" } },
432
719
  createCtx(commitCwd),
433
720
  );
434
- assert.equal(commitGate.block, true);
435
- assert.match(commitGate.reason, /exactly derive/i);
721
+ assert.equal(deliveryResult, undefined, "ordinary delivery policy, not review authority, governs commits");
436
722
  } finally {
437
723
  await rm(commitCwd, { recursive: true, force: true });
438
724
  }
@@ -496,131 +782,13 @@ async function run() {
496
782
  /Controller-owned review lineage/,
497
783
  "a failed-closed dispatch must never inject a substituted candidate view into the lens sub-agent's task",
498
784
  );
785
+ await chmod(view.root, 0o700);
499
786
  registry.cleanup(view.token);
500
787
  } finally {
788
+ restoreWorkspaceWritePermissions(candidateDriftCwd);
501
789
  await rm(candidateDriftCwd, { recursive: true, force: true });
502
790
  }
503
791
 
504
- // Corrective v2 lifecycle settling test: the real controller must call the
505
- // native evidence surface before it accepts a targeted-validation document.
506
- // This is intentionally controller-level rather than a pure resolver test;
507
- // the ordered call trace proves production wiring and the fail-closed gate.
508
- const correctionCwd = await tempWorkspace();
509
- try {
510
- const { createGentleAiExtension } = await import(pathToFileURL(join(ROOT, "extensions/gentle-ai.ts")).href);
511
- const { CandidateViewRegistry } = await import(pathToFileURL(join(ROOT, "lib/review-candidate-view.ts")).href);
512
- gitSync(correctionCwd, "init", "-b", "main");
513
- await writeFile(join(correctionCwd, "app.ts"), "export const value = 1;\n");
514
- gitSync(correctionCwd, "add", "app.ts");
515
- gitSync(correctionCwd, "-c", "user.name=Runtime Harness", "-c", "user.email=runtime-harness@example.invalid", "commit", "-m", "base");
516
- await writeFile(join(correctionCwd, "app.ts"), "export const value = 2;\n");
517
- const frozen = new CandidateViewRegistry().create({ contributorRoot: correctionCwd });
518
- const sha = (digit) => `sha256:${digit.repeat(64)}`;
519
- 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" };
520
- 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") };
521
- 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: {} };
522
- 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") };
523
- 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 }] } } };
524
- frozen.cleanup();
525
- const calls = [];
526
- let statuses = 0;
527
- const nativeReviewCli = {
528
- async targetStatus() { calls.push("status"); statuses += 1; return statuses === 1 ? status : afterCapture; },
529
- 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") }; },
530
- async finalize() { calls.push("finalize"); return { lineageId: "runtime-correction", state: "approved", action: "approved", storeRevision: sha("3") }; },
531
- 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"); },
532
- };
533
- const correctionPi = createPi();
534
- createGentleAiExtension({ nativeReviewCli, candidateViews: new CandidateViewRegistry() })(correctionPi.pi);
535
- const controller = correctionPi.tools.get("gentle_review");
536
- 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));
537
- assert.deepEqual(calls, ["status", "capture-evidence", "status", "finalize"]);
538
- assert.equal(response.details.result.state, "approved");
539
- } finally {
540
- await rm(correctionCwd, { recursive: true, force: true });
541
- }
542
-
543
- // Task 11.2 (migrate-review-integration-v2): an unimplemented
544
- // next_transition.execute.operation (e.g. a future "dispose-result") must
545
- // raise a typed, named unsupported-transition-operation refusal and stop —
546
- // never fall through to a generic schema-incompatible error, and never
547
- // synthesize an invocation for it. Drives the real negotiated STATUS decode
548
- // path (NativeReviewCliV216.targetStatus) against a stubbed adapter, so no
549
- // live binary needs to advertise the unimplemented operation to prove this.
550
- {
551
- const capabilitiesFixturePath = join(
552
- ROOT, "contracts", "review-integration", "v2", "fixtures", "capabilities.fixture.json",
553
- );
554
- const capabilitiesFixture = JSON.parse(await readFile(capabilitiesFixturePath, "utf8"));
555
- const executableDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705";
556
- const capabilitiesBody = {
557
- ...capabilitiesFixture,
558
- package: { ...capabilitiesFixture.package, version: "2.4.0" },
559
- };
560
- const statusBody = {
561
- schema: "gentle-ai.review-integration.status/v3",
562
- contract: "gentle-ai.review-integration/v2",
563
- operation: "review.status",
564
- applicability: "unrelated",
565
- receipt: { status: "not_applicable" },
566
- action: "start",
567
- replayability: "not_replayable",
568
- target_identity: `sha256:${"a".repeat(64)}`,
569
- repair: {
570
- schema: "gentle-ai.review-authority-repair-assessment/v1",
571
- status: "unsupported",
572
- counts: { lineages: 0, compact_lineages: 0, legacy_lineages: 0, events: 0, bytes: 0, eligible_candidates: 0, unsupported_lineages: 0, conflicts: 0 },
573
- supported_operations: ["review/complete-fix", "review/validate-fix"],
574
- authorization_schema: "gentle-ai.review-repair-authorization/v1",
575
- },
576
- projection: {
577
- schema: "gentle-ai.review-integration.projection/v1",
578
- kind: "current-changes",
579
- projection: "workspace",
580
- base_tree: "b".repeat(40),
581
- initial_review_tree: "b".repeat(40),
582
- current_candidate_tree: "b".repeat(40),
583
- paths_digest: `sha256:${"a".repeat(64)}`,
584
- paths: [],
585
- intended_untracked: [],
586
- intended_untracked_proof: `sha256:${"a".repeat(64)}`,
587
- initial_snapshot_identity: `sha256:${"a".repeat(64)}`,
588
- current_snapshot_identity: `sha256:${"a".repeat(64)}`,
589
- },
590
- candidates: [],
591
- next_transition: {
592
- kind: "execute",
593
- reason_code: "disposable_result_pending",
594
- execute: {
595
- operation: "review.dispose-result",
596
- arguments: [],
597
- preconditions: [],
598
- binding: { target_identity: `sha256:${"a".repeat(64)}` },
599
- },
600
- },
601
- };
602
- const calls = [];
603
- const adapter = async (request) => {
604
- calls.push(request.arguments);
605
- const body = calls.length === 1 ? capabilitiesBody : statusBody;
606
- return { stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
607
- };
608
- const client = new NativeReviewCliV216(
609
- adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024,
610
- async () => undefined, () => executableDigest,
611
- );
612
- let thrown;
613
- try {
614
- await client.targetStatus({ cwd: "/repo" });
615
- } catch (error) {
616
- thrown = error;
617
- }
618
- assert.ok(thrown instanceof NativeReviewCliError, "an unimplemented next_transition.execute.operation must raise a typed NativeReviewCliError");
619
- assert.equal(thrown.code, NATIVE_REVIEW_ERROR_CODE.UNSUPPORTED_TRANSITION_OPERATION);
620
- assert.match(thrown.message, /review\.dispose-result/, "the refusal must name the specific unimplemented operation");
621
- assert.equal(calls.length, 2, "capabilities negotiation plus one STATUS call — never a third call synthesizing the unimplemented operation");
622
- }
623
-
624
792
  const bannerCwd = await tempWorkspace();
625
793
  try {
626
794
  const ctx = createCtx(bannerCwd, true);
@@ -925,17 +1093,13 @@ async function run() {
925
1093
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
926
1094
  assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
927
1095
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
928
- assert.equal(ctx.ui.selections.length, 3);
929
- assert.equal(ctx.ui.selections[0].label, "SDD execution mode");
930
- assert.equal(ctx.ui.selections[1].label, "SDD artifact store");
931
- assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
932
- assert.equal(ctx.ui.selections[2].label, "SDD PR chaining");
933
- 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/);
934
1098
  assert.deepEqual(
935
1099
  await inputHook({ text: "please use sdd for this change", source: "interactive" }, ctx),
936
1100
  { action: "continue" },
937
1101
  );
938
- 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");
939
1103
  assert.deepEqual(
940
1104
  await inputHook({ text: "/sdd", source: "interactive" }, ctx),
941
1105
  { action: "continue" },
@@ -948,7 +1112,7 @@ async function run() {
948
1112
  await inputHook({ text: "/sdd:plan", source: "interactive" }, ctx),
949
1113
  { action: "continue" },
950
1114
  );
951
- assert.equal(ctx.ui.selections.length, 3);
1115
+ assert.equal(ctx.ui.selections.length, 0, "slash SDD triggers use automatic defaults without prompts");
952
1116
 
953
1117
  assert.deepEqual(
954
1118
  await inputHook({ text: "/sdd-plan this change", source: "interactive" }, ctx),
@@ -976,19 +1140,18 @@ async function run() {
976
1140
  "global SDD model routing must be materialized in agent frontmatter, not project settings overrides",
977
1141
  );
978
1142
  }
979
- assert.equal(ctx.ui.selections.length, 3);
980
- assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
981
- 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/);
982
1145
  await commands.get("gentle:status").handler("", ctx);
983
1146
  assert.match(ctx.ui.notifications.at(-1).message, /Global SDD assets stale: 0 file\(s\)/);
984
1147
  assert.doesNotMatch(ctx.ui.notifications.at(-1).message, /install-sdd --force/);
985
1148
 
986
1149
  await inputHook({ text: "/sdd-plan another change", source: "interactive" }, ctx);
987
- 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");
988
1151
  const promptHook = hooks.get("before_agent_start")[0];
989
1152
  const promptResult = await promptHook({ systemPrompt: "base" }, ctx);
990
1153
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
991
- assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
1154
+ assert.match(promptResult.systemPrompt, /Execution mode: auto/);
992
1155
  const workerPromptResult = await promptHook(
993
1156
  { agentName: "worker", systemPrompt: "worker base" },
994
1157
  ctx,
@@ -1003,7 +1166,7 @@ async function run() {
1003
1166
  await rm(globalModelsPath, { force: true });
1004
1167
  }
1005
1168
 
1006
- 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()) {
1007
1170
  const slashSddCwd = await tempWorkspace();
1008
1171
  try {
1009
1172
  const ctx = createCtx(slashSddCwd, true, `slash-sdd-session-${index}`);
@@ -1013,7 +1176,7 @@ async function run() {
1013
1176
  });
1014
1177
  assert.equal(existsSync(join(slashSddCwd, ".pi", "agents", "sdd-apply.md")), false);
1015
1178
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
1016
- 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`);
1017
1180
  } finally {
1018
1181
  await rm(slashSddCwd, { recursive: true, force: true });
1019
1182
  }
@@ -1025,9 +1188,11 @@ async function run() {
1025
1188
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1026
1189
  assert.equal(existsSync(join(commandSddCwd, ".pi", "agents", "sdd-apply.md")), false);
1027
1190
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
1028
- 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/);
1029
1194
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1030
- 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");
1031
1196
  } finally {
1032
1197
  await rm(commandSddCwd, { recursive: true, force: true });
1033
1198
  }
@@ -1046,7 +1211,7 @@ async function run() {
1046
1211
  assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "chains", "sdd-full.chain.md")), false);
1047
1212
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
1048
1213
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
1049
- assert.equal(ctx.ui.selections.length, 3);
1214
+ assert.equal(ctx.ui.selections.length, 0, "automatic SDD-agent startup must not render selectors");
1050
1215
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
1051
1216
  assert.doesNotMatch(
1052
1217
  promptResult.systemPrompt,
@@ -1067,7 +1232,7 @@ async function run() {
1067
1232
  },
1068
1233
  ctx,
1069
1234
  );
1070
- 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");
1071
1236
  assert.doesNotMatch(
1072
1237
  reusedPromptResult.systemPrompt,
1073
1238
  /el Gentleman Identity and Harness/,
@@ -1089,7 +1254,7 @@ async function run() {
1089
1254
  ctx,
1090
1255
  );
1091
1256
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
1092
- assert.match(promptResult.systemPrompt, /No interactive UI was available/);
1257
+ assert.match(promptResult.systemPrompt, /canonical defaults or persisted choices/);
1093
1258
  assert.equal(ctx.ui.selections.length, 0);
1094
1259
  assert.equal(existsSync(join(noUiSddAgentCwd, ".pi", "agents", "sdd-apply.md")), false);
1095
1260
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
@@ -1115,14 +1280,14 @@ async function run() {
1115
1280
  pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
1116
1281
  const ctx = createCtx(engramSddCwd, true, "engram-session");
1117
1282
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1118
- assert.deepEqual(ctx.ui.selections[1].options, ["openspec", "engram", "both"]);
1283
+ assert.deepEqual(ctx.ui.selections[1].options, ["openspec", "engram", "hybrid"]);
1119
1284
  } finally {
1120
1285
  pi.setActiveTools(["read", "bash", "edit", "write"]);
1121
1286
  await rm(engramSddCwd, { recursive: true, force: true });
1122
1287
  }
1123
1288
 
1124
1289
  // Issue #64: selecting engram as the artifact store must not cause
1125
- // /sdd-init to create openspec/ or openspec/config.yaml.
1290
+ // /gentle-sdd-init to create openspec/ or openspec/config.yaml.
1126
1291
  const engramSddInitCwd = await tempWorkspace();
1127
1292
  try {
1128
1293
  pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
@@ -1132,21 +1297,21 @@ async function run() {
1132
1297
  return options[0];
1133
1298
  };
1134
1299
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1135
- await commands.get("sdd-init").handler("", ctx);
1300
+ await commands.get("gentle-sdd-init").handler("", ctx);
1136
1301
  assert.equal(
1137
1302
  existsSync(join(engramSddInitCwd, "openspec")),
1138
1303
  false,
1139
- "/sdd-init must not create openspec/ when artifactStore is engram",
1304
+ "/gentle-sdd-init must not create openspec/ when artifactStore is engram",
1140
1305
  );
1141
1306
  assert.equal(
1142
1307
  existsSync(join(engramSddInitCwd, "openspec", "config.yaml")),
1143
1308
  false,
1144
- "/sdd-init must not write openspec/config.yaml when artifactStore is engram",
1309
+ "/gentle-sdd-init must not write openspec/config.yaml when artifactStore is engram",
1145
1310
  );
1146
1311
  assert.doesNotMatch(
1147
1312
  ctx.ui.notifications.at(-1).message,
1148
1313
  /Wrote openspec\/config\.yaml/,
1149
- "/sdd-init must not announce openspec/config.yaml when artifactStore is engram",
1314
+ "/gentle-sdd-init must not announce openspec/config.yaml when artifactStore is engram",
1150
1315
  );
1151
1316
  assert.match(
1152
1317
  ctx.ui.notifications.at(-1).message,
@@ -1166,30 +1331,30 @@ async function run() {
1166
1331
  pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
1167
1332
  const ctx = createCtx(bothSddInitCwd, true, "both-sdd-init-session");
1168
1333
  ctx.ui.select = async (label, options) => {
1169
- if (label === "SDD artifact store") return "both";
1334
+ if (label === "SDD artifact store") return "hybrid";
1170
1335
  return options[0];
1171
1336
  };
1172
1337
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1173
- await commands.get("sdd-init").handler("", ctx);
1338
+ await commands.get("gentle-sdd-init").handler("", ctx);
1174
1339
  assert.equal(
1175
1340
  existsSync(join(bothSddInitCwd, "openspec", "specs")),
1176
1341
  true,
1177
- "/sdd-init must create openspec/specs when artifactStore is both",
1342
+ "/gentle-sdd-init must create openspec/specs when artifactStore is both",
1178
1343
  );
1179
1344
  assert.equal(
1180
1345
  existsSync(join(bothSddInitCwd, "openspec", "changes", "archive")),
1181
1346
  true,
1182
- "/sdd-init must create openspec/changes/archive when artifactStore is both",
1347
+ "/gentle-sdd-init must create openspec/changes/archive when artifactStore is both",
1183
1348
  );
1184
1349
  assert.equal(
1185
1350
  existsSync(join(bothSddInitCwd, "openspec", "config.yaml")),
1186
1351
  true,
1187
- "/sdd-init must write openspec/config.yaml when artifactStore is both",
1352
+ "/gentle-sdd-init must write openspec/config.yaml when artifactStore is both",
1188
1353
  );
1189
1354
  assert.match(
1190
1355
  ctx.ui.notifications.at(-1).message,
1191
1356
  /Wrote openspec\/config\.yaml/,
1192
- "/sdd-init must announce openspec/config.yaml when artifactStore is both",
1357
+ "/gentle-sdd-init must announce openspec/config.yaml when artifactStore is both",
1193
1358
  );
1194
1359
  assert.equal(ctx.ui.notifications.at(-1).level, "info");
1195
1360
  } finally {
@@ -1202,7 +1367,7 @@ async function run() {
1202
1367
  pi.setActiveTools(["read", "bash", "edit", "write", "engram_mem_save"]);
1203
1368
  const ctx = createCtx(directEngramToolCwd, true, "direct-engram-session");
1204
1369
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1205
- 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");
1206
1371
  } finally {
1207
1372
  pi.setActiveTools(["read", "bash", "edit", "write"]);
1208
1373
  await rm(directEngramToolCwd, { recursive: true, force: true });
@@ -1252,7 +1417,7 @@ async function run() {
1252
1417
  const sddCwd = await tempWorkspace();
1253
1418
  try {
1254
1419
  const ctx = createCtx(sddCwd, true);
1255
- await commands.get("sdd-init").handler("", ctx);
1420
+ await commands.get("gentle-sdd-init").handler("", ctx);
1256
1421
  assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), false);
1257
1422
  assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
1258
1423
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
@@ -1260,12 +1425,12 @@ async function run() {
1260
1425
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
1261
1426
  assert.equal(existsSync(join(globalAgentHome, "gentle-ai", "support", "sdd-status-contract.md")), true);
1262
1427
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
1263
- assert.equal(ctx.ui.selections.length, 3);
1428
+ assert.equal(ctx.ui.selections.length, 0, "sdd-init uses automatic preflight defaults");
1264
1429
  assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
1265
1430
  assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
1266
1431
 
1267
1432
  await commands.get("gentle:sdd-preflight").handler("", ctx);
1268
- 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");
1269
1434
  } finally {
1270
1435
  await rm(sddCwd, { recursive: true, force: true });
1271
1436
  }
@@ -1279,7 +1444,7 @@ async function run() {
1279
1444
  );
1280
1445
  await writeFile(globalModelsPath, "{ invalid json");
1281
1446
  const ctx = createCtx(invalidSddInitCwd, true, "invalid-sdd-init-session");
1282
- await commands.get("sdd-init").handler("", ctx);
1447
+ await commands.get("gentle-sdd-init").handler("", ctx);
1283
1448
  assert.equal(ctx.ui.notifications[0].level, "warning");
1284
1449
  assert.match(ctx.ui.notifications[0].message, /Model routing skipped:/);
1285
1450
  assert.match(ctx.ui.notifications[0].message, /models\.json/);