pi-gsd 2.0.1 → 2.0.3

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 (66) hide show
  1. package/dist/pi-gsd-hooks.js +1533 -0
  2. package/dist/pi-gsd-tools.js +53 -52
  3. package/package.json +3 -5
  4. package/.gsd/extensions/pi-gsd-hooks.ts +0 -973
  5. package/src/cli.ts +0 -644
  6. package/src/commands/base.ts +0 -67
  7. package/src/commands/commit.ts +0 -22
  8. package/src/commands/config.ts +0 -71
  9. package/src/commands/frontmatter.ts +0 -51
  10. package/src/commands/index.ts +0 -76
  11. package/src/commands/init.ts +0 -43
  12. package/src/commands/milestone.ts +0 -37
  13. package/src/commands/phase.ts +0 -92
  14. package/src/commands/progress.ts +0 -71
  15. package/src/commands/roadmap.ts +0 -40
  16. package/src/commands/scaffold.ts +0 -19
  17. package/src/commands/state.ts +0 -102
  18. package/src/commands/template.ts +0 -52
  19. package/src/commands/verify.ts +0 -70
  20. package/src/commands/workstream.ts +0 -98
  21. package/src/commands/wxp.ts +0 -65
  22. package/src/lib/commands.ts +0 -1040
  23. package/src/lib/config.ts +0 -385
  24. package/src/lib/core.ts +0 -1167
  25. package/src/lib/frontmatter.ts +0 -462
  26. package/src/lib/init.ts +0 -517
  27. package/src/lib/milestone.ts +0 -290
  28. package/src/lib/model-profiles.ts +0 -272
  29. package/src/lib/phase.ts +0 -1012
  30. package/src/lib/profile-output.ts +0 -237
  31. package/src/lib/profile-pipeline.ts +0 -556
  32. package/src/lib/roadmap.ts +0 -378
  33. package/src/lib/schemas.ts +0 -290
  34. package/src/lib/security.ts +0 -176
  35. package/src/lib/state.ts +0 -1175
  36. package/src/lib/template.ts +0 -246
  37. package/src/lib/uat.ts +0 -289
  38. package/src/lib/verify.ts +0 -879
  39. package/src/lib/workstream.ts +0 -524
  40. package/src/output.ts +0 -45
  41. package/src/schemas/pi-gsd-settings.schema.json +0 -80
  42. package/src/schemas/wxp.xsd +0 -619
  43. package/src/schemas/wxp.zod.ts +0 -318
  44. package/src/wxp/__tests__/arguments.test.ts +0 -86
  45. package/src/wxp/__tests__/conditions.test.ts +0 -106
  46. package/src/wxp/__tests__/executor.test.ts +0 -95
  47. package/src/wxp/__tests__/helpers.ts +0 -26
  48. package/src/wxp/__tests__/integration.test.ts +0 -166
  49. package/src/wxp/__tests__/new-features.test.ts +0 -222
  50. package/src/wxp/__tests__/parser.test.ts +0 -159
  51. package/src/wxp/__tests__/paste.test.ts +0 -66
  52. package/src/wxp/__tests__/schema.test.ts +0 -120
  53. package/src/wxp/__tests__/security.test.ts +0 -87
  54. package/src/wxp/__tests__/shell.test.ts +0 -85
  55. package/src/wxp/__tests__/string-ops.test.ts +0 -25
  56. package/src/wxp/__tests__/variables.test.ts +0 -65
  57. package/src/wxp/arguments.ts +0 -89
  58. package/src/wxp/conditions.ts +0 -78
  59. package/src/wxp/executor.ts +0 -191
  60. package/src/wxp/index.ts +0 -191
  61. package/src/wxp/parser.ts +0 -198
  62. package/src/wxp/paste.ts +0 -51
  63. package/src/wxp/security.ts +0 -102
  64. package/src/wxp/shell.ts +0 -81
  65. package/src/wxp/string-ops.ts +0 -44
  66. package/src/wxp/variables.ts +0 -109
@@ -1,556 +0,0 @@
1
- /**
2
- * profile-pipeline.ts - Profile rendering pipeline (session scanning, message extraction).
3
- *
4
- * Supports two session storage formats:
5
- *
6
- * ## Claude / agent sessions
7
- * Path: ~/.claude/projects/<encoded-project>/<session>.jsonl
8
- * (also checks ~/.agent/projects/ for the legacy agent harness)
9
- * Format: Each line is a raw AgentMessage JSON object with a top-level `role` field.
10
- * User messages have role "human"; assistant messages have role "assistant".
11
- *
12
- * ## Pi sessions
13
- * Path: ~/.pi/agent/sessions/--<path-with-slashes-as-dashes>--/<timestamp>_<uuid>.jsonl
14
- * The directory name encodes the project cwd: "/" is replaced with "-" and the
15
- * whole path is wrapped in "--" delimiters. E.g. "/home/user/my-project" becomes
16
- * "--home-user-my-project--".
17
- * Format: Each line is a JSONL entry (SessionEntry) with a "type" discriminant:
18
- * - Header (first line only): {"type":"session","version":3,"id":"<uuid>","timestamp":"...","cwd":"/path"}
19
- * - Message entry: {"type":"message","id":"<8hex>","parentId":"<8hex>|null","timestamp":"...",
20
- * "message":{"role":"user"|"assistant"|"toolResult",...}}
21
- * The message.role for human turns is "user" (not "human" as in Claude sessions).
22
- * message.content is a string (user) or array of content blocks (assistant/toolResult).
23
- * - Other entries (model_change, session_info, compaction, etc.) are ignored for profiling.
24
- *
25
- * Auto-detection: cmdScanSessions detects both harness types. When --harness pi is given,
26
- * pi sessions are listed first and marked as priority. Existing Claude session reading
27
- * is fully preserved.
28
- */
29
-
30
- import { gsdError, output } from "./core.js";
31
-
32
- interface ProfileSampleOptions {
33
- limit?: number;
34
- maxPerProject?: number | null;
35
- maxChars?: number;
36
- /** "pi" | "claude" | "agent" - when set, prioritise that harness */
37
- harness?: string | null;
38
- }
39
-
40
- interface ExtractMessagesOptions {
41
- sessionId?: string | null;
42
- limit?: number | null;
43
- }
44
-
45
- interface ScanSessionsOptions {
46
- verbose?: boolean;
47
- json?: boolean;
48
- /** "pi" | "claude" | "agent" - when set, prioritise that harness */
49
- harness?: string | null;
50
- }
51
-
52
- // eslint-disable-next-line @typescript-eslint/no-require-imports
53
- const fs = require("fs") as typeof import("fs");
54
- // eslint-disable-next-line @typescript-eslint/no-require-imports
55
- const path = require("path") as typeof import("path");
56
-
57
- // ─── Path helpers ─────────────────────────────────────────────────────────────
58
-
59
- /**
60
- * Returns the Claude/agent session base path.
61
- * Checks ~/.agent/projects first (legacy agent harness), falls back to ~/.claude/projects.
62
- */
63
- function getClaudeSessionsBasePath(overridePath?: string | null): string {
64
- if (overridePath) return overridePath;
65
- const home = process.env["HOME"] ?? "";
66
- const agentProjects = path.join(home, ".agent", "projects");
67
- if (fs.existsSync(agentProjects)) return agentProjects;
68
- return path.join(home, ".claude", "projects");
69
- }
70
-
71
- /**
72
- * Returns the pi session base path: ~/.pi/agent/sessions/
73
- *
74
- * Pi stores sessions in per-project subdirectories named by encoding the project's
75
- * cwd: every "/" is replaced with "-" and the result is wrapped in "--" delimiters.
76
- * Example: "/home/user/my-proj" → "--home-user-my-proj--"
77
- */
78
- function getPiSessionsBasePath(): string {
79
- const home = process.env["HOME"] ?? "";
80
- return path.join(home, ".pi", "agent", "sessions");
81
- }
82
-
83
- /**
84
- * Extracts the human-readable project path from a pi session directory name.
85
- * "--home-user-my-proj--" → "/home/user/my-proj"
86
- */
87
- function decodePiProjectDir(dirName: string): string {
88
- // Strip surrounding "--" delimiters, then replace "-" back to "/"
89
- // Note: this is lossy (hyphens in path become slashes) but sufficient for display.
90
- if (dirName.startsWith("--") && dirName.endsWith("--")) {
91
- return "/" + dirName.slice(2, -2).replace(/-/g, "/");
92
- }
93
- return dirName;
94
- }
95
-
96
- // ─── Pi JSONL helpers ─────────────────────────────────────────────────────────
97
-
98
- /**
99
- * A pi session entry. Only the fields relevant for profiling are typed here.
100
- * All other entry types (model_change, compaction, etc.) are represented as unknown.
101
- */
102
- interface PiSessionEntry {
103
- type: string;
104
- id?: string;
105
- parentId?: string | null;
106
- timestamp?: string;
107
- // For type === "session" (header)
108
- version?: number;
109
- cwd?: string;
110
- // For type === "message"
111
- message?: {
112
- role: "user" | "assistant" | "toolResult" | string;
113
- content?: unknown; // string | ContentBlock[]
114
- timestamp?: number;
115
- };
116
- }
117
-
118
- /**
119
- * Returns true if the first parseable line of a JSONL file looks like a pi session header.
120
- * Pi session files begin with: {"type":"session","version":...}
121
- */
122
- function isPiSessionFile(filePath: string): boolean {
123
- try {
124
- const firstLine = fs
125
- .readFileSync(filePath, "utf-8")
126
- .split("\n")
127
- .find((l) => l.trim().length > 0);
128
- if (!firstLine) return false;
129
- const parsed = JSON.parse(firstLine) as Record<string, unknown>;
130
- return parsed["type"] === "session" && "version" in parsed;
131
- } catch {
132
- return false;
133
- }
134
- }
135
-
136
- /**
137
- * Extracts the text content from a pi message's content field.
138
- * Content may be a plain string or an array of content blocks ({type:"text", text:"..."}).
139
- */
140
- function extractPiMessageText(content: unknown): string {
141
- if (typeof content === "string") return content;
142
- if (Array.isArray(content)) {
143
- return content
144
- .filter(
145
- (b) =>
146
- b !== null &&
147
- typeof b === "object" &&
148
- (b as Record<string, unknown>)["type"] === "text",
149
- )
150
- .map((b) => String((b as Record<string, unknown>)["text"] ?? ""))
151
- .join(" ");
152
- }
153
- return "";
154
- }
155
-
156
- /**
157
- * Reads all user-turn messages from a pi JSONL session file.
158
- * Returns raw entry objects so callers can inspect role/content themselves.
159
- */
160
- function readPiSessionMessages(filePath: string): PiSessionEntry[] {
161
- try {
162
- const lines = fs
163
- .readFileSync(filePath, "utf-8")
164
- .split("\n")
165
- .filter(Boolean);
166
- const messages: PiSessionEntry[] = [];
167
- for (const line of lines) {
168
- try {
169
- const entry = JSON.parse(line) as PiSessionEntry;
170
- // Only keep message entries (skip header, model_change, etc.)
171
- if (entry.type === "message" && entry.message) {
172
- messages.push(entry);
173
- }
174
- } catch {
175
- /* skip malformed lines */
176
- }
177
- }
178
- return messages;
179
- } catch {
180
- return [];
181
- }
182
- }
183
-
184
- // ─── Exported commands ────────────────────────────────────────────────────────
185
-
186
- export async function cmdScanSessions(
187
- overridePath: string | null | undefined,
188
- options: ScanSessionsOptions,
189
- raw: boolean,
190
- ): Promise<void> {
191
- const harness = options.harness ?? null;
192
- const isPiHarness = harness === "pi";
193
-
194
- // Collect pi sessions
195
- const piBase = getPiSessionsBasePath();
196
- const piAvailable = fs.existsSync(piBase);
197
- const piProjects: Array<{
198
- name: string;
199
- sessions: number;
200
- path: string;
201
- source: "pi";
202
- cwd: string;
203
- }> = [];
204
-
205
- if (piAvailable) {
206
- try {
207
- const entries = fs
208
- .readdirSync(piBase, { withFileTypes: true })
209
- .filter((e) => e.isDirectory());
210
- for (const entry of entries) {
211
- const projectDir = path.join(piBase, entry.name);
212
- const sessionFiles = fs
213
- .readdirSync(projectDir)
214
- .filter((f) => f.endsWith(".jsonl"));
215
- piProjects.push({
216
- name: entry.name,
217
- sessions: sessionFiles.length,
218
- path: projectDir,
219
- source: "pi",
220
- cwd: decodePiProjectDir(entry.name),
221
- });
222
- }
223
- } catch {
224
- /* ok - partial failures are non-fatal */
225
- }
226
- }
227
-
228
- // Collect Claude/agent sessions (skip when explicit pi harness + no override path)
229
- const claudeBase = getClaudeSessionsBasePath(
230
- isPiHarness && !overridePath ? null : overridePath,
231
- );
232
- const claudeAvailable =
233
- !isPiHarness || overridePath ? fs.existsSync(claudeBase) : false;
234
- const claudeProjects: Array<{
235
- name: string;
236
- sessions: number;
237
- path: string;
238
- source: "claude";
239
- }> = [];
240
-
241
- if (claudeAvailable && (!isPiHarness || overridePath)) {
242
- try {
243
- const entries = fs
244
- .readdirSync(claudeBase, { withFileTypes: true })
245
- .filter((e) => e.isDirectory());
246
- for (const entry of entries) {
247
- const projectDir = path.join(claudeBase, entry.name);
248
- const sessionFiles = fs
249
- .readdirSync(projectDir)
250
- .filter((f) => f.endsWith(".jsonl") || f.endsWith(".json"));
251
- claudeProjects.push({
252
- name: entry.name,
253
- sessions: sessionFiles.length,
254
- path: projectDir,
255
- source: "claude",
256
- });
257
- }
258
- } catch {
259
- /* ok */
260
- }
261
- }
262
-
263
- // Pi sessions first when pi harness is prioritised
264
- const projects = isPiHarness
265
- ? [...piProjects, ...claudeProjects]
266
- : [...claudeProjects, ...piProjects];
267
-
268
- if (projects.length === 0) {
269
- const searched: string[] = [];
270
- if (piAvailable) searched.push(piBase);
271
- else searched.push(piBase + " (not found)");
272
- if (!isPiHarness)
273
- searched.push(claudeAvailable ? claudeBase : claudeBase + " (not found)");
274
- output(
275
- {
276
- available: false,
277
- reason: `No sessions found. Searched: ${searched.join(", ")}`,
278
- projects: [],
279
- count: 0,
280
- },
281
- raw,
282
- );
283
- return;
284
- }
285
-
286
- output(
287
- {
288
- available: true,
289
- pi_base: piAvailable ? piBase : null,
290
- claude_base: claudeAvailable ? claudeBase : null,
291
- projects,
292
- count: projects.length,
293
- },
294
- raw,
295
- );
296
- }
297
-
298
- export async function cmdExtractMessages(
299
- projectArg: string,
300
- options: ExtractMessagesOptions,
301
- raw: boolean,
302
- overridePath?: string | null,
303
- ): Promise<void> {
304
- // Try pi sessions first (project dir may be a decoded cwd or a raw --path-- dir name)
305
- const piBase = getPiSessionsBasePath();
306
- let resolvedDir: string | null = null;
307
- let sessionFormat: "pi" | "claude" = "claude";
308
-
309
- if (fs.existsSync(piBase)) {
310
- // Direct match on encoded dir name (e.g. "--home-user-proj--")
311
- const direct = path.join(piBase, projectArg);
312
- if (fs.existsSync(direct)) {
313
- resolvedDir = direct;
314
- sessionFormat = "pi";
315
- } else {
316
- // Try fuzzy match: find a pi project dir whose decoded cwd ends with projectArg
317
- try {
318
- const dirs = fs
319
- .readdirSync(piBase, { withFileTypes: true })
320
- .filter((e) => e.isDirectory());
321
- for (const d of dirs) {
322
- const decoded = decodePiProjectDir(d.name);
323
- if (
324
- decoded.endsWith("/" + projectArg) ||
325
- decoded === projectArg ||
326
- d.name === projectArg
327
- ) {
328
- resolvedDir = path.join(piBase, d.name);
329
- sessionFormat = "pi";
330
- break;
331
- }
332
- }
333
- } catch {
334
- /* ok */
335
- }
336
- }
337
- }
338
-
339
- // Fall back to Claude/agent path
340
- if (!resolvedDir) {
341
- const claudeBase = getClaudeSessionsBasePath(overridePath);
342
- const claudeDir = path.join(claudeBase, projectArg);
343
- if (fs.existsSync(claudeDir)) {
344
- resolvedDir = claudeDir;
345
- sessionFormat = "claude";
346
- }
347
- }
348
-
349
- if (!resolvedDir) {
350
- output(
351
- { error: `Project not found: ${projectArg}`, available_projects: [] },
352
- raw,
353
- );
354
- return;
355
- }
356
-
357
- const messages: unknown[] = [];
358
- const sessionFiles = fs
359
- .readdirSync(resolvedDir)
360
- .filter((f) => f.endsWith(".jsonl"));
361
- const limit = options.limit ?? null;
362
-
363
- for (const file of sessionFiles) {
364
- if (options.sessionId && !file.includes(options.sessionId)) continue;
365
- const filePath = path.join(resolvedDir, file);
366
-
367
- if (sessionFormat === "pi" || isPiSessionFile(filePath)) {
368
- // Pi format: entries wrapped in {type:"message", message:{...}}
369
- try {
370
- const lines = fs
371
- .readFileSync(filePath, "utf-8")
372
- .split("\n")
373
- .filter(Boolean);
374
- for (const line of lines) {
375
- try {
376
- const entry = JSON.parse(line) as PiSessionEntry;
377
- if (entry.type === "message" && entry.message) {
378
- messages.push(entry.message);
379
- if (limit && messages.length >= limit) break;
380
- }
381
- } catch {
382
- /* skip malformed */
383
- }
384
- }
385
- } catch {
386
- /* ok */
387
- }
388
- } else {
389
- // Claude format: each line is a raw message object
390
- try {
391
- const lines = fs
392
- .readFileSync(filePath, "utf-8")
393
- .split("\n")
394
- .filter(Boolean);
395
- for (const line of lines) {
396
- try {
397
- const msg = JSON.parse(line);
398
- messages.push(msg);
399
- if (limit && messages.length >= limit) break;
400
- } catch {
401
- /* skip malformed */
402
- }
403
- }
404
- } catch {
405
- /* ok */
406
- }
407
- }
408
- if (limit && messages.length >= limit) break;
409
- }
410
-
411
- output(
412
- {
413
- project: projectArg,
414
- source: sessionFormat,
415
- messages,
416
- count: messages.length,
417
- },
418
- raw,
419
- );
420
- }
421
-
422
- export async function cmdProfileSample(
423
- overridePath: string | null | undefined,
424
- options: ProfileSampleOptions,
425
- raw: boolean,
426
- ): Promise<void> {
427
- const harness = options.harness ?? null;
428
- const isPiHarness = harness === "pi";
429
- const limit = options.limit ?? 150;
430
- const maxChars = options.maxChars ?? 500;
431
-
432
- const samples: unknown[] = [];
433
-
434
- // ── Pi sessions ──────────────────────────────────────────────────────────
435
- const piBase = getPiSessionsBasePath();
436
- if (fs.existsSync(piBase)) {
437
- try {
438
- const projects = fs
439
- .readdirSync(piBase, { withFileTypes: true })
440
- .filter((e) => e.isDirectory());
441
-
442
- outer_pi: for (const project of projects) {
443
- const projectDir = path.join(piBase, project.name);
444
- const sessionFiles = fs
445
- .readdirSync(projectDir)
446
- .filter((f) => f.endsWith(".jsonl"));
447
- let perProject = 0;
448
- for (const file of sessionFiles) {
449
- const entries = readPiSessionMessages(path.join(projectDir, file));
450
- for (const entry of entries) {
451
- if (entry.message?.role === "user") {
452
- const text = extractPiMessageText(entry.message.content).slice(
453
- 0,
454
- maxChars,
455
- );
456
- if (text.length > 20) {
457
- samples.push({
458
- project: decodePiProjectDir(project.name),
459
- text,
460
- source: "pi",
461
- });
462
- perProject++;
463
- if (
464
- options.maxPerProject &&
465
- perProject >= options.maxPerProject
466
- )
467
- break;
468
- if (samples.length >= limit) break outer_pi;
469
- }
470
- }
471
- }
472
- }
473
- }
474
- } catch {
475
- /* ok */
476
- }
477
- }
478
-
479
- // ── Claude / agent sessions ───────────────────────────────────────────────
480
- // Skip Claude scan when --harness pi and enough samples collected, but always
481
- // include Claude if an explicit --path override is given.
482
- const skipClaude = isPiHarness && !overridePath && samples.length >= limit;
483
- if (!skipClaude) {
484
- const claudeBase = getClaudeSessionsBasePath(overridePath);
485
- if (fs.existsSync(claudeBase)) {
486
- try {
487
- const projects = fs
488
- .readdirSync(claudeBase, { withFileTypes: true })
489
- .filter((e) => e.isDirectory());
490
-
491
- outer_claude: for (const project of projects) {
492
- const projectDir = path.join(claudeBase, project.name);
493
- const sessionFiles = fs
494
- .readdirSync(projectDir)
495
- .filter((f) => f.endsWith(".jsonl"));
496
- let perProject = 0;
497
- for (const file of sessionFiles) {
498
- try {
499
- const lines = fs
500
- .readFileSync(path.join(projectDir, file), "utf-8")
501
- .split("\n")
502
- .filter(Boolean);
503
- for (const line of lines) {
504
- try {
505
- const msg = JSON.parse(line);
506
- // Claude sessions: role "human"; some formats use type "human"
507
- if (msg.role === "human" || msg.type === "human") {
508
- const text = (msg.content || msg.message || "").slice(
509
- 0,
510
- maxChars,
511
- );
512
- if (text.length > 20) {
513
- samples.push({
514
- project: project.name,
515
- text,
516
- source: "claude",
517
- });
518
- perProject++;
519
- if (
520
- options.maxPerProject &&
521
- perProject >= options.maxPerProject
522
- )
523
- break;
524
- if (samples.length >= limit) break outer_claude;
525
- }
526
- }
527
- } catch {
528
- /* ok */
529
- }
530
- }
531
- } catch {
532
- /* ok */
533
- }
534
- }
535
- }
536
- } catch {
537
- /* ok */
538
- }
539
- }
540
- }
541
-
542
- if (samples.length === 0) {
543
- output(
544
- {
545
- available: false,
546
- reason: "No user messages found in any session",
547
- samples: [],
548
- count: 0,
549
- },
550
- raw,
551
- );
552
- return;
553
- }
554
-
555
- output({ available: true, samples, count: samples.length }, raw);
556
- }