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.
- package/dist/resources/extensions/gsd/auto-post-unit.ts +1 -1
- package/dist/resources/extensions/gsd/auto-recovery.ts +22 -16
- package/dist/resources/extensions/gsd/auto-worktree-sync.ts +6 -7
- package/dist/resources/extensions/gsd/commands-handlers.ts +1 -20
- package/dist/resources/extensions/gsd/commands-logs.ts +14 -13
- package/dist/resources/extensions/gsd/commands.ts +20 -0
- package/dist/resources/extensions/gsd/json-persistence.ts +1 -16
- package/dist/resources/extensions/gsd/queue-order.ts +11 -10
- package/dist/resources/extensions/gsd/session-status-io.ts +41 -23
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-post-unit.ts +1 -1
- package/src/resources/extensions/gsd/auto-recovery.ts +22 -16
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +6 -7
- package/src/resources/extensions/gsd/commands-handlers.ts +1 -20
- package/src/resources/extensions/gsd/commands-logs.ts +14 -13
- package/src/resources/extensions/gsd/commands.ts +20 -0
- package/src/resources/extensions/gsd/json-persistence.ts +1 -16
- package/src/resources/extensions/gsd/queue-order.ts +11 -10
- package/src/resources/extensions/gsd/session-status-io.ts +41 -23
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
+
const entries = readdirSync(dir);
|
|
118
|
+
for (const entry of entries) {
|
|
111
119
|
if (!entry.endsWith(STATUS_SUFFIX)) continue;
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
+
const entries = readdirSync(dir);
|
|
118
|
+
for (const entry of entries) {
|
|
111
119
|
if (!entry.endsWith(STATUS_SUFFIX)) continue;
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 ───────────────────────────────────────────────────────
|