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/README.md +101 -195
- package/README_zh.md +115 -205
- package/dist/{chunk-B652T7QR.js → chunk-G72UHPBN.js} +218 -121
- package/dist/extension.cjs +286 -168
- package/dist/extension.js +73 -48
- package/dist/index.cjs +220 -121
- package/dist/index.d.cts +7 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +5 -1
- package/package.json +3 -3
- package/src/decay.ts +54 -52
- package/src/extension.ts +74 -55
- package/src/matcher.ts +27 -6
- package/src/settlement.ts +5 -3
- package/src/store.ts +75 -18
- package/src/tui.ts +82 -59
- package/src/types.ts +1 -0
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
return store.withLock(() => {
|
|
65
|
+
const state = store.loadState();
|
|
66
|
+
const result: SweepResult = {
|
|
67
|
+
transitionedToSleeping: [],
|
|
68
|
+
wokenToActive: [],
|
|
69
|
+
evictedToGraveyard: []
|
|
70
|
+
};
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
state.lastSweepAt = now;
|
|
119
|
+
if (stateModified) {
|
|
120
|
+
store.saveState(state);
|
|
121
|
+
}
|
|
121
122
|
|
|
122
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
78
|
-
|
|
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:
|
|
146
|
-
pi.on('session_shutdown', async (
|
|
147
|
-
|
|
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
|
-
|
|
158
|
+
gitStatus = execSync('git status --porcelain', {
|
|
150
159
|
cwd: ctx.cwd,
|
|
151
160
|
encoding: 'utf-8',
|
|
152
|
-
timeout:
|
|
161
|
+
timeout: 2000,
|
|
153
162
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
154
|
-
});
|
|
163
|
+
}).replace(/[\r\n]+$/, '');
|
|
155
164
|
const changed = gitStatus
|
|
156
|
-
.split(
|
|
157
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
236
|
+
if (!hasActiveUI(ctx)) continue;
|
|
202
237
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
//
|
|
439
|
-
pi.
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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:
|
|
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
|
|
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(
|
|
371
|
-
const numbers =
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
608
|
-
|
|
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.
|
|
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[] =
|
|
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
|
|
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[] =
|
|
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
|
|
734
|
+
return all.filter(a => !a.cwd || isSameOrSubPath(a.cwd, targetCwd));
|
|
678
735
|
} catch {
|
|
679
736
|
return [];
|
|
680
737
|
}
|