gsd-pi 2.24.0 → 2.25.0

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 (113) hide show
  1. package/README.md +2 -1
  2. package/dist/models-resolver.d.ts +0 -11
  3. package/dist/models-resolver.js +0 -15
  4. package/dist/resource-loader.d.ts +0 -1
  5. package/dist/resource-loader.js +0 -9
  6. package/dist/resources/GSD-WORKFLOW.md +12 -9
  7. package/dist/resources/extensions/bg-shell/overlay.ts +18 -17
  8. package/dist/resources/extensions/get-secrets-from-user.ts +5 -23
  9. package/dist/resources/extensions/gsd/activity-log.ts +5 -3
  10. package/dist/resources/extensions/gsd/auto-prompts.ts +14 -0
  11. package/dist/resources/extensions/gsd/auto-worktree.ts +119 -1
  12. package/dist/resources/extensions/gsd/auto.ts +184 -36
  13. package/dist/resources/extensions/gsd/cache.ts +3 -1
  14. package/dist/resources/extensions/gsd/doctor.ts +2 -0
  15. package/dist/resources/extensions/gsd/git-service.ts +74 -14
  16. package/dist/resources/extensions/gsd/gsd-db.ts +78 -1
  17. package/dist/resources/extensions/gsd/guided-flow.ts +34 -12
  18. package/dist/resources/extensions/gsd/index.ts +14 -1
  19. package/dist/resources/extensions/gsd/memory-extractor.ts +352 -0
  20. package/dist/resources/extensions/gsd/memory-store.ts +441 -0
  21. package/dist/resources/extensions/gsd/migrate/command.ts +2 -2
  22. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  23. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +2 -2
  24. package/dist/resources/extensions/gsd/prompts/discuss.md +4 -4
  25. package/dist/resources/extensions/gsd/prompts/execute-task.md +1 -1
  26. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  27. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  28. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  29. package/dist/resources/extensions/gsd/prompts/queue.md +1 -1
  30. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  31. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
  32. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +58 -0
  33. package/dist/resources/extensions/gsd/tests/git-service.test.ts +70 -4
  34. package/dist/resources/extensions/gsd/tests/gsd-db.test.ts +2 -2
  35. package/dist/resources/extensions/gsd/tests/md-importer.test.ts +2 -3
  36. package/dist/resources/extensions/gsd/tests/memory-extractor.test.ts +180 -0
  37. package/dist/resources/extensions/gsd/tests/memory-store.test.ts +345 -0
  38. package/dist/resources/extensions/gsd/tests/smart-entry-draft.test.ts +1 -1
  39. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +147 -2
  40. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +88 -10
  41. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +314 -87
  42. package/dist/resources/extensions/gsd/triage-ui.ts +1 -1
  43. package/dist/resources/extensions/gsd/visualizer-data.ts +291 -10
  44. package/dist/resources/extensions/gsd/visualizer-overlay.ts +237 -28
  45. package/dist/resources/extensions/gsd/visualizer-views.ts +462 -48
  46. package/dist/resources/extensions/gsd/worktree.ts +9 -2
  47. package/dist/resources/extensions/search-the-web/native-search.ts +15 -5
  48. package/package.json +1 -1
  49. package/packages/pi-agent-core/dist/agent-loop.js +2 -0
  50. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  51. package/packages/pi-agent-core/src/agent-loop.ts +2 -0
  52. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  53. package/packages/pi-ai/dist/providers/anthropic.js +39 -0
  54. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  55. package/packages/pi-ai/dist/providers/mistral.js +3 -0
  56. package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
  57. package/packages/pi-ai/dist/types.d.ts +23 -1
  58. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  59. package/packages/pi-ai/dist/types.js.map +1 -1
  60. package/packages/pi-ai/src/providers/anthropic.ts +38 -1
  61. package/packages/pi-ai/src/providers/mistral.ts +3 -0
  62. package/packages/pi-ai/src/types.ts +19 -1
  63. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  64. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -0
  65. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
  67. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +72 -0
  69. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  70. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +18 -0
  71. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +84 -0
  72. package/src/resources/GSD-WORKFLOW.md +12 -9
  73. package/src/resources/extensions/bg-shell/overlay.ts +18 -17
  74. package/src/resources/extensions/get-secrets-from-user.ts +5 -23
  75. package/src/resources/extensions/gsd/activity-log.ts +5 -3
  76. package/src/resources/extensions/gsd/auto-prompts.ts +14 -0
  77. package/src/resources/extensions/gsd/auto-worktree.ts +119 -1
  78. package/src/resources/extensions/gsd/auto.ts +184 -36
  79. package/src/resources/extensions/gsd/cache.ts +3 -1
  80. package/src/resources/extensions/gsd/doctor.ts +2 -0
  81. package/src/resources/extensions/gsd/git-service.ts +74 -14
  82. package/src/resources/extensions/gsd/gsd-db.ts +78 -1
  83. package/src/resources/extensions/gsd/guided-flow.ts +34 -12
  84. package/src/resources/extensions/gsd/index.ts +14 -1
  85. package/src/resources/extensions/gsd/memory-extractor.ts +352 -0
  86. package/src/resources/extensions/gsd/memory-store.ts +441 -0
  87. package/src/resources/extensions/gsd/migrate/command.ts +2 -2
  88. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -1
  89. package/src/resources/extensions/gsd/prompts/discuss-headless.md +2 -2
  90. package/src/resources/extensions/gsd/prompts/discuss.md +4 -4
  91. package/src/resources/extensions/gsd/prompts/execute-task.md +1 -1
  92. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  93. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  94. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -1
  95. package/src/resources/extensions/gsd/prompts/queue.md +1 -1
  96. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +1 -1
  97. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
  98. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +58 -0
  99. package/src/resources/extensions/gsd/tests/git-service.test.ts +70 -4
  100. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +2 -2
  101. package/src/resources/extensions/gsd/tests/md-importer.test.ts +2 -3
  102. package/src/resources/extensions/gsd/tests/memory-extractor.test.ts +180 -0
  103. package/src/resources/extensions/gsd/tests/memory-store.test.ts +345 -0
  104. package/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts +1 -1
  105. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +147 -2
  106. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +88 -10
  107. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +314 -87
  108. package/src/resources/extensions/gsd/triage-ui.ts +1 -1
  109. package/src/resources/extensions/gsd/visualizer-data.ts +291 -10
  110. package/src/resources/extensions/gsd/visualizer-overlay.ts +237 -28
  111. package/src/resources/extensions/gsd/visualizer-views.ts +462 -48
  112. package/src/resources/extensions/gsd/worktree.ts +9 -2
  113. package/src/resources/extensions/search-the-web/native-search.ts +15 -5
@@ -16,9 +16,9 @@ import type {
16
16
  ExtensionCommandContext,
17
17
  } from "@gsd/pi-coding-agent";
18
18
 
19
- import { deriveState, invalidateStateCache } from "./state.js";
19
+ import { deriveState } from "./state.js";
20
20
  import type { BudgetEnforcementMode, GSDState } from "./types.js";
21
- import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js";
21
+ import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides, parseSummary } from "./files.js";
22
22
  import { loadPrompt } from "./prompt-loader.js";
23
23
  export { inlinePriorMilestoneSummary } from "./files.js";
24
24
  import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
@@ -94,7 +94,7 @@ import {
94
94
  parseSliceBranch,
95
95
  setActiveMilestoneId,
96
96
  } from "./worktree.js";
97
- import { GitServiceImpl } from "./git-service.js";
97
+ import { GitServiceImpl, type TaskCommitContext } from "./git-service.js";
98
98
  import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
99
99
  import { formatGitError } from "./git-self-heal.js";
100
100
  import {
@@ -889,23 +889,37 @@ export async function startAuto(
889
889
  );
890
890
  return;
891
891
  }
892
- // Stale lock from a dead process — synthesize crash recovery context.
893
- const activityDir = join(gsdRoot(base), "activity");
894
- const recovery = synthesizeCrashRecovery(
895
- base, crashLock.unitType, crashLock.unitId,
896
- crashLock.sessionFile, activityDir,
897
- );
898
- if (recovery && recovery.trace.toolCallCount > 0) {
899
- pendingCrashRecovery = recovery.prompt;
892
+ // Stale lock from a dead process — validate before synthesizing recovery context.
893
+ // If the recovered unit belongs to a fully-completed milestone (SUMMARY exists),
894
+ // discard recovery context to prevent phantom skip loops (#790).
895
+ const recoveredMid = crashLock.unitId.split("/")[0];
896
+ const milestoneAlreadyComplete = recoveredMid
897
+ ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
898
+ : false;
899
+
900
+ if (milestoneAlreadyComplete) {
900
901
  ctx.ui.notify(
901
- `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
902
- "warning",
902
+ `Crash recovery: discarding stale context for ${crashLock.unitId} milestone ${recoveredMid} is already complete.`,
903
+ "info",
903
904
  );
904
905
  } else {
905
- ctx.ui.notify(
906
- `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
907
- "warning",
906
+ const activityDir = join(gsdRoot(base), "activity");
907
+ const recovery = synthesizeCrashRecovery(
908
+ base, crashLock.unitType, crashLock.unitId,
909
+ crashLock.sessionFile, activityDir,
908
910
  );
911
+ if (recovery && recovery.trace.toolCallCount > 0) {
912
+ pendingCrashRecovery = recovery.prompt;
913
+ ctx.ui.notify(
914
+ `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
915
+ "warning",
916
+ );
917
+ } else {
918
+ ctx.ui.notify(
919
+ `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
920
+ "warning",
921
+ );
922
+ }
909
923
  }
910
924
  clearLock(base);
911
925
  }
@@ -1314,12 +1328,41 @@ export async function handleAgentEnd(
1314
1328
  // Small delay to let files settle (git commits, file writes)
1315
1329
  await new Promise(r => setTimeout(r, 500));
1316
1330
 
1317
- // Auto-commit any dirty files the LLM left behind on the current branch.
1331
+ // Commit any dirty files the LLM left behind on the current branch.
1332
+ // For execute-task units, build a meaningful commit message from the
1333
+ // task summary (one-liner, key_files, inferred type). For other unit
1334
+ // types, fall back to the generic chore() message.
1318
1335
  if (currentUnit) {
1319
1336
  try {
1320
- const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
1337
+ let taskContext: TaskCommitContext | undefined;
1338
+
1339
+ if (currentUnit.type === "execute-task") {
1340
+ const parts = currentUnit.id.split("/");
1341
+ const [mid, sid, tid] = parts;
1342
+ if (mid && sid && tid) {
1343
+ const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY");
1344
+ if (summaryPath) {
1345
+ try {
1346
+ const summaryContent = await loadFile(summaryPath);
1347
+ if (summaryContent) {
1348
+ const summary = parseSummary(summaryContent);
1349
+ taskContext = {
1350
+ taskId: `${sid}/${tid}`,
1351
+ taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid,
1352
+ oneLiner: summary.oneLiner || undefined,
1353
+ keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined,
1354
+ };
1355
+ }
1356
+ } catch {
1357
+ // Non-fatal — fall back to generic message
1358
+ }
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id, taskContext);
1321
1364
  if (commitMsg) {
1322
- ctx.ui.notify(`Auto-committed uncommitted changes.`, "info");
1365
+ ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info");
1323
1366
  }
1324
1367
  } catch {
1325
1368
  // Non-fatal
@@ -1372,7 +1415,8 @@ export async function handleAgentEnd(
1372
1415
  }
1373
1416
  try {
1374
1417
  await rebuildState(basePath);
1375
- autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
1418
+ // State rebuild commit is bookkeeping — generic message is appropriate
1419
+ autoCommitCurrentBranch(basePath, "state-rebuild", currentUnit.id);
1376
1420
  } catch {
1377
1421
  // Non-fatal
1378
1422
  }
@@ -1465,7 +1509,7 @@ export async function handleAgentEnd(
1465
1509
  persistCompletedKey(basePath, completionKey);
1466
1510
  completedKeySet.add(completionKey);
1467
1511
  }
1468
- invalidateStateCache();
1512
+ invalidateAllCaches();
1469
1513
  }
1470
1514
  } catch {
1471
1515
  // Non-fatal — worst case we fall through to normal dispatch which has its own checks
@@ -1504,7 +1548,16 @@ export async function handleAgentEnd(
1504
1548
  if (currentUnit) {
1505
1549
  const modelId = ctx.model?.id ?? "unknown";
1506
1550
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
1507
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1551
+ const hookActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1552
+ if (hookActivityFile) {
1553
+ try {
1554
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1555
+ const llmCallFn = buildMemoryLLMCall(ctx);
1556
+ if (llmCallFn) {
1557
+ extractMemoriesFromUnit(hookActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1558
+ }
1559
+ } catch { /* non-fatal */ }
1560
+ }
1508
1561
  }
1509
1562
  currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
1510
1563
  writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, {
@@ -1646,7 +1699,16 @@ export async function handleAgentEnd(
1646
1699
  if (currentUnit) {
1647
1700
  const modelId = ctx.model?.id ?? "unknown";
1648
1701
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1649
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1702
+ const triageActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1703
+ if (triageActivityFile) {
1704
+ try {
1705
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1706
+ const llmCallFn = buildMemoryLLMCall(ctx);
1707
+ if (llmCallFn) {
1708
+ extractMemoriesFromUnit(triageActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1709
+ }
1710
+ } catch { /* non-fatal */ }
1711
+ }
1650
1712
  }
1651
1713
 
1652
1714
  // Dispatch triage as a new unit (early-dispatch-and-return)
@@ -1724,7 +1786,16 @@ export async function handleAgentEnd(
1724
1786
  if (currentUnit) {
1725
1787
  const modelId = ctx.model?.id ?? "unknown";
1726
1788
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1727
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1789
+ const qtActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1790
+ if (qtActivityFile) {
1791
+ try {
1792
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
1793
+ const llmCallFn = buildMemoryLLMCall(ctx);
1794
+ if (llmCallFn) {
1795
+ extractMemoriesFromUnit(qtActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
1796
+ }
1797
+ } catch { /* non-fatal */ }
1798
+ }
1728
1799
  }
1729
1800
 
1730
1801
  // Dispatch quick-task as a new unit
@@ -2418,26 +2489,61 @@ async function dispatchNextUnit(
2418
2489
  const skipCount = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2419
2490
  unitConsecutiveSkips.set(idempotencyKey, skipCount);
2420
2491
  if (skipCount > MAX_CONSECUTIVE_SKIPS) {
2492
+ // Cross-check: verify deriveState actually returns this unit (#790).
2493
+ // If the unit's milestone is already complete, this is a phantom skip
2494
+ // loop from stale crash recovery context — don't evict.
2495
+ const skippedMid = unitId.split("/")[0];
2496
+ const skippedMilestoneComplete = skippedMid
2497
+ ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
2498
+ : false;
2499
+ if (skippedMilestoneComplete) {
2500
+ // Milestone is complete — evicting this key would fight self-heal.
2501
+ // Clear skip counter and re-dispatch from fresh state.
2502
+ unitConsecutiveSkips.delete(idempotencyKey);
2503
+ invalidateAllCaches();
2504
+ ctx.ui.notify(
2505
+ `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`,
2506
+ "info",
2507
+ );
2508
+ _skipDepth++;
2509
+ await new Promise(r => setTimeout(r, 50));
2510
+ await dispatchNextUnit(ctx, pi);
2511
+ _skipDepth = Math.max(0, _skipDepth - 1);
2512
+ return;
2513
+ }
2421
2514
  unitConsecutiveSkips.delete(idempotencyKey);
2422
2515
  completedKeySet.delete(idempotencyKey);
2423
2516
  removePersistedKey(basePath, idempotencyKey);
2424
- invalidateStateCache();
2517
+ invalidateAllCaches();
2425
2518
  ctx.ui.notify(
2426
2519
  `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`,
2427
2520
  "warning",
2428
2521
  );
2522
+ if (!active) return;
2429
2523
  _skipDepth++;
2430
- await new Promise(r => setTimeout(r, 50));
2524
+ await new Promise(r => setTimeout(r, 150));
2431
2525
  await dispatchNextUnit(ctx, pi);
2432
2526
  _skipDepth = Math.max(0, _skipDepth - 1);
2433
2527
  return;
2434
2528
  }
2529
+ // Count toward lifetime cap so hard-stop fires during skip loops (#792)
2530
+ const lifeSkip = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
2531
+ unitLifetimeDispatches.set(idempotencyKey, lifeSkip);
2532
+ if (lifeSkip > MAX_LIFETIME_DISPATCHES) {
2533
+ await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`);
2534
+ ctx.ui.notify(
2535
+ `Hard loop detected: ${unitType} ${unitId} hit lifetime cap during skip cycle (${lifeSkip} iterations).`,
2536
+ "error",
2537
+ );
2538
+ return;
2539
+ }
2435
2540
  ctx.ui.notify(
2436
2541
  `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
2437
2542
  "info",
2438
2543
  );
2544
+ if (!active) return;
2439
2545
  _skipDepth++;
2440
- await new Promise(r => setTimeout(r, 50));
2546
+ await new Promise(r => setTimeout(r, 150));
2441
2547
  await dispatchNextUnit(ctx, pi);
2442
2548
  _skipDepth = Math.max(0, _skipDepth - 1);
2443
2549
  return;
@@ -2460,31 +2566,62 @@ async function dispatchNextUnit(
2460
2566
  if (verifyExpectedArtifact(unitType, unitId, basePath)) {
2461
2567
  persistCompletedKey(basePath, idempotencyKey);
2462
2568
  completedKeySet.add(idempotencyKey);
2463
- invalidateStateCache();
2569
+ invalidateAllCaches();
2464
2570
  // Same consecutive-skip guard as the idempotency path above.
2465
2571
  const skipCount2 = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
2466
2572
  unitConsecutiveSkips.set(idempotencyKey, skipCount2);
2467
2573
  if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
2574
+ // Cross-check: verify the unit's milestone is still active (#790).
2575
+ const skippedMid2 = unitId.split("/")[0];
2576
+ const skippedMilestoneComplete2 = skippedMid2
2577
+ ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
2578
+ : false;
2579
+ if (skippedMilestoneComplete2) {
2580
+ unitConsecutiveSkips.delete(idempotencyKey);
2581
+ invalidateAllCaches();
2582
+ ctx.ui.notify(
2583
+ `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`,
2584
+ "info",
2585
+ );
2586
+ _skipDepth++;
2587
+ await new Promise(r => setTimeout(r, 50));
2588
+ await dispatchNextUnit(ctx, pi);
2589
+ _skipDepth = Math.max(0, _skipDepth - 1);
2590
+ return;
2591
+ }
2468
2592
  unitConsecutiveSkips.delete(idempotencyKey);
2469
2593
  completedKeySet.delete(idempotencyKey);
2470
2594
  removePersistedKey(basePath, idempotencyKey);
2471
- invalidateStateCache();
2595
+ invalidateAllCaches();
2472
2596
  ctx.ui.notify(
2473
2597
  `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`,
2474
2598
  "warning",
2475
2599
  );
2600
+ if (!active) return;
2476
2601
  _skipDepth++;
2477
- await new Promise(r => setTimeout(r, 50));
2602
+ await new Promise(r => setTimeout(r, 150));
2478
2603
  await dispatchNextUnit(ctx, pi);
2479
2604
  _skipDepth = Math.max(0, _skipDepth - 1);
2480
2605
  return;
2481
2606
  }
2607
+ // Count toward lifetime cap so hard-stop fires during skip loops (#792)
2608
+ const lifeSkip2 = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
2609
+ unitLifetimeDispatches.set(idempotencyKey, lifeSkip2);
2610
+ if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) {
2611
+ await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`);
2612
+ ctx.ui.notify(
2613
+ `Hard loop detected: ${unitType} ${unitId} hit lifetime cap during skip cycle (${lifeSkip2} iterations).`,
2614
+ "error",
2615
+ );
2616
+ return;
2617
+ }
2482
2618
  ctx.ui.notify(
2483
2619
  `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
2484
2620
  "info",
2485
2621
  );
2622
+ if (!active) return;
2486
2623
  _skipDepth++;
2487
- await new Promise(r => setTimeout(r, 50));
2624
+ await new Promise(r => setTimeout(r, 150));
2488
2625
  await dispatchNextUnit(ctx, pi);
2489
2626
  _skipDepth = Math.max(0, _skipDepth - 1);
2490
2627
  return;
@@ -2554,7 +2691,7 @@ async function dispatchNextUnit(
2554
2691
  persistCompletedKey(basePath, reconciledKey);
2555
2692
  completedKeySet.add(reconciledKey);
2556
2693
  unitDispatchCount.delete(dispatchKey);
2557
- invalidateStateCache();
2694
+ invalidateAllCaches();
2558
2695
  await new Promise(r => setImmediate(r));
2559
2696
  await dispatchNextUnit(ctx, pi);
2560
2697
  return;
@@ -2581,7 +2718,7 @@ async function dispatchNextUnit(
2581
2718
  persistCompletedKey(basePath, dispatchKey);
2582
2719
  completedKeySet.add(dispatchKey);
2583
2720
  unitDispatchCount.delete(dispatchKey);
2584
- invalidateStateCache();
2721
+ invalidateAllCaches();
2585
2722
  await new Promise(r => setImmediate(r));
2586
2723
  await dispatchNextUnit(ctx, pi);
2587
2724
  return;
@@ -2601,7 +2738,7 @@ async function dispatchNextUnit(
2601
2738
  persistCompletedKey(basePath, dispatchKey);
2602
2739
  completedKeySet.add(dispatchKey);
2603
2740
  unitDispatchCount.delete(dispatchKey);
2604
- invalidateStateCache();
2741
+ invalidateAllCaches();
2605
2742
  await new Promise(r => setImmediate(r));
2606
2743
  await dispatchNextUnit(ctx, pi);
2607
2744
  return;
@@ -2642,7 +2779,7 @@ async function dispatchNextUnit(
2642
2779
  persistCompletedKey(basePath, repairedKey);
2643
2780
  completedKeySet.add(repairedKey);
2644
2781
  unitDispatchCount.delete(dispatchKey);
2645
- invalidateStateCache();
2782
+ invalidateAllCaches();
2646
2783
  await new Promise(r => setImmediate(r));
2647
2784
  await dispatchNextUnit(ctx, pi);
2648
2785
  return;
@@ -2686,7 +2823,18 @@ async function dispatchNextUnit(
2686
2823
  if (currentUnit) {
2687
2824
  const modelId = ctx.model?.id ?? "unknown";
2688
2825
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2689
- saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2826
+ const activityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2827
+
2828
+ // Fire-and-forget memory extraction from completed unit
2829
+ if (activityFile) {
2830
+ try {
2831
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
2832
+ const llmCallFn = buildMemoryLLMCall(ctx);
2833
+ if (llmCallFn) {
2834
+ extractMemoriesFromUnit(activityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
2835
+ }
2836
+ } catch { /* non-fatal */ }
2837
+ }
2690
2838
 
2691
2839
  // Record routing outcome for adaptive learning
2692
2840
  if (currentUnitRouting) {
@@ -12,16 +12,18 @@
12
12
  import { invalidateStateCache } from './state.js';
13
13
  import { clearPathCache } from './paths.js';
14
14
  import { clearParseCache } from './files.js';
15
+ import { clearArtifacts } from './gsd-db.js';
15
16
 
16
17
  /**
17
18
  * Invalidate all GSD runtime caches in one call.
18
19
  *
19
20
  * Call this after file writes, milestone transitions, merge reconciliation,
20
21
  * or any operation that changes .gsd/ contents on disk. Forgetting to clear
21
- * any single cache causes stale reads (see #431).
22
+ * any single cache causes stale reads (see #431, #793).
22
23
  */
23
24
  export function invalidateAllCaches(): void {
24
25
  invalidateStateCache();
25
26
  clearPathCache();
26
27
  clearParseCache();
28
+ clearArtifacts();
27
29
  }
@@ -4,6 +4,7 @@ import { join, sep } from "node:path";
4
4
  import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
5
5
  import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
6
6
  import { deriveState, isMilestoneComplete } from "./state.js";
7
+ import { invalidateAllCaches } from "./cache.js";
7
8
  import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js";
8
9
  import { listWorktrees, resolveGitDir } from "./worktree-manager.js";
9
10
  import { abortAndReset } from "./git-self-heal.js";
@@ -200,6 +201,7 @@ async function updateStateFile(basePath: string, fixesApplied: string[]): Promis
200
201
 
201
202
  /** Rebuild STATE.md from current disk state. Exported for auto-mode post-hooks. */
202
203
  export async function rebuildState(basePath: string): Promise<void> {
204
+ invalidateAllCaches();
203
205
  const state = await deriveState(basePath);
204
206
  const path = resolveGsdRootFile(basePath, "STATE");
205
207
  await saveFile(path, buildStateMarkdown(state));
@@ -68,6 +68,50 @@ export interface CommitOptions {
68
68
  allowEmpty?: boolean;
69
69
  }
70
70
 
71
+ // ─── Meaningful Commit Message Generation ───────────────────────────────────
72
+
73
+ /** Context for generating a meaningful commit message from task execution results. */
74
+ export interface TaskCommitContext {
75
+ taskId: string;
76
+ taskTitle: string;
77
+ /** The one-liner from the task summary (e.g. "Added retry-aware worker status logging") */
78
+ oneLiner?: string;
79
+ /** Files modified by this task (from task summary frontmatter) */
80
+ keyFiles?: string[];
81
+ }
82
+
83
+ /**
84
+ * Build a meaningful conventional commit message from task execution context.
85
+ * Format: `{type}({sliceId}/{taskId}): {description}`
86
+ *
87
+ * The description is the task summary one-liner if available (it describes
88
+ * what was actually built), falling back to the task title (what was planned).
89
+ */
90
+ export function buildTaskCommitMessage(ctx: TaskCommitContext): string {
91
+ const scope = ctx.taskId; // e.g. "S01/T02" or just "T02"
92
+ const description = ctx.oneLiner || ctx.taskTitle;
93
+ const type = inferCommitType(ctx.taskTitle, ctx.oneLiner);
94
+
95
+ // Truncate description to ~72 chars for subject line
96
+ const maxDescLen = 68 - type.length - scope.length;
97
+ const truncated = description.length > maxDescLen
98
+ ? description.slice(0, maxDescLen - 1).trimEnd() + "…"
99
+ : description;
100
+
101
+ const subject = `${type}(${scope}): ${truncated}`;
102
+
103
+ // Build body with key files if available
104
+ if (ctx.keyFiles && ctx.keyFiles.length > 0) {
105
+ const fileLines = ctx.keyFiles
106
+ .slice(0, 8) // cap at 8 files to keep commit concise
107
+ .map(f => `- ${f}`)
108
+ .join("\n");
109
+ return `${subject}\n\n${fileLines}`;
110
+ }
111
+
112
+ return subject;
113
+ }
114
+
71
115
  /**
72
116
  * Thrown when a slice merge hits code conflicts in non-.gsd files.
73
117
  * The working tree is left in a conflicted state (no reset) so the
@@ -253,18 +297,14 @@ export function runGit(basePath: string, args: string[], options: { allowFailure
253
297
  * Each entry: [keywords[], commitType]
254
298
  */
255
299
  const COMMIT_TYPE_RULES: [string[], string][] = [
256
- [["fix", "bug", "patch", "hotfix"], "fix"],
300
+ [["fix", "fixed", "fixes", "bug", "patch", "hotfix", "repair", "correct"], "fix"],
257
301
  [["refactor", "restructure", "reorganize"], "refactor"],
258
- [["doc", "docs", "documentation"], "docs"],
259
- [["test", "tests", "testing"], "test"],
260
- [["chore", "cleanup", "clean up", "archive", "remove", "delete"], "chore"],
302
+ [["doc", "docs", "documentation", "readme", "changelog"], "docs"],
303
+ [["test", "tests", "testing", "spec", "coverage"], "test"],
304
+ [["perf", "performance", "optimize", "speed", "cache"], "perf"],
305
+ [["chore", "cleanup", "clean up", "dependencies", "deps", "bump", "config", "ci", "archive", "remove", "delete"], "chore"],
261
306
  ];
262
307
 
263
- /**
264
- * Infer a conventional commit type from a slice title.
265
- * Uses case-insensitive word-boundary matching against known keywords.
266
- * Returns "feat" when no keywords match.
267
- */
268
308
  // ─── GitServiceImpl ────────────────────────────────────────────────────
269
309
 
270
310
  export class GitServiceImpl {
@@ -356,11 +396,22 @@ export class GitServiceImpl {
356
396
  }
357
397
 
358
398
  /**
359
- * Auto-commit dirty working tree with a conventional chore message.
399
+ * Auto-commit dirty working tree.
400
+ *
401
+ * When `taskContext` is provided, generates a meaningful conventional commit
402
+ * message from the task execution results (one-liner, title, inferred type).
403
+ * Falls back to a generic `chore()` message when no context is available
404
+ * (e.g. pre-switch commits, stop commits, state rebuild commits).
405
+ *
360
406
  * Returns the commit message on success, or null if nothing to commit.
361
407
  * @param extraExclusions Additional paths to exclude from staging (e.g. [".gsd/"] for pre-switch commits).
362
408
  */
363
- autoCommit(unitType: string, unitId: string, extraExclusions: readonly string[] = []): string | null {
409
+ autoCommit(
410
+ unitType: string,
411
+ unitId: string,
412
+ extraExclusions: readonly string[] = [],
413
+ taskContext?: TaskCommitContext,
414
+ ): string | null {
364
415
  // Quick check: is there anything dirty at all?
365
416
  // Native path uses libgit2 (single syscall), fallback spawns git.
366
417
  if (!nativeHasChanges(this.basePath)) return null;
@@ -371,7 +422,9 @@ export class GitServiceImpl {
371
422
  // (all changes might have been runtime files that got excluded)
372
423
  if (!nativeHasStagedChanges(this.basePath)) return null;
373
424
 
374
- const message = `chore(${unitId}): auto-commit after ${unitType}`;
425
+ const message = taskContext
426
+ ? buildTaskCommitMessage(taskContext)
427
+ : `chore(${unitId}): auto-commit after ${unitType}`;
375
428
  nativeCommit(this.basePath, message, { allowEmpty: false });
376
429
  return message;
377
430
  }
@@ -497,8 +550,15 @@ export class GitServiceImpl {
497
550
 
498
551
  // ─── Commit Type Inference ─────────────────────────────────────────────────
499
552
 
500
- export function inferCommitType(sliceTitle: string): string {
501
- const lower = sliceTitle.toLowerCase();
553
+ /**
554
+ * Infer a conventional commit type from a title (and optional one-liner).
555
+ * Uses case-insensitive word-boundary matching against known keywords.
556
+ * Returns "feat" when no keywords match.
557
+ *
558
+ * Used for both slice squash-merge titles and task commit messages.
559
+ */
560
+ export function inferCommitType(title: string, oneLiner?: string): string {
561
+ const lower = `${title} ${oneLiner || ""}`.toLowerCase();
502
562
 
503
563
  for (const [keywords, commitType] of COMMIT_TYPE_RULES) {
504
564
  for (const keyword of keywords) {
@@ -161,7 +161,7 @@ function openRawDb(path: string): unknown {
161
161
 
162
162
  // ─── Schema ────────────────────────────────────────────────────────────────
163
163
 
164
- const SCHEMA_VERSION = 2;
164
+ const SCHEMA_VERSION = 3;
165
165
 
166
166
  function initSchema(db: DbAdapter, fileBacked: boolean): void {
167
167
  // WAL mode for file-backed databases (must be outside transaction)
@@ -221,9 +221,36 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
221
221
  )
222
222
  `);
223
223
 
224
+ db.exec(`
225
+ CREATE TABLE IF NOT EXISTS memories (
226
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
227
+ id TEXT NOT NULL UNIQUE,
228
+ category TEXT NOT NULL,
229
+ content TEXT NOT NULL,
230
+ confidence REAL NOT NULL DEFAULT 0.8,
231
+ source_unit_type TEXT,
232
+ source_unit_id TEXT,
233
+ created_at TEXT NOT NULL,
234
+ updated_at TEXT NOT NULL,
235
+ superseded_by TEXT DEFAULT NULL,
236
+ hit_count INTEGER NOT NULL DEFAULT 0
237
+ )
238
+ `);
239
+
240
+ db.exec(`
241
+ CREATE TABLE IF NOT EXISTS memory_processed_units (
242
+ unit_key TEXT PRIMARY KEY,
243
+ activity_file TEXT,
244
+ processed_at TEXT NOT NULL
245
+ )
246
+ `);
247
+
248
+ db.exec('CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)');
249
+
224
250
  // Views — DROP + CREATE since CREATE VIEW IF NOT EXISTS doesn't update definitions
225
251
  db.exec(`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`);
226
252
  db.exec(`CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`);
253
+ db.exec(`CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`);
227
254
 
228
255
  // Insert schema version if not already present
229
256
  const existing = db.prepare('SELECT count(*) as cnt FROM schema_version').get();
@@ -274,6 +301,41 @@ function migrateSchema(db: DbAdapter): void {
274
301
  );
275
302
  }
276
303
 
304
+ // v2 → v3: add memories + memory_processed_units tables
305
+ if (currentVersion < 3) {
306
+ db.exec(`
307
+ CREATE TABLE IF NOT EXISTS memories (
308
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
309
+ id TEXT NOT NULL UNIQUE,
310
+ category TEXT NOT NULL,
311
+ content TEXT NOT NULL,
312
+ confidence REAL NOT NULL DEFAULT 0.8,
313
+ source_unit_type TEXT,
314
+ source_unit_id TEXT,
315
+ created_at TEXT NOT NULL,
316
+ updated_at TEXT NOT NULL,
317
+ superseded_by TEXT DEFAULT NULL,
318
+ hit_count INTEGER NOT NULL DEFAULT 0
319
+ )
320
+ `);
321
+
322
+ db.exec(`
323
+ CREATE TABLE IF NOT EXISTS memory_processed_units (
324
+ unit_key TEXT PRIMARY KEY,
325
+ activity_file TEXT,
326
+ processed_at TEXT NOT NULL
327
+ )
328
+ `);
329
+
330
+ db.exec('CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)');
331
+ db.exec('DROP VIEW IF EXISTS active_memories');
332
+ db.exec('CREATE VIEW active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL');
333
+
334
+ db.prepare('INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)').run(
335
+ { ':version': 3, ':applied_at': new Date().toISOString() },
336
+ );
337
+ }
338
+
277
339
  db.exec('COMMIT');
278
340
  } catch (err) {
279
341
  db.exec('ROLLBACK');
@@ -728,6 +790,21 @@ export function upsertRequirement(r: Requirement): void {
728
790
  /**
729
791
  * Insert or replace an artifact. Uses the `path` PK for idempotency.
730
792
  */
793
+ /**
794
+ * Delete all rows from the artifacts table.
795
+ * The artifacts table is a read cache — clearing it forces the next
796
+ * deriveState() to fall through to disk reads (native Rust batch parse).
797
+ * Safe to call when no database is open (no-op).
798
+ */
799
+ export function clearArtifacts(): void {
800
+ if (!currentDb) return;
801
+ try {
802
+ currentDb.exec('DELETE FROM artifacts');
803
+ } catch {
804
+ // Clearing a cache should never be fatal
805
+ }
806
+ }
807
+
731
808
  export function insertArtifact(a: {
732
809
  path: string;
733
810
  artifact_type: string;