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/LICENSE +21 -0
- package/README.md +273 -0
- package/README_zh.md +272 -0
- package/bin/anchor.js +163 -0
- package/dist/chunk-NNYUD5O7.js +1295 -0
- package/dist/extension.cjs +1560 -0
- package/dist/extension.d.cts +5 -0
- package/dist/extension.d.ts +5 -0
- package/dist/extension.js +312 -0
- package/dist/index.cjs +1433 -0
- package/dist/index.d.cts +448 -0
- package/dist/index.d.ts +448 -0
- package/dist/index.js +144 -0
- package/package.json +64 -0
- package/src/context_injector.ts +157 -0
- package/src/decay.ts +123 -0
- package/src/extension.ts +363 -0
- package/src/index.ts +9 -0
- package/src/matcher.ts +130 -0
- package/src/observer.ts +141 -0
- package/src/pi-types.d.ts +42 -0
- package/src/protocol.ts +95 -0
- package/src/settlement.ts +72 -0
- package/src/store.ts +682 -0
- package/src/tui.ts +386 -0
- package/src/types.ts +104 -0
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
|
+
}
|