gsd-pi 2.29.0-dev.49d972f → 2.29.0-dev.7612840

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.
@@ -176,7 +176,7 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
176
176
  );
177
177
  try {
178
178
  const { formatDoctorIssuesForPrompt, formatDoctorReport } = await import("./doctor.js");
179
- const { dispatchDoctorHeal } = await import("./commands-handlers.js");
179
+ const { dispatchDoctorHeal } = await import("./commands.js");
180
180
  const actionable = report.issues.filter(i => i.severity === "error");
181
181
  const reportText = formatDoctorReport(report, { scope: doctorScope, includeWarnings: true });
182
182
  const structuredIssues = formatDoctorIssuesForPrompt(actionable);
@@ -39,7 +39,6 @@ import {
39
39
  import { isValidationTerminal } from "./state.js";
40
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
41
41
  import { atomicWriteSync } from "./atomic-write.js";
42
- import { loadJsonFileOrNull } from "./json-persistence.js";
43
42
  import { dirname, join } from "node:path";
44
43
 
45
44
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
@@ -355,10 +354,6 @@ export function skipExecuteTask(
355
354
 
356
355
  // ─── Disk-backed completed-unit helpers ───────────────────────────────────────
357
356
 
358
- function isStringArray(data: unknown): data is string[] {
359
- return Array.isArray(data) && data.every(item => typeof item === "string");
360
- }
361
-
362
357
  /** Path to the persisted completed-unit keys file. */
363
358
  export function completedKeysPath(base: string): string {
364
359
  return join(base, ".gsd", "completed-units.json");
@@ -367,7 +362,12 @@ export function completedKeysPath(base: string): string {
367
362
  /** Write a completed unit key to disk (read-modify-write append to set). */
368
363
  export function persistCompletedKey(base: string, key: string): void {
369
364
  const file = completedKeysPath(base);
370
- const keys = loadJsonFileOrNull(file, isStringArray) ?? [];
365
+ let keys: string[] = [];
366
+ try {
367
+ if (existsSync(file)) {
368
+ keys = JSON.parse(readFileSync(file, "utf-8"));
369
+ }
370
+ } catch (e) { /* corrupt file — start fresh */ void e; }
371
371
  const keySet = new Set(keys);
372
372
  if (!keySet.has(key)) {
373
373
  keys.push(key);
@@ -378,21 +378,27 @@ export function persistCompletedKey(base: string, key: string): void {
378
378
  /** Remove a stale completed unit key from disk. */
379
379
  export function removePersistedKey(base: string, key: string): void {
380
380
  const file = completedKeysPath(base);
381
- const keys = loadJsonFileOrNull(file, isStringArray);
382
- if (!keys) return;
383
- const filtered = keys.filter(k => k !== key);
384
- if (filtered.length !== keys.length) {
385
- atomicWriteSync(file, JSON.stringify(filtered));
386
- }
381
+ try {
382
+ if (existsSync(file)) {
383
+ const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
384
+ const filtered = keys.filter(k => k !== key);
385
+ // Only write if the key was actually present
386
+ if (filtered.length !== keys.length) {
387
+ atomicWriteSync(file, JSON.stringify(filtered));
388
+ }
389
+ }
390
+ } catch (e) { /* non-fatal: removePersistedKey failure */ void e; }
387
391
  }
388
392
 
389
393
  /** Load all completed unit keys from disk into the in-memory set. */
390
394
  export function loadPersistedKeys(base: string, target: Set<string>): void {
391
395
  const file = completedKeysPath(base);
392
- const keys = loadJsonFileOrNull(file, isStringArray);
393
- if (keys) {
394
- for (const k of keys) target.add(k);
395
- }
396
+ try {
397
+ if (existsSync(file)) {
398
+ const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
399
+ for (const k of keys) target.add(k);
400
+ }
401
+ } catch (e) { /* non-fatal: loadPersistedKeys failure */ void e; }
396
402
  }
397
403
 
398
404
  // ─── Merge State Reconciliation ───────────────────────────────────────────────
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, cpSync, unlinkSync, readdirSync } from "node:fs";
14
- import { loadJsonFileOrNull } from "./json-persistence.js";
15
14
  import { join, sep as pathSep } from "node:path";
16
15
  import { homedir } from "node:os";
17
16
  import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
@@ -113,15 +112,15 @@ export function syncStateToProjectRoot(worktreePath: string, projectRoot: string
113
112
  * Uses gsdVersion instead of syncedAt so that launching a second session
114
113
  * doesn't falsely trigger staleness (#804).
115
114
  */
116
- function isManifestWithVersion(data: unknown): data is { gsdVersion: string } {
117
- return data !== null && typeof data === "object" && "gsdVersion" in data! && typeof (data as Record<string, unknown>).gsdVersion === "string";
118
- }
119
-
120
115
  export function readResourceVersion(): string | null {
121
116
  const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
122
117
  const manifestPath = join(agentDir, "managed-resources.json");
123
- const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
124
- return manifest?.gsdVersion ?? null;
118
+ try {
119
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
120
+ return typeof manifest?.gsdVersion === "string" ? manifest.gsdVersion : null;
121
+ } catch {
122
+ return null;
123
+ }
125
124
  }
126
125
 
127
126
  /**
@@ -19,26 +19,7 @@ import {
19
19
  filterDoctorIssues,
20
20
  } from "./doctor.js";
21
21
  import { isAutoActive } from "./auto.js";
22
- import { projectRoot } from "./commands.js";
23
- import { loadPrompt } from "./prompt-loader.js";
24
-
25
- export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
26
- const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
27
- const workflow = readFileSync(workflowPath, "utf-8");
28
- const prompt = loadPrompt("doctor-heal", {
29
- doctorSummary: reportText,
30
- structuredIssues,
31
- scopeLabel: scope ?? "active milestone / blocking scope",
32
- doctorCommandSuffix: scope ? ` ${scope}` : "",
33
- });
34
-
35
- const content = `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`;
36
-
37
- pi.sendMessage(
38
- { customType: "gsd-doctor-heal", content, display: false },
39
- { triggerTurn: true },
40
- );
41
- }
22
+ import { projectRoot, dispatchDoctorHeal } from "./commands.js";
42
23
 
43
24
  export async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
44
25
  const trimmed = args.trim();
@@ -14,7 +14,6 @@ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
14
14
  import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { gsdRoot } from "./paths.js";
17
- import { loadJsonFileOrNull } from "./json-persistence.js";
18
17
 
19
18
  // ─── Types ──────────────────────────────────────────────────────────────────
20
19
 
@@ -332,18 +331,20 @@ async function handleLogsList(basePath: string, ctx: ExtensionCommandContext): P
332
331
 
333
332
  // Metrics summary
334
333
  const metricsPath = join(gsdRoot(basePath), "metrics.json");
335
- const isMetrics = (d: unknown): d is { units: Array<Record<string, unknown>> } =>
336
- d !== null && typeof d === "object" && "units" in d! && Array.isArray((d as Record<string, unknown>).units);
337
- const metrics = loadJsonFileOrNull(metricsPath, isMetrics);
338
- if (metrics && metrics.units.length > 0) {
339
- const units = metrics.units;
340
- const totalCost = units.reduce((sum: number, u) => sum + ((u.cost as number) ?? 0), 0);
341
- const totalTokens = units.reduce((sum: number, u) => {
342
- const t = u.tokens as Record<string, number> | undefined;
343
- return sum + (t?.total ?? 0);
344
- }, 0);
345
- lines.push("");
346
- lines.push(`Metrics: ${units.length} units tracked · $${totalCost.toFixed(2)} · ${(totalTokens / 1000).toFixed(0)}K tokens`);
334
+ if (existsSync(metricsPath)) {
335
+ try {
336
+ const metrics = JSON.parse(readFileSync(metricsPath, "utf-8"));
337
+ const units = metrics?.units;
338
+ if (Array.isArray(units) && units.length > 0) {
339
+ const totalCost = units.reduce((sum: number, u: Record<string, unknown>) => sum + ((u.cost as number) ?? 0), 0);
340
+ const totalTokens = units.reduce((sum: number, u: Record<string, unknown>) => {
341
+ const t = u.tokens as Record<string, number> | undefined;
342
+ return sum + (t?.total ?? 0);
343
+ }, 0);
344
+ lines.push("");
345
+ lines.push(`Metrics: ${units.length} units tracked · $${totalCost.toFixed(2)} · ${(totalTokens / 1000).toFixed(0)}K tokens`);
346
+ }
347
+ } catch { /* ignore */ }
347
348
  }
348
349
 
349
350
  lines.push("");
@@ -22,6 +22,8 @@ import {
22
22
  getProjectGSDPreferencesPath,
23
23
  loadEffectiveGSDPreferences,
24
24
  } from "./preferences.js";
25
+ import { loadPrompt } from "./prompt-loader.js";
26
+
25
27
  import { handleRemote } from "../remote-questions/mod.js";
26
28
  import { handleQuick } from "./quick.js";
27
29
  import { handleHistory } from "./history.js";
@@ -45,6 +47,24 @@ import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge
45
47
  import { handleLogs } from "./commands-logs.js";
46
48
 
47
49
 
50
+ export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
51
+ const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
52
+ const workflow = readFileSync(workflowPath, "utf-8");
53
+ const prompt = loadPrompt("doctor-heal", {
54
+ doctorSummary: reportText,
55
+ structuredIssues,
56
+ scopeLabel: scope ?? "active milestone / blocking scope",
57
+ doctorCommandSuffix: scope ? ` ${scope}` : "",
58
+ });
59
+
60
+ const content = `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`;
61
+
62
+ pi.sendMessage(
63
+ { customType: "gsd-doctor-heal", content, display: false },
64
+ { triggerTurn: true },
65
+ );
66
+ }
67
+
48
68
  /** Resolve the effective project root, accounting for worktree paths. */
49
69
  export function projectRoot(): string {
50
70
  const root = resolveProjectRoot(process.cwd());
@@ -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 ───────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.29.0-dev.49d972f",
3
+ "version": "2.29.0-dev.7612840",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -176,7 +176,7 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
176
176
  );
177
177
  try {
178
178
  const { formatDoctorIssuesForPrompt, formatDoctorReport } = await import("./doctor.js");
179
- const { dispatchDoctorHeal } = await import("./commands-handlers.js");
179
+ const { dispatchDoctorHeal } = await import("./commands.js");
180
180
  const actionable = report.issues.filter(i => i.severity === "error");
181
181
  const reportText = formatDoctorReport(report, { scope: doctorScope, includeWarnings: true });
182
182
  const structuredIssues = formatDoctorIssuesForPrompt(actionable);
@@ -39,7 +39,6 @@ import {
39
39
  import { isValidationTerminal } from "./state.js";
40
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
41
41
  import { atomicWriteSync } from "./atomic-write.js";
42
- import { loadJsonFileOrNull } from "./json-persistence.js";
43
42
  import { dirname, join } from "node:path";
44
43
 
45
44
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
@@ -355,10 +354,6 @@ export function skipExecuteTask(
355
354
 
356
355
  // ─── Disk-backed completed-unit helpers ───────────────────────────────────────
357
356
 
358
- function isStringArray(data: unknown): data is string[] {
359
- return Array.isArray(data) && data.every(item => typeof item === "string");
360
- }
361
-
362
357
  /** Path to the persisted completed-unit keys file. */
363
358
  export function completedKeysPath(base: string): string {
364
359
  return join(base, ".gsd", "completed-units.json");
@@ -367,7 +362,12 @@ export function completedKeysPath(base: string): string {
367
362
  /** Write a completed unit key to disk (read-modify-write append to set). */
368
363
  export function persistCompletedKey(base: string, key: string): void {
369
364
  const file = completedKeysPath(base);
370
- const keys = loadJsonFileOrNull(file, isStringArray) ?? [];
365
+ let keys: string[] = [];
366
+ try {
367
+ if (existsSync(file)) {
368
+ keys = JSON.parse(readFileSync(file, "utf-8"));
369
+ }
370
+ } catch (e) { /* corrupt file — start fresh */ void e; }
371
371
  const keySet = new Set(keys);
372
372
  if (!keySet.has(key)) {
373
373
  keys.push(key);
@@ -378,21 +378,27 @@ export function persistCompletedKey(base: string, key: string): void {
378
378
  /** Remove a stale completed unit key from disk. */
379
379
  export function removePersistedKey(base: string, key: string): void {
380
380
  const file = completedKeysPath(base);
381
- const keys = loadJsonFileOrNull(file, isStringArray);
382
- if (!keys) return;
383
- const filtered = keys.filter(k => k !== key);
384
- if (filtered.length !== keys.length) {
385
- atomicWriteSync(file, JSON.stringify(filtered));
386
- }
381
+ try {
382
+ if (existsSync(file)) {
383
+ const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
384
+ const filtered = keys.filter(k => k !== key);
385
+ // Only write if the key was actually present
386
+ if (filtered.length !== keys.length) {
387
+ atomicWriteSync(file, JSON.stringify(filtered));
388
+ }
389
+ }
390
+ } catch (e) { /* non-fatal: removePersistedKey failure */ void e; }
387
391
  }
388
392
 
389
393
  /** Load all completed unit keys from disk into the in-memory set. */
390
394
  export function loadPersistedKeys(base: string, target: Set<string>): void {
391
395
  const file = completedKeysPath(base);
392
- const keys = loadJsonFileOrNull(file, isStringArray);
393
- if (keys) {
394
- for (const k of keys) target.add(k);
395
- }
396
+ try {
397
+ if (existsSync(file)) {
398
+ const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
399
+ for (const k of keys) target.add(k);
400
+ }
401
+ } catch (e) { /* non-fatal: loadPersistedKeys failure */ void e; }
396
402
  }
397
403
 
398
404
  // ─── Merge State Reconciliation ───────────────────────────────────────────────
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, cpSync, unlinkSync, readdirSync } from "node:fs";
14
- import { loadJsonFileOrNull } from "./json-persistence.js";
15
14
  import { join, sep as pathSep } from "node:path";
16
15
  import { homedir } from "node:os";
17
16
  import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
@@ -113,15 +112,15 @@ export function syncStateToProjectRoot(worktreePath: string, projectRoot: string
113
112
  * Uses gsdVersion instead of syncedAt so that launching a second session
114
113
  * doesn't falsely trigger staleness (#804).
115
114
  */
116
- function isManifestWithVersion(data: unknown): data is { gsdVersion: string } {
117
- return data !== null && typeof data === "object" && "gsdVersion" in data! && typeof (data as Record<string, unknown>).gsdVersion === "string";
118
- }
119
-
120
115
  export function readResourceVersion(): string | null {
121
116
  const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
122
117
  const manifestPath = join(agentDir, "managed-resources.json");
123
- const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
124
- return manifest?.gsdVersion ?? null;
118
+ try {
119
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
120
+ return typeof manifest?.gsdVersion === "string" ? manifest.gsdVersion : null;
121
+ } catch {
122
+ return null;
123
+ }
125
124
  }
126
125
 
127
126
  /**
@@ -19,26 +19,7 @@ import {
19
19
  filterDoctorIssues,
20
20
  } from "./doctor.js";
21
21
  import { isAutoActive } from "./auto.js";
22
- import { projectRoot } from "./commands.js";
23
- import { loadPrompt } from "./prompt-loader.js";
24
-
25
- export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
26
- const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
27
- const workflow = readFileSync(workflowPath, "utf-8");
28
- const prompt = loadPrompt("doctor-heal", {
29
- doctorSummary: reportText,
30
- structuredIssues,
31
- scopeLabel: scope ?? "active milestone / blocking scope",
32
- doctorCommandSuffix: scope ? ` ${scope}` : "",
33
- });
34
-
35
- const content = `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`;
36
-
37
- pi.sendMessage(
38
- { customType: "gsd-doctor-heal", content, display: false },
39
- { triggerTurn: true },
40
- );
41
- }
22
+ import { projectRoot, dispatchDoctorHeal } from "./commands.js";
42
23
 
43
24
  export async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
44
25
  const trimmed = args.trim();
@@ -14,7 +14,6 @@ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
14
14
  import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { gsdRoot } from "./paths.js";
17
- import { loadJsonFileOrNull } from "./json-persistence.js";
18
17
 
19
18
  // ─── Types ──────────────────────────────────────────────────────────────────
20
19
 
@@ -332,18 +331,20 @@ async function handleLogsList(basePath: string, ctx: ExtensionCommandContext): P
332
331
 
333
332
  // Metrics summary
334
333
  const metricsPath = join(gsdRoot(basePath), "metrics.json");
335
- const isMetrics = (d: unknown): d is { units: Array<Record<string, unknown>> } =>
336
- d !== null && typeof d === "object" && "units" in d! && Array.isArray((d as Record<string, unknown>).units);
337
- const metrics = loadJsonFileOrNull(metricsPath, isMetrics);
338
- if (metrics && metrics.units.length > 0) {
339
- const units = metrics.units;
340
- const totalCost = units.reduce((sum: number, u) => sum + ((u.cost as number) ?? 0), 0);
341
- const totalTokens = units.reduce((sum: number, u) => {
342
- const t = u.tokens as Record<string, number> | undefined;
343
- return sum + (t?.total ?? 0);
344
- }, 0);
345
- lines.push("");
346
- lines.push(`Metrics: ${units.length} units tracked · $${totalCost.toFixed(2)} · ${(totalTokens / 1000).toFixed(0)}K tokens`);
334
+ if (existsSync(metricsPath)) {
335
+ try {
336
+ const metrics = JSON.parse(readFileSync(metricsPath, "utf-8"));
337
+ const units = metrics?.units;
338
+ if (Array.isArray(units) && units.length > 0) {
339
+ const totalCost = units.reduce((sum: number, u: Record<string, unknown>) => sum + ((u.cost as number) ?? 0), 0);
340
+ const totalTokens = units.reduce((sum: number, u: Record<string, unknown>) => {
341
+ const t = u.tokens as Record<string, number> | undefined;
342
+ return sum + (t?.total ?? 0);
343
+ }, 0);
344
+ lines.push("");
345
+ lines.push(`Metrics: ${units.length} units tracked · $${totalCost.toFixed(2)} · ${(totalTokens / 1000).toFixed(0)}K tokens`);
346
+ }
347
+ } catch { /* ignore */ }
347
348
  }
348
349
 
349
350
  lines.push("");
@@ -22,6 +22,8 @@ import {
22
22
  getProjectGSDPreferencesPath,
23
23
  loadEffectiveGSDPreferences,
24
24
  } from "./preferences.js";
25
+ import { loadPrompt } from "./prompt-loader.js";
26
+
25
27
  import { handleRemote } from "../remote-questions/mod.js";
26
28
  import { handleQuick } from "./quick.js";
27
29
  import { handleHistory } from "./history.js";
@@ -45,6 +47,24 @@ import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge
45
47
  import { handleLogs } from "./commands-logs.js";
46
48
 
47
49
 
50
+ export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
51
+ const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
52
+ const workflow = readFileSync(workflowPath, "utf-8");
53
+ const prompt = loadPrompt("doctor-heal", {
54
+ doctorSummary: reportText,
55
+ structuredIssues,
56
+ scopeLabel: scope ?? "active milestone / blocking scope",
57
+ doctorCommandSuffix: scope ? ` ${scope}` : "",
58
+ });
59
+
60
+ const content = `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`;
61
+
62
+ pi.sendMessage(
63
+ { customType: "gsd-doctor-heal", content, display: false },
64
+ { triggerTurn: true },
65
+ );
66
+ }
67
+
48
68
  /** Resolve the effective project root, accounting for worktree paths. */
49
69
  export function projectRoot(): string {
50
70
  const root = resolveProjectRoot(process.cwd());
@@ -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 ───────────────────────────────────────────────────────