gsd-pi 2.29.0-dev.77f06e2 → 2.29.0-dev.953d788

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 (80) hide show
  1. package/README.md +17 -24
  2. package/dist/resources/extensions/bg-shell/process-manager.ts +0 -13
  3. package/dist/resources/extensions/gsd/auto-dashboard.ts +65 -186
  4. package/dist/resources/extensions/gsd/auto-post-unit.ts +3 -6
  5. package/dist/resources/extensions/gsd/auto-recovery.ts +22 -16
  6. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +6 -7
  7. package/dist/resources/extensions/gsd/auto.ts +15 -0
  8. package/dist/resources/extensions/gsd/commands-handlers.ts +1 -20
  9. package/dist/resources/extensions/gsd/commands-logs.ts +14 -13
  10. package/dist/resources/extensions/gsd/commands-prefs-wizard.ts +14 -44
  11. package/dist/resources/extensions/gsd/commands.ts +22 -55
  12. package/dist/resources/extensions/gsd/dashboard-overlay.ts +1 -2
  13. package/dist/resources/extensions/gsd/json-persistence.ts +1 -16
  14. package/dist/resources/extensions/gsd/queue-order.ts +11 -10
  15. package/dist/resources/extensions/gsd/session-status-io.ts +41 -23
  16. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +1 -1
  17. package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +1 -1
  18. package/dist/resources/extensions/gsd/tests/extension-selector-separator.test.ts +38 -60
  19. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +1 -1
  20. package/dist/resources/extensions/mcporter/index.ts +525 -0
  21. package/dist/resources/extensions/remote-questions/discord-adapter.ts +19 -8
  22. package/dist/resources/extensions/remote-questions/slack-adapter.ts +17 -11
  23. package/dist/resources/extensions/remote-questions/telegram-adapter.ts +19 -8
  24. package/package.json +1 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/extensions/loader.js +0 -13
  27. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  28. package/packages/pi-coding-agent/src/core/extensions/loader.ts +0 -13
  29. package/src/resources/extensions/bg-shell/process-manager.ts +0 -13
  30. package/src/resources/extensions/gsd/auto-dashboard.ts +65 -186
  31. package/src/resources/extensions/gsd/auto-post-unit.ts +3 -6
  32. package/src/resources/extensions/gsd/auto-recovery.ts +22 -16
  33. package/src/resources/extensions/gsd/auto-worktree-sync.ts +6 -7
  34. package/src/resources/extensions/gsd/auto.ts +15 -0
  35. package/src/resources/extensions/gsd/commands-handlers.ts +1 -20
  36. package/src/resources/extensions/gsd/commands-logs.ts +14 -13
  37. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +14 -44
  38. package/src/resources/extensions/gsd/commands.ts +22 -55
  39. package/src/resources/extensions/gsd/dashboard-overlay.ts +1 -2
  40. package/src/resources/extensions/gsd/json-persistence.ts +1 -16
  41. package/src/resources/extensions/gsd/queue-order.ts +11 -10
  42. package/src/resources/extensions/gsd/session-status-io.ts +41 -23
  43. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +1 -1
  44. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +1 -1
  45. package/src/resources/extensions/gsd/tests/extension-selector-separator.test.ts +38 -60
  46. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +1 -1
  47. package/src/resources/extensions/mcporter/index.ts +525 -0
  48. package/src/resources/extensions/remote-questions/discord-adapter.ts +19 -8
  49. package/src/resources/extensions/remote-questions/slack-adapter.ts +17 -11
  50. package/src/resources/extensions/remote-questions/telegram-adapter.ts +19 -8
  51. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +0 -544
  52. package/dist/resources/extensions/gsd/prompts/workflow-start.md +0 -28
  53. package/dist/resources/extensions/gsd/tests/workflow-templates.test.ts +0 -173
  54. package/dist/resources/extensions/gsd/workflow-templates/bugfix.md +0 -87
  55. package/dist/resources/extensions/gsd/workflow-templates/dep-upgrade.md +0 -74
  56. package/dist/resources/extensions/gsd/workflow-templates/full-project.md +0 -41
  57. package/dist/resources/extensions/gsd/workflow-templates/hotfix.md +0 -45
  58. package/dist/resources/extensions/gsd/workflow-templates/refactor.md +0 -83
  59. package/dist/resources/extensions/gsd/workflow-templates/registry.json +0 -85
  60. package/dist/resources/extensions/gsd/workflow-templates/security-audit.md +0 -73
  61. package/dist/resources/extensions/gsd/workflow-templates/small-feature.md +0 -81
  62. package/dist/resources/extensions/gsd/workflow-templates/spike.md +0 -69
  63. package/dist/resources/extensions/gsd/workflow-templates.ts +0 -241
  64. package/dist/resources/extensions/mcp-client/index.ts +0 -459
  65. package/dist/resources/extensions/remote-questions/http-client.ts +0 -76
  66. package/src/resources/extensions/gsd/commands-workflow-templates.ts +0 -544
  67. package/src/resources/extensions/gsd/prompts/workflow-start.md +0 -28
  68. package/src/resources/extensions/gsd/tests/workflow-templates.test.ts +0 -173
  69. package/src/resources/extensions/gsd/workflow-templates/bugfix.md +0 -87
  70. package/src/resources/extensions/gsd/workflow-templates/dep-upgrade.md +0 -74
  71. package/src/resources/extensions/gsd/workflow-templates/full-project.md +0 -41
  72. package/src/resources/extensions/gsd/workflow-templates/hotfix.md +0 -45
  73. package/src/resources/extensions/gsd/workflow-templates/refactor.md +0 -83
  74. package/src/resources/extensions/gsd/workflow-templates/registry.json +0 -85
  75. package/src/resources/extensions/gsd/workflow-templates/security-audit.md +0 -73
  76. package/src/resources/extensions/gsd/workflow-templates/small-feature.md +0 -81
  77. package/src/resources/extensions/gsd/workflow-templates/spike.md +0 -69
  78. package/src/resources/extensions/gsd/workflow-templates.ts +0 -241
  79. package/src/resources/extensions/mcp-client/index.ts +0 -459
  80. package/src/resources/extensions/remote-questions/http-client.ts +0 -76
@@ -260,57 +260,27 @@ async function configureModels(ctx: ExtensionCommandContext, prefs: Record<strin
260
260
  group.push(m);
261
261
  }
262
262
  const providers = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b));
263
- // Sort models within each provider
264
- for (const group of byProvider.values()) {
265
- group.sort((a, b) => a.id.localeCompare(b.id));
266
- }
267
263
 
268
- // Build provider menu with model counts
269
- const providerOptions = providers.map(p => {
270
- const count = byProvider.get(p)!.length;
271
- return `${p} (${count} models)`;
272
- });
273
- providerOptions.push("(keep current)", "(clear)", "(type manually)");
264
+ const modelOptions: string[] = [];
265
+ for (const provider of providers) {
266
+ const group = byProvider.get(provider)!;
267
+ modelOptions.push(`─── ${provider} (${group.length}) ───`);
268
+ for (const m of group) {
269
+ modelOptions.push(`${m.id} · ${m.provider}`);
270
+ }
271
+ }
272
+ modelOptions.push("(keep current)", "(clear)");
274
273
 
275
274
  for (const phase of modelPhases) {
276
275
  const current = models[phase] ?? "";
277
- const phaseLabel = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}`;
278
-
279
- // Step 1: pick provider
280
- const providerChoice = await ctx.ui.select(`${phaseLabel} — choose provider:`, providerOptions);
281
- if (!providerChoice || typeof providerChoice !== "string" || providerChoice === "(keep current)") continue;
282
-
283
- if (providerChoice === "(clear)") {
284
- delete models[phase];
285
- continue;
286
- }
287
-
288
- if (providerChoice === "(type manually)") {
289
- const input = await ctx.ui.input(
290
- `${phaseLabel} — enter model ID:`,
291
- current || "e.g. claude-sonnet-4-20250514",
292
- );
293
- if (input !== null && input !== undefined) {
294
- const val = input.trim();
295
- if (val) models[phase] = val;
296
- }
297
- continue;
298
- }
299
-
300
- // Step 2: pick model within provider
301
- const providerName = providerChoice.replace(/ \(\d+ models?\)$/, "");
302
- const group = byProvider.get(providerName);
303
- if (!group) continue;
304
-
305
- const modelOptions = group.map(m => m.id);
306
- modelOptions.push("(keep current)", "(clear)");
276
+ const title = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`;
277
+ const choice = await ctx.ui.select(title, modelOptions);
307
278
 
308
- const modelChoice = await ctx.ui.select(`${phaseLabel} ${providerName}:`, modelOptions);
309
- if (modelChoice && typeof modelChoice === "string" && modelChoice !== "(keep current)") {
310
- if (modelChoice === "(clear)") {
279
+ if (choice && typeof choice === "string" && choice !== "(keep current)") {
280
+ if (choice === "(clear)") {
311
281
  delete models[phase];
312
282
  } else {
313
- models[phase] = modelChoice;
283
+ models[phase] = choice.split(" · ")[0];
314
284
  }
315
285
  }
316
286
  }
@@ -13,8 +13,7 @@ import { deriveState } from "./state.js";
13
13
  import { GSDDashboardOverlay } from "./dashboard-overlay.js";
14
14
  import { GSDVisualizerOverlay } from "./visualizer-overlay.js";
15
15
  import { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.js";
16
- import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js";
17
- import { dispatchDirectPhase } from "./auto-direct-dispatch.js";
16
+ import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, dispatchDirectPhase } from "./auto.js";
18
17
  import { resolveProjectRoot } from "./worktree.js";
19
18
  import { assertSafeDirectory } from "./validate-directory.js";
20
19
  import {
@@ -22,6 +21,8 @@ import {
22
21
  getProjectGSDPreferencesPath,
23
22
  loadEffectiveGSDPreferences,
24
23
  } from "./preferences.js";
24
+ import { loadPrompt } from "./prompt-loader.js";
25
+
25
26
  import { handleRemote } from "../remote-questions/mod.js";
26
27
  import { handleQuick } from "./quick.js";
27
28
  import { handleHistory } from "./history.js";
@@ -43,9 +44,26 @@ import { handleInspect } from "./commands-inspect.js";
43
44
  import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
44
45
  import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
45
46
  import { handleLogs } from "./commands-logs.js";
46
- import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
47
47
 
48
48
 
49
+ export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
50
+ const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
51
+ const workflow = readFileSync(workflowPath, "utf-8");
52
+ const prompt = loadPrompt("doctor-heal", {
53
+ doctorSummary: reportText,
54
+ structuredIssues,
55
+ scopeLabel: scope ?? "active milestone / blocking scope",
56
+ doctorCommandSuffix: scope ? ` ${scope}` : "",
57
+ });
58
+
59
+ const content = `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`;
60
+
61
+ pi.sendMessage(
62
+ { customType: "gsd-doctor-heal", content, display: false },
63
+ { triggerTurn: true },
64
+ );
65
+ }
66
+
49
67
  /** Resolve the effective project root, accounting for worktree paths. */
50
68
  export function projectRoot(): string {
51
69
  const root = resolveProjectRoot(process.cwd());
@@ -55,7 +73,7 @@ export function projectRoot(): string {
55
73
 
56
74
  export function registerGSDCommand(pi: ExtensionAPI): void {
57
75
  pi.registerCommand("gsd", {
58
- description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|new-milestone|parallel|update",
76
+ description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|new-milestone|parallel|update",
59
77
  getArgumentCompletions: (prefix: string) => {
60
78
  const subcommands = [
61
79
  { cmd: "help", desc: "Categorized command reference with descriptions" },
@@ -98,8 +116,6 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
98
116
  { cmd: "park", desc: "Park a milestone — skip without deleting" },
99
117
  { cmd: "unpark", desc: "Reactivate a parked milestone" },
100
118
  { cmd: "update", desc: "Update GSD to the latest version" },
101
- { cmd: "start", desc: "Start a workflow template (bugfix, spike, feature, etc.)" },
102
- { cmd: "templates", desc: "List available workflow templates" },
103
119
  ];
104
120
  const parts = prefix.trim().split(/\s+/);
105
121
 
@@ -285,42 +301,6 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
285
301
  .map((s) => ({ value: `knowledge ${s.cmd}`, label: s.cmd, description: s.desc }));
286
302
  }
287
303
 
288
- if (parts[0] === "start" && parts.length <= 2) {
289
- const subPrefix = parts[1] ?? "";
290
- const subs = [
291
- { cmd: "bugfix", desc: "Triage, fix, test, and ship a bug fix" },
292
- { cmd: "small-feature", desc: "Lightweight feature with optional discussion" },
293
- { cmd: "spike", desc: "Research, prototype, and document findings" },
294
- { cmd: "hotfix", desc: "Minimal: fix it, test it, ship it" },
295
- { cmd: "refactor", desc: "Inventory, plan waves, migrate, verify" },
296
- { cmd: "security-audit", desc: "Scan, triage, remediate, re-scan" },
297
- { cmd: "dep-upgrade", desc: "Assess, upgrade, fix breaks, verify" },
298
- { cmd: "full-project", desc: "Complete GSD workflow with full ceremony" },
299
- { cmd: "resume", desc: "Resume an in-progress workflow" },
300
- { cmd: "--list", desc: "List all available templates" },
301
- { cmd: "--dry-run", desc: "Preview workflow without executing" },
302
- ];
303
- return subs
304
- .filter((s) => s.cmd.startsWith(subPrefix))
305
- .map((s) => ({ value: `start ${s.cmd}`, label: s.cmd, description: s.desc }));
306
- }
307
-
308
- if (parts[0] === "templates" && parts.length <= 2) {
309
- const subPrefix = parts[1] ?? "";
310
- const subs = [
311
- { cmd: "info", desc: "Show detailed template info" },
312
- ];
313
- return subs
314
- .filter((s) => s.cmd.startsWith(subPrefix))
315
- .map((s) => ({ value: `templates ${s.cmd}`, label: s.cmd, description: s.desc }));
316
- }
317
-
318
- if (parts[0] === "templates" && parts[1] === "info" && parts.length <= 3) {
319
- const namePrefix = parts[2] ?? "";
320
- return getTemplateCompletions(namePrefix)
321
- .map((c) => ({ value: `templates ${c.value}`, label: c.label, description: c.description }));
322
- }
323
-
324
304
  if (parts[0] === "doctor") {
325
305
  const modePrefix = parts[1] ?? "";
326
306
  const modes = [
@@ -812,17 +792,6 @@ Examples:
812
792
  return;
813
793
  }
814
794
 
815
- // ─── Workflow Templates ────────────────────────────────────────
816
- if (trimmed === "start" || trimmed.startsWith("start ")) {
817
- await handleStart(trimmed.replace(/^start\s*/, "").trim(), ctx, pi);
818
- return;
819
- }
820
-
821
- if (trimmed === "templates" || trimmed.startsWith("templates ")) {
822
- await handleTemplates(trimmed.replace(/^templates\s*/, "").trim(), ctx);
823
- return;
824
- }
825
-
826
795
  if (trimmed === "") {
827
796
  // Bare /gsd defaults to step mode
828
797
  await startAuto(ctx, pi, projectRoot(), false, { step: true });
@@ -841,8 +810,6 @@ function showHelp(ctx: ExtensionCommandContext): void {
841
810
  const lines = [
842
811
  "GSD — Get Shit Done\n",
843
812
  "WORKFLOW",
844
- " /gsd start <tpl> Start a workflow template (bugfix, spike, feature, hotfix, etc.)",
845
- " /gsd templates List available workflow templates [info <name>]",
846
813
  " /gsd Run next unit in step mode (same as /gsd next)",
847
814
  " /gsd next Execute next task, then pause [--dry-run] [--verbose]",
848
815
  " /gsd auto Run all queued units continuously [--verbose]",
@@ -11,8 +11,7 @@ import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
11
11
  import { deriveState } from "./state.js";
12
12
  import { loadFile, parseRoadmap, parsePlan } from "./files.js";
13
13
  import { resolveMilestoneFile, resolveSliceFile } from "./paths.js";
14
- import { getAutoDashboardData } from "./auto.js";
15
- import type { AutoDashboardData } from "./auto-dashboard.js";
14
+ import { getAutoDashboardData, type AutoDashboardData } from "./auto.js";
16
15
  import {
17
16
  getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice,
18
17
  aggregateByModel, aggregateCacheHitRate, formatCost, formatTokenCount, formatCostProjection,
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
 
4
4
  /**
@@ -50,18 +50,3 @@ export function saveJsonFile<T>(filePath: string, data: T): void {
50
50
  // Non-fatal — don't let persistence failures break operation
51
51
  }
52
52
  }
53
-
54
- /**
55
- * Write a JSON file atomically (write to .tmp, then rename).
56
- * Creates parent directories as needed. Non-fatal on error.
57
- */
58
- export function writeJsonFileAtomic<T>(filePath: string, data: T): void {
59
- try {
60
- mkdirSync(dirname(filePath), { recursive: true });
61
- const tmp = filePath + ".tmp";
62
- writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
63
- renameSync(tmp, filePath);
64
- } catch {
65
- // Non-fatal — don't let persistence failures break operation
66
- }
67
- }
@@ -9,10 +9,10 @@
9
9
  * survives branch switches and is shared across sessions.
10
10
  */
11
11
 
12
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
12
13
  import { join } from "node:path";
13
14
  import { gsdRoot } from "./paths.js";
14
15
  import { milestoneIdSort } from "./milestone-ids.js";
15
- import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
16
16
 
17
17
  // ─── Types ───────────────────────────────────────────────────────────────────
18
18
 
@@ -45,12 +45,6 @@ function queueOrderPath(basePath: string): string {
45
45
  return join(gsdRoot(basePath), "QUEUE-ORDER.json");
46
46
  }
47
47
 
48
- // ─── Type Guards ─────────────────────────────────────────────────────────────
49
-
50
- function isQueueOrderFile(data: unknown): data is QueueOrderFile {
51
- return data !== null && typeof data === "object" && "order" in data! && Array.isArray((data as QueueOrderFile).order);
52
- }
53
-
54
48
  // ─── Read / Write ────────────────────────────────────────────────────────────
55
49
 
56
50
  /**
@@ -58,8 +52,15 @@ function isQueueOrderFile(data: unknown): data is QueueOrderFile {
58
52
  * the file is corrupt/unreadable.
59
53
  */
60
54
  export function loadQueueOrder(basePath: string): string[] | null {
61
- const data = loadJsonFileOrNull(queueOrderPath(basePath), isQueueOrderFile);
62
- return data?.order ?? null;
55
+ const p = queueOrderPath(basePath);
56
+ if (!existsSync(p)) return null;
57
+ try {
58
+ const data: QueueOrderFile = JSON.parse(readFileSync(p, "utf-8"));
59
+ if (!Array.isArray(data.order)) return null;
60
+ return data.order;
61
+ } catch {
62
+ return null;
63
+ }
63
64
  }
64
65
 
65
66
  /**
@@ -70,7 +71,7 @@ export function saveQueueOrder(basePath: string, order: string[]): void {
70
71
  order,
71
72
  updatedAt: new Date().toISOString(),
72
73
  };
73
- saveJsonFile(queueOrderPath(basePath), data);
74
+ writeFileSync(queueOrderPath(basePath), JSON.stringify(data, null, 2) + "\n", "utf-8");
74
75
  }
75
76
 
76
77
  // ─── Sorting ─────────────────────────────────────────────────────────────────
@@ -11,6 +11,9 @@
11
11
  */
12
12
 
13
13
  import {
14
+ writeFileSync,
15
+ readFileSync,
16
+ renameSync,
14
17
  unlinkSync,
15
18
  readdirSync,
16
19
  mkdirSync,
@@ -18,7 +21,6 @@ import {
18
21
  } from "node:fs";
19
22
  import { join } from "node:path";
20
23
  import { gsdRoot } from "./paths.js";
21
- import { loadJsonFileOrNull, writeJsonFileAtomic } from "./json-persistence.js";
22
24
 
23
25
  // ─── Types ─────────────────────────────────────────────────────────────────
24
26
 
@@ -47,16 +49,9 @@ export interface SignalMessage {
47
49
  const PARALLEL_DIR = "parallel";
48
50
  const STATUS_SUFFIX = ".status.json";
49
51
  const SIGNAL_SUFFIX = ".signal.json";
52
+ const TMP_SUFFIX = ".tmp";
50
53
  const DEFAULT_STALE_TIMEOUT_MS = 30_000;
51
54
 
52
- function isSessionStatus(data: unknown): data is SessionStatus {
53
- return data !== null && typeof data === "object" && "milestoneId" in data && "pid" in data;
54
- }
55
-
56
- function isSignalMessage(data: unknown): data is SignalMessage {
57
- return data !== null && typeof data === "object" && "signal" in data && "sentAt" in data;
58
- }
59
-
60
55
  // ─── Helpers ───────────────────────────────────────────────────────────────
61
56
 
62
57
  function parallelDir(basePath: string): string {
@@ -91,13 +86,25 @@ function isPidAlive(pid: number): boolean {
91
86
 
92
87
  /** Write session status atomically (write to .tmp, then rename). */
93
88
  export function writeSessionStatus(basePath: string, status: SessionStatus): void {
94
- ensureParallelDir(basePath);
95
- writeJsonFileAtomic(statusPath(basePath, status.milestoneId), status);
89
+ try {
90
+ ensureParallelDir(basePath);
91
+ const dest = statusPath(basePath, status.milestoneId);
92
+ const tmp = dest + TMP_SUFFIX;
93
+ writeFileSync(tmp, JSON.stringify(status, null, 2), "utf-8");
94
+ renameSync(tmp, dest);
95
+ } catch { /* non-fatal */ }
96
96
  }
97
97
 
98
98
  /** Read a specific milestone's session status. */
99
99
  export function readSessionStatus(basePath: string, milestoneId: string): SessionStatus | null {
100
- return loadJsonFileOrNull(statusPath(basePath, milestoneId), isSessionStatus);
100
+ try {
101
+ const p = statusPath(basePath, milestoneId);
102
+ if (!existsSync(p)) return null;
103
+ const raw = readFileSync(p, "utf-8");
104
+ return JSON.parse(raw) as SessionStatus;
105
+ } catch {
106
+ return null;
107
+ }
101
108
  }
102
109
 
103
110
  /** Read all session status files from .gsd/parallel/. */
@@ -107,10 +114,13 @@ export function readAllSessionStatuses(basePath: string): SessionStatus[] {
107
114
 
108
115
  const results: SessionStatus[] = [];
109
116
  try {
110
- for (const entry of readdirSync(dir)) {
117
+ const entries = readdirSync(dir);
118
+ for (const entry of entries) {
111
119
  if (!entry.endsWith(STATUS_SUFFIX)) continue;
112
- const status = loadJsonFileOrNull(join(dir, entry), isSessionStatus);
113
- if (status) results.push(status);
120
+ try {
121
+ const raw = readFileSync(join(dir, entry), "utf-8");
122
+ results.push(JSON.parse(raw) as SessionStatus);
123
+ } catch { /* skip corrupt files */ }
114
124
  }
115
125
  } catch { /* non-fatal */ }
116
126
  return results;
@@ -128,19 +138,27 @@ export function removeSessionStatus(basePath: string, milestoneId: string): void
128
138
 
129
139
  /** Write a signal file for a worker to consume. */
130
140
  export function sendSignal(basePath: string, milestoneId: string, signal: SessionSignal): void {
131
- ensureParallelDir(basePath);
132
- const msg: SignalMessage = { signal, sentAt: Date.now(), from: "coordinator" };
133
- writeJsonFileAtomic(signalPath(basePath, milestoneId), msg);
141
+ try {
142
+ ensureParallelDir(basePath);
143
+ const dest = signalPath(basePath, milestoneId);
144
+ const tmp = dest + TMP_SUFFIX;
145
+ const msg: SignalMessage = { signal, sentAt: Date.now(), from: "coordinator" };
146
+ writeFileSync(tmp, JSON.stringify(msg, null, 2), "utf-8");
147
+ renameSync(tmp, dest);
148
+ } catch { /* non-fatal */ }
134
149
  }
135
150
 
136
151
  /** Read and delete a signal file (atomic consume). Returns null if no signal pending. */
137
152
  export function consumeSignal(basePath: string, milestoneId: string): SignalMessage | null {
138
- const p = signalPath(basePath, milestoneId);
139
- const msg = loadJsonFileOrNull(p, isSignalMessage);
140
- if (msg) {
141
- try { unlinkSync(p); } catch { /* non-fatal */ }
153
+ try {
154
+ const p = signalPath(basePath, milestoneId);
155
+ if (!existsSync(p)) return null;
156
+ const raw = readFileSync(p, "utf-8");
157
+ unlinkSync(p);
158
+ return JSON.parse(raw) as SignalMessage;
159
+ } catch {
160
+ return null;
142
161
  }
143
- return msg;
144
162
  }
145
163
 
146
164
  // ─── Stale Detection ───────────────────────────────────────────────────────
@@ -5,7 +5,7 @@ import {
5
5
  getBudgetAlertLevel,
6
6
  getBudgetEnforcementAction,
7
7
  getNewBudgetAlertLevel,
8
- } from "../auto-budget.js";
8
+ } from "../auto.js";
9
9
 
10
10
  test("getBudgetAlertLevel returns the expected threshold bucket", () => {
11
11
  assert.equal(getBudgetAlertLevel(0.10), 0);
@@ -17,8 +17,8 @@ import { tmpdir } from "node:os";
17
17
  import {
18
18
  _getUnitConsecutiveSkips,
19
19
  _resetUnitConsecutiveSkips,
20
+ MAX_CONSECUTIVE_SKIPS,
20
21
  } from "../auto.ts";
21
- import { MAX_CONSECUTIVE_SKIPS } from "../auto/session.ts";
22
22
  import { persistCompletedKey, removePersistedKey, loadPersistedKeys } from "../auto-recovery.ts";
23
23
  import { createTestContext } from "./test-helpers.ts";
24
24
 
@@ -1,6 +1,4 @@
1
- // Tests for the SEPARATOR_PREFIX convention used by ExtensionSelectorComponent
2
- // and the two-step provider→model picker in configureModels.
3
- //
1
+ // Tests for the SEPARATOR_PREFIX convention used by ExtensionSelectorComponent.
4
2
  // We cannot import the component directly in node:test because its transitive
5
3
  // dependency (countdown-timer.ts) uses TypeScript parameter properties which
6
4
  // are unsupported under --experimental-strip-types. Instead we duplicate the
@@ -71,17 +69,16 @@ describe("separator detection", () => {
71
69
  });
72
70
  });
73
71
 
74
- describe("two-step provider→model picker", () => {
75
- // Simulate the grouping logic from configureModels
76
- const availableModels = [
77
- { id: "claude-opus-4-6", provider: "anthropic" },
78
- { id: "gpt-4o", provider: "openai" },
79
- { id: "claude-sonnet-4-5", provider: "anthropic" },
80
- { id: "o3-mini", provider: "openai" },
81
- { id: "claude-haiku-4-5", provider: "anthropic" },
82
- ];
72
+ describe("model grouping", () => {
73
+ test("groups models by provider with separator headers", () => {
74
+ // Simulate the grouping logic from configureModels
75
+ const availableModels = [
76
+ { id: "claude-opus-4-6", provider: "anthropic" },
77
+ { id: "gpt-4o", provider: "openai" },
78
+ { id: "claude-sonnet-4-5", provider: "anthropic" },
79
+ { id: "o3-mini", provider: "openai" },
80
+ ];
83
81
 
84
- function buildProviderGroups() {
85
82
  const byProvider = new Map<string, typeof availableModels>();
86
83
  for (const m of availableModels) {
87
84
  let group = byProvider.get(m.provider);
@@ -92,53 +89,34 @@ describe("two-step provider→model picker", () => {
92
89
  group.push(m);
93
90
  }
94
91
  const providers = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b));
95
- for (const group of byProvider.values()) {
96
- group.sort((a, b) => a.id.localeCompare(b.id));
97
- }
98
- return { byProvider, providers };
99
- }
100
-
101
- test("provider menu lists providers with model counts", () => {
102
- const { providers, byProvider } = buildProviderGroups();
103
- const providerOptions = providers.map(p => {
104
- const count = byProvider.get(p)!.length;
105
- return `${p} (${count} models)`;
106
- });
107
- providerOptions.push("(keep current)", "(clear)", "(type manually)");
108
-
109
- assert.strictEqual(providerOptions[0], "anthropic (3 models)");
110
- assert.strictEqual(providerOptions[1], "openai (2 models)");
111
- assert.strictEqual(providerOptions[2], "(keep current)");
112
- assert.strictEqual(providerOptions[3], "(clear)");
113
- assert.strictEqual(providerOptions[4], "(type manually)");
114
- });
115
-
116
- test("model menu for a provider is sorted alphabetically", () => {
117
- const { byProvider } = buildProviderGroups();
118
- const anthropicModels = byProvider.get("anthropic")!;
119
- const modelOptions = anthropicModels.map(m => m.id);
120
-
121
- assert.strictEqual(modelOptions[0], "claude-haiku-4-5");
122
- assert.strictEqual(modelOptions[1], "claude-opus-4-6");
123
- assert.strictEqual(modelOptions[2], "claude-sonnet-4-5");
124
- });
125
-
126
- test("provider name is extracted correctly from choice string", () => {
127
- const choice = "anthropic (3 models)";
128
- const providerName = choice.replace(/ \(\d+ models?\)$/, "");
129
- assert.strictEqual(providerName, "anthropic");
130
-
131
- const singleChoice = "ollama (1 model)";
132
- const singleProvider = singleChoice.replace(/ \(\d+ models?\)$/, "");
133
- assert.strictEqual(singleProvider, "ollama");
134
- });
135
92
 
136
- test("openai models are sorted within their group", () => {
137
- const { byProvider } = buildProviderGroups();
138
- const openaiModels = byProvider.get("openai")!;
139
- const modelOptions = openaiModels.map(m => m.id);
140
-
141
- assert.strictEqual(modelOptions[0], "gpt-4o");
142
- assert.strictEqual(modelOptions[1], "o3-mini");
93
+ const modelOptions: string[] = [];
94
+ for (const provider of providers) {
95
+ const group = byProvider.get(provider)!;
96
+ modelOptions.push(`${SEPARATOR_PREFIX} ${provider} (${group.length}) ${SEPARATOR_PREFIX}`);
97
+ for (const m of group) {
98
+ modelOptions.push(`${m.id} · ${m.provider}`);
99
+ }
100
+ }
101
+ modelOptions.push("(keep current)", "(clear)");
102
+
103
+ // Verify structure
104
+ assert.strictEqual(modelOptions[0], `${SEPARATOR_PREFIX} anthropic (2) ${SEPARATOR_PREFIX}`);
105
+ assert.strictEqual(modelOptions[1], "claude-opus-4-6 · anthropic");
106
+ assert.strictEqual(modelOptions[2], "claude-sonnet-4-5 · anthropic");
107
+ assert.strictEqual(modelOptions[3], `${SEPARATOR_PREFIX} openai (2) ${SEPARATOR_PREFIX}`);
108
+ assert.strictEqual(modelOptions[4], "gpt-4o · openai");
109
+ assert.strictEqual(modelOptions[5], "o3-mini · openai");
110
+ assert.strictEqual(modelOptions[6], "(keep current)");
111
+ assert.strictEqual(modelOptions[7], "(clear)");
112
+
113
+ // Verify separators are correctly detected
114
+ assert.ok(isSeparator(modelOptions, 0));
115
+ assert.ok(!isSeparator(modelOptions, 1));
116
+ assert.ok(isSeparator(modelOptions, 3));
117
+ assert.ok(!isSeparator(modelOptions, 6));
118
+
119
+ // Verify first selectable is index 1, not the separator at 0
120
+ assert.strictEqual(nextSelectable(modelOptions, 0, 1), 1);
143
121
  });
144
122
  });
@@ -30,7 +30,7 @@ import {
30
30
  getBudgetAlertLevel,
31
31
  getNewBudgetAlertLevel,
32
32
  getBudgetEnforcementAction,
33
- } from '../auto-budget.ts';
33
+ } from '../auto.ts';
34
34
  import {
35
35
  type UnitMetrics,
36
36
  type MetricsLedger,