gsd-pi 2.80.0-dev.2be7caf18 → 2.80.0-dev.42bd34838

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 (112) hide show
  1. package/README.md +1 -0
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/phases.js +29 -15
  4. package/dist/resources/extensions/gsd/auto/resolve.js +17 -0
  5. package/dist/resources/extensions/gsd/auto/run-unit.js +13 -1
  6. package/dist/resources/extensions/gsd/auto-recovery.js +43 -1
  7. package/dist/resources/extensions/gsd/auto-supervisor.js +8 -1
  8. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +2 -2
  9. package/dist/resources/extensions/gsd/auto.js +61 -2
  10. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +21 -2
  11. package/dist/resources/extensions/gsd/context-budget.js +37 -2
  12. package/dist/resources/extensions/gsd/db/unit-dispatches.js +39 -0
  13. package/dist/resources/extensions/gsd/db-base-schema.js +4 -2
  14. package/dist/resources/extensions/gsd/db-migration-steps.js +6 -0
  15. package/dist/resources/extensions/gsd/git-service.js +36 -4
  16. package/dist/resources/extensions/gsd/gsd-db.js +46 -13
  17. package/dist/resources/extensions/gsd/guided-flow.js +19 -4
  18. package/dist/resources/extensions/gsd/memory-store.js +69 -12
  19. package/dist/resources/extensions/gsd/prompt-loader.js +28 -2
  20. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +14 -13
  21. package/dist/resources/extensions/gsd/tools/memory-tools.js +1 -0
  22. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +1 -1
  23. package/dist/resources/extensions/gsd/unit-runtime.js +11 -0
  24. package/dist/resources/extensions/gsd/worktree-resolver.js +33 -17
  25. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  26. package/dist/web/standalone/.next/BUILD_ID +1 -1
  27. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  28. package/dist/web/standalone/.next/build-manifest.json +2 -2
  29. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  30. package/dist/web/standalone/.next/required-server-files.json +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.html +1 -1
  48. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  55. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  56. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  57. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  58. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  59. package/dist/web/standalone/server.js +1 -1
  60. package/package.json +3 -3
  61. package/packages/mcp-server/src/workflow-tools.test.ts +9 -2
  62. package/packages/native/tsconfig.tsbuildinfo +1 -1
  63. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +32 -0
  64. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  65. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/agent-session.js +5 -0
  67. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  68. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +36 -0
  69. package/packages/pi-coding-agent/src/core/agent-session.ts +5 -0
  70. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  71. package/src/resources/extensions/gsd/auto/phases.ts +35 -20
  72. package/src/resources/extensions/gsd/auto/resolve.ts +23 -1
  73. package/src/resources/extensions/gsd/auto/run-unit.ts +18 -1
  74. package/src/resources/extensions/gsd/auto-recovery.ts +54 -0
  75. package/src/resources/extensions/gsd/auto-supervisor.ts +7 -0
  76. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -2
  77. package/src/resources/extensions/gsd/auto.ts +69 -1
  78. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +21 -1
  79. package/src/resources/extensions/gsd/context-budget.ts +44 -2
  80. package/src/resources/extensions/gsd/db/unit-dispatches.ts +41 -0
  81. package/src/resources/extensions/gsd/db-base-schema.ts +4 -2
  82. package/src/resources/extensions/gsd/db-migration-steps.ts +8 -0
  83. package/src/resources/extensions/gsd/git-service.ts +46 -8
  84. package/src/resources/extensions/gsd/gsd-db.ts +50 -13
  85. package/src/resources/extensions/gsd/guided-flow.ts +32 -4
  86. package/src/resources/extensions/gsd/memory-store.ts +77 -12
  87. package/src/resources/extensions/gsd/prompt-loader.ts +27 -2
  88. package/src/resources/extensions/gsd/prompts/complete-milestone.md +14 -13
  89. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +71 -0
  90. package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +56 -13
  91. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +14 -1
  92. package/src/resources/extensions/gsd/tests/compaction-snapshot.test.ts +3 -1
  93. package/src/resources/extensions/gsd/tests/context-budget.test.ts +10 -1
  94. package/src/resources/extensions/gsd/tests/dispatch-rule-coverage.test.ts +313 -0
  95. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +54 -0
  96. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +234 -0
  97. package/src/resources/extensions/gsd/tests/memory-decay-factor.test.ts +90 -0
  98. package/src/resources/extensions/gsd/tests/prompt-step-ordering.test.ts +19 -0
  99. package/src/resources/extensions/gsd/tests/schema-v27-v28-sequence.test.ts +156 -0
  100. package/src/resources/extensions/gsd/tests/signal-handlers.test.ts +27 -0
  101. package/src/resources/extensions/gsd/tests/stalled-tool-recovery.test.ts +49 -1
  102. package/src/resources/extensions/gsd/tests/start-auto-detached.test.ts +38 -0
  103. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +30 -0
  104. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +30 -0
  105. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +3 -0
  106. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +63 -1
  107. package/src/resources/extensions/gsd/tools/memory-tools.ts +1 -0
  108. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +1 -1
  109. package/src/resources/extensions/gsd/unit-runtime.ts +11 -0
  110. package/src/resources/extensions/gsd/worktree-resolver.ts +36 -15
  111. /package/dist/web/standalone/.next/static/{3EDDd9ULaybSupPoA_vf- → tht0ltP8EIjsohPOA_WZf}/_buildManifest.js +0 -0
  112. /package/dist/web/standalone/.next/static/{3EDDd9ULaybSupPoA_vf- → tht0ltP8EIjsohPOA_WZf}/_ssgManifest.js +0 -0
@@ -14,6 +14,7 @@ import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
14
14
  import { gsdRoot } from "./paths.js";
15
15
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
16
16
  import { loadEffectiveGSDPreferences } from "./preferences.js";
17
+ import { logWarning } from "./workflow-logger.js";
17
18
 
18
19
 
19
20
  import {
@@ -722,16 +723,53 @@ export class GitServiceImpl {
722
723
  if (keyFiles.length === 0) return false;
723
724
 
724
725
  const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
725
- const paths = Array.from(new Set(
726
- keyFiles
727
- .map(file => normalizeRepoRelativePath(this.basePath, file))
728
- .filter((file): file is string => file !== null)
729
- .filter(file => !isExcludedScopedPath(file, allExclusions)),
730
- ));
726
+ const normalized = keyFiles
727
+ .map(file => normalizeRepoRelativePath(this.basePath, file))
728
+ .filter((file): file is string => file !== null)
729
+ .filter(file => !isExcludedScopedPath(file, allExclusions));
730
+
731
+ // Drop entries that don't exist on disk. The LLM occasionally lists files
732
+ // it intended to write but didn't (or names them with wrong casing/path).
733
+ // Pre-`b304f738b` `git add -A` swallowed these silently; the scoped
734
+ // pathspec form passes each path explicitly, so a single bad entry made
735
+ // the whole commit fail (see #5500). Filter so valid paths still commit.
736
+ const missing: string[] = [];
737
+ const existing: string[] = [];
738
+ for (const path of normalized) {
739
+ if (existsSync(join(this.basePath, path))) {
740
+ existing.push(path);
741
+ } else {
742
+ missing.push(path);
743
+ }
744
+ }
745
+ if (missing.length > 0) {
746
+ logWarning(
747
+ "engine",
748
+ `scoped stage: dropping ${missing.length} non-existent keyFile(s) from task commit: ${missing.join(", ")}`,
749
+ { file: "git-service.ts" },
750
+ );
751
+ }
752
+
753
+ const paths = Array.from(new Set(existing));
731
754
  if (paths.length === 0) return false;
732
755
 
733
- nativeAddPaths(this.basePath, paths);
734
- return true;
756
+ try {
757
+ nativeAddPaths(this.basePath, paths);
758
+ return true;
759
+ } catch (err) {
760
+ // Defense-in-depth: even after existence filtering, libgit2/git can
761
+ // still reject paths (gitignore matches, case-only differences on
762
+ // case-insensitive FS, submodule boundaries). Returning false lets
763
+ // autoCommit fall through to smartStage so the commit still goes out
764
+ // — restoring the resilience the unscoped path used to provide.
765
+ const msg = err instanceof Error ? err.message : String(err);
766
+ logWarning(
767
+ "engine",
768
+ `scoped stage failed (${msg}); falling back to smartStage`,
769
+ { file: "git-service.ts" },
770
+ );
771
+ return false;
772
+ }
735
773
  }
736
774
 
737
775
  /** Tracks whether runtime file cleanup has run this session. */
@@ -23,6 +23,7 @@
23
23
  // excluded from this invariant.
24
24
 
25
25
  import { createRequire } from "node:module";
26
+ import { createHash } from "node:crypto";
26
27
  import { existsSync, copyFileSync, mkdirSync, realpathSync } from "node:fs";
27
28
  import { dirname } from "node:path";
28
29
  import type { Decision, Requirement, GateRow, GateId, GateScope, GateStatus, GateVerdict } from "./types.js";
@@ -78,6 +79,8 @@ import {
78
79
  applyMigrationV22QualityGateRepair,
79
80
  applyMigrationV23MilestoneQueue,
80
81
  applyMigrationV26MilestoneCommitAttributions,
82
+ applyMigrationV27ArtifactHash,
83
+ applyMigrationV28MemoryLastHitAt,
81
84
  } from "./db-migration-steps.js";
82
85
  import { isMemoriesFtsAvailableSchema, tryCreateMemoriesFtsSchema } from "./db-memory-fts-schema.js";
83
86
  import { createDbOpenState, type DbOpenPhase } from "./db-open-state.js";
@@ -106,7 +109,7 @@ const providerLoader = createSqliteProviderLoader({
106
109
  writeStderr: (message: string) => process.stderr.write(message),
107
110
  });
108
111
 
109
- export const SCHEMA_VERSION = 26;
112
+ export const SCHEMA_VERSION = 28;
110
113
 
111
114
  function initSchema(db: DbAdapter, fileBacked: boolean): void {
112
115
  if (fileBacked) db.exec("PRAGMA journal_mode=WAL");
@@ -335,6 +338,16 @@ function migrateSchema(db: DbAdapter): void {
335
338
  recordSchemaVersion(db, 26);
336
339
  }
337
340
 
341
+ if (currentVersion < 27) {
342
+ applyMigrationV27ArtifactHash(db);
343
+ recordSchemaVersion(db, 27);
344
+ }
345
+
346
+ if (currentVersion < 28) {
347
+ applyMigrationV28MemoryLastHitAt(db);
348
+ recordSchemaVersion(db, 28);
349
+ }
350
+
338
351
  db.exec("COMMIT");
339
352
  } catch (err) {
340
353
  db.exec("ROLLBACK");
@@ -913,9 +926,10 @@ export function insertArtifact(a: {
913
926
  full_content: string;
914
927
  }): void {
915
928
  if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
929
+ const contentHash = createHash("sha256").update(a.full_content).digest("hex");
916
930
  currentDb.prepare(
917
- `INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at)
918
- VALUES (:path, :artifact_type, :milestone_id, :slice_id, :task_id, :full_content, :imported_at)`,
931
+ `INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at, content_hash)
932
+ VALUES (:path, :artifact_type, :milestone_id, :slice_id, :task_id, :full_content, :imported_at, :content_hash)`,
919
933
  ).run({
920
934
  ":path": a.path,
921
935
  ":artifact_type": a.artifact_type,
@@ -924,6 +938,7 @@ export function insertArtifact(a: {
924
938
  ":task_id": a.task_id,
925
939
  ":full_content": a.full_content,
926
940
  ":imported_at": new Date().toISOString(),
941
+ ":content_hash": contentHash,
927
942
  });
928
943
  }
929
944
 
@@ -1795,6 +1810,13 @@ export function reconcileWorktreeDb(
1795
1810
  const hasEscalationAwaiting = wtTaskInfo.some((col) => col["name"] === "escalation_awaiting_review");
1796
1811
  const hasEscalationArtifact = wtTaskInfo.some((col) => col["name"] === "escalation_artifact_path");
1797
1812
  const hasEscalationOverride = wtTaskInfo.some((col) => col["name"] === "escalation_override_applied_at");
1813
+ const wtArtifactInfo = adapter.prepare("PRAGMA wt.table_info('artifacts')").all();
1814
+ const hasArtifactContentHash = wtArtifactInfo.some((col) => col["name"] === "content_hash");
1815
+ const wtMemoryInfo = adapter.prepare("PRAGMA wt.table_info('memories')").all();
1816
+ const hasMemoryScope = wtMemoryInfo.some((col) => col["name"] === "scope");
1817
+ const hasMemoryTags = wtMemoryInfo.some((col) => col["name"] === "tags");
1818
+ const hasMemoryStructuredFields = wtMemoryInfo.some((col) => col["name"] === "structured_fields");
1819
+ const hasMemoryLastHitAt = wtMemoryInfo.some((col) => col["name"] === "last_hit_at");
1798
1820
 
1799
1821
  const decConf = adapter.prepare(
1800
1822
  `SELECT m.id FROM decisions m INNER JOIN wt.decisions w ON m.id = w.id WHERE m.decision != w.decision OR m.choice != w.choice OR m.rationale != w.rationale OR ${
@@ -1842,12 +1864,17 @@ export function reconcileWorktreeDb(
1842
1864
  FROM wt.requirements
1843
1865
  `).run());
1844
1866
 
1867
+ // V27: preserve content_hash. If the worktree predates V27 (no column),
1868
+ // fall back to the main DB's existing hash so reconcile doesn't null
1869
+ // out integrity fingerprints on artifacts that were unchanged in wt.
1845
1870
  merged.artifacts = countChanges(adapter.prepare(`
1846
1871
  INSERT OR REPLACE INTO artifacts (
1847
- path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at
1872
+ path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at, content_hash
1848
1873
  )
1849
- SELECT path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at
1850
- FROM wt.artifacts
1874
+ SELECT w.path, w.artifact_type, w.milestone_id, w.slice_id, w.task_id, w.full_content, w.imported_at,
1875
+ ${hasArtifactContentHash ? "w.content_hash" : "m.content_hash"}
1876
+ FROM wt.artifacts w
1877
+ LEFT JOIN artifacts m ON m.path = w.path
1851
1878
  `).run());
1852
1879
 
1853
1880
  // Merge milestones — worktree may have updated status/planning fields.
@@ -1949,15 +1976,25 @@ export function reconcileWorktreeDb(
1949
1976
  LEFT JOIN tasks m ON m.milestone_id = w.milestone_id AND m.slice_id = w.slice_id AND m.id = w.id
1950
1977
  `).run());
1951
1978
 
1952
- // Merge memories — keep worktree-learned insights
1979
+ // Merge memories — keep worktree-learned insights.
1980
+ // V18 (scope, tags), V21 (structured_fields), V28 (last_hit_at): for each
1981
+ // column the wt may not yet have (older worktree DB), fall back to the
1982
+ // main DB's existing value via LEFT JOIN so reconcile never silently
1983
+ // resets these fields to defaults on rows that already had them.
1953
1984
  merged.memories = countChanges(adapter.prepare(`
1954
1985
  INSERT OR REPLACE INTO memories (
1955
1986
  seq, id, category, content, confidence, source_unit_type, source_unit_id,
1956
- created_at, updated_at, superseded_by, hit_count
1987
+ created_at, updated_at, superseded_by, hit_count,
1988
+ scope, tags, structured_fields, last_hit_at
1957
1989
  )
1958
- SELECT seq, id, category, content, confidence, source_unit_type, source_unit_id,
1959
- created_at, updated_at, superseded_by, hit_count
1960
- FROM wt.memories
1990
+ SELECT w.seq, w.id, w.category, w.content, w.confidence, w.source_unit_type, w.source_unit_id,
1991
+ w.created_at, w.updated_at, w.superseded_by, w.hit_count,
1992
+ ${hasMemoryScope ? "w.scope" : "COALESCE(m.scope, 'project')"},
1993
+ ${hasMemoryTags ? "w.tags" : "COALESCE(m.tags, '[]')"},
1994
+ ${hasMemoryStructuredFields ? "w.structured_fields" : "m.structured_fields"},
1995
+ ${hasMemoryLastHitAt ? "w.last_hit_at" : "m.last_hit_at"}
1996
+ FROM wt.memories w
1997
+ LEFT JOIN memories m ON m.id = w.id
1961
1998
  `).run());
1962
1999
 
1963
2000
  // Merge verification evidence — append-only, use INSERT OR IGNORE to avoid duplicates
@@ -3062,8 +3099,8 @@ export function updateMemoryContentRow(
3062
3099
  export function incrementMemoryHitCount(id: string, updatedAt: string): void {
3063
3100
  if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
3064
3101
  currentDb.prepare(
3065
- "UPDATE memories SET hit_count = hit_count + 1, updated_at = :updated_at WHERE id = :id",
3066
- ).run({ ":updated_at": updatedAt, ":id": id });
3102
+ "UPDATE memories SET hit_count = hit_count + 1, updated_at = :updated_at, last_hit_at = :last_hit_at WHERE id = :id",
3103
+ ).run({ ":updated_at": updatedAt, ":last_hit_at": updatedAt, ":id": id });
3067
3104
  }
3068
3105
 
3069
3106
  export function supersedeMemoryRow(oldId: string, newId: string, updatedAt: string): void {
@@ -87,6 +87,34 @@ import { logWarning } from "./workflow-logger.js";
87
87
  import { deleteRuntimeKv } from "./db/runtime-kv.js";
88
88
  import { PAUSED_SESSION_KV_KEY } from "./interrupted-session.js";
89
89
 
90
+ type AutoStartOptions = Parameters<typeof startAutoDetached>[4];
91
+ type AutoStartLauncher = typeof startAutoDetached;
92
+
93
+ function scheduleAutoStartAfterIdle(
94
+ ctx: ExtensionCommandContext,
95
+ pi: ExtensionAPI,
96
+ basePath: string,
97
+ verboseMode: boolean,
98
+ options?: AutoStartOptions,
99
+ launch: AutoStartLauncher = startAutoDetached,
100
+ ): void {
101
+ const waitForIdle =
102
+ typeof (ctx as { waitForIdle?: unknown }).waitForIdle === "function"
103
+ ? ctx.waitForIdle.bind(ctx)
104
+ : async () => {};
105
+ void waitForIdle()
106
+ .then(() => {
107
+ setTimeout(() => launch(ctx, pi, basePath, verboseMode, options), 0);
108
+ })
109
+ .catch((err) => {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ ctx.ui.notify(`Auto-start failed while waiting for the prior turn to settle: ${message}`, "error");
112
+ logWarning("guided", `auto-start idle wait failed: ${message}`);
113
+ });
114
+ }
115
+
116
+ export const _scheduleAutoStartAfterIdleForTest = scheduleAutoStartAfterIdle;
117
+
90
118
  // ─── Scope-based validator wrappers ──────────────────────────────────────────
91
119
  // These thin wrappers accept a MilestoneScope so callers that already hold a
92
120
  // pinned scope never have to re-derive (basePath, milestoneId) separately.
@@ -446,7 +474,7 @@ async function dispatchNextDeepProjectSetupStage(entry: PendingDeepProjectSetupE
446
474
 
447
475
  if (!hasPendingDeepStage(prefs, entry.basePath)) {
448
476
  pendingDeepProjectSetupMap.delete(entry.basePath);
449
- startAutoDetached(entry.ctx, entry.pi, entry.basePath, false, { step: entry.step });
477
+ scheduleAutoStartAfterIdle(entry.ctx, entry.pi, entry.basePath, false, { step: entry.step });
450
478
  return true;
451
479
  }
452
480
 
@@ -479,7 +507,7 @@ async function dispatchNextDeepProjectSetupStage(entry: PendingDeepProjectSetupE
479
507
  entry.ctx.ui.notify(result.reason, result.level);
480
508
  } else if (hasPendingDeepStage(prefs, entry.basePath)) {
481
509
  pendingDeepProjectSetupMap.delete(entry.basePath);
482
- startAutoDetached(entry.ctx, entry.pi, entry.basePath, false, { step: entry.step });
510
+ scheduleAutoStartAfterIdle(entry.ctx, entry.pi, entry.basePath, false, { step: entry.step });
483
511
  return true;
484
512
  }
485
513
  return false;
@@ -487,7 +515,7 @@ async function dispatchNextDeepProjectSetupStage(entry: PendingDeepProjectSetupE
487
515
 
488
516
  if (!USER_DRIVEN_DEEP_SETUP_UNITS.has(result.unitType)) {
489
517
  pendingDeepProjectSetupMap.delete(entry.basePath);
490
- startAutoDetached(entry.ctx, entry.pi, entry.basePath, false, { step: entry.step });
518
+ scheduleAutoStartAfterIdle(entry.ctx, entry.pi, entry.basePath, false, { step: entry.step });
491
519
  return true;
492
520
  }
493
521
 
@@ -693,7 +721,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
693
721
 
694
722
  pendingAutoStartMap.delete(basePath);
695
723
  ctx.ui.notify(`Milestone ${milestoneId} ready.`, "success");
696
- startAutoDetached(ctx, pi, basePath, false, { step });
724
+ scheduleAutoStartAfterIdle(ctx, pi, basePath, false, { step });
697
725
  return true;
698
726
  }
699
727
 
@@ -44,6 +44,8 @@ export interface Memory {
44
44
  * decisions table (Step 5) with the original scope/decision/choice/etc.
45
45
  */
46
46
  structured_fields: Record<string, unknown> | null;
47
+ /** ISO timestamp of the most recent memory_query hit. NULL until first hit. */
48
+ last_hit_at: string | null;
47
49
  }
48
50
 
49
51
  export type MemoryActionCreate = {
@@ -100,6 +102,27 @@ const CATEGORY_PRIORITY: Record<string, number> = {
100
102
  preference: 5,
101
103
  };
102
104
 
105
+ // ─── Scoring Helpers ─────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Time-decay factor for memory relevance scoring.
109
+ * Returns 1.0 for never-hit or recently-hit memories, decaying linearly to
110
+ * 0.7 for memories not accessed in 90+ days. Floor at 0.7 keeps old-but-valid
111
+ * knowledge from being fully suppressed.
112
+ *
113
+ * Defensive parsing: invalid timestamp strings (NaN from Date.parse) are
114
+ * treated as "no decay" rather than propagating NaN into score arithmetic.
115
+ * Future timestamps (clock skew, manual DB edits) clamp to daysAgo=0 so the
116
+ * factor stays in the documented [0.7, 1.0] contract.
117
+ */
118
+ export function memoryDecayFactor(lastHitAt: string | null): number {
119
+ if (!lastHitAt) return 1.0;
120
+ const ts = Date.parse(lastHitAt);
121
+ if (!Number.isFinite(ts)) return 1.0;
122
+ const daysAgo = Math.max(0, (Date.now() - ts) / 86_400_000);
123
+ return Math.max(0.7, 1.0 - 0.3 * Math.min(1.0, daysAgo / 90));
124
+ }
125
+
103
126
  // ─── Row Mapping ────────────────────────────────────────────────────────────
104
127
 
105
128
  function rowToMemory(row: Record<string, unknown>): Memory {
@@ -118,6 +141,7 @@ function rowToMemory(row: Record<string, unknown>): Memory {
118
141
  scope: (row['scope'] as string) ?? 'project',
119
142
  tags: parseTags(row['tags']),
120
143
  structured_fields: parseStructuredFields(row['structured_fields']),
144
+ last_hit_at: (row['last_hit_at'] as string | null) ?? null,
121
145
  };
122
146
  }
123
147
 
@@ -233,15 +257,39 @@ export function queryMemoriesRanked(opts: QueryMemoriesOptions): RankedMemory[]
233
257
  : [];
234
258
 
235
259
  if (keywordHits.length === 0 && semanticHits.length === 0 && !trimmedQuery) {
236
- // No query at all — fall back to the existing ranked-by-score listing.
237
- return getActiveMemoriesRanked(k).map((memory) => ({
238
- memory,
239
- score: memory.confidence * (1 + memory.hit_count * 0.1),
240
- keywordRank: null,
241
- semanticRank: null,
242
- confidenceBoost: memory.confidence * (1 + memory.hit_count * 0.1),
243
- reason: 'ranked' as const,
244
- })).filter((hit) => passesFilters(hit.memory, opts));
260
+ // No query at all — return top-k by decay-aware ranked score.
261
+ //
262
+ // Build the candidate pool from a direct SQL query that honors the
263
+ // request's activeClause (i.e. include_superseded). Using
264
+ // getActiveMemoriesRanked here would silently drop superseded rows even
265
+ // when the caller explicitly opted in, and would slice by raw score
266
+ // before decay/filters had a chance to reorder.
267
+ const candidatePool = Math.min(Math.max(k * 5, 50), 500);
268
+ const rows = adapter
269
+ .prepare(
270
+ `SELECT * FROM memories ${activeClause}
271
+ ORDER BY (confidence * (1.0 + hit_count * 0.1)) DESC
272
+ LIMIT :limit`,
273
+ )
274
+ .all({ ':limit': candidatePool });
275
+
276
+ const ranked: RankedMemory[] = [];
277
+ for (const row of rows) {
278
+ const memory = rowToMemory(row);
279
+ if (!passesFilters(memory, opts)) continue;
280
+ const decay = memoryDecayFactor(memory.last_hit_at);
281
+ const score = memory.confidence * (1 + memory.hit_count * 0.1) * decay;
282
+ ranked.push({
283
+ memory,
284
+ score,
285
+ keywordRank: null,
286
+ semanticRank: null,
287
+ confidenceBoost: score,
288
+ reason: 'ranked' as const,
289
+ });
290
+ }
291
+ ranked.sort((a, b) => b.score - a.score);
292
+ return ranked.slice(0, k);
245
293
  }
246
294
 
247
295
  // 3) Reciprocal rank fusion — each hit contributes 1/(rrfK + rank).
@@ -275,7 +323,7 @@ export function queryMemoriesRanked(opts: QueryMemoriesOptions): RankedMemory[]
275
323
  const ranked: RankedMemory[] = [];
276
324
  for (const entry of fused.values()) {
277
325
  if (!passesFilters(entry.memory, opts)) continue;
278
- const boost = entry.memory.confidence * (1 + entry.memory.hit_count * 0.1);
326
+ const boost = entry.memory.confidence * (1 + entry.memory.hit_count * 0.1) * memoryDecayFactor(entry.memory.last_hit_at);
279
327
  const reason: RankedMemory['reason'] =
280
328
  entry.kwRank != null && entry.semRank != null
281
329
  ? 'both'
@@ -313,6 +361,8 @@ function passesFilters(memory: Memory, filters: QueryMemoriesFilters): boolean {
313
361
  return true;
314
362
  }
315
363
 
364
+ let ftsWarningEmitted = false;
365
+
316
366
  function keywordSearch(
317
367
  adapter: NonNullable<ReturnType<typeof _getAdapter>>,
318
368
  rawQuery: string,
@@ -340,14 +390,29 @@ function keywordSearch(
340
390
  }
341
391
  }
342
392
 
343
- // LIKE fallback — scans the candidate pool.
393
+ // LIKE fallback — scans a capped candidate pool.
394
+ if (!ftsWarningEmitted) {
395
+ ftsWarningEmitted = true;
396
+ logWarning('memory-store', 'FTS5 unavailable — using LIKE fallback scan (consider enabling FTS5)');
397
+ }
398
+
344
399
  const terms = rawQuery
345
400
  .toLowerCase()
346
401
  .split(/[^a-z0-9_]+/)
347
402
  .filter((t) => t.length >= 2);
348
403
  if (terms.length === 0) return [];
349
404
 
350
- const rows = adapter.prepare(`SELECT * FROM memories ${activeClause}`).all();
405
+ const preScanCap = Math.min(limit * 20, 2000);
406
+ // ORDER BY confidence-weighted hit_count DESC so the cap keeps the most
407
+ // valuable candidates instead of the oldest-by-rowid (which would silently
408
+ // exclude recently-stored memories on tables larger than preScanCap).
409
+ const rows = adapter
410
+ .prepare(
411
+ `SELECT * FROM memories ${activeClause}
412
+ ORDER BY (confidence * (1.0 + hit_count * 0.1)) DESC
413
+ LIMIT :preScanCap`,
414
+ )
415
+ .all({ ':preScanCap': preScanCap });
351
416
  const scored: Array<{ memory: Memory; score: number }> = [];
352
417
  for (const row of rows) {
353
418
  const memory = rowToMemory(row);
@@ -123,17 +123,42 @@ function warmCache(): void {
123
123
  }
124
124
 
125
125
  let warmCacheScheduled = false;
126
+ let warmCacheRan = false;
127
+ let warmCacheTimer: ReturnType<typeof setTimeout> | undefined;
128
+
129
+ /**
130
+ * Synchronously snapshot the prompt/template tree into the cache.
131
+ *
132
+ * Safe to call immediately after `initResources()` because that function uses
133
+ * synchronous fs APIs — there is no race window that requires deferring the
134
+ * cache warm via setTimeout. Idempotent: subsequent calls are no-ops.
135
+ *
136
+ * Cancels the fallback `scheduleWarmCache` timer if it is still pending.
137
+ */
138
+ export function primeCache(): void {
139
+ if (warmCacheRan) return;
140
+ if (warmCacheTimer) {
141
+ clearTimeout(warmCacheTimer);
142
+ warmCacheTimer = undefined;
143
+ }
144
+ warmCacheScheduled = true;
145
+ warmCacheRan = true;
146
+ warmCache();
147
+ }
126
148
 
127
149
  function scheduleWarmCache(): void {
128
150
  if (warmCacheScheduled) return;
129
151
  warmCacheScheduled = true;
130
152
 
131
153
  const run = () => {
154
+ warmCacheTimer = undefined;
155
+ if (warmCacheRan) return;
156
+ warmCacheRan = true;
132
157
  warmCache();
133
158
  };
134
159
 
135
- const timer = setTimeout(run, 1000);
136
- timer.unref?.();
160
+ warmCacheTimer = setTimeout(run, 1000);
161
+ warmCacheTimer.unref?.();
137
162
  }
138
163
 
139
164
  // Snapshot the full prompt/template tree after import so extension startup only
@@ -29,14 +29,15 @@ Subagents report only; they do not write user source. Fold any findings into Dec
29
29
 
30
30
  ## Steps
31
31
 
32
- 1. Use the **Milestone Summary** output template from the inlined context above
33
- 2. {{skillActivation}}
34
- 3. **Verify code changes exist.** Compare milestone work against the integration branch (`main`, `master`, or recorded branch), using merge-base as older revision and `HEAD` as newer. If the diff lists non-`.gsd/` files, pass. If `HEAD` equals the integration branch/merge-base, treat it as a self-diff retry: inspect milestone-scoped commit evidence (`GSD-Unit: {{milestoneId}}` or production `GSD-Task: Sxx/Tyy` trailers touching `.gsd/milestones/{{milestoneId}}/`) and verify those commits touched non-`.gsd/` files. Record **verification failure** only when neither source shows implementation files.
35
- 4. Verify every **success criterion** from `{{roadmapPath}}`. If passing validation is present, summarize the validation evidence instead of re-auditing it; otherwise verify with evidence from summaries, tests, or observable behavior. Record unmet criteria as **verification failure**.
36
- 5. Verify **definition of done**: all slices `[x]`, summaries exist, and integrations work. If passing validation is present, trust its integration/verification verdict unless inconsistent with current artifacts. Record unmet items as **verification failure**.
37
- 6. If the roadmap includes a **Horizontal Checklist**, verify each item and note unchecked items in the summary.
38
- 7. Fill the **Decision Re-evaluation** table: compare each key `.gsd/DECISIONS.md` decision from this milestone with what shipped, and flag decisions to revisit.
39
- 8. Validate **requirement status transitions**. For each changed requirement, confirm evidence supports the new status. Requirements may move between Active, Validated, Deferred, Blocked, or Out of Scope only with proof.
32
+ 1. **Duplicate completion guard:** Call `gsd_milestone_status` for `{{milestoneId}}` before any durable writes. If the returned milestone **status is `complete`**, this is a stale or duplicate closeout turn: do NOT mutate requirements, do NOT refresh the project document, do NOT write LEARNINGS, and do NOT persist milestone completion again. Say: "Milestone {{milestoneId}} is already complete." and stop.
33
+ 2. Use the **Milestone Summary** output template from the inlined context above
34
+ 3. {{skillActivation}}
35
+ 4. **Verify code changes exist.** Compare milestone work against the integration branch (`main`, `master`, or recorded branch), using merge-base as older revision and `HEAD` as newer. If the diff lists non-`.gsd/` files, pass. If `HEAD` equals the integration branch/merge-base, treat it as a self-diff retry: inspect milestone-scoped commit evidence (`GSD-Unit: {{milestoneId}}` or production `GSD-Task: Sxx/Tyy` trailers touching `.gsd/milestones/{{milestoneId}}/`) and verify those commits touched non-`.gsd/` files. Record **verification failure** only when neither source shows implementation files.
36
+ 5. Verify every **success criterion** from `{{roadmapPath}}`. If passing validation is present, summarize the validation evidence instead of re-auditing it; otherwise verify with evidence from summaries, tests, or observable behavior. Record unmet criteria as **verification failure**.
37
+ 6. Verify **definition of done**: all slices `[x]`, summaries exist, and integrations work. If passing validation is present, trust its integration/verification verdict unless inconsistent with current artifacts. Record unmet items as **verification failure**.
38
+ 7. If the roadmap includes a **Horizontal Checklist**, verify each item and note unchecked items in the summary.
39
+ 8. Fill the **Decision Re-evaluation** table: compare each key `.gsd/DECISIONS.md` decision from this milestone with what shipped, and flag decisions to revisit.
40
+ 9. Validate **requirement status transitions**. For each changed requirement, confirm evidence supports the new status. Requirements may move between Active, Validated, Deferred, Blocked, or Out of Scope only with proof.
40
41
 
41
42
  **DB access safety:** Do NOT query `.gsd/gsd.db` directly via `sqlite3` or `node -e require('better-sqlite3')`; the engine owns the WAL connection. Use `gsd_milestone_status`, inlined context, or `gsd_*` tools; never direct SQL.
42
43
 
@@ -53,13 +54,13 @@ Subagents report only; they do not write user source. Fold any findings into Dec
53
54
 
54
55
  **Success path** (all verifications passed):
55
56
 
56
- 9. For each requirement whose status changed in step 8, call `gsd_requirement_update` with the requirement ID and updated `status` and `validation` fields — the tool regenerates `.gsd/REQUIREMENTS.md` automatically. Do this BEFORE completing the milestone so requirement updates are persisted.
57
- 10. Update `.gsd/PROJECT.md`: use the `write` tool with `path: ".gsd/PROJECT.md"` and `content` containing the full updated document reflecting milestone completion and current project state. Do NOT use the `edit` tool for this — PROJECT.md is a full-document refresh.
58
- 11. Extract structured learnings from this milestone and persist them to the GSD memory store. Follow the procedure block immediately below — it writes `{{milestoneId}}-LEARNINGS.md` as the audit trail and persists Patterns, Lessons, and Decisions via `capture_thought` (categories: pattern, gotcha/convention, architecture). The memory store is the single source of truth for cross-session durable knowledge (ADR-013).
57
+ 10. For each requirement whose status changed in step 9, call `gsd_requirement_update` with the requirement ID and updated `status` and `validation` fields — the tool regenerates `.gsd/REQUIREMENTS.md` automatically. Do this BEFORE completing the milestone so requirement updates are persisted.
58
+ 11. Update `.gsd/PROJECT.md`: use the `write` tool with `path: ".gsd/PROJECT.md"` and `content` containing the full updated document reflecting milestone completion and current project state. Do NOT use the `edit` tool for this — PROJECT.md is a full-document refresh.
59
+ 12. Extract structured learnings from this milestone and persist them to the GSD memory store. Follow the procedure block immediately below — it writes `{{milestoneId}}-LEARNINGS.md` as the audit trail and persists Patterns, Lessons, and Decisions via `capture_thought` (categories: pattern, gotcha/convention, architecture). The memory store is the single source of truth for cross-session durable knowledge (ADR-013).
59
60
 
60
61
  {{extractLearningsSteps}}
61
62
 
62
- 12. **Persist completion through `gsd_complete_milestone`.** Call it with the parameters below. This must be the final persistent write in the unit. The tool updates the milestone status in the DB, renders `{{milestoneSummaryPath}}`, and validates all slices are complete.
63
+ 13. **Persist completion through `gsd_complete_milestone`.** Call it with the parameters below. This must be the final persistent write in the unit. The tool updates the milestone status in the DB, renders `{{milestoneSummaryPath}}`, and validates all slices are complete.
63
64
 
64
65
  **Required parameters:**
65
66
  - `milestoneId` (string) — Milestone ID (e.g. M001)
@@ -78,7 +79,7 @@ Subagents report only; they do not write user source. Fold any findings into Dec
78
79
  - `followUps` (string) — Follow-up items for future milestones
79
80
  - `deviations` (string) — Deviations from the original plan
80
81
 
81
- 13. Do not commit manually — the system auto-commits your changes after this unit completes.
82
+ 14. Do not commit manually — the system auto-commits your changes after this unit completes.
82
83
  - Say: "Milestone {{milestoneId}} complete."
83
84
 
84
85
  **Important:** Do NOT skip code-change, success-criteria, or definition-of-done verification (steps 3-5). The summary must reflect verified outcomes. Verification failures block completion; there is no override. If a verification tool fails, errors, or returns unexpected output, treat it as failure.
@@ -10,6 +10,8 @@ import {
10
10
  _resetPendingResolve,
11
11
  _hasPendingResolveForTest,
12
12
  _setActiveSession,
13
+ _setSessionSwitchInFlight,
14
+ _consumePendingSwitchCancellation,
13
15
  isSessionSwitchInFlight,
14
16
  } from "../auto/resolve.js";
15
17
  import { runUnit } from "../auto/run-unit.js";
@@ -206,6 +208,28 @@ test("runUnit returns cancelled when session creation fails", async () => {
206
208
  assert.equal(pi.calls.length, 0);
207
209
  });
208
210
 
211
+ test("runUnit clears queued switch cancellation when session creation fails", async () => {
212
+ _resetPendingResolve();
213
+
214
+ const ctx = makeMockCtx();
215
+ const pi = makeMockPi();
216
+ const s = makeMockSession({
217
+ newSessionThrows: "connection refused",
218
+ onNewSessionStart: () => {
219
+ resolveAgentEndCancelled({
220
+ message: "Claude Code process aborted by user",
221
+ category: "aborted",
222
+ isTransient: false,
223
+ });
224
+ },
225
+ });
226
+
227
+ const result = await runUnit(ctx, pi, s, "task", "T01", "prompt");
228
+
229
+ assert.equal(result.status, "cancelled");
230
+ assert.equal(_consumePendingSwitchCancellation(), null);
231
+ });
232
+
209
233
  test("runUnit returns cancelled when session creation times out", async () => {
210
234
  _resetPendingResolve();
211
235
 
@@ -221,6 +245,34 @@ test("runUnit returns cancelled when session creation times out", async () => {
221
245
  assert.equal(pi.calls.length, 0);
222
246
  });
223
247
 
248
+ test("runUnit consumes a cancellation queued during session switch before dispatch", async () => {
249
+ _resetPendingResolve();
250
+
251
+ const ctx = makeMockCtx();
252
+ const pi = makeMockPi();
253
+ let cancellationQueued = false;
254
+ const s = makeMockSession({
255
+ newSessionDelayMs: 10,
256
+ onNewSessionStart: () => {
257
+ setTimeout(() => {
258
+ cancellationQueued = !resolveAgentEndCancelled({
259
+ message: "Claude Code process aborted by user",
260
+ category: "aborted",
261
+ isTransient: false,
262
+ });
263
+ }, 0);
264
+ },
265
+ });
266
+
267
+ const result = await runUnit(ctx, pi, s, "plan-slice", "M009/S01", "prompt");
268
+
269
+ assert.equal(cancellationQueued, true);
270
+ assert.equal(result.status, "cancelled");
271
+ assert.equal(result.errorContext?.category, "aborted");
272
+ assert.equal(result.errorContext?.message, "Claude Code process aborted by user");
273
+ assert.equal(pi.calls.length, 0, "queued switch cancellation must prevent prompt dispatch");
274
+ });
275
+
224
276
  test("runUnit keeps the session-switch guard across a late newSession settlement", async () => {
225
277
  _resetPendingResolve();
226
278
  mock.timers.enable();
@@ -2089,6 +2141,25 @@ test("resolveAgentEndCancelled without args produces no errorContext field", asy
2089
2141
  assert.equal(resolved.errorContext, undefined, "errorContext must not be present when no args passed");
2090
2142
  });
2091
2143
 
2144
+ test("resolveAgentEndCancelled queues cancellation that arrives during session switch", () => {
2145
+ _resetPendingResolve();
2146
+
2147
+ _setSessionSwitchInFlight(true);
2148
+ const resolved = resolveAgentEndCancelled({
2149
+ message: "Claude Code process aborted by user",
2150
+ category: "aborted",
2151
+ isTransient: false,
2152
+ });
2153
+
2154
+ assert.equal(resolved, false);
2155
+ const pending = _consumePendingSwitchCancellation();
2156
+ assert.ok(pending?.errorContext, "queued cancellation should preserve errorContext");
2157
+ assert.equal(pending.errorContext.category, "aborted");
2158
+ assert.equal(pending.errorContext.message, "Claude Code process aborted by user");
2159
+ assert.equal(_consumePendingSwitchCancellation(), null);
2160
+ _resetPendingResolve();
2161
+ });
2162
+
2092
2163
  // ─── #1571: artifact verification retry ──────────────────────────────────────
2093
2164
 
2094
2165
  test("autoLoop re-iterates when postUnitPreVerification returns retry (#1571)", async () => {