pi-anchor 0.1.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.
package/src/store.ts ADDED
@@ -0,0 +1,682 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import type {
5
+ Anchor,
6
+ AnchorDecayPolicy,
7
+ AnchorDurability,
8
+ AnchorEvidence,
9
+ AnchorPriority,
10
+ AnchorStatus,
11
+ AnchorStoreState
12
+ } from './types.ts';
13
+ import { DEFAULT_DECAY_POLICY, DURABLE_DECAY_POLICY, EPHEMERAL_DECAY_POLICY } from './types.ts';
14
+ import { normalizePath } from './matcher.ts';
15
+
16
+ /**
17
+ * Detect whether a task is a short-term ephemeral reminder or a long-term architectural vision
18
+ */
19
+ export function detectDurability(title: string): AnchorDurability {
20
+ const temporalKeywords = [
21
+ '今天', '今日', '晚上', '今晚', '明天', '后天', '大后天', '稍后', '待会', '下午', '上午', '临时', '一会儿', '等等',
22
+ 'today', 'tonight', 'tomorrow', 'later', 'temp', 'soon'
23
+ ];
24
+ const lower = title.toLowerCase();
25
+ for (const kw of temporalKeywords) {
26
+ if (lower.includes(kw)) {
27
+ return 'ephemeral';
28
+ }
29
+ }
30
+ return 'durable';
31
+ }
32
+
33
+ /**
34
+ * Get custom ephemeral decay policy based on temporal span (e.g. 后天, 大后天)
35
+ */
36
+ export function getEphemeralDecayPolicy(title: string): AnchorDecayPolicy {
37
+ const lower = title.toLowerCase();
38
+ if (lower.includes('大后天')) {
39
+ return { activeDays: 3, sleepDays: 1, graveyardDays: 4 };
40
+ }
41
+ if (lower.includes('后天')) {
42
+ return { activeDays: 2, sleepDays: 1, graveyardDays: 3 };
43
+ }
44
+ return { ...EPHEMERAL_DECAY_POLICY };
45
+ }
46
+
47
+ /**
48
+ * Detect expected completion date from title keywords or explicit date string, returning 'YYYY-MM-DD'
49
+ */
50
+ export function detectTargetDate(titleOrDate: string, now: number = Date.now()): string | undefined {
51
+ const lower = titleOrDate.toLowerCase().trim();
52
+ const MS_DAY = 24 * 60 * 60 * 1000;
53
+
54
+ if (lower.includes('今天') || lower.includes('今日') || lower.includes('今晚') || lower === 'today' || lower === 'tonight') {
55
+ return getTodayDateString(now);
56
+ }
57
+ if (lower.includes('明天') || lower === 'tomorrow') {
58
+ return getTodayDateString(now + MS_DAY);
59
+ }
60
+ if (lower.includes('后天')) {
61
+ return getTodayDateString(now + 2 * MS_DAY);
62
+ }
63
+ if (lower.includes('大后天')) {
64
+ return getTodayDateString(now + 3 * MS_DAY);
65
+ }
66
+
67
+ // Weekdays (e.g. 周五, 星期五, 下周一, friday)
68
+ const weekdayMap: Record<string, number> = {
69
+ '周日': 0, '星期日': 0, 'sunday': 0, 'sun': 0,
70
+ '周一': 1, '星期一': 1, 'monday': 1, 'mon': 1,
71
+ '周二': 2, '星期二': 2, 'tuesday': 2, 'tue': 2,
72
+ '周三': 3, '星期三': 3, 'wednesday': 3, 'wed': 3,
73
+ '周四': 4, '星期四': 4, 'thursday': 4, 'thu': 4,
74
+ '周五': 5, '星期五': 5, 'friday': 5, 'fri': 5,
75
+ '周六': 6, '星期六': 6, 'saturday': 6, 'sat': 6,
76
+ };
77
+
78
+ const isNextWeek = lower.includes('下周') || lower.includes('next');
79
+ for (const [name, targetDay] of Object.entries(weekdayMap)) {
80
+ if (lower.includes(name)) {
81
+ const curDay = new Date(now).getDay();
82
+ let diff = targetDay - curDay;
83
+ if (diff <= 0 || isNextWeek) {
84
+ diff += 7;
85
+ }
86
+ return getTodayDateString(now + diff * MS_DAY);
87
+ }
88
+ }
89
+
90
+ const fullDateMatch = titleOrDate.match(/\b(20\d\d)-(\d{1,2})-(\d{1,2})\b/);
91
+ if (fullDateMatch) {
92
+ const y = fullDateMatch[1];
93
+ const m = fullDateMatch[2].padStart(2, '0');
94
+ const d = fullDateMatch[3].padStart(2, '0');
95
+ return `${y}-${m}-${d}`;
96
+ }
97
+
98
+ const mmddMatch = titleOrDate.match(/\b(\d{1,2})-(\d{1,2})\b/);
99
+ if (mmddMatch) {
100
+ const curYear = new Date(now).getFullYear();
101
+ const m = mmddMatch[1].padStart(2, '0');
102
+ const d = mmddMatch[2].padStart(2, '0');
103
+ return `${curYear}-${m}-${d}`;
104
+ }
105
+
106
+ return undefined;
107
+ }
108
+
109
+ /**
110
+ * Detect recurring patterns, e.g. daily habits
111
+ */
112
+ export function detectRecurrence(title: string): 'daily' | undefined {
113
+ const lower = title.toLowerCase();
114
+ if (lower.includes('每天') || lower.includes('每日') || lower.includes('daily') || lower.includes('every day')) {
115
+ return 'daily';
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * Get current date string 'YYYY-MM-DD' in local timezone
122
+ */
123
+ export function getTodayDateString(timestamp: number = Date.now()): string {
124
+ const d = new Date(timestamp);
125
+ const year = d.getFullYear();
126
+ const month = String(d.getMonth() + 1).padStart(2, '0');
127
+ const day = String(d.getDate()).padStart(2, '0');
128
+ return `${year}-${month}-${day}`;
129
+ }
130
+
131
+ /**
132
+ * Resolve the default storage directory for Anchor (~/.anchor).
133
+ * Hierarchy:
134
+ * 1. Process environment: ANCHOR_DIR
135
+ * 2. Canonical user-global store: ~/.anchor/
136
+ * 3. Legacy Pi harness fallback: ~/.pi/agent/anchors/ (migrates automatically if present)
137
+ */
138
+ export function getDefaultStorageDir(): string {
139
+ if (process.env.ANCHOR_DIR) {
140
+ return path.resolve(process.env.ANCHOR_DIR);
141
+ }
142
+
143
+ const primaryDir = path.join(os.homedir(), '.anchor');
144
+ const legacyPiDir = path.join(os.homedir(), '.pi', 'agent', 'anchors');
145
+
146
+ // Automatic seamless migration from legacy Pi directory if present
147
+ if (!fs.existsSync(primaryDir) && fs.existsSync(legacyPiDir)) {
148
+ try {
149
+ fs.mkdirSync(primaryDir, { recursive: true });
150
+ for (const file of ['state.json', 'archive.jsonl', 'graveyard.jsonl']) {
151
+ const src = path.join(legacyPiDir, file);
152
+ const dest = path.join(primaryDir, file);
153
+ if (fs.existsSync(src) && !fs.existsSync(dest)) {
154
+ fs.copyFileSync(src, dest);
155
+ }
156
+ }
157
+ } catch {}
158
+ }
159
+
160
+ return primaryDir;
161
+ }
162
+
163
+ /**
164
+ * Synchronous sleep for spin-retry delay
165
+ */
166
+ function sleepSync(ms: number): void {
167
+ const end = Date.now() + ms;
168
+ while (Date.now() < end) {}
169
+ }
170
+
171
+ interface LockMetadata {
172
+ pid: number;
173
+ createdAt: number;
174
+ }
175
+
176
+ /**
177
+ * Check if a process is still alive on Windows/POSIX
178
+ */
179
+ function isProcessAlive(pid: number): boolean {
180
+ try {
181
+ process.kill(pid, 0);
182
+ return true;
183
+ } catch (err: any) {
184
+ return err.code === 'EPERM'; // Alive but permission denied
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Acquire exclusive lockfile using atomic 'wx' file creation (zero external dependencies).
190
+ * Features:
191
+ * - 0 async infection (100% synchronous)
192
+ * - Automatic stale lock eviction (>5000ms or dead PID)
193
+ * - Exponential backoff with jitter
194
+ */
195
+ export function acquireSyncLock(lockPath: string, maxWaitMs = 1500, staleTimeoutMs = 5000): () => void {
196
+ const start = Date.now();
197
+ let attempt = 0;
198
+
199
+ while (Date.now() - start < maxWaitMs) {
200
+ attempt++;
201
+ try {
202
+ const fd = fs.openSync(lockPath, 'wx');
203
+ const meta: LockMetadata = { pid: process.pid, createdAt: Date.now() };
204
+ fs.writeFileSync(fd, JSON.stringify(meta), 'utf-8');
205
+ fs.closeSync(fd);
206
+
207
+ return () => {
208
+ try {
209
+ if (fs.existsSync(lockPath)) {
210
+ fs.unlinkSync(lockPath);
211
+ }
212
+ } catch {}
213
+ };
214
+ } catch (err: any) {
215
+ if (err.code === 'EEXIST') {
216
+ try {
217
+ const content = fs.readFileSync(lockPath, 'utf-8');
218
+ const meta: LockMetadata = JSON.parse(content);
219
+ const isStale = (Date.now() - meta.createdAt) > staleTimeoutMs;
220
+ const isDead = meta.pid && !isProcessAlive(meta.pid);
221
+
222
+ if (isStale || isDead) {
223
+ try {
224
+ fs.unlinkSync(lockPath);
225
+ continue;
226
+ } catch {}
227
+ }
228
+ } catch {
229
+ // If corrupt or in-flight write, wait for next attempt
230
+ }
231
+
232
+ const delay = Math.min(40, Math.floor(attempt * 4 + Math.random() * 8));
233
+ sleepSync(delay);
234
+ continue;
235
+ }
236
+ break;
237
+ }
238
+ }
239
+
240
+ // Fallback release if lock acquisition timed out
241
+ return () => {};
242
+ }
243
+
244
+ /**
245
+ * Windows-tolerant atomic rename with exponential spin-retry to combat NTFS EBUSY/EPERM file locks
246
+ */
247
+ export function atomicRenameWithRetry(tempPath: string, targetPath: string, maxAttempts = 6): void {
248
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
249
+ try {
250
+ fs.renameSync(tempPath, targetPath);
251
+ return;
252
+ } catch (err: any) {
253
+ const code = err?.code;
254
+ if ((code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') && attempt < maxAttempts) {
255
+ const delay = Math.floor(attempt * 20 + Math.random() * 15);
256
+ sleepSync(delay);
257
+ continue;
258
+ }
259
+ // On exhaustion, preserve crash dump rather than wiping uncommitted state
260
+ const crashDump = `${tempPath}.failed`;
261
+ try {
262
+ if (fs.existsSync(tempPath)) fs.renameSync(tempPath, crashDump);
263
+ } catch {}
264
+ throw err;
265
+ }
266
+ }
267
+ }
268
+
269
+ export class AnchorStore {
270
+ public readonly storageDir: string;
271
+ public readonly statePath: string;
272
+ public readonly archivePath: string;
273
+ public readonly graveyardPath: string;
274
+
275
+ constructor(customStorageDir?: string) {
276
+ if (customStorageDir) {
277
+ this.storageDir = path.resolve(customStorageDir);
278
+ } else {
279
+ this.storageDir = getDefaultStorageDir();
280
+ }
281
+ this.statePath = path.join(this.storageDir, 'state.json');
282
+ this.archivePath = path.join(this.storageDir, 'archive.jsonl');
283
+ this.graveyardPath = path.join(this.storageDir, 'graveyard.jsonl');
284
+ this.ensureDirs();
285
+ }
286
+
287
+ public get lockPath(): string {
288
+ return path.join(this.storageDir, 'state.lock');
289
+ }
290
+
291
+ /**
292
+ * Execute mutation within a cross-process exclusive lock
293
+ */
294
+ public withLock<T>(fn: () => T): T {
295
+ const release = acquireSyncLock(this.lockPath);
296
+ try {
297
+ return fn();
298
+ } finally {
299
+ release();
300
+ }
301
+ }
302
+
303
+ private ensureDirs(): void {
304
+ if (!fs.existsSync(this.storageDir)) {
305
+ try {
306
+ fs.mkdirSync(this.storageDir, { recursive: true });
307
+ } catch {}
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Load store state. If corrupt or missing, returns safe default.
313
+ */
314
+ public loadState(): AnchorStoreState {
315
+ this.ensureDirs();
316
+ if (!fs.existsSync(this.statePath)) {
317
+ return {
318
+ version: 1,
319
+ anchors: [],
320
+ lastSweepAt: Date.now()
321
+ };
322
+ }
323
+
324
+ let raw: string | null = null;
325
+ for (let attempt = 1; attempt <= 3; attempt++) {
326
+ try {
327
+ raw = fs.readFileSync(this.statePath, 'utf-8');
328
+ break;
329
+ } catch (err: any) {
330
+ if ((err?.code === 'EBUSY' || err?.code === 'EPERM') && attempt < 3) {
331
+ sleepSync(15);
332
+ continue;
333
+ }
334
+ }
335
+ }
336
+
337
+ if (raw === null) {
338
+ return { version: 1, anchors: [], lastSweepAt: Date.now() };
339
+ }
340
+
341
+ try {
342
+ const data = JSON.parse(raw);
343
+ if (!Array.isArray(data.anchors)) {
344
+ throw new Error('Invalid state: anchors must be array');
345
+ }
346
+ return data as AnchorStoreState;
347
+ } catch {
348
+ const backupPath = path.join(this.storageDir, `state.corrupt.${Date.now()}.json`);
349
+ try {
350
+ fs.renameSync(this.statePath, backupPath);
351
+ } catch {}
352
+ return {
353
+ version: 1,
354
+ anchors: [],
355
+ lastSweepAt: Date.now()
356
+ };
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Atomically save store state via temp file + atomic rename with Windows NTFS spin-retry
362
+ */
363
+ public saveState(state: AnchorStoreState): void {
364
+ this.ensureDirs();
365
+ const tempPath = path.join(this.storageDir, `state.tmp.${process.pid}.${Date.now()}`);
366
+ fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), 'utf-8');
367
+ atomicRenameWithRetry(tempPath, this.statePath);
368
+ }
369
+
370
+ private generateId(existingAnchors: Anchor[]): string {
371
+ const numbers = existingAnchors
372
+ .map(a => {
373
+ const match = a.id.match(/^anc-(\d+)$/);
374
+ return match ? parseInt(match[1], 10) : 0;
375
+ })
376
+ .filter(n => n > 0);
377
+ const nextNum = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
378
+ return `anc-${nextNum}`;
379
+ }
380
+
381
+ /**
382
+ * Create a new Anchor with automatic project and cwd tagging
383
+ */
384
+ public create(input: {
385
+ title: string;
386
+ description?: string;
387
+ priority?: AnchorPriority;
388
+ durability?: AnchorDurability;
389
+ recurrence?: 'daily';
390
+ cwd?: string;
391
+ project?: string;
392
+ targetDate?: string;
393
+ files?: string[];
394
+ tags?: string[];
395
+ verifyCommand?: string;
396
+ decay?: AnchorDecayPolicy;
397
+ }): Anchor {
398
+ return this.withLock(() => {
399
+ const state = this.loadState();
400
+ const now = Date.now();
401
+ const id = this.generateId(state.anchors);
402
+
403
+ const cwd = input.cwd ? normalizePath(input.cwd) : '';
404
+ const projectName = input.project
405
+ ? input.project.trim()
406
+ : (cwd ? path.basename(cwd) : 'global');
407
+
408
+ const recurrence = input.recurrence || detectRecurrence(input.title);
409
+ const targetDate = input.targetDate || detectTargetDate(input.title, now);
410
+ // Recurring tasks are always durable
411
+ const durability = recurrence ? 'durable' : (input.durability || detectDurability(input.title));
412
+ const decayPolicy = durability === 'ephemeral'
413
+ ? getEphemeralDecayPolicy(input.title)
414
+ : DURABLE_DECAY_POLICY;
415
+
416
+ const anchor: Anchor = {
417
+ id,
418
+ title: input.title.trim(),
419
+ description: input.description?.trim(),
420
+ priority: input.priority || 'p1',
421
+ status: 'active',
422
+ durability,
423
+ recurrence,
424
+ targetDate,
425
+ project: projectName,
426
+ cwd,
427
+ createdAt: now,
428
+ updatedAt: now,
429
+ lastTouchedAt: now,
430
+ files: (input.files || []).map(f => normalizePath(f)).filter(Boolean),
431
+ tags: (input.tags || []).map(t => t.trim()).filter(Boolean),
432
+ verifyCommand: input.verifyCommand?.trim() || undefined,
433
+ decay: input.decay ? { ...input.decay } : { ...decayPolicy }
434
+ };
435
+
436
+ state.anchors.push(anchor);
437
+ this.saveState(state);
438
+ return anchor;
439
+ });
440
+ }
441
+
442
+ private findAnchorIndex(anchors: Anchor[], id: string): number {
443
+ const clean = id.trim().replace(/^#/, '');
444
+ return anchors.findIndex(a =>
445
+ a.id === clean ||
446
+ a.id === `anc-${clean}` ||
447
+ (clean.match(/^\d+$/) ? a.id === `anc-${parseInt(clean, 10)}` : false)
448
+ );
449
+ }
450
+
451
+ /**
452
+ * Get an anchor by ID (supports 'anc-1', '#anc-1', '1', '01')
453
+ */
454
+ public get(id: string): Anchor | undefined {
455
+ const state = this.loadState();
456
+ const idx = this.findAnchorIndex(state.anchors, id);
457
+ return idx === -1 ? undefined : state.anchors[idx];
458
+ }
459
+
460
+ /**
461
+ * List anchors. By default, if cwd is provided, only returns anchors
462
+ * belonging to this cwd or marked as global (''). Pass all: true for global view.
463
+ */
464
+ public list(filter?: {
465
+ status?: AnchorStatus;
466
+ priority?: AnchorPriority;
467
+ cwd?: string;
468
+ all?: boolean;
469
+ now?: number;
470
+ }): Anchor[] {
471
+ const state = this.loadState();
472
+ const targetCwd = filter?.cwd ? normalizePath(filter.cwd) : null;
473
+ const today = getTodayDateString(filter?.now);
474
+
475
+ return state.anchors.filter(a => {
476
+ // Recurring task completed today is hidden from active list
477
+ if (!filter?.all && filter?.status === 'active' && a.recurrence === 'daily' && a.lastCompletedDate === today) {
478
+ return false;
479
+ }
480
+
481
+ if (filter?.status && a.status !== filter.status) return false;
482
+ if (filter?.priority && a.priority !== filter.priority) return false;
483
+
484
+ // Project context filtering:
485
+ // If an anchor has specific code files attached, scope it to that cwd.
486
+ // If an anchor has NO code files attached (habits, general tasks, study), it is universal and visible everywhere!
487
+ if (!filter?.all && targetCwd) {
488
+ if (a.files.length > 0 && a.cwd && a.cwd !== targetCwd) {
489
+ return false;
490
+ }
491
+ }
492
+
493
+ return true;
494
+ });
495
+ }
496
+
497
+ /**
498
+ * Update an existing anchor
499
+ */
500
+ public update(id: string, patch: Partial<Omit<Anchor, 'id' | 'createdAt'>>): Anchor {
501
+ return this.withLock(() => {
502
+ const state = this.loadState();
503
+ const idx = this.findAnchorIndex(state.anchors, id);
504
+ if (idx === -1) {
505
+ throw new Error(`Anchor not found: ${id}`);
506
+ }
507
+
508
+ const current = state.anchors[idx];
509
+ const updated: Anchor = {
510
+ ...current,
511
+ ...patch,
512
+ updatedAt: Date.now()
513
+ };
514
+
515
+ if (patch.files) {
516
+ updated.files = patch.files.map(f => normalizePath(f)).filter(Boolean);
517
+ }
518
+
519
+ state.anchors[idx] = updated;
520
+ this.saveState(state);
521
+ return updated;
522
+ });
523
+ }
524
+
525
+ /**
526
+ * Touch an anchor to refresh its decay window and reactivate sleeping state
527
+ */
528
+ public touch(id: string, timestamp: number = Date.now()): Anchor {
529
+ return this.withLock(() => {
530
+ const state = this.loadState();
531
+ const idx = this.findAnchorIndex(state.anchors, id);
532
+ if (idx === -1) {
533
+ throw new Error(`Anchor not found: ${id}`);
534
+ }
535
+
536
+ const current = state.anchors[idx];
537
+ current.lastTouchedAt = timestamp;
538
+ current.updatedAt = timestamp;
539
+ if (current.status === 'sleeping') {
540
+ current.status = 'active';
541
+ }
542
+
543
+ state.anchors[idx] = current;
544
+ this.saveState(state);
545
+ return current;
546
+ });
547
+ }
548
+
549
+ /**
550
+ * Settle an anchor (recurring daily habits complete for today and wake up tomorrow)
551
+ */
552
+ public settle(id: string, evidence: AnchorEvidence = {}, now: number = Date.now()): Anchor {
553
+ return this.withLock(() => {
554
+ const state = this.loadState();
555
+ const idx = this.findAnchorIndex(state.anchors, id);
556
+ if (idx === -1) {
557
+ throw new Error(`Anchor not found: ${id}`);
558
+ }
559
+
560
+ const anchor = state.anchors[idx];
561
+ const today = getTodayDateString(now);
562
+
563
+ // If it's a recurring daily habit:
564
+ if (anchor.recurrence === 'daily') {
565
+ anchor.lastCompletedDate = today;
566
+ anchor.lastTouchedAt = now;
567
+ anchor.updatedAt = now;
568
+ anchor.evidence = {
569
+ ...evidence,
570
+ settledAt: now,
571
+ settledBy: evidence.settledBy || 'manual-command'
572
+ };
573
+
574
+ this.ensureDirs();
575
+ fs.appendFileSync(this.archivePath, JSON.stringify({ ...anchor, settledForDate: today }) + '\n', 'utf-8');
576
+ this.saveState(state);
577
+ return anchor;
578
+ }
579
+
580
+ // Normal one-off task: remove from state and archive
581
+ const [settled] = state.anchors.splice(idx, 1);
582
+ settled.status = 'settled';
583
+ settled.updatedAt = now;
584
+ settled.evidence = {
585
+ ...evidence,
586
+ settledAt: now,
587
+ settledBy: evidence.settledBy || 'manual-command'
588
+ };
589
+
590
+ this.ensureDirs();
591
+ fs.appendFileSync(this.archivePath, JSON.stringify(settled) + '\n', 'utf-8');
592
+ this.saveState(state);
593
+ return settled;
594
+ });
595
+ }
596
+
597
+ /**
598
+ * Reverse/undo the last settled anchor, popping it from archive.jsonl back into state.json
599
+ */
600
+ public undoSettle(): Anchor {
601
+ return this.withLock(() => {
602
+ if (!fs.existsSync(this.archivePath)) {
603
+ throw new Error('No archived anchors to undo');
604
+ }
605
+
606
+ const raw = fs.readFileSync(this.archivePath, 'utf-8');
607
+ const lines = raw.split('\n').filter(Boolean);
608
+ if (lines.length === 0) {
609
+ throw new Error('Archive is empty, nothing to undo');
610
+ }
611
+
612
+ const lastLine = lines.pop()!;
613
+ const anchor: Anchor = JSON.parse(lastLine);
614
+
615
+ // 1. Re-write archive without the last line
616
+ fs.writeFileSync(this.archivePath, lines.length > 0 ? lines.join('\n') + '\n' : '', 'utf-8');
617
+
618
+ // 2. Restore to active state
619
+ anchor.status = 'active';
620
+ anchor.updatedAt = Date.now();
621
+ delete anchor.evidence;
622
+
623
+ const state = this.loadState();
624
+ state.anchors.push(anchor);
625
+ this.saveState(state);
626
+
627
+ return anchor;
628
+ });
629
+ }
630
+
631
+ /**
632
+ * Evict anchor to graveyard.jsonl upon total decay expiry
633
+ */
634
+ public dropToGraveyard(id: string, reason: string): Anchor {
635
+ return this.withLock(() => {
636
+ const state = this.loadState();
637
+ const idx = state.anchors.findIndex(a => a.id === id);
638
+ if (idx === -1) {
639
+ throw new Error(`Anchor not found: ${id}`);
640
+ }
641
+
642
+ const [anchor] = state.anchors.splice(idx, 1);
643
+ anchor.status = 'graveyard';
644
+ anchor.updatedAt = Date.now();
645
+ anchor.evidence = {
646
+ summary: reason,
647
+ settledAt: Date.now()
648
+ };
649
+
650
+ this.ensureDirs();
651
+ fs.appendFileSync(this.graveyardPath, JSON.stringify(anchor) + '\n', 'utf-8');
652
+ this.saveState(state);
653
+ return anchor;
654
+ });
655
+ }
656
+
657
+ public getArchive(filter?: { cwd?: string; all?: boolean }): Anchor[] {
658
+ if (!fs.existsSync(this.archivePath)) return [];
659
+ try {
660
+ const raw = fs.readFileSync(this.archivePath, 'utf-8');
661
+ const all: Anchor[] = raw.split('\n').filter(Boolean).map((l: string) => JSON.parse(l));
662
+ if (filter?.all || !filter?.cwd) return all;
663
+ const targetCwd = normalizePath(filter.cwd);
664
+ return all.filter(a => !a.cwd || a.cwd === targetCwd);
665
+ } catch {
666
+ return [];
667
+ }
668
+ }
669
+
670
+ public getGraveyard(filter?: { cwd?: string; all?: boolean }): Anchor[] {
671
+ if (!fs.existsSync(this.graveyardPath)) return [];
672
+ try {
673
+ const raw = fs.readFileSync(this.graveyardPath, 'utf-8');
674
+ const all: Anchor[] = raw.split('\n').filter(Boolean).map((l: string) => JSON.parse(l));
675
+ if (filter?.all || !filter?.cwd) return all;
676
+ const targetCwd = normalizePath(filter.cwd);
677
+ return all.filter(a => !a.cwd || a.cwd === targetCwd);
678
+ } catch {
679
+ return [];
680
+ }
681
+ }
682
+ }