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
@@ -5,12 +5,16 @@ import {
5
5
  createFindTool,
6
6
  createGrepTool,
7
7
  createLsTool,
8
- createReadTool,
8
+ createReadToolDefinition,
9
9
  createWriteTool,
10
+ keyHint,
10
11
  } from "@earendil-works/pi-coding-agent";
11
- import { Text } from "@earendil-works/pi-tui";
12
+ import { Text, type Component } from "@earendil-works/pi-tui";
12
13
  import { homedir } from "node:os";
14
+ import { isAbsolute } from "node:path";
15
+ import { resolveGentleAiDevBinaryOverride, type GentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
13
16
  import { quietToolsEnabled } from "../lib/quiet-tools-config.ts";
17
+ import { getGentleAiRenderState, renderGentleAiLifecycleCall, renderGentleAiResult, type GentleAiRenderContext } from "../lib/gentle-ai-renderer.ts";
14
18
  import { sanitizeTerminalText } from "../lib/terminal-theme.ts";
15
19
 
16
20
  type QuietToolName = "read" | "bash" | "grep" | "find" | "ls" | "edit" | "write";
@@ -20,7 +24,7 @@ type ThemeLike = {
20
24
  };
21
25
 
22
26
  const TOOL_CREATORS = {
23
- read: createReadTool,
27
+ read: createReadToolDefinition,
24
28
  bash: createBashTool,
25
29
  grep: createGrepTool,
26
30
  find: createFindTool,
@@ -35,14 +39,14 @@ const COLLAPSED_COUNT_LABELS: Partial<Record<QuietToolName, string>> = {
35
39
  ls: "entries",
36
40
  };
37
41
 
38
- const NO_COLLAPSED_RESULT_TOOLS = new Set<QuietToolName>(["read", "bash"]);
39
- const COLLAPSED_TAIL_TOOLS = new Set<QuietToolName>(["edit", "write"]);
40
42
  const COLLAPSED_TAIL_LINE_LIMIT = 10;
43
+ const PREVIEW_LINE_LIMIT = 3;
41
44
 
42
45
  const EMPTY_RESULT_MESSAGES: Partial<Record<QuietToolName, string[]>> = {
43
46
  grep: ["No matches found"],
44
47
  find: ["No files found matching pattern"],
45
- ls: ["Directory is empty"],
48
+ ls: ["Directory is empty", "(empty directory)"],
49
+ bash: ["(no output)"],
46
50
  };
47
51
 
48
52
  const toolCache = new Map<string, Record<QuietToolName, any>>();
@@ -83,6 +87,43 @@ export function tailLines(text: string, limit: number): string {
83
87
  return lines.slice(Math.max(0, lines.length - limit)).join("\n");
84
88
  }
85
89
 
90
+ function outputLines(text: string): string[] {
91
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\n$/, "");
92
+ return normalized.length > 0 ? normalized.split("\n") : [];
93
+ }
94
+
95
+ function firstLines(text: string, limit: number): string {
96
+ return outputLines(text).slice(0, limit).join("\n");
97
+ }
98
+
99
+ function lastOutputLines(text: string, limit: number): string {
100
+ return outputLines(text).slice(-limit).join("\n");
101
+ }
102
+
103
+ function lastMeaningfulOutputLines(text: string, limit: number): string {
104
+ return outputLines(text)
105
+ .filter((line) => line.trim().length > 0)
106
+ .slice(-limit)
107
+ .join("\n");
108
+ }
109
+
110
+ function semanticJsonPreview(text: string): string | undefined {
111
+ const trimmed = text.trim();
112
+ if (!/^[\[{]/.test(trimmed)) return undefined;
113
+ let parsed: unknown;
114
+ try {
115
+ parsed = JSON.parse(trimmed);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ const normalized = JSON.stringify(parsed, null, 2);
120
+ if (normalized === undefined) return undefined;
121
+ return outputLines(normalized)
122
+ .filter((line) => !/^[\s{}\[\],]*$/.test(line))
123
+ .slice(0, PREVIEW_LINE_LIMIT)
124
+ .join("\n");
125
+ }
126
+
86
127
  export function extractTextContent(result: AgentToolResult<unknown>): string {
87
128
  return result.content
88
129
  .flatMap((content) => (content.type === "text" ? [content.text] : []))
@@ -93,9 +134,32 @@ function safeText(value: string): string {
93
134
  return sanitizeTerminalText(value);
94
135
  }
95
136
 
137
+ function sanitizeValue(value: unknown): unknown {
138
+ if (typeof value === "string") return safeText(value);
139
+ return value;
140
+ }
141
+
142
+ function sanitizedArgs(args: Record<string, unknown> | undefined): Record<string, unknown> {
143
+ return (sanitizeValue(args ?? {}) as Record<string, unknown>) ?? {};
144
+ }
145
+
146
+ function sanitizedResult(result: AgentToolResult<unknown>): AgentToolResult<unknown> {
147
+ return {
148
+ ...result,
149
+ content: result.content.map((content) => content.type === "text" ? { ...content, text: safeText(content.text) } : content.type === "image" ? { ...content, mimeType: safeText(content.mimeType) } : content),
150
+ details: sanitizeValue(result.details),
151
+ };
152
+ }
153
+
96
154
  function isEmptyResultMessage(toolName: QuietToolName, text: string): boolean {
97
155
  const normalized = text.trim();
98
- return EMPTY_RESULT_MESSAGES[toolName]?.some((message) => normalized.startsWith(message)) ?? false;
156
+ return EMPTY_RESULT_MESSAGES[toolName]?.some((message) => normalized === message) ?? false;
157
+ }
158
+
159
+ function grepMatchCount(text: string, args: Record<string, unknown> | undefined): number {
160
+ const context = args?.context;
161
+ if (typeof context !== "number" || context <= 0) return countNonEmptyLines(text);
162
+ return outputLines(text).filter((line) => /^\s*.+:\d+:\s?/.test(line)).length;
99
163
  }
100
164
 
101
165
  function isGitCommand(args: Record<string, unknown> | undefined): boolean {
@@ -103,36 +167,311 @@ function isGitCommand(args: Record<string, unknown> | undefined): boolean {
103
167
  return /^(?:env\s+\S+=\S+\s+|command\s+|\w+=\S+\s+)*git(?:\s|$)/.test(command);
104
168
  }
105
169
 
170
+ export type GentleAiRoutineCommand = "sdd-status" | "sdd-continue" | "sdd-attempt" | "review";
171
+
172
+ const GENTLE_AI_EXECUTABLE = String.raw`(?:gentle-ai(?:\.exe)?|(?:\.{1,2}[\\/]|(?:[A-Za-z]:)?(?:[\\/][^\\/\r\n]+)*[\\/])\.gentle-ai[\\/]v\d+\.\d+\.\d+[\\/]gentle-ai(?:\.exe)?)`;
173
+ const GENTLE_AI_COMMAND_ARGUMENTS = new RegExp(`^${GENTLE_AI_EXECUTABLE}$`);
174
+
175
+ function createGentleAiCommandArguments(activeDevBinaryPath?: string): RegExp {
176
+ if (!activeDevBinaryPath) return GENTLE_AI_COMMAND_ARGUMENTS;
177
+ const escapedPath = activeDevBinaryPath.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
178
+ return new RegExp(`^(?:${GENTLE_AI_EXECUTABLE}|${escapedPath})$`);
179
+ }
180
+
181
+ type ShellTokenization =
182
+ | { kind: "complete" | "incomplete"; tokens: string[] }
183
+ | { kind: "generic" };
184
+
185
+ function shellTokens(command: string): ShellTokenization {
186
+ const tokens: string[] = [];
187
+ let token = "";
188
+ let quote: "single" | "double" | undefined;
189
+ let tokenStarted = false;
190
+ const push = () => {
191
+ if (tokenStarted) tokens.push(token);
192
+ token = "";
193
+ tokenStarted = false;
194
+ };
195
+ for (let index = 0; index < command.length; index += 1) {
196
+ const character = command[index]!;
197
+ if (character === "\r" || character === "\n") return { kind: "generic" };
198
+ if (quote === "single") {
199
+ if (character === "'") quote = undefined;
200
+ else token += character;
201
+ continue;
202
+ }
203
+ if (quote === "double") {
204
+ if (character === '"') { quote = undefined; continue; }
205
+ if (character === "\\") {
206
+ const next = command[++index];
207
+ if (next === undefined || next === "\r" || next === "\n") return { kind: "incomplete", tokens };
208
+ token += "$`\"\\".includes(next) ? next : `\\${next}`;
209
+ continue;
210
+ }
211
+ if (character === "$" || character === "`") return { kind: "generic" };
212
+ token += character;
213
+ continue;
214
+ }
215
+ if (/\s/.test(character)) { push(); continue; }
216
+ if (character === "'") { quote = "single"; tokenStarted = true; continue; }
217
+ if (character === '"') { quote = "double"; tokenStarted = true; continue; }
218
+ if (character === "\\") {
219
+ const next = command[++index];
220
+ if (next === undefined || next === "\r" || next === "\n") return { kind: "incomplete", tokens };
221
+ const windowsPath = token === "." || /^[A-Za-z]:$/.test(token) || token.includes("\\");
222
+ token += windowsPath ? `\\${next}` : next;
223
+ tokenStarted = true;
224
+ continue;
225
+ }
226
+ if ("*?~{}".includes(character)) return { kind: "generic" };
227
+ if (character === "[" && command.indexOf("]", index + 1) >= 0) return { kind: "generic" };
228
+ if (";&|<>`()".includes(character) || character === "$" || character === "`") return { kind: "generic" };
229
+ if (character === "#" && !tokenStarted) return { kind: "generic" };
230
+ token += character;
231
+ tokenStarted = true;
232
+ }
233
+ if (quote) return { kind: "incomplete", tokens };
234
+ push();
235
+ return { kind: "complete", tokens };
236
+ }
237
+
238
+ function isAssignment(token: string): boolean {
239
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
240
+ }
241
+ const SDD_ATTEMPT_VERBS = new Set(["acquire", "settle", "grant"]);
242
+
243
+ const REVIEW_DIRECT_OPERATIONS = new Set([
244
+ "capabilities",
245
+ "start",
246
+ "finalize",
247
+ "status",
248
+ "repair",
249
+ "invalidate",
250
+ "abandon",
251
+ "recover",
252
+ "reclaim",
253
+ "validate",
254
+ "capture-result",
255
+ "capture-refuter",
256
+ "capture-validation",
257
+ "capture-evidence",
258
+ "preserve-result",
259
+ "lens-context",
260
+ "retry-final-verification",
261
+ "store-reset",
262
+ "inspect-authority",
263
+ "inspect-candidate",
264
+ "dispose-result",
265
+ "reopen-results",
266
+ "opencode-transport",
267
+ "bind-sdd",
268
+ ]);
269
+ const REVIEW_MODE_VALUES = new Set(["enable", "disable", "status"]);
270
+ const REVIEW_VALIDATE_GATES = new Set(["post-apply", "pre-commit", "pre-push", "pre-pr", "release"]);
271
+ const REVIEW_SCHEMA_NAMES = new Set([
272
+ "capture-result-dry-run",
273
+ "final-verification-incident",
274
+ "refuter",
275
+ "reviewer",
276
+ "validator",
277
+ "verification-evidence",
278
+ "verification-evidence-record",
279
+ ]);
280
+
281
+ function gentleAiCommandTokensFrom(tokens: string[], commandArguments: RegExp): string[] | undefined {
282
+ let index = 0;
283
+ while (isAssignment(tokens[index] ?? "")) index += 1;
284
+ if (tokens[index] === "command") {
285
+ index += 1;
286
+ if (tokens[index] === "--") index += 1;
287
+ else if ((tokens[index] ?? "").startsWith("-")) return undefined;
288
+ }
289
+ if (tokens[index] === "env") {
290
+ index += 1;
291
+ while (isAssignment(tokens[index] ?? "")) index += 1;
292
+ if ((tokens[index] ?? "").startsWith("-")) return undefined;
293
+ }
294
+ if (!commandArguments.test(tokens[index] ?? "")) return undefined;
295
+ return tokens.slice(index + 1);
296
+ }
297
+
298
+ function gentleAiCommandTokens(args: Record<string, unknown> | undefined, commandArguments = GENTLE_AI_COMMAND_ARGUMENTS): string[] | undefined {
299
+ const shell = shellTokens(typeof args?.command === "string" ? args.command : "");
300
+ return shell.kind === "complete" ? gentleAiCommandTokensFrom(shell.tokens, commandArguments) : undefined;
301
+ }
302
+
303
+ function displayToken(token: string): string {
304
+ return token.replace(/-/g, " ");
305
+ }
306
+
307
+ function authorizationRootCount(tokens: string[]): number {
308
+ let count = 0;
309
+ for (let index = 0; index < tokens.length; index += 1) {
310
+ const token = tokens[index]!;
311
+ if (token === "--authorization-root") {
312
+ const value = tokens[index + 1];
313
+ if (value !== undefined && !value.startsWith("-")) count += 1;
314
+ continue;
315
+ }
316
+ if (token.startsWith("--authorization-root=") && token.slice("--authorization-root=".length).length > 0) {
317
+ count += 1;
318
+ }
319
+ }
320
+ return count;
321
+ }
322
+
323
+ function validateGate(tokens: string[]): string | undefined {
324
+ const gateFlag = tokens.findIndex((token) => token === "--gate" || token.startsWith("--gate="));
325
+ if (gateFlag < 0) return undefined;
326
+ const gate = tokens[gateFlag]!.startsWith("--gate=")
327
+ ? tokens[gateFlag]!.slice("--gate=".length)
328
+ : tokens[gateFlag + 1];
329
+ return gate !== undefined && REVIEW_VALIDATE_GATES.has(gate) ? gate : "";
330
+ }
331
+
332
+ /**
333
+ * Matches only supported routine Gentle AI CLI calls, including bounded
334
+ * package-local paths, not arbitrary shell output that merely mentions
335
+ * gentle-ai. These commands otherwise emit machine-readable SDD/RDD data.
336
+ */
337
+ export function isGentleAiDirectCommand(args: Record<string, unknown> | undefined, commandArguments = GENTLE_AI_COMMAND_ARGUMENTS): boolean {
338
+ return gentleAiCommandTokens(args, commandArguments) !== undefined;
339
+ }
340
+
341
+ export function gentleAiRoutineCommand(args: Record<string, unknown> | undefined, commandArguments = GENTLE_AI_COMMAND_ARGUMENTS): GentleAiRoutineCommand | undefined {
342
+ const tokens = gentleAiCommandTokens(args, commandArguments);
343
+ if (!tokens) return undefined;
344
+ if (tokens[0] === "sdd-status") return "sdd-status";
345
+ if (tokens[0] === "sdd-continue") return "sdd-continue";
346
+ if (tokens[0] === "sdd-attempt" && SDD_ATTEMPT_VERBS.has(tokens[1] ?? "")) return "sdd-attempt";
347
+ if (tokens[0] === "review") return "review";
348
+ return undefined;
349
+ }
350
+
351
+ function gentleAiOperationPathFrom(tokens: string[]): string {
352
+ if (tokens[0] === "sdd-status") return "sdd status";
353
+ if (tokens[0] === "sdd-continue") return "sdd continue";
354
+ if (tokens[0] === "sdd-attempt") {
355
+ const verb = tokens[1] ?? "";
356
+ if (!SDD_ATTEMPT_VERBS.has(verb)) return "sdd attempt";
357
+ if (verb !== "grant") return `sdd attempt ${verb}`;
358
+ const rootCount = authorizationRootCount(tokens);
359
+ return rootCount > 0
360
+ ? `sdd attempt grant · ${rootCount} root${rootCount === 1 ? "" : "s"}`
361
+ : "sdd attempt grant";
362
+ }
363
+ if (tokens[0] === "version") return "version";
364
+ if (tokens[0] !== "review") return "command";
365
+
366
+ const operation = tokens[1];
367
+ if (operation === undefined) return "review";
368
+ if (operation === "mode") {
369
+ const mode = tokens[2];
370
+ return mode !== undefined && REVIEW_MODE_VALUES.has(mode) ? `review mode ${displayToken(mode)}` : "review";
371
+ }
372
+ if (operation === "validate") {
373
+ const gate = validateGate(tokens);
374
+ if (gate === "") return "review";
375
+ return gate === undefined ? "review validate" : `review validate ${displayToken(gate)}`;
376
+ }
377
+ if (operation === "schema") {
378
+ const schema = tokens[2];
379
+ return schema !== undefined && REVIEW_SCHEMA_NAMES.has(schema) ? `review schema ${displayToken(schema)}` : "review schema";
380
+ }
381
+ return REVIEW_DIRECT_OPERATIONS.has(operation) ? `review ${displayToken(operation)}` : "review";
382
+ }
383
+
384
+ export function gentleAiOperationPath(args: Record<string, unknown> | undefined, commandArguments = GENTLE_AI_COMMAND_ARGUMENTS): string | undefined {
385
+ const tokens = gentleAiCommandTokens(args, commandArguments);
386
+ return tokens ? gentleAiOperationPathFrom(tokens) : undefined;
387
+ }
388
+
389
+ export function isGentleAiGrantCommand(args: Record<string, unknown> | undefined, commandArguments = GENTLE_AI_COMMAND_ARGUMENTS): boolean {
390
+ const tokens = gentleAiCommandTokens(args, commandArguments);
391
+ return tokens?.[0] === "sdd-attempt" && tokens[1] === "grant";
392
+ }
393
+
106
394
  interface ToolResultFormatOptions {
107
395
  expanded: boolean;
108
396
  isError?: boolean;
109
397
  args?: Record<string, unknown>;
110
398
  }
111
399
 
400
+ function detailsRecord(result: AgentToolResult<unknown>): Record<string, unknown> {
401
+ const details = sanitizeValue(result.details);
402
+ return details && typeof details === "object" && !Array.isArray(details)
403
+ ? details as Record<string, unknown>
404
+ : {};
405
+ }
406
+
407
+ function diffStats(diff: string): { additions: number; removals: number } {
408
+ let additions = 0;
409
+ let removals = 0;
410
+ for (const line of diff.split("\n")) {
411
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
412
+ if (line.startsWith("-") && !line.startsWith("---")) removals++;
413
+ }
414
+ return { additions, removals };
415
+ }
416
+
417
+ function editSummary(result: AgentToolResult<unknown>): string {
418
+ const diff = detailsRecord(result).diff;
419
+ if (typeof diff === "string") {
420
+ const stats = diffStats(diff);
421
+ return `✓ +${stats.additions} / -${stats.removals}`;
422
+ }
423
+ return "✓ applied";
424
+ }
425
+
426
+ function writeSummary(text: string): string {
427
+ const bytes = text.match(/(?:Successfully )?wrote\s+(\d+)\s+bytes/i)?.[1];
428
+ return `✓ ${bytes ? `wrote ${bytes} bytes` : "written"}`;
429
+ }
430
+
431
+ function expandedResultText(toolName: QuietToolName, result: AgentToolResult<unknown>, text: string): string {
432
+ if (toolName === "edit") {
433
+ const diff = detailsRecord(result).diff;
434
+ if (typeof diff === "string") return safeText(diff);
435
+ }
436
+ return text;
437
+ }
438
+
112
439
  export function formatToolResultOutput(
113
440
  toolName: QuietToolName,
114
441
  result: AgentToolResult<unknown>,
115
442
  { expanded, isError = false, args }: ToolResultFormatOptions,
116
443
  ): string {
117
444
  const text = safeText(extractTextContent(result));
118
- if (expanded || isError) return text ? `\n${text}` : "";
119
-
445
+ if (expanded) {
446
+ const detail = expandedResultText(toolName, result, text);
447
+ return detail ? `\n${detail}` : "";
448
+ }
449
+ if (isError) {
450
+ const tail = lastMeaningfulOutputLines(text, PREVIEW_LINE_LIMIT);
451
+ return tail ? `\n${tail}` : "";
452
+ }
453
+ const summaryLabel = COLLAPSED_COUNT_LABELS[toolName];
454
+ if (summaryLabel) {
455
+ if (isEmptyResultMessage(toolName, text)) return "";
456
+ const count = toolName === "grep" ? grepMatchCount(text, args) : countNonEmptyLines(text);
457
+ return count > 0 ? ` → ${count} ${summaryLabel}` : "";
458
+ }
120
459
  if (toolName === "bash" && isGitCommand(args)) {
121
460
  const tail = tailLines(text, COLLAPSED_TAIL_LINE_LIMIT);
122
461
  return tail ? `\n${tail}` : "";
123
462
  }
124
- if (NO_COLLAPSED_RESULT_TOOLS.has(toolName)) return "";
125
- if (COLLAPSED_TAIL_TOOLS.has(toolName)) {
126
- const tail = tailLines(text, COLLAPSED_TAIL_LINE_LIMIT);
463
+ if (toolName === "read") {
464
+ const head = firstLines(text, PREVIEW_LINE_LIMIT);
465
+ return head ? `\n${head}` : "";
466
+ }
467
+ if (toolName === "bash") {
468
+ const preview = semanticJsonPreview(text);
469
+ const tail = preview ?? lastOutputLines(text, PREVIEW_LINE_LIMIT);
127
470
  return tail ? `\n${tail}` : "";
128
471
  }
129
- if (isEmptyResultMessage(toolName, text)) return "";
130
-
131
- const summaryLabel = COLLAPSED_COUNT_LABELS[toolName];
132
- if (!summaryLabel) return "";
133
-
134
- const count = countNonEmptyLines(text);
135
- return count > 0 ? ` → ${count} ${summaryLabel}` : "";
472
+ if (toolName === "edit") return `\n${editSummary(result)}`;
473
+ if (toolName === "write") return `\n${writeSummary(text)}`;
474
+ return "";
136
475
  }
137
476
 
138
477
  function lineRangeSuffix(args: Record<string, unknown>, theme: ThemeLike): string {
@@ -142,6 +481,18 @@ function lineRangeSuffix(args: Record<string, unknown>, theme: ThemeLike): strin
142
481
  return theme.fg("warning", `:${startLine}${endLine === undefined ? "" : `-${endLine}`}`);
143
482
  }
144
483
 
484
+ interface ToolRenderContextLike {
485
+ args?: Record<string, unknown>;
486
+ argsComplete?: boolean;
487
+ executionStarted?: boolean;
488
+ isPartial?: boolean;
489
+ isError?: boolean;
490
+ lastComponent?: unknown;
491
+ state?: unknown;
492
+ cwd?: string;
493
+ [key: string]: unknown;
494
+ }
495
+
145
496
  function formatToolCall(toolName: QuietToolName, args: Record<string, unknown>, theme: ThemeLike): string {
146
497
  switch (toolName) {
147
498
  case "read": {
@@ -179,12 +530,98 @@ function formatToolCall(toolName: QuietToolName, args: Record<string, unknown>,
179
530
  }
180
531
  }
181
532
 
182
- function partialLabel(toolName: QuietToolName): string {
183
- return toolName === "bash" ? "Running..." : `${toolName}...`;
533
+ interface BoundedRowSection {
534
+ text: string;
535
+ rows: number;
536
+ tail?: boolean;
537
+ }
538
+
539
+ class BoundedRows implements Component {
540
+ private readonly sections: readonly BoundedRowSection[];
541
+
542
+ constructor(sections: readonly BoundedRowSection[]) {
543
+ this.sections = sections;
544
+ }
545
+
546
+ render(width: number): string[] {
547
+ return this.sections.flatMap(({ text, rows, tail = false }) => {
548
+ if (rows <= 0) return [];
549
+ const rendered = new Text(text, 0, 0).render(width);
550
+ return tail ? rendered.slice(-rows) : rendered.slice(0, rows);
551
+ });
552
+ }
553
+
554
+ invalidate(): void {}
555
+ }
556
+
557
+ function shouldRenderPreviewTail(
558
+ toolName: QuietToolName,
559
+ text: string,
560
+ isError: boolean,
561
+ args: Record<string, unknown> | undefined,
562
+ ): boolean {
563
+ if (isError) return true;
564
+ if (toolName !== "bash") return false;
565
+ return isGitCommand(args) || semanticJsonPreview(text) === undefined;
566
+ }
567
+
568
+ function partialLabel(toolName: QuietToolName, text: string): string {
569
+ const lineCount = countNonEmptyLines(text);
570
+ return lineCount === 0
571
+ ? `… ${toolName}`
572
+ : `… ${toolName} · ${lineCount} ${lineCount === 1 ? "line" : "lines"}`;
573
+ }
574
+
575
+ function hasImageContent(result: AgentToolResult<unknown>): boolean {
576
+ return result.content.some((content) => content.type === "image");
577
+ }
578
+
579
+ function hasExpandableContent(toolName: QuietToolName, result: AgentToolResult<unknown>, text: string): boolean {
580
+ if (isEmptyResultMessage(toolName, text)) return false;
581
+ if (text.length > 0 || hasImageContent(result)) return true;
582
+ const diff = detailsRecord(result).diff;
583
+ return toolName === "edit" && typeof diff === "string" && diff.length > 0;
584
+ }
585
+
586
+ function sanitizedRenderContext(context: ToolRenderContextLike | undefined): ToolRenderContextLike {
587
+ if (!context) return { args: {} };
588
+ return {
589
+ ...context,
590
+ args: sanitizedArgs(context.args),
591
+ cwd: typeof context.cwd === "string" ? safeText(context.cwd) : context.cwd,
592
+ };
593
+ }
594
+
595
+ type GentleAiDevBinaryOverrideResolver = () => GentleAiDevBinaryOverride | undefined;
596
+ function resolveQuietToolsDevBinaryPath(resolveOverride: GentleAiDevBinaryOverrideResolver): string | undefined {
597
+ try { const path = resolveOverride()?.path; return typeof path === "string" && isAbsolute(path) ? path : undefined; }
598
+ catch { return undefined; }
184
599
  }
185
600
 
186
- function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName): void {
601
+ function gentleAiRenderTransition(
602
+ args: Record<string, unknown> | undefined, context: ToolRenderContextLike | undefined,
603
+ commandArguments: RegExp, options: { result?: boolean; isPartial?: boolean } = {},
604
+ ): { operationPath?: string; directResult: boolean } {
605
+ const state = getGentleAiRenderState(context?.state);
606
+ const tokenization = shellTokens(typeof args?.command === "string" ? args.command : "");
607
+ const directTokens = tokenization.kind === "generic" ? undefined : gentleAiCommandTokensFrom(tokenization.tokens, commandArguments);
608
+ const operationPath = directTokens ? gentleAiOperationPathFrom(directTokens) : undefined;
609
+ const argsComplete = context?.argsComplete === true;
610
+ const forResult = options.result === true;
611
+ const isPartial = options.isPartial === true || context?.isPartial === true;
612
+ if (operationPath && (tokenization.kind === "complete" || !argsComplete) && state?.genericLocked !== true) {
613
+ return { operationPath, directResult: forResult };
614
+ }
615
+ if (forResult && state?.lifecycleComponent === true && state.genericLocked !== true) return { directResult: true };
616
+ if (state && (tokenization.kind === "generic" || argsComplete || (forResult && !isPartial))) {
617
+ state.genericLocked = true; state.lifecycleComponent = false;
618
+ }
619
+ return { directResult: false };
620
+ }
621
+
622
+ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArguments: () => RegExp): void {
187
623
  const registrationTool = getBuiltInTools(process.cwd())[toolName];
624
+ const officialRenderResult = registrationTool.renderResult;
188
625
 
189
626
  pi.registerTool({
190
627
  ...registrationTool,
@@ -192,27 +629,73 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName): void {
192
629
  const runtimeTool = getBuiltInTools(ctx.cwd)[toolName];
193
630
  return runtimeTool.execute(toolCallId, params, signal, onUpdate, ctx);
194
631
  },
195
- renderCall(args, theme) {
196
- return new Text(formatToolCall(toolName, args as Record<string, unknown>, theme), 0, 0);
632
+ renderCall(args, theme, context) {
633
+ const callArgs = args as Record<string, unknown>;
634
+ const renderContext = sanitizedRenderContext(context as ToolRenderContextLike | undefined);
635
+ const operationPath = toolName === "bash"
636
+ ? gentleAiRenderTransition(callArgs, renderContext, commandArguments()).operationPath
637
+ : undefined;
638
+ if (operationPath) {
639
+ const detail = renderContext.expanded === true && typeof callArgs.command === "string" ? `$ ${callArgs.command}` : undefined;
640
+ return renderGentleAiLifecycleCall(operationPath, theme, renderContext as GentleAiRenderContext, detail);
641
+ }
642
+ return new Text(formatToolCall(toolName, callArgs, theme), 0, 0);
197
643
  },
198
644
  renderResult(result, options, theme, context) {
645
+ const renderContext = context as ToolRenderContextLike | undefined;
646
+ const safeResult = sanitizedResult(result);
647
+ const text = safeText(extractTextContent(safeResult));
648
+ const isError = renderContext?.isError ?? options.isError ?? false;
649
+ const directResult = toolName === "bash" && gentleAiRenderTransition(
650
+ renderContext?.args,
651
+ renderContext,
652
+ commandArguments(),
653
+ { result: true, isPartial: options.isPartial },
654
+ ).directResult;
655
+ if (directResult) {
656
+ return renderGentleAiResult(safeResult, { expanded: options.expanded });
657
+ }
199
658
  if (options.isPartial) {
200
- return new Text(theme.fg("warning", partialLabel(toolName)), 0, 0);
659
+ if (options.expanded) return new Text(`${theme.fg("warning", partialLabel(toolName, text))}\n${theme.fg("muted", text)}`, 0, 0);
660
+ const visible = lastOutputLines(text, PREVIEW_LINE_LIMIT);
661
+ return new BoundedRows([
662
+ { text: theme.fg("warning", partialLabel(toolName, text)), rows: 1 },
663
+ ...(visible ? [{ text: theme.fg("muted", visible), rows: PREVIEW_LINE_LIMIT, tail: true }] : []),
664
+ ]);
665
+ }
666
+ if (options.expanded && toolName === "read" && hasImageContent(safeResult) && officialRenderResult) {
667
+ return officialRenderResult(
668
+ safeResult,
669
+ options,
670
+ theme,
671
+ sanitizedRenderContext(renderContext) as any,
672
+ );
201
673
  }
202
- const output = formatToolResultOutput(toolName, result, {
674
+ const output = formatToolResultOutput(toolName, safeResult, {
203
675
  expanded: options.expanded,
204
- isError: options.isError,
205
- args: context.args as Record<string, unknown> | undefined,
676
+ isError,
677
+ args: renderContext?.args,
206
678
  });
207
- const color = options.expanded ? "toolOutput" : options.isError ? "error" : "muted";
208
- return new Text(output ? theme.fg(color, output) : "", 0, 0);
679
+ const hint = !options.expanded && !directResult && hasExpandableContent(toolName, safeResult, text)
680
+ ? `\n${keyHint("app.tools.expand", "to expand")}`
681
+ : "";
682
+ const color = options.expanded ? "toolOutput" : isError ? "error" : "muted";
683
+ if (options.expanded) return new Text(output ? theme.fg(color, output) : "", 0, 0);
684
+ if (output) {
685
+ const tail = shouldRenderPreviewTail(toolName, text, isError, renderContext?.args);
686
+ return new BoundedRows([
687
+ { text: theme.fg(color, output.replace(/^\n/, "")), rows: PREVIEW_LINE_LIMIT, tail },
688
+ ...(hint ? [{ text: theme.fg(color, hint.slice(1)), rows: 1 }] : []),
689
+ ]);
690
+ }
691
+ return new Text(hint ? theme.fg(color, hint.slice(1)) : "", 0, 0);
209
692
  },
210
693
  });
211
694
  }
212
695
 
213
- export default function quietTools(pi: ExtensionAPI): void {
696
+ export default function quietTools(pi: ExtensionAPI, resolveOverride: GentleAiDevBinaryOverrideResolver = () => resolveGentleAiDevBinaryOverride()): void {
214
697
  if (!quietToolsEnabled()) return;
215
698
  for (const toolName of Object.keys(TOOL_CREATORS) as QuietToolName[]) {
216
- registerQuietTool(pi, toolName);
699
+ registerQuietTool(pi, toolName, () => createGentleAiCommandArguments(resolveQuietToolsDevBinaryPath(resolveOverride)));
217
700
  }
218
701
  }
@@ -771,15 +771,11 @@ function ensureOpenSpecDirs(cwd: string): void {
771
771
  }
772
772
 
773
773
  export default function (pi: ExtensionAPI) {
774
- pi.registerCommand("sdd-init", {
774
+ pi.registerCommand("gentle-sdd-init", {
775
775
  description:
776
776
  "Auto-detect project stack and bootstrap openspec/config.yaml for SDD.",
777
777
  handler: async (_args: unknown, ctx: any) => {
778
- const prefs = await ensureSddPreflight(ctx, {
779
- pi,
780
- installAssets: (cwd) => installSddAssets(cwd, false),
781
- applyModelConfig: () => applySavedModelConfig(ctx),
782
- });
778
+ const prefs = await ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installSddAssets(cwd, false), applyModelConfig: () => applySavedModelConfig(ctx) }, { promptFields: [] });
783
779
 
784
780
  const detection = detectProject(ctx.cwd);
785
781
  const testSummary = detection.testCommand
@@ -789,7 +785,7 @@ export default function (pi: ExtensionAPI) {
789
785
 
790
786
  const shouldCreateOpenSpec =
791
787
  prefs.artifactStore === "openspec" ||
792
- prefs.artifactStore === "both";
788
+ prefs.artifactStore === "hybrid";
793
789
  if (!shouldCreateOpenSpec) {
794
790
  ctx.ui.notify(
795
791
  `SDD initialized for ${prefs.artifactStore}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}; tests found: ${layerSummary}.`,
@@ -801,7 +797,7 @@ export default function (pi: ExtensionAPI) {
801
797
  const configPath = join(ctx.cwd, CONFIG_REL_PATH);
802
798
  if (existsSync(configPath)) {
803
799
  ctx.ui.notify(
804
- `${CONFIG_REL_PATH} already exists. Edit it manually or remove it before re-running /sdd-init.`,
800
+ `${CONFIG_REL_PATH} already exists. Edit it manually or remove it before re-running /gentle-sdd-init.`,
805
801
  "warning",
806
802
  );
807
803
  return;