pi-gsd 2.0.1 → 2.0.2

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 (65) hide show
  1. package/dist/pi-gsd-hooks.js +1532 -0
  2. package/package.json +3 -5
  3. package/.gsd/extensions/pi-gsd-hooks.ts +0 -973
  4. package/src/cli.ts +0 -644
  5. package/src/commands/base.ts +0 -67
  6. package/src/commands/commit.ts +0 -22
  7. package/src/commands/config.ts +0 -71
  8. package/src/commands/frontmatter.ts +0 -51
  9. package/src/commands/index.ts +0 -76
  10. package/src/commands/init.ts +0 -43
  11. package/src/commands/milestone.ts +0 -37
  12. package/src/commands/phase.ts +0 -92
  13. package/src/commands/progress.ts +0 -71
  14. package/src/commands/roadmap.ts +0 -40
  15. package/src/commands/scaffold.ts +0 -19
  16. package/src/commands/state.ts +0 -102
  17. package/src/commands/template.ts +0 -52
  18. package/src/commands/verify.ts +0 -70
  19. package/src/commands/workstream.ts +0 -98
  20. package/src/commands/wxp.ts +0 -65
  21. package/src/lib/commands.ts +0 -1040
  22. package/src/lib/config.ts +0 -385
  23. package/src/lib/core.ts +0 -1167
  24. package/src/lib/frontmatter.ts +0 -462
  25. package/src/lib/init.ts +0 -517
  26. package/src/lib/milestone.ts +0 -290
  27. package/src/lib/model-profiles.ts +0 -272
  28. package/src/lib/phase.ts +0 -1012
  29. package/src/lib/profile-output.ts +0 -237
  30. package/src/lib/profile-pipeline.ts +0 -556
  31. package/src/lib/roadmap.ts +0 -378
  32. package/src/lib/schemas.ts +0 -290
  33. package/src/lib/security.ts +0 -176
  34. package/src/lib/state.ts +0 -1175
  35. package/src/lib/template.ts +0 -246
  36. package/src/lib/uat.ts +0 -289
  37. package/src/lib/verify.ts +0 -879
  38. package/src/lib/workstream.ts +0 -524
  39. package/src/output.ts +0 -45
  40. package/src/schemas/pi-gsd-settings.schema.json +0 -80
  41. package/src/schemas/wxp.xsd +0 -619
  42. package/src/schemas/wxp.zod.ts +0 -318
  43. package/src/wxp/__tests__/arguments.test.ts +0 -86
  44. package/src/wxp/__tests__/conditions.test.ts +0 -106
  45. package/src/wxp/__tests__/executor.test.ts +0 -95
  46. package/src/wxp/__tests__/helpers.ts +0 -26
  47. package/src/wxp/__tests__/integration.test.ts +0 -166
  48. package/src/wxp/__tests__/new-features.test.ts +0 -222
  49. package/src/wxp/__tests__/parser.test.ts +0 -159
  50. package/src/wxp/__tests__/paste.test.ts +0 -66
  51. package/src/wxp/__tests__/schema.test.ts +0 -120
  52. package/src/wxp/__tests__/security.test.ts +0 -87
  53. package/src/wxp/__tests__/shell.test.ts +0 -85
  54. package/src/wxp/__tests__/string-ops.test.ts +0 -25
  55. package/src/wxp/__tests__/variables.test.ts +0 -65
  56. package/src/wxp/arguments.ts +0 -89
  57. package/src/wxp/conditions.ts +0 -78
  58. package/src/wxp/executor.ts +0 -191
  59. package/src/wxp/index.ts +0 -191
  60. package/src/wxp/parser.ts +0 -198
  61. package/src/wxp/paste.ts +0 -51
  62. package/src/wxp/security.ts +0 -102
  63. package/src/wxp/shell.ts +0 -81
  64. package/src/wxp/string-ops.ts +0 -44
  65. package/src/wxp/variables.ts +0 -109
@@ -1,973 +0,0 @@
1
- /**
2
- * pi-gsd-hooks.ts — pi-gsd Extension
3
- * pi-gsd-extension-version: 1.6.2
4
- *
5
- * Pi lifecycle extension for the Get Shit Done (GSD) workflow framework.
6
- * Provides three non-blocking hooks:
7
- *
8
- * session_start → background GSD update check (24 h cache)
9
- * tool_call → workflow guard advisory (write/edit outside GSD context)
10
- * tool_result → context usage monitor with debounced warnings
11
- *
12
- * Non-blocking guarantee: all failures are silent; hook errors never prevent
13
- * tool execution or session startup.
14
- *
15
- * Auto-discovered by pi from .pi/extensions/ (no settings.json entry required).
16
- * Source: https://github.com/fulgidus/pi-gsd
17
- */
18
-
19
- import { execSync } from "node:child_process";
20
- import {
21
- copyFileSync,
22
- existsSync,
23
- lstatSync,
24
- mkdirSync,
25
- readFileSync,
26
- readdirSync,
27
- writeFileSync,
28
- } from "node:fs";
29
- import { homedir } from "node:os";
30
- import { dirname, join, relative } from "node:path";
31
- import type { ContextUsage, ExtensionAPI } from "@mariozechner/pi-coding-agent";
32
- import { processWxpTrustedContent, WxpProcessingError, readWorkflowVersionTag } from "../../src/wxp/index.js";
33
- import { DEFAULT_SHELL_ALLOWLIST } from "../../src/wxp/security.js";
34
- import type { WxpSecurityConfig } from "../../src/schemas/wxp.zod.js";
35
-
36
- /**
37
- * Ensures .pi/gsd/ in the project is a symlink to the harness files
38
- * inside the pi-gsd package. Creates the symlink on first run; skips
39
- * if already present. Never overwrites a real directory (user may have
40
- * customised it).
41
- */
42
-
43
- /**
44
- * Copy-on-first-run harness distribution (HRN-01, HRN-03).
45
- * - Detects symlinks and replaces with real file copies.
46
- * - Copies missing files; never overwrites existing real files.
47
- * - Silent on any failure (non-blocking).
48
- */
49
- function copyHarness(
50
- src: string,
51
- dest: string,
52
- ): { symlinksReplaced: number; filesCopied: number } {
53
- let symlinksReplaced = 0;
54
- let filesCopied = 0;
55
-
56
- const walk = (srcDir: string, destDir: string): void => {
57
- mkdirSync(destDir, { recursive: true });
58
- const entries = readdirSync(srcDir, { withFileTypes: true });
59
- for (const entry of entries) {
60
- const srcPath = join(srcDir, entry.name);
61
- const destPath = join(destDir, entry.name);
62
- if (entry.isDirectory()) {
63
- walk(srcPath, destPath);
64
- continue;
65
- }
66
- if (existsSync(destPath)) {
67
- try {
68
- const st = lstatSync(destPath);
69
- if (st.isSymbolicLink()) {
70
- // Replace symlink with real copy (HRN-03)
71
- try {
72
- // unlinkSync removes the symlink without following it
73
- const { unlinkSync } = require("node:fs") as typeof import("node:fs");
74
- unlinkSync(destPath);
75
- } catch { /* ignore */ }
76
- copyFileSync(srcPath, destPath);
77
- symlinksReplaced++;
78
- }
79
- // Real file exists → skip (HRN-01: never overwrite)
80
- } catch { /* ignore */ }
81
- continue;
82
- }
83
- try {
84
- copyFileSync(srcPath, destPath);
85
- filesCopied++;
86
- } catch { /* ignore */ }
87
- }
88
- };
89
-
90
- walk(src, dest);
91
- return { symlinksReplaced, filesCopied };
92
- }
93
-
94
- /**
95
- * Extract the raw arguments string from a message that was produced by pi template expansion.
96
- * Pi replaces $ARGUMENTS in prompt templates with the user's typed text.
97
- * After <gsd-include> resolution, $ARGUMENTS text appears as trailing plain text
98
- * at the end of the message — everything after the last WXP/include tag block.
99
- *
100
- * Example message after pi expansion + include resolution:
101
- * [workflow content with <gsd-execute> blocks...]
102
- * 16 --auto
103
- *
104
- * Returns: "16 --auto"
105
- */
106
- function extractRawArguments(content: string): string {
107
- // Find the last <...> block (WXP tag or include) position
108
- const lastTagEnd = (() => {
109
- const tagPattern = /<\/(?:gsd-[a-zA-Z0-9_-]+|shell|if|then|else|condition|args|outs|string-op|settings)>/g;
110
- let lastEnd = 0;
111
- let m: RegExpExecArray | null;
112
- while ((m = tagPattern.exec(content)) !== null) {
113
- lastEnd = m.index + m[0].length;
114
- }
115
- return lastEnd;
116
- })();
117
-
118
- // Everything after the last closing tag is the trailing plain text ($ARGUMENTS expansion)
119
- const trailing = content.slice(lastTagEnd).trim();
120
-
121
- // Only return if it looks like user arguments (not a full document block)
122
- // Reject if it contains markdown headings or is very long (probably included file content)
123
- if (trailing.length === 0 || trailing.length > 500 || trailing.includes("\n\n\n")) {
124
- return "";
125
- }
126
- return trailing;
127
- }
128
-
129
- export default function (pi: ExtensionAPI) {
130
- /** Resolve a single <gsd-include> match: file lookup + selector extraction. */
131
- function resolveGsdInclude(
132
- match: RegExpMatchArray,
133
- cwd: string,
134
- pkgHarness: string,
135
- errors: string[],
136
- ): string | null {
137
- const filePath = match[1];
138
- const selectExpr = match[2] ?? "";
139
-
140
- // ── Resolve file path ───────────────────────────────────────
141
- const subPath = filePath.replace(/^\.pi\/gsd\//, "");
142
- const candidates = [
143
- join(cwd, filePath),
144
- ...(filePath.startsWith(".pi/gsd/") && pkgHarness
145
- ? [join(pkgHarness, subPath)]
146
- : []),
147
- ];
148
-
149
- let raw: string | null = null;
150
- for (const c of candidates) {
151
- try {
152
- if (existsSync(c)) {
153
- raw = readFileSync(c, "utf8");
154
- break;
155
- }
156
- } catch {
157
- /* try next */
158
- }
159
- }
160
- if (raw === null) {
161
- errors.push("File not found: " + filePath);
162
- return null;
163
- }
164
-
165
- // ── Apply selector ─────────────────────────────────────────
166
- let result = raw;
167
- if (!selectExpr) return result;
168
-
169
- const parts = selectExpr.split("|");
170
- if (parts.length > 2) {
171
- errors.push("Invalid selector (max 2 segments): " + selectExpr);
172
- return null;
173
- }
174
- if (parts.length > 1 && parts.some((p) => p.trim().startsWith("lines:"))) {
175
- errors.push("lines: cannot be chained — use it alone: " + selectExpr);
176
- return null;
177
- }
178
-
179
- for (const part of parts) {
180
- const p = part.trim();
181
-
182
- if (p.startsWith("tag:")) {
183
- const tagName = p.slice(4);
184
- const tagRe = new RegExp("<" + tagName + ">([\\s\\S]*?)</" + tagName + ">", "i");
185
- const tagMatch = result.match(tagRe);
186
- if (!tagMatch) {
187
- errors.push("Tag <" + tagName + "> not found in " + filePath);
188
- return null;
189
- }
190
- result = tagMatch[1].trim();
191
- } else if (p.startsWith("heading:")) {
192
- const headingText = p.slice(8);
193
- const escaped = headingText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
194
- const headingRe = new RegExp("(^|\\n)(#{1,6})\\s+" + escaped + "\\s*\\n");
195
- const hMatch = result.match(headingRe);
196
- if (!hMatch) {
197
- errors.push('Heading "' + headingText + '" not found in ' + filePath);
198
- return null;
199
- }
200
- const level = hMatch[2].length;
201
- const startIdx = (hMatch.index ?? 0) + hMatch[0].length;
202
- const nextHeading = result.slice(startIdx).search(new RegExp("\\n#{1," + level + "}\\s"));
203
- result =
204
- nextHeading === -1
205
- ? result.slice(startIdx).trim()
206
- : result.slice(startIdx, startIdx + nextHeading).trim();
207
- } else if (p.startsWith("lines:")) {
208
- const rangeMatch = p.match(/^lines:(\d+)-(\d+)$/);
209
- if (!rangeMatch) {
210
- errors.push("Invalid lines selector: " + p);
211
- return null;
212
- }
213
- const start = parseInt(rangeMatch[1], 10) - 1;
214
- const end = parseInt(rangeMatch[2], 10);
215
- result = result.split("\n").slice(start, end).join("\n");
216
- } else {
217
- errors.push("Unknown selector: " + p);
218
- return null;
219
- }
220
- }
221
-
222
- return result;
223
- }
224
-
225
- // ── context: <gsd-include> injection ────────────────────────────────────────
226
- // Fires AFTER template expansion, before each LLM call.
227
- // Scans user messages for <gsd-include path="..." select="..." /> tags,
228
- // resolves files, applies selectors, replaces tags with content.
229
- // On ANY failure: red error + return empty messages to block the LLM call.
230
- pi.on("context", async (event, ctx) => {
231
- const includePattern = /<gsd-include\s+path="([^"]+)"(?:\s+select="([^"]*)")?\s*\/>/g;
232
-
233
- // Package harness fallback path
234
- const extFile = typeof __filename !== "undefined" ? __filename : "";
235
- const pkgHarness = extFile
236
- ? join(dirname(extFile), "..", "harnesses", "pi", "get-shit-done")
237
- : "";
238
-
239
- const errors: string[] = [];
240
- const messages = event.messages;
241
-
242
- for (const msg of messages) {
243
- if (msg.role !== "user") continue;
244
-
245
- // Handle both string content and content block arrays
246
- if (typeof msg.content === "string") {
247
- const includes = [...msg.content.matchAll(includePattern)];
248
- if (includes.length === 0) continue;
249
-
250
- let transformed = msg.content;
251
- for (const match of includes) {
252
- const replacement = resolveGsdInclude(match, ctx.cwd, pkgHarness, errors);
253
- if (replacement === null) continue;
254
- transformed = transformed.replace(match[0], replacement);
255
- }
256
- msg.content = transformed;
257
- } else if (Array.isArray(msg.content)) {
258
- for (const block of msg.content) {
259
- if (block.type !== "text" || !block.text) continue;
260
- const includes = [...block.text.matchAll(includePattern)];
261
- if (includes.length === 0) continue;
262
-
263
- let transformed = block.text;
264
- for (const match of includes) {
265
- const replacement = resolveGsdInclude(match, ctx.cwd, pkgHarness, errors);
266
- if (replacement === null) continue;
267
- transformed = transformed.replace(match[0], replacement);
268
- }
269
- block.text = transformed;
270
- }
271
- }
272
- }
273
-
274
- if (errors.length > 0) {
275
- ctx.ui.notify("\u274c GSD include failed:\n" + errors.map((e) => " \u2022 " + e).join("\n"), "error");
276
- return { messages: [] }; // block LLM call
277
- }
278
-
279
- // ── WXP post-processing: run after <gsd-include> resolution (WXP-14) ──
280
- // Load global + project settings (HRN-06, HRN-07)
281
- const extFile2 = typeof __filename !== "undefined" ? __filename : "";
282
- const pkgRoot2 = join(dirname(extFile2), "..", "..");
283
-
284
- type SettingsFile = {
285
- shellAllowlist?: string[];
286
- shellBanlist?: string[];
287
- trustedPaths?: Array<{ position: "project" | "pkg" | "absolute"; path: string }>;
288
- untrustedPaths?: Array<{ position: "project" | "pkg" | "absolute"; path: string }>;
289
- shellTimeoutMs?: number;
290
- };
291
- const loadSettings = (settingsPath: string): SettingsFile => {
292
- try {
293
- if (existsSync(settingsPath)) {
294
- return JSON.parse(readFileSync(settingsPath, "utf8")) as SettingsFile;
295
- }
296
- } catch { /* ignore */ }
297
- return {};
298
- };
299
- const globalSettings = loadSettings(join(homedir(), ".gsd", "pi-gsd-settings.json"));
300
- const projectSettings = loadSettings(join(ctx.cwd, ".pi", "gsd", "pi-gsd-settings.json"));
301
- const mergedAllowlist = [
302
- ...DEFAULT_SHELL_ALLOWLIST,
303
- ...(globalSettings.shellAllowlist ?? []),
304
- ...(projectSettings.shellAllowlist ?? []),
305
- ];
306
- const wxpSecurity: WxpSecurityConfig = {
307
- trustedPaths: [
308
- ...(globalSettings.trustedPaths ?? []),
309
- ...(projectSettings.trustedPaths ?? []),
310
- { position: "pkg", path: ".gsd/harnesses/pi/get-shit-done" },
311
- { position: "project", path: ".pi/gsd" },
312
- ],
313
- untrustedPaths: [
314
- ...(globalSettings.untrustedPaths ?? []),
315
- ...(projectSettings.untrustedPaths ?? []),
316
- ],
317
- shellAllowlist: [...new Set(mergedAllowlist)],
318
- shellBanlist: [
319
- ...(globalSettings.shellBanlist ?? []),
320
- ...(projectSettings.shellBanlist ?? []),
321
- ],
322
- shellTimeoutMs: projectSettings.shellTimeoutMs ?? globalSettings.shellTimeoutMs ?? 30_000,
323
- };
324
-
325
- try {
326
- for (const msg of messages) {
327
- if (msg.role !== "user") continue;
328
- if (typeof msg.content === "string") {
329
- if (!msg.content.includes("<gsd-")) continue;
330
- const virtualPath = join(ctx.cwd, ".pi", "gsd", "workflows", "_message.md");
331
- const rawArgs = extractRawArguments(msg.content);
332
- msg.content = processWxpTrustedContent(msg.content, virtualPath, wxpSecurity, ctx.cwd, pkgRoot2, rawArgs, (m, lv) => ctx.ui.notify(m, lv === "error" ? "error" : "info"));
333
- } else if (Array.isArray(msg.content)) {
334
- for (const block of msg.content) {
335
- if (block.type !== "text" || !block.text) continue;
336
- if (!block.text.includes("<gsd-")) continue;
337
- const virtualPath = join(ctx.cwd, ".pi", "gsd", "workflows", "_message.md");
338
- const rawArgs = extractRawArguments(block.text);
339
- block.text = processWxpTrustedContent(block.text, virtualPath, wxpSecurity, ctx.cwd, pkgRoot2, rawArgs, (m, lv) => ctx.ui.notify(m, lv === "error" ? "error" : "info"));
340
- }
341
- }
342
- }
343
- } catch (wxpErr) {
344
- if (wxpErr instanceof WxpProcessingError) {
345
- ctx.ui.notify(wxpErr.message, "error");
346
- return { messages: [] }; // WXP-09: no partial content reaches LLM
347
- }
348
- // Non-WXP error: log but don't block
349
- const errMsg = wxpErr instanceof Error ? wxpErr.message : String(wxpErr);
350
- ctx.ui.notify(`GSD WXP: unexpected context error: ${errMsg}`, "info");
351
- }
352
-
353
- return { messages };
354
- });
355
-
356
- // ── session_start: GSD update check ──────────────────────────────────────
357
- pi.on("session_start", async (_event, ctx) => {
358
- // Copy-on-first-run harness distribution (HRN-01, HRN-03)
359
- try {
360
- const extFile = typeof __filename !== "undefined" ? __filename : "";
361
- const pkgRoot = join(dirname(extFile), "..", "..");
362
- const pkgHarness = join(pkgRoot, ".gsd", "harnesses", "pi", "get-shit-done");
363
- const projectHarness = join(ctx.cwd, ".pi", "gsd");
364
- if (existsSync(pkgHarness)) {
365
- const { symlinksReplaced } = copyHarness(pkgHarness, projectHarness);
366
- if (symlinksReplaced > 0) {
367
- ctx.ui.notify(
368
- `ℹ️ GSD: Replaced ${symlinksReplaced} symlink(s) in .pi/gsd/ with real file copies.`,
369
- "info",
370
- );
371
- }
372
-
373
- // Version-aware update detection (HRN-02)
374
- try {
375
- const pkgJsonPath = join(pkgRoot, "package.json");
376
- if (existsSync(pkgJsonPath)) {
377
- const pkgVersion = (JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { version?: string }).version ?? "0.0.0";
378
- const outdated: string[] = [];
379
- // Check a sample of key workflow files for version drift
380
- const sampleFiles = ["workflows/execute-phase.md", "workflows/plan-phase.md"];
381
- for (const rel of sampleFiles) {
382
- const projFile = join(projectHarness, rel);
383
- if (!existsSync(projFile)) continue;
384
- const content = readFileSync(projFile, "utf8");
385
- const vtag = readWorkflowVersionTag(content);
386
- if (!vtag || vtag.doNotUpdate) continue;
387
- if (vtag.version !== pkgVersion) outdated.push(rel);
388
- }
389
- if (outdated.length > 0) {
390
- ctx.ui.notify(
391
- `ℹ️ GSD harness update available (package v${pkgVersion}).\n` +
392
- `Outdated files: ${outdated.join(", ")}\n` +
393
- `Run: pi-gsd-tools harness update [y|n|pick|diff]`,
394
- "info",
395
- );
396
- }
397
- }
398
- } catch { /* silent */ }
399
- }
400
- } catch { /* silent */ }
401
- try {
402
- const cacheDir = join(homedir(), ".pi", "cache");
403
- const cacheFile = join(cacheDir, "gsd-update-check.json");
404
- const CACHE_TTL_SECONDS = 86_400; // 24 hours
405
-
406
- // Show cached update notification if available
407
- if (existsSync(cacheFile)) {
408
- try {
409
- const cache = JSON.parse(readFileSync(cacheFile, "utf8")) as {
410
- update_available?: boolean;
411
- installed?: string;
412
- latest?: string;
413
- checked?: number;
414
- };
415
- const ageSeconds =
416
- Math.floor(Date.now() / 1000) - (cache.checked ?? 0);
417
-
418
- if (cache.update_available && cache.latest) {
419
- ctx.ui.notify(
420
- `GSD update available: ${cache.installed ?? "?"} → ${cache.latest}. Run: npm i -g pi-gsd`,
421
- "info",
422
- );
423
- }
424
-
425
- // Cache is fresh - skip network check
426
- if (ageSeconds < CACHE_TTL_SECONDS) return;
427
- } catch {
428
- // Corrupt cache - fall through to fresh check
429
- }
430
- }
431
-
432
- // Run network check asynchronously after 3 s to avoid blocking startup
433
- setTimeout(() => {
434
- try {
435
- mkdirSync(cacheDir, { recursive: true });
436
-
437
- // Resolve installed version from project or global GSD install
438
- let installed = "0.0.0";
439
- const versionPaths = [
440
- join(ctx.cwd, ".pi", "gsd", "VERSION"),
441
- join(homedir(), ".pi", "gsd", "VERSION"),
442
- ];
443
- for (const vp of versionPaths) {
444
- if (existsSync(vp)) {
445
- try {
446
- installed = readFileSync(vp, "utf8").trim();
447
- break;
448
- } catch {
449
- /* skip unreadable */
450
- }
451
- }
452
- }
453
-
454
- let latest: string | null = null;
455
- try {
456
- latest = execSync("npm view pi-gsd version", {
457
- encoding: "utf8",
458
- timeout: 10_000,
459
- windowsHide: true,
460
- }).trim();
461
- } catch {
462
- /* offline or npm unavailable */
463
- }
464
-
465
- writeFileSync(
466
- cacheFile,
467
- JSON.stringify({
468
- update_available:
469
- latest !== null &&
470
- installed !== "0.0.0" &&
471
- installed !== latest,
472
- installed,
473
- latest: latest ?? "unknown",
474
- checked: Math.floor(Date.now() / 1000),
475
- }),
476
- );
477
- } catch {
478
- /* silent fail */
479
- }
480
- }, 3_000);
481
- } catch {
482
- /* silent fail - never throw from session_start */
483
- }
484
- });
485
-
486
- // ── tool_call: workflow guard (advisory only, never blocking) ────────────
487
- pi.on("tool_call", async (event, ctx) => {
488
- try {
489
- // Only guard write and edit tool calls
490
- if (event.toolName !== "write" && event.toolName !== "edit")
491
- return undefined;
492
-
493
- const filePath = (event.input as { path?: string }).path ?? "";
494
-
495
- // Allow .planning/ edits (GSD state management)
496
- if (filePath.includes(".planning/")) return undefined;
497
-
498
- // Allow common config/docs files that don't need GSD tracking
499
- const allowed = [
500
- /\.gitignore$/,
501
- /\.env/,
502
- /AGENTS\.md$/,
503
- /settings\.json$/,
504
- /pi-gsd-hooks\.ts$/,
505
- ];
506
- if (allowed.some((p) => p.test(filePath))) return undefined;
507
-
508
- // Only activate when GSD project has workflow_guard enabled
509
- const configPath = join(ctx.cwd, ".planning", "config.json");
510
- if (!existsSync(configPath)) return undefined; // No GSD project
511
-
512
- try {
513
- const config = JSON.parse(readFileSync(configPath, "utf8")) as {
514
- hooks?: { workflow_guard?: boolean };
515
- };
516
- if (!config.hooks?.workflow_guard) return undefined; // Guard disabled (default)
517
- } catch {
518
- return undefined;
519
- }
520
-
521
- // Advisory only - never block tool execution
522
- const fileName = filePath.split("/").pop() ?? filePath;
523
- ctx.ui.notify(
524
- `⚠️ GSD: Editing ${fileName} outside a GSD workflow. Consider /gsd-fast or /gsd-quick to maintain state tracking.`,
525
- "info",
526
- );
527
- } catch {
528
- /* silent fail - never block tool execution */
529
- }
530
-
531
- return undefined;
532
- });
533
-
534
- // ── Instant commands (zero LLM, deterministic output) ────────────────────
535
-
536
- // JSON shapes returned by pi-gsd-tools
537
- interface GsdPhase {
538
- number: string;
539
- name: string;
540
- plans: number;
541
- summaries: number;
542
- status: string;
543
- }
544
- interface GsdProgress {
545
- milestone_version: string;
546
- milestone_name: string;
547
- phases: GsdPhase[];
548
- total_plans: number;
549
- total_summaries: number;
550
- percent: number;
551
- }
552
- interface GsdStats extends GsdProgress {
553
- phases_completed: number;
554
- phases_total: number;
555
- plan_percent: number;
556
- requirements_total: number;
557
- requirements_complete: number;
558
- git_commits: number;
559
- git_first_commit_date: string;
560
- last_activity: string;
561
- }
562
- interface GsdState {
563
- milestone: string;
564
- milestone_name: string;
565
- status: string;
566
- last_activity: string;
567
- progress: {
568
- total_phases: string;
569
- completed_phases: string;
570
- total_plans: string;
571
- completed_plans: string;
572
- };
573
- }
574
- interface GsdHealth {
575
- status: string;
576
- errors: Array<{ code: string; message: string; repair?: string }>;
577
- warnings: Array<{ code: string; message: string }>;
578
- }
579
-
580
- const runJson = <T>(args: string, cwd: string): T | null => {
581
- try {
582
- const raw = execSync(
583
- `pi-gsd-tools ${args} --raw --cwd ${JSON.stringify(cwd)}`,
584
- { encoding: "utf8", timeout: 10_000, windowsHide: true },
585
- ).trim();
586
- return JSON.parse(raw) as T;
587
- } catch {
588
- return null;
589
- }
590
- };
591
-
592
- const bar = (pct: number, width = 20): string => {
593
- const filled = Math.round((pct / 100) * width);
594
- return "█".repeat(filled) + "░".repeat(width - filled);
595
- };
596
-
597
- const cap = (s: string, max = 42): string =>
598
- s.length > max ? s.slice(0, max - 1) + "…" : s;
599
-
600
- /** Derive the next GSD action from phase data — no LLM needed. */
601
- const nextSteps = (phases: GsdPhase[]): string[] => {
602
- const pending = phases.filter((p) => p.status !== "Complete");
603
- if (pending.length === 0) {
604
- return [
605
- " ✅ All phases complete!",
606
- " → /gsd-audit-milestone Review before archiving",
607
- " → /gsd-complete-milestone Archive and start next",
608
- ];
609
- }
610
- const next = pending[0];
611
- const n = next.number;
612
- const lines: string[] = [` ⏳ Phase ${n}: ${cap(next.name)}`];
613
- if (next.plans === 0) {
614
- lines.push(` → /gsd-discuss-phase ${n} Gather context first`);
615
- lines.push(` → /gsd-plan-phase ${n} Jump straight to planning`);
616
- } else if (next.summaries < next.plans) {
617
- lines.push(
618
- ` → /gsd-execute-phase ${n} ${next.summaries}/${next.plans} plans done`,
619
- );
620
- } else {
621
- lines.push(` → /gsd-verify-work ${n} All plans done, verify UAT`);
622
- }
623
- lines.push(` → /gsd-next Auto-advance`);
624
- if (pending.length > 1) {
625
- lines.push(
626
- ` (+ ${pending.length - 1} more phase${pending.length > 2 ? "s" : ""} pending)`,
627
- );
628
- }
629
- return lines;
630
- };
631
-
632
- const formatProgress = (
633
- cwd: string,
634
- ): { text: string; data: GsdProgress | null } => {
635
- const data = runJson<GsdProgress>("progress json", cwd);
636
- if (!data)
637
- return {
638
- text: "❌ No GSD project found. Run /gsd-new-project to initialise.",
639
- data: null,
640
- };
641
-
642
- const done = data.phases.filter((p) => p.status === "Complete").length;
643
- const total = data.phases.length;
644
- const phasePct = total > 0 ? Math.round((done / total) * 100) : 0;
645
- const planPct =
646
- data.total_plans > 0
647
- ? Math.round((data.total_summaries / data.total_plans) * 100)
648
- : 0;
649
-
650
- const lines = [
651
- `━━ GSD Progress ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
652
- `📋 ${data.milestone_name} (${data.milestone_version})`,
653
- ``,
654
- `Phases ${bar(phasePct)} ${done}/${total} (${phasePct}%)`,
655
- `Plans ${bar(planPct)} ${data.total_summaries}/${data.total_plans} (${planPct}%)`,
656
- ``,
657
- `Next steps:`,
658
- ...nextSteps(data.phases),
659
- ``,
660
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
661
- ];
662
- return { text: lines.join("\n"), data };
663
- };
664
-
665
- const formatStats = (
666
- cwd: string,
667
- ): { text: string; data: GsdStats | null } => {
668
- const data = runJson<GsdStats>("stats json", cwd);
669
- if (!data)
670
- return {
671
- text: "❌ No GSD project found. Run /gsd-new-project to initialise.",
672
- data: null,
673
- };
674
-
675
- const reqPct =
676
- data.requirements_total > 0
677
- ? Math.round(
678
- (data.requirements_complete / data.requirements_total) * 100,
679
- )
680
- : 0;
681
-
682
- const lines = [
683
- `━━ GSD Stats ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
684
- `📋 ${data.milestone_name} (${data.milestone_version})`,
685
- ``,
686
- `Phases ${bar(data.percent)} ${data.phases_completed}/${data.phases_total} (${data.percent}%)`,
687
- `Plans ${bar(data.plan_percent)} ${data.total_summaries}/${data.total_plans} (${data.plan_percent}%)`,
688
- `Reqs ${bar(reqPct)} ${data.requirements_complete}/${data.requirements_total} (${reqPct}%)`,
689
- ``,
690
- `🗂 Git commits: ${data.git_commits}`,
691
- `📅 Started: ${data.git_first_commit_date}`,
692
- `📅 Last activity: ${data.last_activity}`,
693
- ``,
694
- `Next steps:`,
695
- ...nextSteps(data.phases),
696
- ``,
697
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
698
- ];
699
- return { text: lines.join("\n"), data };
700
- };
701
-
702
- const formatHealth = (cwd: string, repair: boolean): string => {
703
- const data = runJson<GsdHealth>(
704
- `validate health${repair ? " --repair" : ""}`,
705
- cwd,
706
- );
707
- if (!data)
708
- return "❌ No GSD project found. Run /gsd-new-project to initialise.";
709
-
710
- const icon =
711
- data.status === "ok" ? "✅" : data.status === "broken" ? "❌" : "⚠️";
712
- const lines = [
713
- `━━ GSD Health ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
714
- `${icon} Status: ${data.status.toUpperCase()}`,
715
- ];
716
-
717
- if (data.errors?.length) {
718
- lines.push(``, `Errors (${data.errors.length}):`);
719
- for (const e of data.errors) {
720
- lines.push(` ✗ [${e.code}] ${e.message}`);
721
- if (e.repair) lines.push(` fix: ${e.repair}`);
722
- }
723
- }
724
- if (data.warnings?.length) {
725
- lines.push(``, `Warnings (${data.warnings.length}):`);
726
- for (const w of data.warnings) {
727
- lines.push(` ⚠ [${w.code}] ${w.message}`);
728
- }
729
- }
730
- if (data.status !== "ok" && !repair) {
731
- lines.push(``, ` → /gsd-health --repair Auto-fix all issues`);
732
- }
733
- lines.push(``, `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
734
- return lines.join("\n");
735
- };
736
-
737
- /** Derive the suggested next command string from phase data. */
738
- const nextCommand = (phases: GsdPhase[]): string | null => {
739
- const pending = phases.filter((p) => p.status !== "Complete");
740
- if (pending.length === 0) return "/gsd-audit-milestone";
741
- const next = pending[0];
742
- const n = next.number;
743
- if (next.plans === 0) return `/gsd-discuss-phase ${n}`;
744
- if (next.summaries < next.plans) return `/gsd-execute-phase ${n}`;
745
- return `/gsd-verify-work ${n}`;
746
- };
747
-
748
- pi.registerCommand("gsd-progress", {
749
- description: "Show project progress with next steps (instant)",
750
- handler: async (_args, ctx) => {
751
- const { text, data } = formatProgress(ctx.cwd);
752
- ctx.ui.notify(text, "info");
753
- // Pivot affordance: pre-fill the editor with the most relevant next action
754
- // so the user can run it, modify it, or just type something else entirely
755
- if (data) {
756
- const cmd = nextCommand(data.phases);
757
- if (cmd) ctx.ui.setEditorText(cmd);
758
- }
759
- },
760
- });
761
-
762
- pi.registerCommand("gsd-stats", {
763
- description: "Show project statistics (instant)",
764
- handler: async (_args, ctx) => {
765
- const { text, data } = formatStats(ctx.cwd);
766
- ctx.ui.notify(text, "info");
767
- if (data) {
768
- const cmd = nextCommand(data.phases);
769
- if (cmd) ctx.ui.setEditorText(cmd);
770
- }
771
- },
772
- });
773
-
774
- pi.registerCommand("gsd-health", {
775
- description: "Check .planning/ integrity (instant)",
776
- handler: async (args, ctx) => {
777
- ctx.ui.notify(
778
- formatHealth(ctx.cwd, !!args?.includes("--repair")),
779
- "info",
780
- );
781
- },
782
- getArgumentCompletions: (prefix) => {
783
- const options = [
784
- { value: "--repair", label: "--repair Auto-fix issues" },
785
- ];
786
- return options.filter((o) => o.value.startsWith(prefix));
787
- },
788
- });
789
-
790
- pi.registerCommand("gsd-next", {
791
- description: "Auto-advance to the next GSD action (instant, no LLM)",
792
- handler: async (_args, ctx) => {
793
- const data = runJson<GsdProgress>("progress json", ctx.cwd);
794
- if (!data) {
795
- ctx.ui.notify(
796
- "❌ No GSD project found. Run /gsd-new-project to initialise.",
797
- "error",
798
- );
799
- ctx.ui.setEditorText("/gsd-new-project");
800
- return;
801
- }
802
-
803
- const pending = data.phases.filter((p) => p.status !== "Complete");
804
-
805
- if (pending.length === 0) {
806
- ctx.ui.notify(
807
- [
808
- `━━ GSD Next ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
809
- `✅ All phases complete!`,
810
- `→ /gsd-audit-milestone`,
811
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
812
- ].join("\n"),
813
- "info",
814
- );
815
- ctx.ui.setEditorText("/gsd-audit-milestone");
816
- return;
817
- }
818
-
819
- const next = pending[0];
820
- const n = next.number;
821
- let action: string;
822
- let reason: string;
823
-
824
- if (next.plans === 0) {
825
- action = `/gsd-discuss-phase ${n}`;
826
- reason = `Phase ${n} has no plans yet — start with discussion`;
827
- } else if (next.summaries < next.plans) {
828
- action = `/gsd-execute-phase ${n}`;
829
- reason = `Phase ${n}: ${next.summaries}/${next.plans} plans done — continue execution`;
830
- } else {
831
- action = `/gsd-verify-work ${n}`;
832
- reason = `Phase ${n}: all plans done — verify UAT`;
833
- }
834
-
835
- ctx.ui.notify(
836
- [
837
- `━━ GSD Next ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
838
- `⏩ ${reason}`,
839
- `→ ${action}`,
840
- ...(pending.length > 1
841
- ? [
842
- ` (${pending.length - 1} more phase${pending.length > 2 ? "s" : ""} pending after this)`,
843
- ]
844
- : []),
845
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
846
- ].join("\n"),
847
- "info",
848
- );
849
- ctx.ui.setEditorText(action);
850
- },
851
- });
852
-
853
- pi.registerCommand("gsd-help", {
854
- description: "List all GSD commands (instant)",
855
- handler: async (_args, ctx) => {
856
- ctx.ui.notify(
857
- [
858
- "━━ GSD Commands ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
859
- "Lifecycle:",
860
- " /gsd-new-project Initialise project",
861
- " /gsd-new-milestone Start next milestone",
862
- " /gsd-discuss-phase N Discuss before planning",
863
- " /gsd-plan-phase N Create phase plan",
864
- " /gsd-execute-phase N Execute phase",
865
- " /gsd-verify-work N UAT testing",
866
- " /gsd-validate-phase N Validate completion",
867
- " /gsd-next Auto-advance",
868
- " /gsd-autonomous Run all phases",
869
- " /gsd-plan-milestone Plan all phases at once",
870
- " /gsd-execute-milestone Execute all phases with gates",
871
- "",
872
- "Quick:",
873
- " /gsd-quick <task> Tracked ad-hoc task",
874
- " /gsd-fast <task> Inline, no subagents",
875
- " /gsd-do <text> Route automatically",
876
- " /gsd-debug Debug session",
877
- "",
878
- "Instant (no LLM):",
879
- " /gsd-progress Progress + next steps",
880
- " /gsd-stats Full statistics",
881
- " /gsd-health [--repair] .planning/ integrity",
882
- " /gsd-help This list",
883
- "",
884
- "Management:",
885
- " /gsd-setup-pi Wire pi extension",
886
- " /gsd-set-profile <p> quality|balanced|budget",
887
- " /gsd-settings Workflow toggles",
888
- " /gsd-progress Roadmap overview",
889
- "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
890
- ].join("\n"),
891
- "info",
892
- );
893
- },
894
- });
895
-
896
- // ── tool_result: context usage monitor ───────────────────────────────────
897
- const WARNING_THRESHOLD = 35; // warn when remaining % ≤ 35
898
- const CRITICAL_THRESHOLD = 25; // critical when remaining % ≤ 25
899
- const DEBOUNCE_CALLS = 5; // minimum tool uses between repeated warnings
900
-
901
- let callsSinceWarn = 0;
902
- let lastLevel: "warning" | "critical" | null = null;
903
-
904
- pi.on("tool_result", async (_event, ctx) => {
905
-
906
- try {
907
- const usage: ContextUsage | undefined = ctx.getContextUsage();
908
- if (!usage || usage.percent === null) return undefined;
909
-
910
- const usedPct = Math.round(usage.percent);
911
- const remaining = 100 - usedPct;
912
-
913
- // Below warning threshold - just increment debounce counter
914
- if (remaining > WARNING_THRESHOLD) {
915
- callsSinceWarn++;
916
- return undefined;
917
- }
918
-
919
- // Respect opt-out via project config
920
- const configPath = join(ctx.cwd, ".planning", "config.json");
921
- if (existsSync(configPath)) {
922
- try {
923
- const config = JSON.parse(readFileSync(configPath, "utf8")) as {
924
- hooks?: { context_warnings?: boolean };
925
- };
926
- if (config.hooks?.context_warnings === false) return undefined;
927
- } catch {
928
- /* ignore config errors */
929
- }
930
- }
931
-
932
- const isCritical = remaining <= CRITICAL_THRESHOLD;
933
- const currentLevel: "warning" | "critical" = isCritical
934
- ? "critical"
935
- : "warning";
936
-
937
- callsSinceWarn++;
938
-
939
- // Debounce - allow severity escalation (warning → critical bypasses debounce)
940
- const severityEscalated =
941
- currentLevel === "critical" && lastLevel === "warning";
942
- if (
943
- lastLevel !== null &&
944
- callsSinceWarn < DEBOUNCE_CALLS &&
945
- !severityEscalated
946
- ) {
947
- return undefined;
948
- }
949
-
950
- callsSinceWarn = 0;
951
- lastLevel = currentLevel;
952
-
953
- const isGsdActive = existsSync(join(ctx.cwd, ".planning", "STATE.md"));
954
-
955
- let msg: string;
956
- if (isCritical) {
957
- msg = isGsdActive
958
- ? `🔴 CONTEXT CRITICAL: ${usedPct}% used (${remaining}% left). GSD state is in STATE.md. Inform user to run /gsd-pause-work.`
959
- : `🔴 CONTEXT CRITICAL: ${usedPct}% used (${remaining}% left). Inform user context is nearly exhausted.`;
960
- } else {
961
- msg = isGsdActive
962
- ? `⚠️ CONTEXT WARNING: ${usedPct}% used (${remaining}% left). Avoid starting new complex work.`
963
- : `⚠️ CONTEXT WARNING: ${usedPct}% used (${remaining}% left). Context is getting limited.`;
964
- }
965
-
966
- ctx.ui.notify(msg, isCritical ? "error" : "info");
967
- } catch {
968
- /* silent fail - never throw from tool_result */
969
- }
970
-
971
- return undefined;
972
- });
973
- }