gsd-pi 2.28.0-dev.853dfc5 → 2.28.0-dev.a8d3050

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 (73) hide show
  1. package/dist/resource-loader.js +80 -8
  2. package/dist/resources/extensions/gsd/auto-verification.ts +41 -7
  3. package/dist/resources/extensions/gsd/auto.ts +2 -2
  4. package/dist/resources/extensions/gsd/commands-handlers.ts +1 -9
  5. package/dist/resources/extensions/gsd/commands-prefs-wizard.ts +14 -22
  6. package/dist/resources/extensions/gsd/commands.ts +1 -7
  7. package/dist/resources/extensions/gsd/index.ts +2 -1
  8. package/dist/resources/extensions/gsd/json-persistence.ts +52 -0
  9. package/dist/resources/extensions/gsd/metrics.ts +17 -31
  10. package/dist/resources/extensions/gsd/paths.ts +0 -8
  11. package/dist/resources/extensions/gsd/routing-history.ts +13 -17
  12. package/dist/resources/extensions/gsd/tests/gsd-inspect.test.ts +1 -1
  13. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +26 -24
  14. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +136 -7
  15. package/dist/resources/extensions/gsd/types.ts +1 -0
  16. package/dist/resources/extensions/gsd/unit-runtime.ts +16 -13
  17. package/dist/resources/extensions/gsd/verification-evidence.ts +2 -0
  18. package/dist/resources/extensions/gsd/verification-gate.ts +13 -2
  19. package/dist/resources/extensions/remote-questions/discord-adapter.ts +2 -2
  20. package/dist/resources/extensions/remote-questions/notify.ts +1 -2
  21. package/dist/resources/extensions/remote-questions/slack-adapter.ts +1 -2
  22. package/dist/resources/extensions/remote-questions/telegram-adapter.ts +1 -2
  23. package/dist/resources/extensions/remote-questions/types.ts +3 -0
  24. package/dist/resources/extensions/shared/mod.ts +3 -0
  25. package/package.json +4 -1
  26. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +3 -0
  27. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  28. package/packages/pi-coding-agent/dist/core/settings-manager.js +8 -0
  29. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  31. package/packages/pi-coding-agent/dist/core/system-prompt.js +10 -0
  32. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  34. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +4 -1
  35. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  36. package/packages/pi-coding-agent/scripts/copy-assets.cjs +39 -8
  37. package/packages/pi-coding-agent/src/core/settings-manager.ts +11 -0
  38. package/packages/pi-coding-agent/src/core/system-prompt.ts +11 -0
  39. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +4 -1
  40. package/packages/pi-tui/dist/autocomplete.d.ts +3 -0
  41. package/packages/pi-tui/dist/autocomplete.d.ts.map +1 -1
  42. package/packages/pi-tui/dist/autocomplete.js +14 -0
  43. package/packages/pi-tui/dist/autocomplete.js.map +1 -1
  44. package/packages/pi-tui/src/autocomplete.ts +19 -1
  45. package/src/resources/extensions/gsd/auto-verification.ts +41 -7
  46. package/src/resources/extensions/gsd/auto.ts +2 -2
  47. package/src/resources/extensions/gsd/commands-handlers.ts +1 -9
  48. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +14 -22
  49. package/src/resources/extensions/gsd/commands.ts +1 -7
  50. package/src/resources/extensions/gsd/index.ts +2 -1
  51. package/src/resources/extensions/gsd/json-persistence.ts +52 -0
  52. package/src/resources/extensions/gsd/metrics.ts +17 -31
  53. package/src/resources/extensions/gsd/paths.ts +0 -8
  54. package/src/resources/extensions/gsd/routing-history.ts +13 -17
  55. package/src/resources/extensions/gsd/tests/gsd-inspect.test.ts +1 -1
  56. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +26 -24
  57. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +136 -7
  58. package/src/resources/extensions/gsd/types.ts +1 -0
  59. package/src/resources/extensions/gsd/unit-runtime.ts +16 -13
  60. package/src/resources/extensions/gsd/verification-evidence.ts +2 -0
  61. package/src/resources/extensions/gsd/verification-gate.ts +13 -2
  62. package/src/resources/extensions/remote-questions/discord-adapter.ts +2 -2
  63. package/src/resources/extensions/remote-questions/notify.ts +1 -2
  64. package/src/resources/extensions/remote-questions/slack-adapter.ts +1 -2
  65. package/src/resources/extensions/remote-questions/telegram-adapter.ts +1 -2
  66. package/src/resources/extensions/remote-questions/types.ts +3 -0
  67. package/src/resources/extensions/shared/mod.ts +3 -0
  68. package/dist/resources/extensions/gsd/preferences-hooks.ts +0 -10
  69. package/dist/resources/extensions/shared/progress-widget.ts +0 -282
  70. package/dist/resources/extensions/shared/thinking-widget.ts +0 -107
  71. package/src/resources/extensions/gsd/preferences-hooks.ts +0 -10
  72. package/src/resources/extensions/shared/progress-widget.ts +0 -282
  73. package/src/resources/extensions/shared/thinking-widget.ts +0 -107
@@ -1,6 +1,7 @@
1
1
  import { DefaultResourceLoader } from '@gsd/pi-coding-agent';
2
+ import { createHash } from 'node:crypto';
2
3
  import { homedir } from 'node:os';
3
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
5
  import { dirname, join, relative, resolve } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { compareSemver } from './update-check.js';
@@ -41,7 +42,11 @@ function getBundledGsdVersion() {
41
42
  }
42
43
  }
43
44
  function writeManagedResourceManifest(agentDir) {
44
- const manifest = { gsdVersion: getBundledGsdVersion(), syncedAt: Date.now() };
45
+ const manifest = {
46
+ gsdVersion: getBundledGsdVersion(),
47
+ syncedAt: Date.now(),
48
+ contentHash: computeResourceFingerprint(),
49
+ };
45
50
  writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest));
46
51
  }
47
52
  export function readManagedResourceVersion(agentDir) {
@@ -53,6 +58,44 @@ export function readManagedResourceVersion(agentDir) {
53
58
  return null;
54
59
  }
55
60
  }
61
+ function readManagedResourceManifest(agentDir) {
62
+ try {
63
+ return JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8'));
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * Computes a lightweight content fingerprint of the bundled resources directory.
71
+ *
72
+ * Walks all files under resourcesDir and hashes their relative paths + sizes.
73
+ * This catches same-version content changes (npm link dev workflow, hotfixes
74
+ * within a release) without the cost of reading every file's contents.
75
+ *
76
+ * ~1ms for a typical resources tree (~100 files) — just stat calls, no reads.
77
+ */
78
+ function computeResourceFingerprint() {
79
+ const entries = [];
80
+ collectFileEntries(resourcesDir, resourcesDir, entries);
81
+ entries.sort();
82
+ return createHash('sha256').update(entries.join('\n')).digest('hex').slice(0, 16);
83
+ }
84
+ function collectFileEntries(dir, root, out) {
85
+ if (!existsSync(dir))
86
+ return;
87
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
88
+ const fullPath = join(dir, entry.name);
89
+ if (entry.isDirectory()) {
90
+ collectFileEntries(fullPath, root, out);
91
+ }
92
+ else {
93
+ const rel = relative(root, fullPath);
94
+ const size = statSync(fullPath).size;
95
+ out.push(`${rel}:${size}`);
96
+ }
97
+ }
98
+ }
56
99
  export function getNewerManagedResourceVersion(agentDir, currentVersion) {
57
100
  const managedVersion = readManagedResourceVersion(agentDir);
58
101
  if (!managedVersion) {
@@ -111,10 +154,34 @@ function syncResourceDir(srcDir, destDir) {
111
154
  rmSync(target, { recursive: true, force: true });
112
155
  }
113
156
  }
114
- cpSync(srcDir, destDir, { recursive: true, force: true });
157
+ try {
158
+ cpSync(srcDir, destDir, { recursive: true, force: true });
159
+ }
160
+ catch {
161
+ // Fallback for Windows paths with non-ASCII characters where cpSync
162
+ // fails with the \\?\ extended-length prefix (#1178).
163
+ copyDirRecursive(srcDir, destDir);
164
+ }
115
165
  makeTreeWritable(destDir);
116
166
  }
117
167
  }
168
+ /**
169
+ * Recursive directory copy using copyFileSync — workaround for cpSync failures
170
+ * on Windows paths containing non-ASCII characters (#1178).
171
+ */
172
+ function copyDirRecursive(src, dest) {
173
+ mkdirSync(dest, { recursive: true });
174
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
175
+ const srcPath = join(src, entry.name);
176
+ const destPath = join(dest, entry.name);
177
+ if (entry.isDirectory()) {
178
+ copyDirRecursive(srcPath, destPath);
179
+ }
180
+ else {
181
+ copyFileSync(srcPath, destPath);
182
+ }
183
+ }
184
+ }
118
185
  /**
119
186
  * Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
120
187
  *
@@ -132,12 +199,17 @@ function syncResourceDir(srcDir, destDir) {
132
199
  */
133
200
  export function initResources(agentDir) {
134
201
  mkdirSync(agentDir, { recursive: true });
135
- // Skip the full copy when the synced version already matches the running version.
136
- // This avoids ~800ms of synchronous rmSync + cpSync on every startup.
202
+ // Skip the full copy when both version AND content fingerprint match.
203
+ // Version-only checks miss same-version content changes (npm link dev workflow,
204
+ // hotfixes within a release). The content hash catches those at ~1ms cost.
137
205
  const currentVersion = getBundledGsdVersion();
138
- const managedVersion = readManagedResourceVersion(agentDir);
139
- if (managedVersion && managedVersion === currentVersion) {
140
- return;
206
+ const manifest = readManagedResourceManifest(agentDir);
207
+ if (manifest && manifest.gsdVersion === currentVersion) {
208
+ // Version matches — check content fingerprint for same-version staleness.
209
+ const currentHash = computeResourceFingerprint();
210
+ if (manifest.contentHash && manifest.contentHash === currentHash) {
211
+ return;
212
+ }
141
213
  }
142
214
  syncResourceDir(bundledExtensionsDir, join(agentDir, 'extensions'));
143
215
  syncResourceDir(join(resourcesDir, 'agents'), join(agentDir, 'agents'));
@@ -105,19 +105,39 @@ export async function runPostUnitVerification(
105
105
  const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
106
106
 
107
107
  if (result.checks.length > 0) {
108
- const passCount = result.checks.filter(c => c.exitCode === 0).length;
109
- const total = result.checks.length;
108
+ const blockingChecks = result.checks.filter(c => c.blocking);
109
+ const advisoryChecks = result.checks.filter(c => !c.blocking);
110
+ const blockingPassCount = blockingChecks.filter(c => c.exitCode === 0).length;
111
+ const advisoryFailCount = advisoryChecks.filter(c => c.exitCode !== 0).length;
112
+
110
113
  if (result.passed) {
111
- ctx.ui.notify(`Verification gate: ${passCount}/${total} checks passed`);
114
+ let msg = blockingChecks.length > 0
115
+ ? `Verification gate: ${blockingPassCount}/${blockingChecks.length} blocking checks passed`
116
+ : `Verification gate: passed (no blocking checks)`;
117
+ if (advisoryFailCount > 0) {
118
+ msg += ` (${advisoryFailCount} advisory warning${advisoryFailCount > 1 ? "s" : ""})`;
119
+ }
120
+ ctx.ui.notify(msg);
121
+ // Log advisory warnings to stderr for visibility
122
+ if (advisoryFailCount > 0) {
123
+ const advisoryFailures = advisoryChecks.filter(c => c.exitCode !== 0);
124
+ process.stderr.write(`verification-gate: ${advisoryFailCount} advisory (non-blocking) failure(s)\n`);
125
+ for (const f of advisoryFailures) {
126
+ process.stderr.write(` [advisory] ${f.command} exited ${f.exitCode}\n`);
127
+ }
128
+ }
112
129
  } else {
113
- const failures = result.checks.filter(c => c.exitCode !== 0);
114
- const failNames = failures.map(f => f.command).join(", ");
130
+ const blockingFailures = blockingChecks.filter(c => c.exitCode !== 0);
131
+ const failNames = blockingFailures.map(f => f.command).join(", ");
115
132
  ctx.ui.notify(`Verification gate: FAILED — ${failNames}`);
116
- process.stderr.write(`verification-gate: ${total - passCount}/${total} checks failed\n`);
117
- for (const f of failures) {
133
+ process.stderr.write(`verification-gate: ${blockingFailures.length}/${blockingChecks.length} blocking checks failed\n`);
134
+ for (const f of blockingFailures) {
118
135
  process.stderr.write(` ${f.command} exited ${f.exitCode}\n`);
119
136
  if (f.stderr) process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`);
120
137
  }
138
+ if (advisoryFailCount > 0) {
139
+ process.stderr.write(`verification-gate: ${advisoryFailCount} additional advisory (non-blocking) failure(s)\n`);
140
+ }
121
141
  }
122
142
  }
123
143
 
@@ -155,6 +175,20 @@ export async function runPostUnitVerification(
155
175
  s.verificationRetryCount.delete(s.currentUnit.id);
156
176
  s.pendingVerificationRetry = null;
157
177
  return "continue";
178
+ } else if (result.discoverySource === "package-json") {
179
+ // Auto-discovered checks from package.json may fail on pre-existing errors
180
+ // that the current task didn't introduce. Don't trigger the retry loop —
181
+ // log a warning and let the task proceed (#1186).
182
+ process.stderr.write(
183
+ `verification-gate: auto-discovered checks failed (source: package-json) — treating as advisory, not blocking\n`,
184
+ );
185
+ ctx.ui.notify(
186
+ `Verification: auto-discovered checks failed (pre-existing errors likely). Continuing without retry.`,
187
+ "warning",
188
+ );
189
+ s.verificationRetryCount.delete(s.currentUnit.id);
190
+ s.pendingVerificationRetry = null;
191
+ return "continue";
158
192
  } else if (autoFixEnabled && attempt + 1 <= maxRetries) {
159
193
  const nextAttempt = attempt + 1;
160
194
  s.verificationRetryCount.set(s.currentUnit.id, nextAttempt);
@@ -1212,7 +1212,7 @@ async function dispatchNextUnit(
1212
1212
  try { process.chdir(s.basePath); } catch { /* best-effort */ }
1213
1213
  }
1214
1214
  }
1215
- } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() !== "none") {
1215
+ } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
1216
1216
  try {
1217
1217
  const currentBranch = getCurrentBranch(s.basePath);
1218
1218
  const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
@@ -1314,7 +1314,7 @@ async function dispatchNextUnit(
1314
1314
  try { process.chdir(s.basePath); } catch { /* best-effort */ }
1315
1315
  }
1316
1316
  }
1317
- } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() !== "none") {
1317
+ } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
1318
1318
  try {
1319
1319
  const currentBranch = getCurrentBranch(s.basePath);
1320
1320
  const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
@@ -20,15 +20,7 @@ import {
20
20
  } from "./doctor.js";
21
21
  import { loadPrompt } from "./prompt-loader.js";
22
22
  import { isAutoActive } from "./auto.js";
23
- import { resolveProjectRoot } from "./worktree.js";
24
- import { assertSafeDirectory } from "./validate-directory.js";
25
-
26
- /** Resolve the effective project root, accounting for worktree paths. */
27
- function projectRoot(): string {
28
- const root = resolveProjectRoot(process.cwd());
29
- assertSafeDirectory(root);
30
- return root;
31
- }
23
+ import { projectRoot } from "./commands.js";
32
24
 
33
25
  function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
34
26
  const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
@@ -22,6 +22,14 @@ import {
22
22
  import { loadFile, saveFile, splitFrontmatter, parseFrontmatterMap } from "./files.js";
23
23
  import { runClaudeImportFlow } from "./claude-import.js";
24
24
 
25
+ /** Extract body content after frontmatter closing delimiter, or null if none. */
26
+ function extractBodyAfterFrontmatter(content: string): string | null {
27
+ const closingIdx = content.indexOf("\n---", content.indexOf("---"));
28
+ if (closingIdx === -1) return null;
29
+ const afterFrontmatter = content.slice(closingIdx + 4);
30
+ return afterFrontmatter.trim() ? afterFrontmatter : null;
31
+ }
32
+
25
33
  export async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise<void> {
26
34
  const trimmed = args.trim();
27
35
 
@@ -98,12 +106,8 @@ export async function handleImportClaude(ctx: ExtensionCommandContext, scope: "g
98
106
  const frontmatter = serializePreferencesToFrontmatter(prefs);
99
107
  let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n";
100
108
  if (existsSync(path)) {
101
- const existingContent = readFileSync(path, "utf-8");
102
- const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---"));
103
- if (closingIdx !== -1) {
104
- const afterFrontmatter = existingContent.slice(closingIdx + 4);
105
- if (afterFrontmatter.trim()) body = afterFrontmatter;
106
- }
109
+ const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8"));
110
+ if (preserved) body = preserved;
107
111
  }
108
112
  await saveFile(path, `---\n${frontmatter}---${body}`);
109
113
  };
@@ -124,14 +128,8 @@ export async function handlePrefsMode(ctx: ExtensionCommandContext, scope: "glob
124
128
 
125
129
  let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n";
126
130
  if (existsSync(path)) {
127
- const existingContent = readFileSync(path, "utf-8");
128
- const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---"));
129
- if (closingIdx !== -1) {
130
- const afterFrontmatter = existingContent.slice(closingIdx + 4);
131
- if (afterFrontmatter.trim()) {
132
- body = afterFrontmatter;
133
- }
134
- }
131
+ const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8"));
132
+ if (preserved) body = preserved;
135
133
  }
136
134
 
137
135
  const content = `---\n${frontmatter}---${body}`;
@@ -622,14 +620,8 @@ export async function handlePrefsWizard(
622
620
  // Preserve existing body content (everything after closing ---)
623
621
  let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n";
624
622
  if (existsSync(path)) {
625
- const existingContent = readFileSync(path, "utf-8");
626
- const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---"));
627
- if (closingIdx !== -1) {
628
- const afterFrontmatter = existingContent.slice(closingIdx + 4); // skip past "\n---"
629
- if (afterFrontmatter.trim()) {
630
- body = afterFrontmatter;
631
- }
632
- }
623
+ const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8"));
624
+ if (preserved) body = preserved;
633
625
  }
634
626
 
635
627
  const content = `---\n${frontmatter}---${body}`;
@@ -45,12 +45,6 @@ import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun
45
45
  import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
46
46
  import { handleLogs } from "./commands-logs.js";
47
47
 
48
- // ─── Re-exports (preserve public API surface) ───────────────────────────────
49
- export { handlePrefs, handlePrefsMode, handlePrefsWizard, ensurePreferencesFile, handleImportClaude, buildCategorySummaries, serializePreferencesToFrontmatter, yamlSafeString, configureMode } from "./commands-prefs-wizard.js";
50
- export { TOOL_KEYS, loadToolApiKeys, getConfigAuthStorage, handleConfig } from "./commands-config.js";
51
- export { type InspectData, formatInspectOutput, handleInspect } from "./commands-inspect.js";
52
- export { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
53
- export { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
54
48
 
55
49
  export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
56
50
  const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
@@ -846,7 +840,7 @@ function showHelp(ctx: ExtensionCommandContext): void {
846
840
  " /gsd init Project init wizard — detect, configure, bootstrap .gsd/",
847
841
  " /gsd setup Global setup status [llm|search|remote|keys|prefs]",
848
842
  " /gsd mode Set workflow mode (solo/team) [global|project]",
849
- " /gsd prefs Manage preferences [global|project|status|wizard|setup]",
843
+ " /gsd prefs Manage preferences [global|project|status|wizard|setup|import-claude]",
850
844
  " /gsd config Set API keys for external tools",
851
845
  " /gsd keys API key manager [list|add|remove|test|rotate|doctor]",
852
846
  " /gsd hooks Show post-unit hook configuration",
@@ -27,7 +27,8 @@ import { createBashTool, createWriteTool, createReadTool, createEditTool, isTool
27
27
  import { Type } from "@sinclair/typebox";
28
28
 
29
29
  import { debugLog, debugTime } from "./debug-logger.js";
30
- import { registerGSDCommand, loadToolApiKeys } from "./commands.js";
30
+ import { registerGSDCommand } from "./commands.js";
31
+ import { loadToolApiKeys } from "./commands-config.js";
31
32
  import { registerExitCommand } from "./exit-command.js";
32
33
  import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js";
33
34
  import { getActiveAutoWorktreeContext } from "./auto-worktree.js";
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ /**
5
+ * Load a JSON file with validation, returning a default on failure.
6
+ * Handles missing files, corrupt JSON, and schema mismatches uniformly.
7
+ */
8
+ export function loadJsonFile<T>(
9
+ filePath: string,
10
+ validate: (data: unknown) => data is T,
11
+ defaultFactory: () => T,
12
+ ): T {
13
+ try {
14
+ if (!existsSync(filePath)) return defaultFactory();
15
+ const raw = readFileSync(filePath, "utf-8");
16
+ const parsed = JSON.parse(raw);
17
+ return validate(parsed) ? parsed : defaultFactory();
18
+ } catch {
19
+ return defaultFactory();
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Load a JSON file with validation, returning null on failure.
25
+ * For callers that distinguish "no data" from "default data".
26
+ */
27
+ export function loadJsonFileOrNull<T>(
28
+ filePath: string,
29
+ validate: (data: unknown) => data is T,
30
+ ): T | null {
31
+ try {
32
+ if (!existsSync(filePath)) return null;
33
+ const raw = readFileSync(filePath, "utf-8");
34
+ const parsed = JSON.parse(raw);
35
+ return validate(parsed) ? parsed : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Save a JSON file, creating parent directories as needed.
43
+ * Non-fatal — swallows errors to prevent persistence from breaking operations.
44
+ */
45
+ export function saveJsonFile<T>(filePath: string, data: T): void {
46
+ try {
47
+ mkdirSync(dirname(filePath), { recursive: true });
48
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
49
+ } catch {
50
+ // Non-fatal — don't let persistence failures break operation
51
+ }
52
+ }
@@ -13,11 +13,11 @@
13
13
  * 4. On crash recovery or fresh start, the ledger is loaded from disk
14
14
  */
15
15
 
16
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
17
16
  import { join } from "node:path";
18
17
  import type { ExtensionContext } from "@gsd/pi-coding-agent";
19
18
  import { gsdRoot } from "./paths.js";
20
19
  import { getAndClearSkills } from "./skill-telemetry.js";
20
+ import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
21
21
 
22
22
  // Re-export from shared — canonical implementation lives in format-utils.
23
23
  export { formatTokenCount } from "../shared/mod.js";
@@ -502,45 +502,31 @@ function metricsPath(base: string): string {
502
502
  return join(gsdRoot(base), "metrics.json");
503
503
  }
504
504
 
505
+ function isMetricsLedger(data: unknown): data is MetricsLedger {
506
+ return (
507
+ typeof data === "object" &&
508
+ data !== null &&
509
+ (data as MetricsLedger).version === 1 &&
510
+ Array.isArray((data as MetricsLedger).units)
511
+ );
512
+ }
513
+
514
+ function defaultLedger(): MetricsLedger {
515
+ return { version: 1, projectStartedAt: Date.now(), units: [] };
516
+ }
517
+
505
518
  /**
506
519
  * Load ledger from disk without initializing in-memory state.
507
520
  * Used by history/export commands outside of auto-mode.
508
521
  */
509
522
  export function loadLedgerFromDisk(base: string): MetricsLedger | null {
510
- try {
511
- const raw = readFileSync(metricsPath(base), "utf-8");
512
- const parsed = JSON.parse(raw);
513
- if (parsed.version === 1 && Array.isArray(parsed.units)) {
514
- return parsed as MetricsLedger;
515
- }
516
- } catch {
517
- // File doesn't exist or is corrupt
518
- }
519
- return null;
523
+ return loadJsonFileOrNull(metricsPath(base), isMetricsLedger);
520
524
  }
521
525
 
522
526
  function loadLedger(base: string): MetricsLedger {
523
- try {
524
- const raw = readFileSync(metricsPath(base), "utf-8");
525
- const parsed = JSON.parse(raw);
526
- if (parsed.version === 1 && Array.isArray(parsed.units)) {
527
- return parsed as MetricsLedger;
528
- }
529
- } catch {
530
- // File doesn't exist or is corrupt — start fresh
531
- }
532
- return {
533
- version: 1,
534
- projectStartedAt: Date.now(),
535
- units: [],
536
- };
527
+ return loadJsonFile(metricsPath(base), isMetricsLedger, defaultLedger);
537
528
  }
538
529
 
539
530
  function saveLedger(base: string, data: MetricsLedger): void {
540
- try {
541
- mkdirSync(gsdRoot(base), { recursive: true });
542
- writeFileSync(metricsPath(base), JSON.stringify(data, null, 2) + "\n", "utf-8");
543
- } catch {
544
- // Don't let metrics failures break auto-mode
545
- }
531
+ saveJsonFile(metricsPath(base), data);
546
532
  }
@@ -137,14 +137,6 @@ export function clearPathCache(): void {
137
137
 
138
138
  // ─── Name Builders ─────────────────────────────────────────────────────────
139
139
 
140
- /**
141
- * Build a directory name from an ID.
142
- * ("M001") → "M001"
143
- */
144
- export function buildDirName(id: string): string {
145
- return id;
146
- }
147
-
148
140
  /**
149
141
  * Build a milestone-level file name.
150
142
  * ("M001", "CONTEXT") → "M001-CONTEXT.md"
@@ -2,10 +2,10 @@
2
2
  // Tracks success/failure per tier per unit-type pattern to improve
3
3
  // classification accuracy over time.
4
4
 
5
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
5
  import { join } from "node:path";
7
6
  import { gsdRoot } from "./paths.js";
8
7
  import type { ComplexityTier } from "./types.js";
8
+ import { loadJsonFile, saveJsonFile } from "./json-persistence.js";
9
9
 
10
10
  // ─── Types ───────────────────────────────────────────────────────────────────
11
11
 
@@ -267,24 +267,20 @@ function historyPath(base: string): string {
267
267
  return join(gsdRoot(base), HISTORY_FILE);
268
268
  }
269
269
 
270
+ function isRoutingHistoryData(data: unknown): data is RoutingHistoryData {
271
+ return (
272
+ typeof data === "object" &&
273
+ data !== null &&
274
+ (data as RoutingHistoryData).version === 1 &&
275
+ typeof (data as RoutingHistoryData).patterns === "object" &&
276
+ (data as RoutingHistoryData).patterns !== null
277
+ );
278
+ }
279
+
270
280
  function loadHistory(base: string): RoutingHistoryData {
271
- try {
272
- const raw = readFileSync(historyPath(base), "utf-8");
273
- const parsed = JSON.parse(raw);
274
- if (parsed.version === 1 && parsed.patterns) {
275
- return parsed as RoutingHistoryData;
276
- }
277
- } catch {
278
- // File doesn't exist or is corrupt — start fresh
279
- }
280
- return createEmptyHistory();
281
+ return loadJsonFile(historyPath(base), isRoutingHistoryData, createEmptyHistory);
281
282
  }
282
283
 
283
284
  function saveHistory(base: string, data: RoutingHistoryData): void {
284
- try {
285
- mkdirSync(gsdRoot(base), { recursive: true });
286
- writeFileSync(historyPath(base), JSON.stringify(data, null, 2) + "\n", "utf-8");
287
- } catch {
288
- // Non-fatal — don't let history failures break auto-mode
289
- }
285
+ saveJsonFile(historyPath(base), data);
290
286
  }
@@ -3,7 +3,7 @@
3
3
  // Tests the pure formatInspectOutput function with known data.
4
4
 
5
5
  import { createTestContext } from './test-helpers.ts';
6
- import { formatInspectOutput, type InspectData } from '../commands.ts';
6
+ import { formatInspectOutput, type InspectData } from '../commands-inspect.ts';
7
7
 
8
8
  const { assertEq, assertTrue, assertMatch, report } = createTestContext();
9
9