pi-anchor 0.2.1 → 0.2.3

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/decay.ts CHANGED
@@ -61,63 +61,65 @@ export interface SweepResult {
61
61
  * Run decay sweep across store: transitions statuses and evicts expired anchors
62
62
  */
63
63
  export function sweepStore(store: AnchorStore, now: number = Date.now()): SweepResult {
64
- const state = store.loadState();
65
- const result: SweepResult = {
66
- transitionedToSleeping: [],
67
- wokenToActive: [],
68
- evictedToGraveyard: []
69
- };
64
+ return store.withLock(() => {
65
+ const state = store.loadState();
66
+ const result: SweepResult = {
67
+ transitionedToSleeping: [],
68
+ wokenToActive: [],
69
+ evictedToGraveyard: []
70
+ };
70
71
 
71
- const toEvict: Anchor[] = [];
72
- let stateModified = false;
73
-
74
- for (const anchor of state.anchors) {
75
- const evalResult = evaluateAnchorDecay(anchor, now);
76
-
77
- if (evalResult.nextStatus === 'graveyard') {
78
- toEvict.push(anchor);
79
- } else if (evalResult.nextStatus !== anchor.status) {
80
- if (evalResult.nextStatus === 'sleeping' && anchor.status === 'active') {
81
- anchor.status = 'sleeping';
82
- anchor.updatedAt = now;
83
- result.transitionedToSleeping.push(anchor.id);
84
- stateModified = true;
85
- } else if (evalResult.nextStatus === 'active' && anchor.status === 'sleeping') {
86
- anchor.status = 'active';
87
- anchor.updatedAt = now;
88
- result.wokenToActive.push(anchor.id);
89
- stateModified = true;
72
+ const toEvict: Anchor[] = [];
73
+ let stateModified = false;
74
+
75
+ for (const anchor of state.anchors) {
76
+ const evalResult = evaluateAnchorDecay(anchor, now);
77
+
78
+ if (evalResult.nextStatus === 'graveyard') {
79
+ toEvict.push(anchor);
80
+ } else if (evalResult.nextStatus !== anchor.status) {
81
+ if (evalResult.nextStatus === 'sleeping' && anchor.status === 'active') {
82
+ anchor.status = 'sleeping';
83
+ anchor.updatedAt = now;
84
+ result.transitionedToSleeping.push(anchor.id);
85
+ stateModified = true;
86
+ } else if (evalResult.nextStatus === 'active' && anchor.status === 'sleeping') {
87
+ anchor.status = 'active';
88
+ anchor.updatedAt = now;
89
+ result.wokenToActive.push(anchor.id);
90
+ stateModified = true;
91
+ }
90
92
  }
91
93
  }
92
- }
93
94
 
94
- // Single-pass batch eviction and state save
95
- if (toEvict.length > 0) {
96
- const evictIds = new Set(toEvict.map(e => e.id));
97
- state.anchors = state.anchors.filter(a => !evictIds.has(a.id));
98
- stateModified = true;
99
-
100
- const graveyardLines = toEvict.map(exp => {
101
- const rec = {
102
- ...exp,
103
- status: 'graveyard',
104
- updatedAt: now,
105
- evictedAt: now,
106
- evictionReason: `Exceeded decay threshold (${exp.decay.graveyardDays} days untouched)`
107
- };
108
- return JSON.stringify(rec);
109
- }).join('\n') + '\n';
110
-
111
- fs.appendFileSync(store.graveyardPath, graveyardLines, 'utf-8');
112
- for (const exp of toEvict) {
113
- result.evictedToGraveyard.push(exp.id);
95
+ // Single-pass batch eviction and state save
96
+ if (toEvict.length > 0) {
97
+ const evictIds = new Set(toEvict.map(e => e.id));
98
+ state.anchors = state.anchors.filter(a => !evictIds.has(a.id));
99
+ stateModified = true;
100
+
101
+ const graveyardLines = toEvict.map(exp => {
102
+ const rec = {
103
+ ...exp,
104
+ status: 'graveyard',
105
+ updatedAt: now,
106
+ evictedAt: now,
107
+ evictionReason: `Exceeded decay threshold (${exp.decay.graveyardDays} days untouched)`
108
+ };
109
+ return JSON.stringify(rec);
110
+ }).join('\n') + '\n';
111
+
112
+ fs.appendFileSync(store.graveyardPath, graveyardLines, 'utf-8');
113
+ for (const exp of toEvict) {
114
+ result.evictedToGraveyard.push(exp.id);
115
+ }
114
116
  }
115
- }
116
117
 
117
- state.lastSweepAt = now;
118
- if (stateModified) {
119
- store.saveState(state);
120
- }
118
+ state.lastSweepAt = now;
119
+ if (stateModified) {
120
+ store.saveState(state);
121
+ }
121
122
 
122
- return result;
123
+ return result;
124
+ });
123
125
  }
package/src/extension.ts CHANGED
@@ -4,9 +4,9 @@ import { AnchorStore } from './store.ts';
4
4
  import { SessionTouchObserver } from './observer.ts';
5
5
  import { renderColdStartAnchorsContext, renderActiveAnchorsContext, makeSafeTaskAnnotation } from './context_injector.ts';
6
6
  import { generateSettlementProposals, runPhysicalVerification } from './settlement.ts';
7
- import { normalizePath, findMatchedAnchors } from './matcher.ts';
7
+ import { normalizePath, isSameOrSubPath, findMatchedAnchors } from './matcher.ts';
8
8
  import { sweepStore } from './decay.ts';
9
- import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner, truncateToWidth } from './tui.ts';
9
+ import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner, truncateToWidth, hasActiveUI } from './tui.ts';
10
10
  import { execSync } from 'node:child_process';
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
@@ -32,10 +32,10 @@ export default function (pi: ExtensionAPI) {
32
32
  ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} 个非活跃任务已进入休眠`, 'info');
33
33
  }
34
34
 
35
- // Physical Pre-flight Verification: auto-settle passing tasks or record failing output
35
+ // Physical Pre-flight Verification: auto-settle passing tasks or record failing output (scoped strictly to current cwd)
36
36
  const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
37
37
  for (const a of activeAnchors) {
38
- if (a.verifyCommand) {
38
+ if (a.verifyCommand && (!a.cwd || isSameOrSubPath(a.cwd, ctx.cwd))) {
39
39
  try {
40
40
  const res = runPhysicalVerification(a, ctx.cwd);
41
41
  if (res.success) {
@@ -57,10 +57,13 @@ export default function (pi: ExtensionAPI) {
57
57
  const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
58
58
  if (fs.existsSync(pendingPath)) {
59
59
  const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
60
- if (pending.cwd === ctx.cwd && Date.now() - pending.timestamp < 24 * 60 * 60 * 1000) {
61
- ctx.ui.notify(`⌖ [Anchor 退出守护] 检测到上次异常退出且有 ${pending.modifiedCount} 个未提交改动,可用 anchor(action="pin") 固化任务。`, 'info');
60
+ const isExpired = Date.now() - pending.timestamp >= 24 * 60 * 60 * 1000;
61
+ if (!isExpired && isSameOrSubPath(pending.cwd, ctx.cwd)) {
62
+ ctx.ui.notify(`⌖ [Exit Guard] Detected ${pending.modifiedCount} uncommitted changes from previous session exit. Run /pin to record.`, 'info');
63
+ fs.unlinkSync(pendingPath);
64
+ } else if (isExpired) {
65
+ fs.unlinkSync(pendingPath);
62
66
  }
63
- fs.unlinkSync(pendingPath);
64
67
  }
65
68
  } catch (_) {}
66
69
 
@@ -69,13 +72,19 @@ export default function (pi: ExtensionAPI) {
69
72
  // Only render startup banner on initial startup or new session, not on mid-session resume
70
73
  if (event.reason !== 'resume') {
71
74
  updateStartupBanner(ctx, store);
75
+ } else if (hasActiveUI(ctx)) {
76
+ try {
77
+ ctx.ui.setWidget('anchor-startup', undefined);
78
+ } catch {}
72
79
  }
73
80
  });
74
81
 
75
82
  // 2. Auto-clear startup banner when agent starts first turn to keep screen clean
76
83
  pi.on('agent_start', async (_event: any, ctx: ExtensionContext) => {
77
- if (ctx.hasUI && ctx.ui) {
78
- ctx.ui.setWidget('anchor-startup', undefined);
84
+ if (hasActiveUI(ctx)) {
85
+ try {
86
+ ctx.ui.setWidget('anchor-startup', undefined);
87
+ } catch {}
79
88
  }
80
89
  });
81
90
 
@@ -105,7 +114,7 @@ export default function (pi: ExtensionAPI) {
105
114
  const pathInput = (event.input as any)?.path;
106
115
  if (typeof pathInput !== 'string') return;
107
116
 
108
- const touchedPath = normalizePath(pathInput);
117
+ const touchedPath = normalizePath(pathInput, ctx.cwd);
109
118
  const anchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
110
119
  const matches = findMatchedAnchors(anchors, [touchedPath]);
111
120
 
@@ -142,25 +151,43 @@ export default function (pi: ExtensionAPI) {
142
151
  }
143
152
  });
144
153
 
145
- // 4. Session shutdown: One-tap settlement check scoped to current workspace
146
- pi.on('session_shutdown', async (_event: any, ctx: ExtensionContext) => {
147
- // Opportunistically scan git modified files
154
+ // 4. Session shutdown: Single-pass git inspection, one-tap settlement check & Exit Guard
155
+ pi.on('session_shutdown', async (event: any, ctx: ExtensionContext) => {
156
+ let gitStatus = '';
148
157
  try {
149
- const gitStatus = execSync('git status --porcelain', {
158
+ gitStatus = execSync('git status --porcelain', {
150
159
  cwd: ctx.cwd,
151
160
  encoding: 'utf-8',
152
- timeout: 3000,
161
+ timeout: 2000,
153
162
  stdio: ['ignore', 'pipe', 'ignore']
154
- });
163
+ }).replace(/[\r\n]+$/, '');
155
164
  const changed = gitStatus
156
- .split('\n')
157
- .map((l: string) => l.slice(3).trim())
165
+ .split(/\r?\n/)
166
+ .filter(Boolean)
167
+ .map((l: string) => l.slice(3).replace(/^"(.*)"$/, '$1').trim())
158
168
  .filter(Boolean);
159
169
  for (const f of changed) {
160
170
  observer.addTouchedFile(f);
161
171
  }
162
172
  } catch {}
163
173
 
174
+ // Exit Guard: detect uncommitted changes on abrupt session shutdown
175
+ if (event?.reason === 'quit' && gitStatus) {
176
+ try {
177
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
178
+ if (activeAnchors.length === 0) {
179
+ const home = process.env.HOME || process.env.USERPROFILE || '';
180
+ const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
181
+ const data = {
182
+ cwd: ctx.cwd,
183
+ timestamp: Date.now(),
184
+ modifiedCount: gitStatus.split('\n').filter(Boolean).length
185
+ };
186
+ fs.writeFileSync(pendingPath, JSON.stringify(data, null, 2), 'utf-8');
187
+ }
188
+ } catch {}
189
+ }
190
+
164
191
  const modified = observer.getModifiedFiles();
165
192
  if (modified.length === 0) return;
166
193
 
@@ -179,7 +206,11 @@ export default function (pi: ExtensionAPI) {
179
206
  summary: `Auto-settled via Git commit: ${commitMatch.message}`,
180
207
  touchedFiles: prop.matchedFiles
181
208
  });
182
- ctx.ui.notify(`⌖ Auto-settled #${a.id} via Git commit (run /anchor undo to revert)`, 'info');
209
+ if (hasActiveUI(ctx)) {
210
+ try {
211
+ ctx.ui.notify(`⌖ Auto-settled #${a.id} via Git commit (run /anchor undo to revert)`, 'info');
212
+ } catch {}
213
+ }
183
214
  continue;
184
215
  }
185
216
 
@@ -192,26 +223,32 @@ export default function (pi: ExtensionAPI) {
192
223
  summary: `Automated test passed: ${a.verifyCommand}`,
193
224
  touchedFiles: prop.matchedFiles
194
225
  });
195
- ctx.ui.notify(`⌖ Test passed (${a.verifyCommand}), auto-settled #${a.id}`, 'info');
226
+ if (hasActiveUI(ctx)) {
227
+ try {
228
+ ctx.ui.notify(`⌖ Test passed (${a.verifyCommand}), auto-settled #${a.id}`, 'info');
229
+ } catch {}
230
+ }
196
231
  continue;
197
232
  }
198
233
  }
199
234
 
200
235
  // 3. Fallback: Prompt user for one-tap settlement on exit
201
- if (!ctx.hasUI || !ctx.ui) continue;
236
+ if (!hasActiveUI(ctx)) continue;
202
237
 
203
- const ok = await ctx.ui.confirm(
204
- '⌖ Settle Anchor Task',
205
- `Task #${a.id} [${a.title}] touched files (${prop.matchedFiles.slice(0, 2).join(', ')}).\nMark as completed and archive?`
206
- );
238
+ try {
239
+ const ok = await ctx.ui.confirm(
240
+ '⌖ Settle Anchor Task',
241
+ `Task #${a.id} [${a.title}] touched files (${prop.matchedFiles.slice(0, 2).join(', ')}).\nMark as completed and archive?`
242
+ );
207
243
 
208
- if (ok) {
209
- store.settle(a.id, {
210
- settledBy: 'one-tap-settlement',
211
- touchedFiles: prop.matchedFiles
212
- });
213
- ctx.ui.notify(`⌖ Settled #${a.id}: "${a.title}"`, 'info');
214
- }
244
+ if (ok) {
245
+ store.settle(a.id, {
246
+ settledBy: 'one-tap-settlement',
247
+ touchedFiles: prop.matchedFiles
248
+ });
249
+ ctx.ui.notify(`⌖ Settled #${a.id}: "${a.title}"`, 'info');
250
+ }
251
+ } catch {}
215
252
  }
216
253
  });
217
254
 
@@ -435,29 +472,11 @@ export default function (pi: ExtensionAPI) {
435
472
  }
436
473
  });
437
474
 
438
- // 7. Exit Guard: detect uncommitted changes on abrupt session shutdown
439
- pi.on('session_shutdown', async (event: any, ctx: ExtensionContext) => {
440
- if (event.reason === 'quit') {
441
- try {
442
- const gitStatus = execSync('git status --porcelain', {
443
- cwd: ctx.cwd,
444
- encoding: 'utf-8',
445
- timeout: 2000,
446
- stdio: ['ignore', 'pipe', 'ignore']
447
- }).trim();
448
-
449
- const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
450
- if (gitStatus && activeAnchors.length === 0) {
451
- const home = process.env.HOME || process.env.USERPROFILE || '';
452
- const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
453
- const data = {
454
- cwd: ctx.cwd,
455
- timestamp: Date.now(),
456
- modifiedCount: gitStatus.split('\n').filter(Boolean).length
457
- };
458
- fs.writeFileSync(pendingPath, JSON.stringify(data, null, 2), 'utf-8');
459
- }
460
- } catch (_) {}
475
+ // Alias /anchors to open dashboard
476
+ pi.registerCommand('anchors', {
477
+ description: 'Inspect and settle active anchors',
478
+ handler: async (_args: string, ctx: ExtensionContext) => {
479
+ await openAnchorDashboard(ctx, store);
461
480
  }
462
481
  });
463
482
  }
package/src/matcher.ts CHANGED
@@ -5,12 +5,16 @@ import type { Anchor, TouchMatchResult } from './types.ts';
5
5
  * Convert simple glob pattern (e.g. *.ts, src/**\/*.ts, src/auth/*.ts) to RegExp
6
6
  */
7
7
  export function globToRegExp(pattern: string): RegExp {
8
- const escaped = pattern
8
+ const collapsed = pattern
9
+ .slice(0, 512)
10
+ .replace(/\/+(\*\*\/+)+/g, '/**/')
11
+ .replace(/\*{3,}/g, '**');
12
+ const escaped = collapsed
9
13
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
10
14
  .replace(/\/\*\*\//g, '___GLOB_DIR_SLASH___')
11
15
  .replace(/\*\*/g, '___GLOB_STAR_STAR___')
12
16
  .replace(/\*/g, '[^/]*')
13
- .replace(/___GLOB_DIR_SLASH___/g, '(?:/|/.+/)')
17
+ .replace(/___GLOB_DIR_SLASH___/g, '(?:/|/(?:[^/]+/)+)')
14
18
  .replace(/___GLOB_STAR_STAR___/g, '.*');
15
19
  return new RegExp(`^${escaped}$`);
16
20
  }
@@ -21,15 +25,32 @@ export function globToRegExp(pattern: string): RegExp {
21
25
  export function normalizePath(filePath: string, baseCwd?: string): string {
22
26
  if (typeof filePath !== 'string') return '';
23
27
  let norm = filePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
24
- if (baseCwd && (path.isAbsolute(filePath) || norm.startsWith('/'))) {
25
- const normBase = baseCwd.trim().replace(/\\/g, '/');
26
- if (norm.toLowerCase().startsWith(normBase.toLowerCase())) {
27
- norm = norm.slice(normBase.length).replace(/^\/+/, '');
28
+ if (baseCwd && (path.isAbsolute(filePath) || norm.startsWith('/') || /^[a-zA-Z]:\//.test(norm))) {
29
+ const normBase = baseCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
30
+ const lowerNorm = norm.toLowerCase();
31
+ const lowerBase = normBase.toLowerCase();
32
+ if (lowerNorm === lowerBase) {
33
+ norm = '';
34
+ } else if (lowerNorm.startsWith(lowerBase + '/')) {
35
+ norm = norm.slice(normBase.length + 1).replace(/^\/+/, '');
28
36
  }
29
37
  }
30
38
  return norm;
31
39
  }
32
40
 
41
+ /**
42
+ * Compare two workspace CWDs safely (case-insensitive on Windows, supports subdirectory scoping)
43
+ */
44
+ export function isSameOrSubPath(anchorCwd?: string, targetCwd?: string): boolean {
45
+ if (!anchorCwd || !targetCwd) return false;
46
+ const normA = anchorCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
47
+ const normT = targetCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
48
+ const isWinStyle = process.platform === 'win32' || /^[a-zA-Z]:\//.test(normA) || /^[a-zA-Z]:\//.test(normT);
49
+ const cmpA = isWinStyle ? normA.toLowerCase() : normA;
50
+ const cmpT = isWinStyle ? normT.toLowerCase() : normT;
51
+ return cmpA === cmpT || cmpT.startsWith(cmpA + '/') || cmpA.startsWith(cmpT + '/');
52
+ }
53
+
33
54
  /**
34
55
  * Match touched files against an anchor's registered physical patterns
35
56
  * Only anchors with explicit files/globs/prefixes are matched against file touches.
package/src/settlement.ts CHANGED
@@ -7,15 +7,17 @@ import { execSync } from 'node:child_process';
7
7
  * Execute physical verification test command for an anchor
8
8
  */
9
9
  export function runPhysicalVerification(anchor: Anchor, cwd: string): { success: boolean; output: string } {
10
- if (!anchor.verifyCommand) {
10
+ if (!anchor.verifyCommand?.trim()) {
11
11
  return { success: false, output: 'No verification command specified' };
12
12
  }
13
13
 
14
+ const targetCwd = anchor.cwd || cwd;
14
15
  try {
15
16
  const stdout = execSync(anchor.verifyCommand, {
16
- cwd,
17
+ cwd: targetCwd,
17
18
  encoding: 'utf-8',
18
- timeout: 30000,
19
+ timeout: 8000,
20
+ maxBuffer: 4 * 1024 * 1024,
19
21
  stdio: ['ignore', 'pipe', 'pipe']
20
22
  });
21
23
  return { success: true, output: stdout.trim() };
package/src/store.ts CHANGED
@@ -11,7 +11,7 @@ import type {
11
11
  AnchorStoreState
12
12
  } from './types.ts';
13
13
  import { DEFAULT_DECAY_POLICY, DURABLE_DECAY_POLICY, EPHEMERAL_DECAY_POLICY } from './types.ts';
14
- import { normalizePath } from './matcher.ts';
14
+ import { normalizePath, isSameOrSubPath } from './matcher.ts';
15
15
 
16
16
  /**
17
17
  * Detect whether a task is a short-term ephemeral reminder or a long-term architectural vision
@@ -340,9 +340,21 @@ export class AnchorStore {
340
340
 
341
341
  try {
342
342
  const data = JSON.parse(raw);
343
- if (!Array.isArray(data.anchors)) {
343
+ if (!data || typeof data !== 'object' || !Array.isArray(data.anchors)) {
344
344
  throw new Error('Invalid state: anchors must be array');
345
345
  }
346
+ data.anchors = data.anchors
347
+ .filter((a: any) => a && typeof a === 'object' && typeof a.id === 'string' && typeof a.title === 'string')
348
+ .map((a: any) => ({
349
+ ...a,
350
+ files: Array.isArray(a.files) ? a.files.filter((f: unknown) => typeof f === 'string') : [],
351
+ tags: Array.isArray(a.tags) ? a.tags.filter((t: unknown) => typeof t === 'string') : [],
352
+ decay: {
353
+ activeDays: Number.isFinite(a.decay?.activeDays) && a.decay.activeDays > 0 ? a.decay.activeDays : DURABLE_DECAY_POLICY.activeDays,
354
+ sleepDays: Number.isFinite(a.decay?.sleepDays) && a.decay.sleepDays > 0 ? a.decay.sleepDays : DURABLE_DECAY_POLICY.sleepDays,
355
+ graveyardDays: Number.isFinite(a.decay?.graveyardDays) && a.decay.graveyardDays > 0 ? a.decay.graveyardDays : DURABLE_DECAY_POLICY.graveyardDays,
356
+ },
357
+ }));
346
358
  return data as AnchorStoreState;
347
359
  } catch {
348
360
  const backupPath = path.join(this.storageDir, `state.corrupt.${Date.now()}.json`);
@@ -362,19 +374,22 @@ export class AnchorStore {
362
374
  */
363
375
  public saveState(state: AnchorStoreState): void {
364
376
  this.ensureDirs();
365
- const tempPath = path.join(this.storageDir, `state.tmp.${process.pid}.${Date.now()}`);
377
+ const rand = Math.random().toString(36).slice(2, 7);
378
+ const tempPath = path.join(this.storageDir, `state.tmp.${process.pid}.${Date.now()}.${rand}`);
366
379
  fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), 'utf-8');
367
380
  atomicRenameWithRetry(tempPath, this.statePath);
368
381
  }
369
382
 
370
- private generateId(existingAnchors: Anchor[]): string {
371
- const numbers = existingAnchors
383
+ private generateId(state: AnchorStoreState): string {
384
+ const numbers = state.anchors
372
385
  .map(a => {
373
386
  const match = a.id.match(/^anc-(\d+)$/);
374
387
  return match ? parseInt(match[1], 10) : 0;
375
388
  })
376
389
  .filter(n => n > 0);
377
- const nextNum = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
390
+ const maxExisting = numbers.length > 0 ? Math.max(...numbers) : 0;
391
+ const nextNum = Math.max(maxExisting + 1, state.nextId || 1);
392
+ state.nextId = nextNum + 1;
378
393
  return `anc-${nextNum}`;
379
394
  }
380
395
 
@@ -398,7 +413,7 @@ export class AnchorStore {
398
413
  return this.withLock(() => {
399
414
  const state = this.loadState();
400
415
  const now = Date.now();
401
- const id = this.generateId(state.anchors);
416
+ const id = this.generateId(state);
402
417
 
403
418
  const cwd = input.cwd ? normalizePath(input.cwd) : '';
404
419
  const projectName = input.project
@@ -485,7 +500,7 @@ export class AnchorStore {
485
500
  // If an anchor has specific code files attached, scope it to that cwd.
486
501
  // If an anchor has NO code files attached (habits, general tasks, study), it is universal and visible everywhere!
487
502
  if (!filter?.all && targetCwd) {
488
- if (a.files.length > 0 && a.cwd && a.cwd !== targetCwd) {
503
+ if (a.files.length > 0 && a.cwd && !isSameOrSubPath(a.cwd, targetCwd)) {
489
504
  return false;
490
505
  }
491
506
  }
@@ -604,14 +619,21 @@ export class AnchorStore {
604
619
  }
605
620
 
606
621
  const raw = fs.readFileSync(this.archivePath, 'utf-8');
607
- const lines = raw.split('\n').filter(Boolean);
608
- if (lines.length === 0) {
622
+ const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
623
+ let anchor: Anchor | null = null;
624
+ while (lines.length > 0 && !anchor) {
625
+ const candidate = lines.pop()!;
626
+ try {
627
+ const parsed = JSON.parse(candidate);
628
+ if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string' && typeof parsed.title === 'string') {
629
+ anchor = parsed;
630
+ }
631
+ } catch {}
632
+ }
633
+ if (!anchor) {
609
634
  throw new Error('Archive is empty, nothing to undo');
610
635
  }
611
636
 
612
- const lastLine = lines.pop()!;
613
- const anchor: Anchor = JSON.parse(lastLine);
614
-
615
637
  // 1. Re-write archive without the last line
616
638
  fs.writeFileSync(this.archivePath, lines.length > 0 ? lines.join('\n') + '\n' : '', 'utf-8');
617
639
 
@@ -619,9 +641,28 @@ export class AnchorStore {
619
641
  anchor.status = 'active';
620
642
  anchor.updatedAt = Date.now();
621
643
  delete anchor.evidence;
644
+ delete (anchor as any).settledForDate;
645
+ if (anchor.recurrence === 'daily') {
646
+ delete anchor.lastCompletedDate;
647
+ }
622
648
 
623
649
  const state = this.loadState();
624
- state.anchors.push(anchor);
650
+ const existingIdx = state.anchors.findIndex(a => a.id === anchor.id);
651
+ if (existingIdx !== -1) {
652
+ if (state.anchors[existingIdx].title === anchor.title) {
653
+ state.anchors[existingIdx] = anchor;
654
+ } else {
655
+ anchor.id = this.generateId(state);
656
+ state.anchors.push(anchor);
657
+ }
658
+ } else {
659
+ const match = anchor.id.match(/^anc-(\d+)$/);
660
+ if (match) {
661
+ const restoredNum = parseInt(match[1], 10);
662
+ state.nextId = Math.max(state.nextId || 1, restoredNum + 1);
663
+ }
664
+ state.anchors.push(anchor);
665
+ }
625
666
  this.saveState(state);
626
667
 
627
668
  return anchor;
@@ -658,10 +699,18 @@ export class AnchorStore {
658
699
  if (!fs.existsSync(this.archivePath)) return [];
659
700
  try {
660
701
  const raw = fs.readFileSync(this.archivePath, 'utf-8');
661
- const all: Anchor[] = raw.split('\n').filter(Boolean).map((l: string) => JSON.parse(l));
702
+ const all: Anchor[] = [];
703
+ for (const l of raw.split(/\r?\n/)) {
704
+ const trimmed = l.trim();
705
+ if (!trimmed) continue;
706
+ try {
707
+ const parsed = JSON.parse(trimmed);
708
+ if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') all.push(parsed);
709
+ } catch {}
710
+ }
662
711
  if (filter?.all || !filter?.cwd) return all;
663
712
  const targetCwd = normalizePath(filter.cwd);
664
- return all.filter(a => !a.cwd || a.cwd === targetCwd);
713
+ return all.filter(a => !a.cwd || isSameOrSubPath(a.cwd, targetCwd));
665
714
  } catch {
666
715
  return [];
667
716
  }
@@ -671,10 +720,18 @@ export class AnchorStore {
671
720
  if (!fs.existsSync(this.graveyardPath)) return [];
672
721
  try {
673
722
  const raw = fs.readFileSync(this.graveyardPath, 'utf-8');
674
- const all: Anchor[] = raw.split('\n').filter(Boolean).map((l: string) => JSON.parse(l));
723
+ const all: Anchor[] = [];
724
+ for (const l of raw.split(/\r?\n/)) {
725
+ const trimmed = l.trim();
726
+ if (!trimmed) continue;
727
+ try {
728
+ const parsed = JSON.parse(trimmed);
729
+ if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') all.push(parsed);
730
+ } catch {}
731
+ }
675
732
  if (filter?.all || !filter?.cwd) return all;
676
733
  const targetCwd = normalizePath(filter.cwd);
677
- return all.filter(a => !a.cwd || a.cwd === targetCwd);
734
+ return all.filter(a => !a.cwd || isSameOrSubPath(a.cwd, targetCwd));
678
735
  } catch {
679
736
  return [];
680
737
  }