gsd-pi 2.28.0-dev.e19bf89 → 2.29.0-dev.49d972f

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 (116) hide show
  1. package/dist/cli.js +15 -9
  2. package/dist/resource-loader.js +80 -8
  3. package/dist/resources/extensions/gsd/auto-post-unit.ts +9 -4
  4. package/dist/resources/extensions/gsd/auto-recovery.ts +33 -23
  5. package/dist/resources/extensions/gsd/auto-start.ts +25 -10
  6. package/dist/resources/extensions/gsd/auto-verification.ts +41 -7
  7. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +21 -6
  8. package/dist/resources/extensions/gsd/auto.ts +67 -22
  9. package/dist/resources/extensions/gsd/commands-handlers.ts +3 -11
  10. package/dist/resources/extensions/gsd/commands-logs.ts +536 -0
  11. package/dist/resources/extensions/gsd/commands-prefs-wizard.ts +46 -33
  12. package/dist/resources/extensions/gsd/commands.ts +22 -28
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +2 -1
  14. package/dist/resources/extensions/gsd/doctor-types.ts +13 -0
  15. package/dist/resources/extensions/gsd/doctor.ts +2 -6
  16. package/dist/resources/extensions/gsd/export.ts +28 -2
  17. package/dist/resources/extensions/gsd/gsd-db.ts +19 -0
  18. package/dist/resources/extensions/gsd/index.ts +2 -1
  19. package/dist/resources/extensions/gsd/json-persistence.ts +67 -0
  20. package/dist/resources/extensions/gsd/metrics.ts +17 -31
  21. package/dist/resources/extensions/gsd/paths.ts +0 -8
  22. package/dist/resources/extensions/gsd/queue-order.ts +10 -11
  23. package/dist/resources/extensions/gsd/routing-history.ts +13 -17
  24. package/dist/resources/extensions/gsd/session-lock.ts +284 -0
  25. package/dist/resources/extensions/gsd/session-status-io.ts +23 -41
  26. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +1 -1
  27. package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +1 -1
  28. package/dist/resources/extensions/gsd/tests/commands-logs.test.ts +241 -0
  29. package/dist/resources/extensions/gsd/tests/gsd-inspect.test.ts +1 -1
  30. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +1 -1
  31. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +315 -0
  32. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +55 -0
  33. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +26 -24
  34. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +136 -7
  35. package/dist/resources/extensions/gsd/types.ts +1 -0
  36. package/dist/resources/extensions/gsd/unit-runtime.ts +16 -13
  37. package/dist/resources/extensions/gsd/verification-evidence.ts +2 -0
  38. package/dist/resources/extensions/gsd/verification-gate.ts +13 -2
  39. package/dist/resources/extensions/remote-questions/discord-adapter.ts +9 -20
  40. package/dist/resources/extensions/remote-questions/http-client.ts +76 -0
  41. package/dist/resources/extensions/remote-questions/notify.ts +1 -2
  42. package/dist/resources/extensions/remote-questions/slack-adapter.ts +11 -18
  43. package/dist/resources/extensions/remote-questions/telegram-adapter.ts +8 -20
  44. package/dist/resources/extensions/remote-questions/types.ts +3 -0
  45. package/dist/resources/extensions/shared/mod.ts +3 -0
  46. package/package.json +6 -3
  47. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +3 -0
  48. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  49. package/packages/pi-coding-agent/dist/core/settings-manager.js +8 -0
  50. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  51. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/system-prompt.js +10 -0
  53. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +4 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  57. package/packages/pi-coding-agent/package.json +1 -1
  58. package/packages/pi-coding-agent/scripts/copy-assets.cjs +39 -8
  59. package/packages/pi-coding-agent/src/core/settings-manager.ts +11 -0
  60. package/packages/pi-coding-agent/src/core/system-prompt.ts +11 -0
  61. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +4 -1
  62. package/packages/pi-tui/dist/autocomplete.d.ts +3 -0
  63. package/packages/pi-tui/dist/autocomplete.d.ts.map +1 -1
  64. package/packages/pi-tui/dist/autocomplete.js +14 -0
  65. package/packages/pi-tui/dist/autocomplete.js.map +1 -1
  66. package/packages/pi-tui/src/autocomplete.ts +19 -1
  67. package/pkg/package.json +1 -1
  68. package/src/resources/extensions/gsd/auto-post-unit.ts +9 -4
  69. package/src/resources/extensions/gsd/auto-recovery.ts +33 -23
  70. package/src/resources/extensions/gsd/auto-start.ts +25 -10
  71. package/src/resources/extensions/gsd/auto-verification.ts +41 -7
  72. package/src/resources/extensions/gsd/auto-worktree-sync.ts +21 -6
  73. package/src/resources/extensions/gsd/auto.ts +67 -22
  74. package/src/resources/extensions/gsd/commands-handlers.ts +3 -11
  75. package/src/resources/extensions/gsd/commands-logs.ts +536 -0
  76. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +46 -33
  77. package/src/resources/extensions/gsd/commands.ts +22 -28
  78. package/src/resources/extensions/gsd/dashboard-overlay.ts +2 -1
  79. package/src/resources/extensions/gsd/doctor-types.ts +13 -0
  80. package/src/resources/extensions/gsd/doctor.ts +2 -6
  81. package/src/resources/extensions/gsd/export.ts +28 -2
  82. package/src/resources/extensions/gsd/gsd-db.ts +19 -0
  83. package/src/resources/extensions/gsd/index.ts +2 -1
  84. package/src/resources/extensions/gsd/json-persistence.ts +67 -0
  85. package/src/resources/extensions/gsd/metrics.ts +17 -31
  86. package/src/resources/extensions/gsd/paths.ts +0 -8
  87. package/src/resources/extensions/gsd/queue-order.ts +10 -11
  88. package/src/resources/extensions/gsd/routing-history.ts +13 -17
  89. package/src/resources/extensions/gsd/session-lock.ts +284 -0
  90. package/src/resources/extensions/gsd/session-status-io.ts +23 -41
  91. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +1 -1
  92. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +1 -1
  93. package/src/resources/extensions/gsd/tests/commands-logs.test.ts +241 -0
  94. package/src/resources/extensions/gsd/tests/gsd-inspect.test.ts +1 -1
  95. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +1 -1
  96. package/src/resources/extensions/gsd/tests/session-lock.test.ts +315 -0
  97. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +55 -0
  98. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +26 -24
  99. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +136 -7
  100. package/src/resources/extensions/gsd/types.ts +1 -0
  101. package/src/resources/extensions/gsd/unit-runtime.ts +16 -13
  102. package/src/resources/extensions/gsd/verification-evidence.ts +2 -0
  103. package/src/resources/extensions/gsd/verification-gate.ts +13 -2
  104. package/src/resources/extensions/remote-questions/discord-adapter.ts +9 -20
  105. package/src/resources/extensions/remote-questions/http-client.ts +76 -0
  106. package/src/resources/extensions/remote-questions/notify.ts +1 -2
  107. package/src/resources/extensions/remote-questions/slack-adapter.ts +11 -18
  108. package/src/resources/extensions/remote-questions/telegram-adapter.ts +8 -20
  109. package/src/resources/extensions/remote-questions/types.ts +3 -0
  110. package/src/resources/extensions/shared/mod.ts +3 -0
  111. package/dist/resources/extensions/gsd/preferences-hooks.ts +0 -10
  112. package/dist/resources/extensions/shared/progress-widget.ts +0 -282
  113. package/dist/resources/extensions/shared/thinking-widget.ts +0 -107
  114. package/src/resources/extensions/gsd/preferences-hooks.ts +0 -10
  115. package/src/resources/extensions/shared/progress-widget.ts +0 -282
  116. package/src/resources/extensions/shared/thinking-widget.ts +0 -107
package/dist/cli.js CHANGED
@@ -71,6 +71,21 @@ function parseCliArgs(argv) {
71
71
  }
72
72
  const cliFlags = parseCliArgs(process.argv);
73
73
  const isPrintMode = cliFlags.print || cliFlags.mode !== undefined;
74
+ // Early resource-skew check — must run before TTY gate so version mismatch
75
+ // errors surface even in non-TTY environments.
76
+ exitIfManagedResourcesAreNewer(agentDir);
77
+ // Early TTY check — must come before heavy initialization to avoid dangling
78
+ // handles that prevent process.exit() from completing promptly.
79
+ const hasSubcommand = cliFlags.messages.length > 0;
80
+ if (!process.stdin.isTTY && !isPrintMode && !hasSubcommand && !cliFlags.listModels) {
81
+ process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n');
82
+ process.stderr.write('[gsd] Non-interactive alternatives:\n');
83
+ process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n');
84
+ process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n');
85
+ process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n');
86
+ process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n');
87
+ process.exit(1);
88
+ }
74
89
  // `gsd <subcommand> --help` — show subcommand-specific help
75
90
  const subcommand = cliFlags.messages[0];
76
91
  if (subcommand && process.argv.includes('--help')) {
@@ -420,14 +435,5 @@ if (enabledModelPatterns && enabledModelPatterns.length > 0) {
420
435
  session.setScopedModels(scopedModels);
421
436
  }
422
437
  }
423
- if (!process.stdin.isTTY) {
424
- process.stderr.write('[gsd] Error: Interactive mode requires a terminal (TTY).\n');
425
- process.stderr.write('[gsd] Non-interactive alternatives:\n');
426
- process.stderr.write('[gsd] gsd --print "your message" Single-shot prompt\n');
427
- process.stderr.write('[gsd] gsd --mode rpc JSON-RPC over stdin/stdout\n');
428
- process.stderr.write('[gsd] gsd --mode mcp MCP server over stdin/stdout\n');
429
- process.stderr.write('[gsd] gsd --mode text "message" Text output mode\n');
430
- process.exit(1);
431
- }
432
438
  const interactiveMode = new InteractiveMode(session);
433
439
  await interactiveMode.run();
@@ -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'));
@@ -35,6 +35,7 @@ import {
35
35
  import { writeUnitRuntimeRecord, clearUnitRuntimeRecord } from "./unit-runtime.js";
36
36
  import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences } from "./preferences.js";
37
37
  import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js";
38
+ import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
38
39
  import { recordHealthSnapshot, checkHealEscalation } from "./doctor-proactive.js";
39
40
  import { syncStateToProjectRoot } from "./auto-worktree-sync.js";
40
41
  import { resetRewriteCircuitBreaker } from "./auto-dispatch.js";
@@ -154,13 +155,17 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
154
155
  ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
155
156
  }
156
157
 
157
- // Proactive health tracking
158
- const summary = summarizeDoctorIssues(report.issues);
158
+ // Proactive health tracking — exclude completion-transition codes at task level
159
+ // since they are expected after the last task and resolved by complete-slice
160
+ const issuesForHealth = effectiveFixLevel === "task"
161
+ ? report.issues.filter(i => !COMPLETION_TRANSITION_CODES.has(i.code))
162
+ : report.issues;
163
+ const summary = summarizeDoctorIssues(issuesForHealth);
159
164
  recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length);
160
165
 
161
166
  // Check if we should escalate to LLM-assisted heal
162
167
  if (summary.errors > 0) {
163
- const unresolvedErrors = report.issues
168
+ const unresolvedErrors = issuesForHealth
164
169
  .filter(i => i.severity === "error" && !i.fixable)
165
170
  .map(i => ({ code: i.code, message: i.message, unitId: i.unitId }));
166
171
  const escalation = checkHealEscalation(summary.errors, unresolvedErrors);
@@ -171,7 +176,7 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
171
176
  );
172
177
  try {
173
178
  const { formatDoctorIssuesForPrompt, formatDoctorReport } = await import("./doctor.js");
174
- const { dispatchDoctorHeal } = await import("./commands.js");
179
+ const { dispatchDoctorHeal } = await import("./commands-handlers.js");
175
180
  const actionable = report.issues.filter(i => i.severity === "error");
176
181
  const reportText = formatDoctorReport(report, { scope: doctorScope, includeWarnings: true });
177
182
  const structuredIssues = formatDoctorIssuesForPrompt(actionable);
@@ -36,8 +36,10 @@ import {
36
36
  clearPathCache,
37
37
  resolveGsdRootFile,
38
38
  } from "./paths.js";
39
+ import { isValidationTerminal } from "./state.js";
39
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
40
41
  import { atomicWriteSync } from "./atomic-write.js";
42
+ import { loadJsonFileOrNull } from "./json-persistence.js";
41
43
  import { dirname, join } from "node:path";
42
44
 
43
45
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
@@ -137,6 +139,21 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
137
139
  if (!absPath) return false;
138
140
  if (!existsSync(absPath)) return false;
139
141
 
142
+ // validate-milestone must have a VALIDATION file with a terminal verdict
143
+ // (pass, needs-attention, or needs-remediation). Without this check, a
144
+ // VALIDATION file with missing/malformed frontmatter or an unrecognized
145
+ // verdict is treated as "complete" by the artifact check but deriveState
146
+ // still returns phase:"validating-milestone" (because isValidationTerminal
147
+ // returns false), creating an infinite skip loop that hits the lifetime cap.
148
+ if (unitType === "validate-milestone") {
149
+ try {
150
+ const validationContent = readFileSync(absPath, "utf-8");
151
+ if (!isValidationTerminal(validationContent)) return false;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
140
157
  // plan-slice must produce a plan with actual task entries, not just a scaffold.
141
158
  // The plan file may exist from a prior discussion/context step with only headings
142
159
  // but no tasks. Without this check the artifact is considered "complete" and the
@@ -211,7 +228,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
211
228
  try {
212
229
  const roadmapContent = readFileSync(roadmapFile, "utf-8");
213
230
  const roadmap = parseRoadmap(roadmapContent);
214
- const slice = roadmap.slices.find(s => s.id === sid);
231
+ const slice = (roadmap.slices ?? []).find(s => s.id === sid);
215
232
  if (slice && !slice.done) return false;
216
233
  } catch {
217
234
  // Corrupt/unparseable roadmap — fail verification so the unit
@@ -338,6 +355,10 @@ export function skipExecuteTask(
338
355
 
339
356
  // ─── Disk-backed completed-unit helpers ───────────────────────────────────────
340
357
 
358
+ function isStringArray(data: unknown): data is string[] {
359
+ return Array.isArray(data) && data.every(item => typeof item === "string");
360
+ }
361
+
341
362
  /** Path to the persisted completed-unit keys file. */
342
363
  export function completedKeysPath(base: string): string {
343
364
  return join(base, ".gsd", "completed-units.json");
@@ -346,12 +367,7 @@ export function completedKeysPath(base: string): string {
346
367
  /** Write a completed unit key to disk (read-modify-write append to set). */
347
368
  export function persistCompletedKey(base: string, key: string): void {
348
369
  const file = completedKeysPath(base);
349
- let keys: string[] = [];
350
- try {
351
- if (existsSync(file)) {
352
- keys = JSON.parse(readFileSync(file, "utf-8"));
353
- }
354
- } catch (e) { /* corrupt file — start fresh */ void e; }
370
+ const keys = loadJsonFileOrNull(file, isStringArray) ?? [];
355
371
  const keySet = new Set(keys);
356
372
  if (!keySet.has(key)) {
357
373
  keys.push(key);
@@ -362,27 +378,21 @@ export function persistCompletedKey(base: string, key: string): void {
362
378
  /** Remove a stale completed unit key from disk. */
363
379
  export function removePersistedKey(base: string, key: string): void {
364
380
  const file = completedKeysPath(base);
365
- try {
366
- if (existsSync(file)) {
367
- const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
368
- const filtered = keys.filter(k => k !== key);
369
- // Only write if the key was actually present
370
- if (filtered.length !== keys.length) {
371
- atomicWriteSync(file, JSON.stringify(filtered));
372
- }
373
- }
374
- } catch (e) { /* non-fatal: removePersistedKey failure */ void e; }
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
+ }
375
387
  }
376
388
 
377
389
  /** Load all completed unit keys from disk into the in-memory set. */
378
390
  export function loadPersistedKeys(base: string, target: Set<string>): void {
379
391
  const file = completedKeysPath(base);
380
- try {
381
- if (existsSync(file)) {
382
- const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
383
- for (const k of keys) target.add(k);
384
- }
385
- } catch (e) { /* non-fatal: loadPersistedKeys failure */ void e; }
392
+ const keys = loadJsonFileOrNull(file, isStringArray);
393
+ if (keys) {
394
+ for (const k of keys) target.add(k);
395
+ }
386
396
  }
387
397
 
388
398
  // ─── Merge State Reconciliation ───────────────────────────────────────────────
@@ -26,6 +26,13 @@ import {
26
26
  import { invalidateAllCaches } from "./cache.js";
27
27
  import { synthesizeCrashRecovery } from "./session-forensics.js";
28
28
  import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js";
29
+ import {
30
+ acquireSessionLock,
31
+ updateSessionLock,
32
+ releaseSessionLock,
33
+ readSessionLockData,
34
+ isSessionLockProcessAlive,
35
+ } from "./session-lock.js";
29
36
  import { selfHealRuntimeRecords } from "./auto-recovery.js";
30
37
  import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
31
38
  import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js";
@@ -81,6 +88,18 @@ export async function bootstrapAutoSession(
81
88
  ): Promise<boolean> {
82
89
  const { shouldUseWorktreeIsolation, registerSigtermHandler, lockBase } = deps;
83
90
 
91
+ // ── Session lock: acquire FIRST, before any state mutation ──────────────
92
+ // This is the primary guard against concurrent sessions on the same project.
93
+ // Uses OS-level file locking (proper-lockfile) to prevent TOCTOU races.
94
+ const lockResult = acquireSessionLock(base);
95
+ if (!lockResult.acquired) {
96
+ ctx.ui.notify(
97
+ `${lockResult.reason}\nStop it with \`kill ${lockResult.existingPid ?? "the other process"}\` before starting a new session.`,
98
+ "error",
99
+ );
100
+ return false;
101
+ }
102
+
84
103
  // Ensure git repo exists
85
104
  if (!nativeIsRepo(base)) {
86
105
  const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
@@ -109,16 +128,11 @@ export async function bootstrapAutoSession(
109
128
  // Initialize GitServiceImpl
110
129
  s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
111
130
 
112
- // Check for crash from previous session
131
+ // Check for crash from previous session (use both old and new lock data)
113
132
  const crashLock = readCrashLock(base);
114
133
  if (crashLock) {
115
- if (isLockProcessAlive(crashLock)) {
116
- ctx.ui.notify(
117
- `Another auto-mode session (PID ${crashLock.pid}) appears to be running.\nStop it with \`kill ${crashLock.pid}\` before starting a new session.`,
118
- "error",
119
- );
120
- return false;
121
- }
134
+ // We already hold the session lock, so no concurrent session is running.
135
+ // The crash lock is from a dead process — recover context from it.
122
136
  const recoveredMid = crashLock.unitId.split("/")[0];
123
137
  const milestoneAlreadyComplete = recoveredMid
124
138
  ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
@@ -401,13 +415,14 @@ export async function bootstrapAutoSession(
401
415
  ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
402
416
  ctx.ui.setFooter(hideFooter);
403
417
  const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
404
- const pendingCount = state.registry.filter(m => m.status !== 'complete' && m.status !== 'parked').length;
418
+ const pendingCount = (state.registry ?? []).filter(m => m.status !== 'complete' && m.status !== 'parked').length;
405
419
  const scopeMsg = pendingCount > 1
406
420
  ? `Will loop through ${pendingCount} milestones.`
407
421
  : "Will loop until milestone complete.";
408
422
  ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
409
423
 
410
- // Write initial lock file
424
+ // Update lock file with milestone info (OS lock already acquired at bootstrap start)
425
+ updateSessionLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0);
411
426
  writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0);
412
427
 
413
428
  // Secrets collection gate — pause instead of blocking (#1146)
@@ -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);
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, cpSync, unlinkSync, readdirSync } from "node:fs";
14
+ import { loadJsonFileOrNull } from "./json-persistence.js";
14
15
  import { join, sep as pathSep } from "node:path";
15
16
  import { homedir } from "node:os";
16
17
  import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
@@ -36,6 +37,12 @@ export function syncProjectRootToWorktree(projectRoot: string, worktreePath: str
36
37
  // has newer artifacts (e.g. slices that don't exist in the worktree yet)
37
38
  safeCopyRecursive(join(prGsd, "milestones", milestoneId), join(wtGsd, "milestones", milestoneId))
38
39
 
40
+ // Copy living documents from project root to worktree so agents have the
41
+ // latest decisions, requirements, project state, and knowledge.
42
+ for (const doc of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md"]) {
43
+ safeCopy(join(prGsd, doc), join(wtGsd, doc), { force: true });
44
+ }
45
+
39
46
  // Delete worktree gsd.db so it rebuilds from the freshly synced files.
40
47
  // Stale DB rows are the root cause of the infinite skip loop (#853).
41
48
  try {
@@ -89,6 +96,14 @@ export function syncStateToProjectRoot(worktreePath: string, projectRoot: string
89
96
  // worktree. If the next session resolves basePath before worktree re-entry,
90
97
  // selfHeal can't find or clear the stale record (#769).
91
98
  safeCopyRecursive(join(wtGsd, "runtime", "units"), join(prGsd, "runtime", "units"), { force: true })
99
+
100
+ // 5. Living documents — decisions, requirements, project description, knowledge.
101
+ // Agents update these during slice execution. Without syncing, a new session
102
+ // reads stale copies from the project root, losing architectural decisions,
103
+ // requirement status updates, and accumulated knowledge (#1168).
104
+ for (const doc of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md"]) {
105
+ safeCopy(join(wtGsd, doc), join(prGsd, doc), { force: true });
106
+ }
92
107
  }
93
108
 
94
109
  // ─── Resource Staleness ───────────────────────────────────────────────────
@@ -98,15 +113,15 @@ export function syncStateToProjectRoot(worktreePath: string, projectRoot: string
98
113
  * Uses gsdVersion instead of syncedAt so that launching a second session
99
114
  * doesn't falsely trigger staleness (#804).
100
115
  */
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
+
101
120
  export function readResourceVersion(): string | null {
102
121
  const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
103
122
  const manifestPath = join(agentDir, "managed-resources.json");
104
- try {
105
- const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
106
- return typeof manifest?.gsdVersion === "string" ? manifest.gsdVersion : null;
107
- } catch {
108
- return null;
109
- }
123
+ const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
124
+ return manifest?.gsdVersion ?? null;
110
125
  }
111
126
 
112
127
  /**