@pi-unipi/unipi 2.4.2 → 2.6.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 (69) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -0
  3. package/docs/prefix-cache-architecture.md +89 -0
  4. package/package.json +24 -22
  5. package/packages/ask-user/package.json +2 -2
  6. package/packages/autocomplete/package.json +1 -1
  7. package/packages/autocomplete/src/constants.ts +2 -0
  8. package/packages/btw/package.json +2 -2
  9. package/packages/cocoindex/README.md +2 -1
  10. package/packages/cocoindex/index.ts +6 -13
  11. package/packages/cocoindex/package.json +4 -3
  12. package/packages/cocoindex/tools.ts +45 -11
  13. package/packages/compactor/README.md +4 -2
  14. package/packages/compactor/package.json +3 -3
  15. package/packages/compactor/src/session/snapshot.ts +3 -2
  16. package/packages/compactor/src/tools/register.ts +6 -2
  17. package/packages/compactor/src/tools/vcc-recall.ts +18 -3
  18. package/packages/core/bounded-output.ts +106 -0
  19. package/packages/core/constants.ts +2 -0
  20. package/packages/core/index.ts +1 -0
  21. package/packages/core/package.json +1 -1
  22. package/packages/core/sandbox.ts +7 -6
  23. package/packages/footer/package.json +2 -2
  24. package/packages/image/package.json +2 -2
  25. package/packages/info-screen/package.json +2 -2
  26. package/packages/input-shortcuts/package.json +2 -2
  27. package/packages/kanboard/package.json +2 -2
  28. package/packages/mcp/README.md +13 -0
  29. package/packages/mcp/package.json +5 -2
  30. package/packages/mcp/src/bridge/registry.ts +244 -137
  31. package/packages/mcp/src/bridge/translator.ts +96 -21
  32. package/packages/mcp/src/index.ts +43 -67
  33. package/packages/mcp/src/tui/settings-overlay.ts +3 -9
  34. package/packages/memory/README.md +15 -11
  35. package/packages/memory/bridge/mempalace_bridge.py +636 -0
  36. package/packages/memory/index.ts +56 -34
  37. package/packages/memory/mempalace.ts +169 -18
  38. package/packages/memory/package.json +3 -3
  39. package/packages/memory/storage.ts +27 -10
  40. package/packages/milestone/README.md +11 -3
  41. package/packages/milestone/hooks.ts +103 -29
  42. package/packages/milestone/index.ts +1 -1
  43. package/packages/milestone/package.json +5 -2
  44. package/packages/notify/package.json +2 -2
  45. package/packages/ralph/index.ts +12 -18
  46. package/packages/ralph/package.json +6 -3
  47. package/packages/ralph/reminder.ts +40 -0
  48. package/packages/ralph/tools.ts +5 -1
  49. package/packages/subagents/README.md +2 -0
  50. package/packages/subagents/package.json +1 -1
  51. package/packages/subagents/src/agent-manager.ts +5 -1
  52. package/packages/subagents/src/agent-runner.ts +2 -2
  53. package/packages/subagents/src/core-compat.ts +73 -0
  54. package/packages/subagents/src/custom-agents.ts +10 -2
  55. package/packages/subagents/src/index.ts +14 -3
  56. package/packages/subagents/src/types.ts +2 -0
  57. package/packages/unipi/bundled.js +1445 -686
  58. package/packages/updater/package.json +2 -2
  59. package/packages/utility/README.md +9 -0
  60. package/packages/utility/package.json +2 -2
  61. package/packages/utility/src/index.ts +48 -0
  62. package/packages/utility/src/lifecycle/cleanup.ts +29 -0
  63. package/packages/utility/src/prefix-cache.ts +263 -0
  64. package/packages/utility/src/types.ts +1 -1
  65. package/packages/web-api/package.json +2 -2
  66. package/packages/workflow/README.md +8 -0
  67. package/packages/workflow/commands.ts +16 -26
  68. package/packages/workflow/index.ts +165 -85
  69. package/packages/workflow/package.json +5 -2
@@ -35,6 +35,54 @@ import { isEmbeddingReady, hasModelChanged } from "./settings.js";
35
35
  /** Package version */
36
36
  const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
37
37
 
38
+ interface MemoryReminderInput {
39
+ projectName: string;
40
+ memories: Array<{ title: string }>;
41
+ canSearch: boolean;
42
+ canStore: boolean;
43
+ }
44
+
45
+ /**
46
+ * Build the deterministic, model-visible first-turn memory reminder.
47
+ * Exported so provider-payload regressions can exercise the exact production
48
+ * content without initializing a storage backend.
49
+ */
50
+ export function buildMemoryRecallReminder(input: MemoryReminderInput): string {
51
+ const lines = [
52
+ "## 🧠 Memory System Active",
53
+ "",
54
+ `You have ${input.memories.length} memories stored for project "${input.projectName}".`,
55
+ ];
56
+
57
+ if (input.canSearch && input.memories.length > 0) {
58
+ const titleList = input.memories.slice(0, 20).map((memory) => `- ${memory.title}`).join("\n");
59
+ const extra = input.memories.length > 20
60
+ ? `\n... and ${input.memories.length - 20} more`
61
+ : "";
62
+ lines.push(
63
+ "**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
64
+ "",
65
+ "Available memories:",
66
+ titleList + extra,
67
+ );
68
+ }
69
+
70
+ if (input.canStore) {
71
+ lines.push(
72
+ "",
73
+ "**AFTER completing the task**, if you learned something non-obvious,",
74
+ "call `memory_store` to save it for future sessions.",
75
+ );
76
+ }
77
+
78
+ lines.push(
79
+ "",
80
+ "Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
81
+ );
82
+
83
+ return lines.join("\n");
84
+ }
85
+
38
86
  /** Storage instance for current project */
39
87
  let projectStorage: MemoryStorage | null = null;
40
88
 
@@ -251,44 +299,18 @@ export default function (pi: ExtensionAPI) {
251
299
  return;
252
300
  }
253
301
 
254
- const lines = [
255
- "## 🧠 Memory System Active",
256
- "",
257
- `You have ${projectMemories.length} memories stored for project "${projectName}".`,
258
- ];
259
-
260
- if (canSearch && projectMemories.length > 0) {
261
- const titleList = projectMemories.slice(0, 20).map(m => `- ${m.title}`).join("\n");
262
- const extra = projectMemories.length > 20 ? `\n... and ${projectMemories.length - 20} more` : "";
263
- lines.push(
264
- "**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
265
- "",
266
- "Available memories:",
267
- titleList + extra,
268
- );
269
- } else {
270
- recallDone = true;
271
- }
272
-
273
- if (canStore) {
274
- lines.push(
275
- "",
276
- "**AFTER completing the task**, if you learned something non-obvious,",
277
- "call `memory_store` to save it for future sessions.",
278
- );
279
- } else {
280
- storeDone = true;
281
- }
282
-
283
- lines.push(
284
- "",
285
- "Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
286
- );
302
+ if (!canSearch || projectMemories.length === 0) recallDone = true;
303
+ if (!canStore) storeDone = true;
287
304
 
288
305
  return {
289
306
  message: {
290
307
  customType: "unipi-memory-recall-reminder",
291
- content: lines.join("\n"),
308
+ content: buildMemoryRecallReminder({
309
+ projectName,
310
+ memories: projectMemories,
311
+ canSearch,
312
+ canStore,
313
+ }),
292
314
  display: false,
293
315
  },
294
316
  };
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  import { spawnSync, spawn } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { createRequire } from "node:module";
15
17
  import * as fs from "node:fs";
16
18
  import * as path from "node:path";
17
19
  import * as os from "node:os";
@@ -27,11 +29,74 @@ const MIGRATED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-mi
27
29
  const PING_VERIFIED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-ping-verified");
28
30
  const PING_VERIFIED_TTL_MS = 24 * 60 * 60 * 1000; // 24h
29
31
 
30
- /** Path to the bundled bridge script. */
31
- const BRIDGE_PATH = path.join(dirname(fileURLToPath(import.meta.url)), "bridge", "mempalace_bridge.py");
32
+ /** Migration marker schema. Increment when migration semantics change. */
33
+ export const MIGRATION_STATE_VERSION = 2;
34
+
35
+ export interface MigrationResult {
36
+ discovered: number;
37
+ imported: number;
38
+ updated: number;
39
+ skipped: number;
40
+ failed: number;
41
+ verified: number;
42
+ errors?: string[];
43
+ }
44
+
45
+ export interface MigrationState {
46
+ version: number;
47
+ completedAt: string;
48
+ sourceFingerprint: string;
49
+ result: MigrationResult;
50
+ }
51
+
52
+ let cachedBridgePath: string | null | undefined;
53
+
54
+ function isReadableFile(candidate: string): boolean {
55
+ try {
56
+ return fs.statSync(candidate).isFile() && fs.accessSync(candidate, fs.constants.R_OK) === undefined;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Resolve the Python bridge in both supported layouts:
64
+ *
65
+ * - standalone @pi-unipi/memory: <package>/bridge/mempalace_bridge.py
66
+ * - bundled @pi-unipi/unipi: <umbrella>/packages/memory/bridge/...
67
+ *
68
+ * The explicit environment override is useful for custom packagers. The
69
+ * package-resolution fallback handles npm layouts where dependencies are not
70
+ * hoisted beside the umbrella package.
71
+ */
72
+ export function resolveMempalaceBridgePath(moduleUrl = import.meta.url): string | null {
73
+ const moduleDir = path.dirname(fileURLToPath(moduleUrl));
74
+ const candidates: string[] = [];
75
+ if (process.env.UNIPI_MEMPALACE_BRIDGE) {
76
+ candidates.push(path.resolve(process.env.UNIPI_MEMPALACE_BRIDGE));
77
+ }
78
+ candidates.push(
79
+ path.join(moduleDir, "bridge", "mempalace_bridge.py"),
80
+ path.join(moduleDir, "..", "memory", "bridge", "mempalace_bridge.py"),
81
+ );
82
+
83
+ try {
84
+ const require = createRequire(moduleUrl);
85
+ const memoryPackage = require.resolve("@pi-unipi/memory/package.json");
86
+ candidates.push(path.join(path.dirname(memoryPackage), "bridge", "mempalace_bridge.py"));
87
+ } catch { /* standalone/source layout may not expose package resolution */ }
88
+
89
+ return candidates.find(isReadableFile) ?? null;
90
+ }
91
+
92
+ function getBridgePath(): string | null {
93
+ if (cachedBridgePath === undefined) cachedBridgePath = resolveMempalaceBridgePath();
94
+ return cachedBridgePath;
95
+ }
32
96
 
33
- function dirname(p: string): string {
34
- return path.dirname(p);
97
+ /** Clear bridge discovery cache (primarily for recovery/tests). */
98
+ export function invalidateBridgePathCache(): void {
99
+ cachedBridgePath = undefined;
35
100
  }
36
101
 
37
102
  export interface BridgeResponse<T> {
@@ -196,22 +261,99 @@ export function invalidatePingVerified(): void {
196
261
  try { if (fs.existsSync(PING_VERIFIED_FLAG)) fs.unlinkSync(PING_VERIFIED_FLAG); } catch { /* ignore */ }
197
262
  }
198
263
 
199
- /** Has the one-way legacy migration been completed? */
200
- export function isMigrated(): boolean {
201
- return fs.existsSync(MIGRATED_FLAG);
264
+ /**
265
+ * Fingerprint all durable legacy sources. This makes migration catch-up
266
+ * automatic for existing installations instead of treating a years-old
267
+ * timestamp flag as permanently complete.
268
+ */
269
+ export function getMemorySourceFingerprint(
270
+ sourceDir = path.join(os.homedir(), ".unipi", "memory"),
271
+ ): string {
272
+ const hash = createHash("sha256");
273
+ if (!fs.existsSync(sourceDir)) return hash.update("missing").digest("hex");
274
+
275
+ const visit = (dir: string): void => {
276
+ let entries: fs.Dirent[];
277
+ try {
278
+ entries = fs.readdirSync(dir, { withFileTypes: true })
279
+ .sort((a, b) => a.name.localeCompare(b.name));
280
+ } catch {
281
+ hash.update(`unreadable:${dir}`);
282
+ return;
283
+ }
284
+ for (const entry of entries) {
285
+ if (entry.name.startsWith(".")) continue;
286
+ const full = path.join(dir, entry.name);
287
+ if (entry.isDirectory()) {
288
+ visit(full);
289
+ continue;
290
+ }
291
+ if (!entry.isFile() || (entry.name !== "memory.db" && !entry.name.endsWith(".md"))) continue;
292
+ try {
293
+ const stat = fs.statSync(full);
294
+ hash.update(`${path.relative(sourceDir, full)}\0${stat.size}\0${stat.mtimeMs}\n`);
295
+ } catch {
296
+ hash.update(`unreadable:${path.relative(sourceDir, full)}\n`);
297
+ }
298
+ }
299
+ };
300
+ visit(sourceDir);
301
+ return hash.digest("hex");
202
302
  }
203
303
 
204
- /** Mark the one-way legacy migration complete. */
205
- export function markMigrated(): void {
304
+ /** Read a verified v2 migration state. Legacy timestamp markers return null. */
305
+ export function readMigrationState(flagPath = MIGRATED_FLAG): MigrationState | null {
206
306
  try {
207
- fs.mkdirSync(path.dirname(MIGRATED_FLAG), { recursive: true });
208
- fs.writeFileSync(MIGRATED_FLAG, new Date().toISOString(), "utf-8");
209
- } catch { /* ignore */ }
307
+ const parsed = JSON.parse(fs.readFileSync(flagPath, "utf-8")) as MigrationState;
308
+ if (
309
+ parsed?.version !== MIGRATION_STATE_VERSION ||
310
+ typeof parsed.completedAt !== "string" ||
311
+ typeof parsed.sourceFingerprint !== "string" ||
312
+ !parsed.result ||
313
+ parsed.result.failed !== 0 ||
314
+ parsed.result.verified !== parsed.result.discovered
315
+ ) return null;
316
+ return parsed;
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ /** Is the palace verified against the current durable source set? */
323
+ export function isMigrated(
324
+ sourceFingerprint = getMemorySourceFingerprint(),
325
+ flagPath = MIGRATED_FLAG,
326
+ ): boolean {
327
+ return readMigrationState(flagPath)?.sourceFingerprint === sourceFingerprint;
328
+ }
329
+
330
+ /** Mark migration complete only after the caller has verified every record. */
331
+ export function markMigrated(
332
+ sourceFingerprint: string,
333
+ result: MigrationResult,
334
+ flagPath = MIGRATED_FLAG,
335
+ ): boolean {
336
+ if (result.failed !== 0 || result.verified !== result.discovered) return false;
337
+ try {
338
+ fs.mkdirSync(path.dirname(flagPath), { recursive: true });
339
+ const state: MigrationState = {
340
+ version: MIGRATION_STATE_VERSION,
341
+ completedAt: new Date().toISOString(),
342
+ sourceFingerprint,
343
+ result,
344
+ };
345
+ const temp = `${flagPath}.${process.pid}.tmp`;
346
+ fs.writeFileSync(temp, JSON.stringify(state, null, 2), "utf-8");
347
+ fs.renameSync(temp, flagPath);
348
+ return true;
349
+ } catch {
350
+ return false;
351
+ }
210
352
  }
211
353
 
212
354
  /** Force re-migration by clearing the flag. */
213
- export function clearMigratedFlag(): void {
214
- try { if (fs.existsSync(MIGRATED_FLAG)) fs.unlinkSync(MIGRATED_FLAG); } catch { /* ignore */ }
355
+ export function clearMigratedFlag(flagPath = MIGRATED_FLAG): void {
356
+ try { if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath); } catch { /* ignore */ }
215
357
  }
216
358
 
217
359
  /**
@@ -223,7 +365,10 @@ export function runBridge<T = unknown>(
223
365
  palace: string,
224
366
  cmd: string,
225
367
  args: Record<string, unknown> = {},
368
+ timeoutMs = 60_000,
226
369
  ): T | null {
370
+ const bridgePath = getBridgePath();
371
+ if (!bridgePath) return null;
227
372
  let argsJson: string;
228
373
  try {
229
374
  argsJson = JSON.stringify(args);
@@ -232,9 +377,9 @@ export function runBridge<T = unknown>(
232
377
  }
233
378
  let res;
234
379
  try {
235
- res = spawnSync(install.python, [BRIDGE_PATH, palace, cmd, argsJson], {
380
+ res = spawnSync(install.python, [bridgePath, palace, cmd, argsJson], {
236
381
  encoding: "utf-8",
237
- timeout: 60_000,
382
+ timeout: timeoutMs,
238
383
  maxBuffer: 64 * 1024 * 1024,
239
384
  });
240
385
  } catch {
@@ -266,8 +411,14 @@ export function runBridgeAsync<T = unknown>(
266
411
  palace: string,
267
412
  cmd: string,
268
413
  args: Record<string, unknown> = {},
414
+ timeoutMs = 60_000,
269
415
  ): Promise<T | null> {
270
416
  return new Promise((resolve) => {
417
+ const bridgePath = getBridgePath();
418
+ if (!bridgePath) {
419
+ resolve(null);
420
+ return;
421
+ }
271
422
  let argsJson: string;
272
423
  try {
273
424
  argsJson = JSON.stringify(args);
@@ -278,7 +429,7 @@ export function runBridgeAsync<T = unknown>(
278
429
 
279
430
  let child;
280
431
  try {
281
- child = spawn(install.python, [BRIDGE_PATH, palace, cmd, argsJson], {
432
+ child = spawn(install.python, [bridgePath, palace, cmd, argsJson], {
282
433
  stdio: ["ignore", "pipe", "ignore"],
283
434
  });
284
435
  } catch {
@@ -298,7 +449,7 @@ export function runBridgeAsync<T = unknown>(
298
449
  const timer = setTimeout(() => {
299
450
  try { child.kill(); } catch { /* already gone */ }
300
451
  finish(null);
301
- }, 60_000);
452
+ }, timeoutMs);
302
453
  // Do not hold the process open purely for a background bridge call.
303
454
  timer.unref?.();
304
455
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -43,8 +43,8 @@
43
43
  "better-sqlite3": "^12.9.0",
44
44
  "sqlite-vec": "^0.1.9",
45
45
  "js-yaml": "^4.1.0",
46
- "@pi-unipi/core": "2.4.1",
47
- "@pi-unipi/info-screen": "2.4.1"
46
+ "@pi-unipi/core": "2.6.0",
47
+ "@pi-unipi/info-screen": "2.6.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -20,6 +20,7 @@ import {
20
20
  runBridgeAsync,
21
21
  isMigrated,
22
22
  markMigrated,
23
+ getMemorySourceFingerprint,
23
24
  isPingVerified,
24
25
  markPingVerified,
25
26
  invalidatePingVerified,
@@ -29,6 +30,7 @@ import {
29
30
  type MempalaceSearchResult,
30
31
  type MempalaceListItem,
31
32
  type MempalaceListItemAll,
33
+ type MigrationResult,
32
34
  } from "./mempalace.js";
33
35
 
34
36
  export type MemoryBackend = "mempalace" | "sqlite";
@@ -102,6 +104,7 @@ export interface SearchResult {
102
104
 
103
105
  /** Memory file frontmatter */
104
106
  interface MemoryFrontmatter {
107
+ id?: string;
105
108
  title: string;
106
109
  tags: string[];
107
110
  project: string;
@@ -201,7 +204,7 @@ export function parseMemoryContent(content: string): MemoryRecord | null {
201
204
  const frontmatter = yaml.load(frontmatterStr) as MemoryFrontmatter;
202
205
 
203
206
  return {
204
- id: "",
207
+ id: frontmatter.id || "",
205
208
  title: frontmatter.title,
206
209
  content: body.trim(),
207
210
  tags: frontmatter.tags || [],
@@ -217,6 +220,7 @@ export function parseMemoryContent(content: string): MemoryRecord | null {
217
220
  */
218
221
  export function writeMemoryFile(filePath: string, record: MemoryRecord): void {
219
222
  const frontmatter: MemoryFrontmatter = {
223
+ id: record.id,
220
224
  title: record.title,
221
225
  tags: record.tags,
222
226
  project: record.project,
@@ -383,16 +387,28 @@ export class MemoryStorage {
383
387
  this.mempalaceInstall = install;
384
388
  this.backend = "mempalace";
385
389
 
386
- // One-way auto-migration of legacy memories (idempotent).
387
- if (!isMigrated()) {
390
+ // Idempotent migration + automatic catch-up. The source fingerprint turns
391
+ // the old one-shot timestamp into a resumable state: new/changed markdown
392
+ // or SQLite sources trigger another verified upsert pass. Never mark a
393
+ // failed/partial run complete; it will retry on a later session.
394
+ const sourceFingerprint = getMemorySourceFingerprint(getMemoryBaseDir());
395
+ if (!isMigrated(sourceFingerprint)) {
388
396
  try {
389
- runBridge(install, this.palacePath, "migrate", {
397
+ // First migrations can embed thousands of records. Give the bridge a
398
+ // practical bounded window rather than the normal per-operation 60s.
399
+ const result = runBridge<MigrationResult>(install, this.palacePath, "migrate", {
390
400
  source_dir: getMemoryBaseDir(),
391
- });
392
- markMigrated();
401
+ }, 15 * 60_000);
402
+ if (
403
+ result &&
404
+ result.failed === 0 &&
405
+ result.verified === result.discovered
406
+ ) {
407
+ markMigrated(sourceFingerprint, result);
408
+ }
393
409
  } catch {
394
- // Migration failed — palace still usable for new stores; legacy
395
- // memories can be re-migrated later. Do not block.
410
+ // Palace remains available for current writes; durable markdown/SQLite
411
+ // sources are untouched and migration retries because no state is set.
396
412
  }
397
413
  }
398
414
 
@@ -629,8 +645,9 @@ export class MemoryStorage {
629
645
  const record = parseMemoryFile(filePath);
630
646
  if (!record) continue;
631
647
 
632
- // Generate ID from title (same logic as store())
633
- const id = record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
648
+ // New files preserve the authoritative store ID. Legacy files have no
649
+ // `id` frontmatter, so retain the historical title-derived fallback.
650
+ const id = record.id || record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
634
651
 
635
652
  if (existingIds.has(id)) continue; // Already in DB
636
653
 
@@ -15,9 +15,17 @@ Workflow operates at the task level — brainstorm, plan, work, review. Project
15
15
 
16
16
  ### Session Start
17
17
 
18
- On `before_agent_start`, milestone reads `.unipi/docs/MILESTONES.md` and appends a progress summary to the system prompt:
18
+ On `before_agent_start`, milestone reads `.unipi/docs/MILESTONES.md` from `ctx.cwd` and appends a hidden `unipi-milestone-snapshot` custom message to the session:
19
19
 
20
20
  ```
21
+ # UniPi Milestone Snapshot
22
+
23
+ This snapshot supersedes all prior UniPi milestone snapshots; use only this snapshot for milestone status.
24
+
25
+ Workspace: /path/to/project
26
+
27
+ Status: active
28
+
21
29
  ## Project Milestones
22
30
  Overall progress: 5/10 items (50%)
23
31
  Phase 1: Foundation: 3/5 done
@@ -25,11 +33,11 @@ Overall progress: 5/10 items (50%)
25
33
  Current focus: Phase 1: Foundation
26
34
  ```
27
35
 
28
- If MILESTONES.md doesn't exist, no context is injected.
36
+ Snapshots are append-only and hidden from the transcript. They keep the system-prompt prefix stable, persist milestone context in session history, and are deduplicated against the latest milestone custom message in the effective (compaction-aware) LLM context. If milestones disappear while an older active snapshot remains effective, an inactive snapshot is appended to supersede it. A clean workspace with no milestones and no effective snapshot receives no injected message.
29
37
 
30
38
  ### Session End
31
39
 
32
- On `session_shutdown`, milestone scans workflow docs modified during the session. Detects items that changed from `- [ ]` to `- [x]` and auto-updates MILESTONES.md using exact text matching.
40
+ On `session_shutdown`, milestone scans workflow docs modified during the session. It uses the workspace captured from `session_start`'s `ctx.cwd`, detects items that changed from `- [ ]` to `- [x]`, and auto-updates MILESTONES.md using exact text matching.
33
41
 
34
42
  ### Coexist Triggers
35
43