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
@@ -1,404 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { chmod, mkdtemp, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join, posix, win32 } from "node:path";
5
-
6
- export const OPAQUE_PI_REVIEWER_ARGV = Object.freeze([
7
- "--print",
8
- // #1140: text mode turns a run that spent its turn on a tool call into zero
9
- // bytes and exit 0. JSON mode emits pi's own event stream, so the transport
10
- // can always tell a silent child from an assistant answer, and can recover
11
- // the answer text even when tool calls interleaved with it.
12
- "--mode", "json",
13
- "--no-session",
14
- "--no-tools",
15
- "--no-extensions",
16
- "--no-skills",
17
- "--no-prompt-templates",
18
- "--no-themes",
19
- "--no-context-files",
20
- "--no-approve",
21
- ] as const);
22
-
23
- export const OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE = {
24
- SCRATCH_FAILED: "scratch-failed",
25
- LAUNCH_FAILED: "launch-failed",
26
- CANCELLED: "cancelled",
27
- TIMED_OUT: "timed-out",
28
- NONZERO_EXIT: "nonzero-exit",
29
- EMPTY_OUTPUT: "empty-output",
30
- CLEANUP_FAILED: "cleanup-failed",
31
- } as const;
32
- export type OpaquePiReviewerTransportFailureKind = (typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE)[keyof typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE];
33
-
34
- export interface OpaquePiReviewerOptions {
35
- readonly piExecutable?: string;
36
- readonly environment?: NodeJS.ProcessEnv;
37
- readonly timeoutMs?: number;
38
- readonly signal?: AbortSignal;
39
- /**
40
- * Caller-owned tokens appended verbatim after the frozen argv (spawn keeps
41
- * shell:false, so every token is one argv entry). The adapter never
42
- * interprets them; its caller owns their meaning and validation.
43
- */
44
- readonly extraArguments?: readonly string[];
45
- }
46
-
47
- export interface OpaquePiReviewerResult {
48
- readonly stdout: Buffer;
49
- readonly promptByteLength: number;
50
- readonly stdoutByteLength: number;
51
- }
52
-
53
- export interface OpaquePiReviewerTransportDetails {
54
- readonly exitCode?: number | null;
55
- readonly stderr?: Buffer;
56
- readonly timedOut?: boolean;
57
- readonly cancelled?: boolean;
58
- /** Wall time for a launched Pi process; absent when no process was launched. */
59
- readonly elapsedMs?: number;
60
- /** The bound applied to a launched Pi process; absent when no process was launched. */
61
- readonly timeoutMs?: number;
62
- /** What the child's output stream revealed when no assistant answer could be recovered. */
63
- readonly evidence?: PiReviewOutputEvidence;
64
- }
65
-
66
- export class OpaquePiReviewerTransportError extends Error {
67
- readonly kind: OpaquePiReviewerTransportFailureKind;
68
- readonly exitCode: number | null;
69
- readonly stderr: Buffer;
70
- readonly timedOut: boolean;
71
- readonly cancelled: boolean;
72
- readonly elapsedMs: number | null;
73
- readonly timeoutMs: number | null;
74
- readonly evidence: PiReviewOutputEvidence | undefined;
75
-
76
- constructor(kind: OpaquePiReviewerTransportFailureKind, message: string, details: OpaquePiReviewerTransportDetails = {}) {
77
- super(message);
78
- this.name = "OpaquePiReviewerTransportError";
79
- this.kind = kind;
80
- this.exitCode = details.exitCode ?? null;
81
- this.stderr = details.stderr ?? Buffer.alloc(0);
82
- this.timedOut = details.timedOut ?? false;
83
- this.cancelled = details.cancelled ?? false;
84
- this.elapsedMs = details.elapsedMs ?? null;
85
- this.timeoutMs = details.timeoutMs ?? null;
86
- this.evidence = details.evidence;
87
- }
88
- }
89
-
90
- interface OpaquePiProcessResult {
91
- readonly stdout: Buffer;
92
- readonly stderr: Buffer;
93
- readonly exitCode: number | null;
94
- readonly timedOut: boolean;
95
- readonly cancelled: boolean;
96
- readonly elapsedMs: number;
97
- readonly timeoutMs: number;
98
- }
99
-
100
- const DEFAULT_OPAQUE_PI_TIMEOUT_MS = 600_000;
101
-
102
- export interface PiLaunch {
103
- readonly file: string;
104
- readonly arguments: readonly string[];
105
- }
106
-
107
- interface PiHostProcess {
108
- readonly execPath: string;
109
- readonly entry: string | undefined;
110
- }
111
-
112
- /**
113
- * The exact spawn shape for the fresh Pi process. A bare `pi` on Windows
114
- * resolves to pi.cmd, pi.ps1, or a POSIX shim, none of which Node can spawn
115
- * with shell:false (EINVAL or ENOENT). This adapter already runs inside Pi, so
116
- * on win32 the host's own JavaScript entry is spawned through the host's
117
- * process.execPath instead; a shell is never enabled. Every other platform,
118
- * and every explicit launcher, keeps the exact shape it always had.
119
- */
120
- export function resolvePiLaunch(
121
- piExecutable: string | undefined,
122
- platform: NodeJS.Platform = process.platform,
123
- host: PiHostProcess = { execPath: process.execPath, entry: process.argv[1] },
124
- extraArguments: readonly string[] = [],
125
- ): PiLaunch {
126
- const arguments_ = [...OPAQUE_PI_REVIEWER_ARGV, ...extraArguments];
127
- if (piExecutable !== undefined) return { file: piExecutable, arguments: arguments_ };
128
- if (platform !== "win32") return { file: "pi", arguments: arguments_ };
129
- if (typeof host.entry !== "string" || host.entry.length === 0 || !(platform === "win32" ? win32 : posix).isAbsolute(host.entry)) {
130
- throw new Error(`Pi host entry could not be resolved from the running process (received ${JSON.stringify(host.entry ?? null)}); a bare pi launcher cannot be spawned on Windows without a shell`);
131
- }
132
- return { file: host.execPath, arguments: [host.entry, ...arguments_] };
133
- }
134
-
135
- function errorMessage(error: unknown): string {
136
- return error instanceof Error ? error.message : String(error);
137
- }
138
-
139
- // ---------------------------------------------------------------------------
140
- // The child runs `pi --mode json`, so its stdout is a newline-delimited pi
141
- // event stream. The transport's output is the assistant text of that stream —
142
- // the same bytes an interactive run would have rendered — and everything else
143
- // about the stream becomes typed evidence. A run whose turn was spent on a
144
- // tool call therefore reports what happened (which reviewer selection ran,
145
- // that a tool call was attempted) instead of exiting 0 in silence (#1140).
146
- // ---------------------------------------------------------------------------
147
-
148
- export interface PiReviewOutputEvidence {
149
- readonly stdoutKind: "no-output" | "not-a-pi-event-stream" | "no-assistant-text";
150
- /** The reviewer selection the child itself reported in its events, when it named one. */
151
- readonly reviewerModel?: string;
152
- readonly toolCallAttempted?: boolean;
153
- }
154
-
155
- export type PiReviewExtraction =
156
- | { readonly kind: "text"; readonly text: string }
157
- | { readonly kind: "none"; readonly evidence: PiReviewOutputEvidence };
158
-
159
- interface PiEventMessagePart {
160
- type?: unknown;
161
- text?: unknown;
162
- }
163
-
164
- interface PiEventMessage {
165
- role?: unknown;
166
- content?: unknown;
167
- }
168
-
169
- interface PiEventEnvelope {
170
- type?: unknown;
171
- message?: unknown;
172
- }
173
-
174
- function parsePiEventLines(stdout: Buffer): PiEventEnvelope[] | undefined {
175
- const lines = stdout.toString("utf8").split("\n").filter((line) => line.trim().length > 0);
176
- if (lines.length === 0) return undefined;
177
- const events: PiEventEnvelope[] = [];
178
- for (const line of lines) {
179
- let parsed: unknown;
180
- try {
181
- parsed = JSON.parse(line);
182
- } catch {
183
- return undefined;
184
- }
185
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
186
- events.push(parsed as PiEventEnvelope);
187
- }
188
- return events;
189
- }
190
-
191
- export function extractPiAssistantText(stdout: Buffer): PiReviewExtraction {
192
- if (stdout.length === 0) return { kind: "none", evidence: { stdoutKind: "no-output" } };
193
- const events = parsePiEventLines(stdout);
194
- if (events === undefined) return { kind: "none", evidence: { stdoutKind: "not-a-pi-event-stream" } };
195
- const parts: string[] = [];
196
- let reviewerModel: string | undefined;
197
- let toolCallAttempted = false;
198
- for (const event of events) {
199
- // The final content of one assistant message lives in its end event;
200
- // start and update events repeat or stream the same parts.
201
- if (event.type !== "message_end") continue;
202
- const message = event.message;
203
- if (!message || typeof message !== "object" || Array.isArray(message)) continue;
204
- const candidate = message as PiEventMessage;
205
- if (candidate.role !== "assistant") continue;
206
- // The event envelope names the selection the child itself ran with the wire
207
- // key pi uses for it; read as quoted data, never as an identifier.
208
- const reported = (candidate as Record<string, unknown>)["model"];
209
- if (typeof reported === "string" && reported.length > 0 && reviewerModel === undefined) reviewerModel = reported;
210
- if (!Array.isArray(candidate.content)) continue;
211
- for (const part of candidate.content as PiEventMessagePart[]) {
212
- if (!part || typeof part !== "object") continue;
213
- if (typeof part.type === "string" && part.type.startsWith("tool")) toolCallAttempted = true;
214
- if (part.type === "text" && typeof part.text === "string") parts.push(part.text);
215
- }
216
- }
217
- const text = parts.join("");
218
- if (text.length === 0) {
219
- return {
220
- kind: "none",
221
- evidence: {
222
- stdoutKind: "no-assistant-text",
223
- ...(reviewerModel === undefined ? {} : { reviewerModel }),
224
- ...(toolCallAttempted ? { toolCallAttempted } : {}),
225
- },
226
- };
227
- }
228
- return { kind: "text", text };
229
- }
230
-
231
- function emptyOutputMessage(evidence: PiReviewOutputEvidence): string {
232
- const details = [`stdout kind: ${evidence.stdoutKind}`];
233
- if (evidence.reviewerModel !== undefined) details.push(`reviewer selection: ${evidence.reviewerModel}`);
234
- if (evidence.toolCallAttempted) details.push("a tool call was attempted");
235
- return `Pi process produced no assistant text (${details.join("; ")})`;
236
- }
237
-
238
- function runPiProcess(prompt: Buffer, scratchDirectory: string, options: OpaquePiReviewerOptions): Promise<OpaquePiProcessResult> {
239
- return new Promise((resolve, reject) => {
240
- const startedAt = Date.now();
241
- let launch: PiLaunch;
242
- try {
243
- launch = resolvePiLaunch(options.piExecutable, process.platform, { execPath: process.execPath, entry: process.argv[1] }, options.extraArguments ?? []);
244
- } catch (error) {
245
- reject(error);
246
- return;
247
- }
248
- const child = spawn(launch.file, [...launch.arguments], {
249
- cwd: scratchDirectory,
250
- env: options.environment ?? process.env,
251
- stdio: ["pipe", "pipe", "pipe"],
252
- shell: false,
253
- windowsHide: true,
254
- });
255
- const stdout: Buffer[] = [];
256
- const stderr: Buffer[] = [];
257
- const timeoutMs = options.timeoutMs ?? DEFAULT_OPAQUE_PI_TIMEOUT_MS;
258
- let timedOut = false;
259
- let cancelled = false;
260
- let settled = false;
261
- const timer = timeoutMs > 0
262
- ? setTimeout(() => {
263
- timedOut = true;
264
- child.kill("SIGKILL");
265
- }, timeoutMs)
266
- : undefined;
267
- timer?.unref();
268
- const cancel = () => {
269
- cancelled = true;
270
- child.kill("SIGKILL");
271
- };
272
- const clear = () => {
273
- if (timer !== undefined) clearTimeout(timer);
274
- options.signal?.removeEventListener("abort", cancel);
275
- };
276
-
277
- child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
278
- child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
279
- child.on("error", (error) => {
280
- if (settled) return;
281
- settled = true;
282
- clear();
283
- reject(error);
284
- });
285
- child.on("close", (code) => {
286
- if (settled) return;
287
- settled = true;
288
- clear();
289
- resolve({
290
- stdout: Buffer.concat(stdout),
291
- stderr: Buffer.concat(stderr),
292
- exitCode: code,
293
- timedOut,
294
- cancelled,
295
- elapsedMs: Date.now() - startedAt,
296
- timeoutMs,
297
- });
298
- });
299
- if (options.signal?.aborted) cancel();
300
- else options.signal?.addEventListener("abort", cancel, { once: true });
301
- child.stdin.on("error", () => undefined);
302
- child.stdin.end(prompt);
303
- });
304
- }
305
-
306
- /** Runs raw prompt bytes through one fixed, isolated Pi process. */
307
- export async function runOpaquePiReviewer(prompt: Buffer, options: OpaquePiReviewerOptions = {}): Promise<OpaquePiReviewerResult> {
308
- if (options.signal?.aborted) {
309
- throw new OpaquePiReviewerTransportError(
310
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
311
- "Pi process was cancelled before launch",
312
- { cancelled: true },
313
- );
314
- }
315
- if (options.extraArguments !== undefined && options.extraArguments.some((token) => typeof token !== "string" || token.length === 0)) {
316
- throw new TypeError("Pi reviewer launch arguments must all be non-empty strings");
317
- }
318
-
319
- let scratchDirectory: string | undefined;
320
- let primaryFailure = false;
321
- try {
322
- try {
323
- scratchDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-opaque-reviewer-"));
324
- await chmod(scratchDirectory, 0o700);
325
- } catch (error) {
326
- throw new OpaquePiReviewerTransportError(
327
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED,
328
- `Pi scratch directory could not be prepared: ${errorMessage(error)}`,
329
- );
330
- }
331
-
332
- let processResult: OpaquePiProcessResult;
333
- try {
334
- processResult = await runPiProcess(prompt, scratchDirectory, options);
335
- } catch (error) {
336
- if (options.signal?.aborted) {
337
- throw new OpaquePiReviewerTransportError(
338
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
339
- "Pi process was cancelled",
340
- { cancelled: true },
341
- );
342
- }
343
- throw new OpaquePiReviewerTransportError(
344
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED,
345
- `Pi process could not start: ${errorMessage(error)}`,
346
- );
347
- }
348
- const timing = {
349
- elapsedMs: processResult.elapsedMs,
350
- timeoutMs: processResult.timeoutMs,
351
- };
352
- if (processResult.timedOut) {
353
- throw new OpaquePiReviewerTransportError(
354
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT,
355
- "Pi process timed out",
356
- { exitCode: processResult.exitCode, stderr: processResult.stderr, timedOut: true, ...timing },
357
- );
358
- }
359
- if (processResult.cancelled) {
360
- throw new OpaquePiReviewerTransportError(
361
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
362
- "Pi process was cancelled",
363
- { exitCode: processResult.exitCode, stderr: processResult.stderr, cancelled: true, ...timing },
364
- );
365
- }
366
- if (processResult.exitCode !== 0) {
367
- throw new OpaquePiReviewerTransportError(
368
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.NONZERO_EXIT,
369
- "Pi process failed",
370
- { exitCode: processResult.exitCode, stderr: processResult.stderr, ...timing },
371
- );
372
- }
373
- const extraction = extractPiAssistantText(processResult.stdout);
374
- if (extraction.kind === "none") {
375
- throw new OpaquePiReviewerTransportError(
376
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
377
- emptyOutputMessage(extraction.evidence),
378
- { exitCode: 0, stderr: processResult.stderr, evidence: extraction.evidence, ...timing },
379
- );
380
- }
381
- const stdout = Buffer.from(extraction.text, "utf8");
382
- return {
383
- stdout,
384
- promptByteLength: prompt.length,
385
- stdoutByteLength: stdout.length,
386
- };
387
- } catch (error) {
388
- primaryFailure = true;
389
- throw error;
390
- } finally {
391
- if (scratchDirectory !== undefined) {
392
- try {
393
- await rm(scratchDirectory, { recursive: true, force: true });
394
- } catch (error) {
395
- if (!primaryFailure) {
396
- throw new OpaquePiReviewerTransportError(
397
- OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CLEANUP_FAILED,
398
- `Pi scratch directory cleanup failed: ${errorMessage(error)}`,
399
- );
400
- }
401
- }
402
- }
403
- }
404
- }
@@ -1,137 +0,0 @@
1
- ---
2
- name: release
3
- description: "Release gentle-pi through GitHub and npm. Trigger: release, publish, npm publish, GitHub release, version bump."
4
- license: Apache-2.0
5
- metadata:
6
- author: gentleman-programming
7
- version: "1.2"
8
- ---
9
-
10
- ## When to Use
11
-
12
- Use this skill when preparing, publishing, or verifying a `gentle-pi` release.
13
-
14
- ## Hard Rules
15
-
16
- - Do not publish `gentle-pi` to npm from a local machine.
17
- - npm publishing MUST go through the GitHub Actions workflow `.github/workflows/publish.yml` so provenance, environment protection, and registry credentials are controlled by GitHub.
18
- - Dispatch the trusted workflow definition from protected default `main`, never from a release tag. Its only caller input is the exact annotated version tag.
19
- - Use a clean worktree for release commits. Do not package unrelated local files or scratch artifacts.
20
- - Review outcomes are informational. Release delivery follows ordinary repository policy and must not be blocked, authorized, or rewritten by RDD.
21
- - Never infer the release tag target from local `HEAD`; use the freshly fetched `origin/main` commit and the repository's normal release safeguards.
22
- - Never skip package verification. The publish workflow runs verification again, but local validation should still pass before tagging.
23
-
24
- ## Release Procedure
25
-
26
- 1. **Inspect state**
27
-
28
- ```bash
29
- git status --short
30
- git fetch origin main --tags
31
- git log --oneline --decorate --max-count=5 origin/main
32
- ```
33
-
34
- 2. **Prepare the release commit**
35
-
36
- - Apply only intended changes.
37
- - Bump `package.json` to the next semver version.
38
- - Keep lockfile changes out unless dependency resolution actually changed.
39
-
40
- 3. **Verify locally**
41
-
42
- ```bash
43
- pnpm test
44
- node scripts/verify-package-files.mjs
45
- npm pack --dry-run
46
- ```
47
-
48
- `npm pack --dry-run` verifies package contents and lifecycle scripts without entering a publish path.
49
-
50
- 4. **Commit and push**
51
-
52
- ```bash
53
- git add <intended-files>
54
- git commit -m "<type(scope): release-ready change>"
55
- git push origin HEAD:main
56
- git fetch origin main --tags
57
- ```
58
-
59
- 5. **Create and verify the exact version tag**
60
-
61
- ```bash
62
- version="$(node -p "require('./package.json').version")"
63
- tag="v${version}"
64
- release_sha="$(git rev-parse 'origin/main^{commit}')"
65
-
66
- test "$(git rev-parse 'HEAD^{commit}')" = "${release_sha}"
67
- test -z "$(git ls-remote --tags origin "refs/tags/${tag}")"
68
-
69
- git tag -a "${tag}" "${release_sha}" -m "gentle-pi ${tag}"
70
- test "$(git rev-parse "${tag}^{commit}")" = "${release_sha}"
71
-
72
- git fetch origin main
73
- test "$(git rev-parse 'origin/main^{commit}')" = "${release_sha}"
74
- git push origin "refs/tags/${tag}"
75
-
76
- git fetch --no-tags origin "refs/tags/${tag}"
77
- test "$(git rev-parse 'FETCH_HEAD^{commit}')" = "${release_sha}"
78
-
79
- gh release create "${tag}" \
80
- --repo Gentleman-Programming/gentle-pi \
81
- --verify-tag \
82
- --title "gentle-pi ${tag}" \
83
- --notes "<release notes>"
84
- ```
85
-
86
- Do not retag or overwrite an existing version. The tag target comes from the freshly fetched immutable `origin/main` commit, not an ambient local branch.
87
-
88
- 6. **Publish npm through GitHub Actions**
89
-
90
- ```bash
91
- version="$(node -p "require('./package.json').version")"
92
- tag="v${version}"
93
- gh workflow run publish.yml \
94
- --repo Gentleman-Programming/gentle-pi \
95
- --ref main \
96
- -f tag="${tag}"
97
- ```
98
-
99
- The workflow definition always comes from protected default `main`. It accepts only one exact `vSemVer` tag, fetches the remote annotated tag and current remote `main`, and requires the peeled tag commit, dispatch/main workflow commit, checkout, and `package.json` version to match. It re-queries remote tag and `main` immediately before npm publication, derives the dist-tag internally, and uses trusted OIDC with provenance.
100
-
101
- Watch the run and fail the release if it fails:
102
-
103
- ```bash
104
- gh run list --repo Gentleman-Programming/gentle-pi --workflow publish.yml --limit 3
105
- gh run watch <run-id> --repo Gentleman-Programming/gentle-pi --exit-status
106
- ```
107
-
108
- 7. **Verify npm**
109
-
110
- ```bash
111
- npm view gentle-pi@<version> version --registry=https://registry.npmjs.org/
112
- npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/
113
- ```
114
-
115
- ## Failure Handling
116
-
117
- - A publication failure is handled through ordinary repository policy. It does not reopen or alter a review lineage.
118
- - Never attempt or retry `npm publish` locally. Re-dispatch from trusted `main` only when the same tag still targets the current remote `main` and the failure was publication-only.
119
- - If remote `main` advances, do not move or recreate the existing tag. Prepare a new release commit/version and create a new annotated version tag.
120
- - If the workflow fails, inspect logs with:
121
-
122
- ```bash
123
- gh run view <run-id> --repo Gentleman-Programming/gentle-pi --log
124
- ```
125
-
126
- - If npm verification is briefly stale after a successful workflow, check the exact version first (`npm view gentle-pi@<version> version`) before assuming publish failed.
127
-
128
- ## Output Contract
129
-
130
- Report:
131
-
132
- - Commit SHA pushed to `main`.
133
- - Exact version tag and its peeled commit SHA.
134
- - GitHub release URL.
135
- - Publish workflow run URL and conclusion.
136
- - npm exact version and the workflow-derived dist-tag (`latest`, `beta`, or `next`).
137
- - Any remaining follow-up or warnings.