gentle-pi 3.2.1 → 3.4.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 (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -0,0 +1,141 @@
1
+ import {
2
+ CUSTOM_ROW_LABEL,
3
+ MAX_HEADER_LENGTH,
4
+ MAX_LABEL_LENGTH,
5
+ MAX_OPTIONS,
6
+ MAX_QUESTIONS,
7
+ MIN_OPTIONS,
8
+ type QuestionParams,
9
+ } from "./schema.ts";
10
+
11
+ /**
12
+ * A single questionnaire validation violation, keyed by `code` so callers can
13
+ * branch without parsing the human-readable message.
14
+ */
15
+ export type QuestionnaireError =
16
+ | { code: "no_questions"; message: string }
17
+ | { code: "empty_options"; message: string; questionIndex: number }
18
+ | { code: "option_count"; message: string; questionIndex: number; count: number }
19
+ | { code: "too_many_questions"; message: string; count: number }
20
+ | { code: "duplicate_question"; message: string; questionIndex: number }
21
+ | { code: "duplicate_option_label"; message: string; questionIndex: number; optionIndex: number; label: string }
22
+ | { code: "label_too_long"; message: string; questionIndex: number; optionIndex: number; length: number }
23
+ | { code: "header_too_long"; message: string; questionIndex: number; length: number }
24
+ | { code: "reserved_label"; message: string; questionIndex: number; optionIndex: number; label: string };
25
+
26
+ /**
27
+ * Validate questionnaire parameters and return the first violation in a fixed
28
+ * precedence order, or `undefined` when the parameters are valid.
29
+ *
30
+ * Precedence: no questions, empty options, option count, question count,
31
+ * duplicate question text, duplicate option label, label length, header
32
+ * length, then reserved custom-row labels.
33
+ */
34
+ export function validateQuestionnaire(params: QuestionParams): QuestionnaireError | undefined {
35
+ const questions = Array.isArray(params?.questions) ? params.questions : [];
36
+
37
+ if (questions.length === 0) {
38
+ return { code: "no_questions", message: "At least one question is required." };
39
+ }
40
+
41
+ for (const [questionIndex, question] of questions.entries()) {
42
+ if (!Array.isArray(question?.options) || question.options.length === 0) {
43
+ return {
44
+ code: "empty_options",
45
+ message: `Question ${questionIndex + 1} must declare at least one option.`,
46
+ questionIndex,
47
+ };
48
+ }
49
+ }
50
+
51
+ for (const [questionIndex, question] of questions.entries()) {
52
+ const count = question.options.length;
53
+ if (count < MIN_OPTIONS || count > MAX_OPTIONS) {
54
+ return {
55
+ code: "option_count",
56
+ message: `Question ${questionIndex + 1} must have between ${MIN_OPTIONS} and ${MAX_OPTIONS} options, received ${count}.`,
57
+ questionIndex,
58
+ count,
59
+ };
60
+ }
61
+ }
62
+
63
+ if (questions.length > MAX_QUESTIONS) {
64
+ return {
65
+ code: "too_many_questions",
66
+ message: `A questionnaire accepts at most ${MAX_QUESTIONS} questions, received ${questions.length}.`,
67
+ count: questions.length,
68
+ };
69
+ }
70
+
71
+ const seenQuestions = new Set<string>();
72
+ for (const [questionIndex, question] of questions.entries()) {
73
+ if (seenQuestions.has(question.question)) {
74
+ return {
75
+ code: "duplicate_question",
76
+ message: `Question ${questionIndex + 1} duplicates an earlier question text.`,
77
+ questionIndex,
78
+ };
79
+ }
80
+ seenQuestions.add(question.question);
81
+ }
82
+
83
+ // Checked after duplicate questions so a repeated question points at the
84
+ // duplicate itself rather than one of its option labels.
85
+ for (const [questionIndex, question] of questions.entries()) {
86
+ const seenLabels = new Set<string>();
87
+ for (const [optionIndex, option] of question.options.entries()) {
88
+ if (seenLabels.has(option.label)) {
89
+ return {
90
+ code: "duplicate_option_label",
91
+ message: `Question ${questionIndex + 1} option ${optionIndex + 1} duplicates the label "${option.label}".`,
92
+ questionIndex,
93
+ optionIndex,
94
+ label: option.label,
95
+ };
96
+ }
97
+ seenLabels.add(option.label);
98
+ }
99
+ }
100
+
101
+ for (const [questionIndex, question] of questions.entries()) {
102
+ for (const [optionIndex, option] of question.options.entries()) {
103
+ if (option.label.length > MAX_LABEL_LENGTH) {
104
+ return {
105
+ code: "label_too_long",
106
+ message: `Question ${questionIndex + 1} option ${optionIndex + 1} label exceeds ${MAX_LABEL_LENGTH} characters.`,
107
+ questionIndex,
108
+ optionIndex,
109
+ length: option.label.length,
110
+ };
111
+ }
112
+ }
113
+ }
114
+
115
+ for (const [questionIndex, question] of questions.entries()) {
116
+ if (question.header.length > MAX_HEADER_LENGTH) {
117
+ return {
118
+ code: "header_too_long",
119
+ message: `Question ${questionIndex + 1} header exceeds ${MAX_HEADER_LENGTH} characters.`,
120
+ questionIndex,
121
+ length: question.header.length,
122
+ };
123
+ }
124
+ }
125
+
126
+ for (const [questionIndex, question] of questions.entries()) {
127
+ for (const [optionIndex, option] of question.options.entries()) {
128
+ if (option.label === "Other" || option.label === CUSTOM_ROW_LABEL) {
129
+ return {
130
+ code: "reserved_label",
131
+ message: `Question ${questionIndex + 1} option ${optionIndex + 1} uses the reserved label "${option.label}".`,
132
+ questionIndex,
133
+ optionIndex,
134
+ label: option.label,
135
+ };
136
+ }
137
+ }
138
+ }
139
+
140
+ return undefined;
141
+ }
@@ -27,6 +27,21 @@ const WINDOWS_SYSTEM = "S-1-5-18";
27
27
  const WINDOWS_ADMINISTRATORS = "S-1-5-32-544";
28
28
  const WINDOWS_SYSTEM_DIRECTORY = "\\\\?\\GLOBALROOT\\SystemRoot\\System32";
29
29
 
30
+ // Cold Windows runners occasionally exceed the bounded 5-second probe timeout
31
+ // on first process start (whoami/PowerShell/icacls). One bounded retry of an
32
+ // idempotent system probe recovers that transient; authority validation is
33
+ // never retried or weakened because it happens after the probe returns.
34
+ const TRANSIENT_WINDOWS_PROBE_CODES = new Set(["ETIMEDOUT", "EAGAIN", "EBUSY"]);
35
+
36
+ export function withWindowsSystemProbeRetry<T>(probe: () => T): T {
37
+ try {
38
+ return probe();
39
+ } catch (error) {
40
+ if (!TRANSIENT_WINDOWS_PROBE_CODES.has((error as NodeJS.ErrnoException | undefined)?.code ?? "")) throw error;
41
+ return probe();
42
+ }
43
+ }
44
+
30
45
  function windowsSystemExecutable(name: "whoami.exe" | "icacls.exe" | "WindowsPowerShell\\v1.0\\powershell.exe"): string {
31
46
  try {
32
47
  return realpathSync.native(join(WINDOWS_SYSTEM_DIRECTORY, name));
@@ -36,7 +51,7 @@ function windowsSystemExecutable(name: "whoami.exe" | "icacls.exe" | "WindowsPow
36
51
  }
37
52
 
38
53
  function windowsUserSid(): string {
39
- const output = execFileSync(windowsSystemExecutable("whoami.exe"), ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
54
+ const output = withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("whoami.exe"), ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
40
55
  const matches = output.match(/S-\d+(?:-\d+)+/gi) ?? [];
41
56
  if (matches.length !== 1) throw new Error("Windows user SID is unavailable");
42
57
  return matches[0]!.toUpperCase();
@@ -45,7 +60,7 @@ function windowsUserSid(): string {
45
60
  function windowsLocalAdministratorSid(): string {
46
61
  const script = "$ErrorActionPreference='Stop';$descriptor=New-Object System.Security.AccessControl.RawSecurityDescriptor 'D:(A;;FA;;;LA)';$descriptor.DiscretionaryAcl[0].SecurityIdentifier.Value";
47
62
  const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
48
- const output = execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot } });
63
+ const output = withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot } }));
49
64
  const matches = output.match(/S-\d+(?:-\d+)+/gi) ?? [];
50
65
  if (matches.length !== 1 || !isWindowsSid(matches[0]!)) throw new Error("Windows local Administrator SID is unavailable");
51
66
  return matches[0]!.toUpperCase();
@@ -55,7 +70,7 @@ function windowsDacl(path: string): string {
55
70
  const archive = `.gentle-ai-acl-${randomUUID()}.txt`;
56
71
  const archivePath = join(dirname(path), archive);
57
72
  try {
58
- execFileSync(windowsSystemExecutable("icacls.exe"), [path, "/save", archive, "/c"], { cwd: dirname(path), encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
73
+ withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("icacls.exe"), [path, "/save", archive, "/c"], { cwd: dirname(path), encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
59
74
  const sddl = readFileSync(archivePath, "utf16le").match(/D:[^\r\n]+/)?.[0];
60
75
  if (sddl === undefined) throw new Error("Windows DACL is unavailable");
61
76
  return sddl;
@@ -151,7 +166,7 @@ function windowsOwnerSid(path: string, kind: WindowsObjectKind): string {
151
166
  const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
152
167
  let output: string;
153
168
  try {
154
- output = execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_OWNER_PATH: path } });
169
+ output = withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_OWNER_PATH: path } }));
155
170
  } catch {
156
171
  throw new WindowsOwnerValidationError();
157
172
  }
@@ -175,7 +190,7 @@ function enforcePrivateWindowsDacl(path: string, identity: WindowsAclIdentity =
175
190
  const sddl = `D:P(A;OICI;FA;;;${user})(A;OICI;FA;;;${WINDOWS_SYSTEM})(A;OICI;FA;;;${WINDOWS_ADMINISTRATORS})`;
176
191
  const script = "$ErrorActionPreference='Stop';$acl=New-Object System.Security.AccessControl.DirectorySecurity;$acl.SetSecurityDescriptorSddlForm($env:GENTLE_PI_CANDIDATE_ACL_SDDL,[System.Security.AccessControl.AccessControlSections]::Access);[System.IO.Directory]::SetAccessControl($env:GENTLE_PI_CANDIDATE_ACL_PATH,$acl)";
177
192
  const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
178
- execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_ACL_PATH: path, GENTLE_PI_CANDIDATE_ACL_SDDL: sddl } });
193
+ withWindowsSystemProbeRetry(() => execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_ACL_PATH: path, GENTLE_PI_CANDIDATE_ACL_SDDL: sddl } }));
179
194
  assertPrivateWindowsDacl(path, "directory", true, identity);
180
195
  }
181
196
 
@@ -634,6 +634,13 @@ function makeWritableForCleanup(path: string): void {
634
634
  chmodSync(path, 0o644);
635
635
  }
636
636
 
637
+ function candidateOwnerPreparationError(error: unknown): CandidateViewError {
638
+ // Surface the bounded errno of the underlying failure directly in the
639
+ // message so CI logs are self-diagnosing without TAP printing the cause.
640
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
641
+ return new CandidateViewError(`candidate view owner preparation failed${typeof code === "string" && code ? ` (${code})` : ""}`, "candidate-owner-preparation-failed", undefined, { cause: error });
642
+ }
643
+
637
644
  function candidateViewParent(commonDir: string, platform: NodeJS.Platform): string {
638
645
  const control = join(commonDir, "gentle-ai");
639
646
  mkdirSync(control, { recursive: true, mode: 0o700 });
@@ -646,7 +653,7 @@ function candidateViewParent(commonDir: string, platform: NodeJS.Platform): stri
646
653
  try {
647
654
  return prepareCandidateOwnerParent(commonDir, platform);
648
655
  } catch (error) {
649
- throw new CandidateViewError("candidate view owner preparation failed", "candidate-owner-preparation-failed", undefined, { cause: error });
656
+ throw candidateOwnerPreparationError(error);
650
657
  }
651
658
  }
652
659
 
@@ -916,7 +923,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
916
923
  try {
917
924
  owner = createCandidateOwner(canonicalCommonDir, root, platform);
918
925
  } catch (error) {
919
- throw new CandidateViewError("candidate view owner preparation failed", "candidate-owner-preparation-failed", undefined, { cause: error });
926
+ throw candidateOwnerPreparationError(error);
920
927
  }
921
928
  // The worktree is created under the same try/catch cleanup boundary as
922
929
  // the read-tree materialization that follows. addUnbornWorktree's