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.
@@ -0,0 +1,1560 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/extension.ts
31
+ var extension_exports = {};
32
+ __export(extension_exports, {
33
+ default: () => extension_default
34
+ });
35
+ module.exports = __toCommonJS(extension_exports);
36
+ var import_typebox = require("@sinclair/typebox");
37
+
38
+ // src/store.ts
39
+ var import_node_fs = __toESM(require("fs"), 1);
40
+ var import_node_path2 = __toESM(require("path"), 1);
41
+ var import_node_os = __toESM(require("os"), 1);
42
+
43
+ // src/types.ts
44
+ var EPHEMERAL_DECAY_POLICY = {
45
+ activeDays: 1,
46
+ // 24 hours active
47
+ sleepDays: 1,
48
+ // 24 hours sleeping
49
+ graveyardDays: 2
50
+ // 48 hours total before auto-clearing
51
+ };
52
+ var DURABLE_DECAY_POLICY = {
53
+ activeDays: 3,
54
+ sleepDays: 30,
55
+ graveyardDays: 9999
56
+ // Never permanently auto-dropped; preserved indefinitely
57
+ };
58
+
59
+ // src/matcher.ts
60
+ var import_node_path = __toESM(require("path"), 1);
61
+ function globToRegExp(pattern) {
62
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\/\*\*\//g, "___GLOB_DIR_SLASH___").replace(/\*\*/g, "___GLOB_STAR_STAR___").replace(/\*/g, "[^/]*").replace(/___GLOB_DIR_SLASH___/g, "(?:/|/.+/)").replace(/___GLOB_STAR_STAR___/g, ".*");
63
+ return new RegExp(`^${escaped}$`);
64
+ }
65
+ function normalizePath(filePath, baseCwd) {
66
+ if (typeof filePath !== "string") return "";
67
+ let norm = filePath.trim().replace(/\\/g, "/").replace(/^\.\//, "");
68
+ if (baseCwd && (import_node_path.default.isAbsolute(filePath) || norm.startsWith("/"))) {
69
+ const normBase = baseCwd.trim().replace(/\\/g, "/");
70
+ if (norm.toLowerCase().startsWith(normBase.toLowerCase())) {
71
+ norm = norm.slice(normBase.length).replace(/^\/+/, "");
72
+ }
73
+ }
74
+ return norm;
75
+ }
76
+ function matchAnchorAgainstTouchedFiles(anchor, touchedFiles) {
77
+ if (!anchor.files || anchor.files.length === 0) return null;
78
+ const normTouched = touchedFiles.map((f) => normalizePath(f, anchor.cwd)).filter(Boolean);
79
+ if (normTouched.length === 0) return null;
80
+ const matchedExact = [];
81
+ const matchedPrefix = [];
82
+ for (const touched of normTouched) {
83
+ for (const pattern of anchor.files) {
84
+ const normPattern = normalizePath(pattern, anchor.cwd);
85
+ if (normPattern === touched) {
86
+ matchedExact.push(touched);
87
+ break;
88
+ }
89
+ if (normPattern.includes("*")) {
90
+ const regex = globToRegExp(normPattern);
91
+ if (regex.test(touched)) {
92
+ matchedExact.push(touched);
93
+ break;
94
+ }
95
+ }
96
+ const dirPrefix = normPattern.endsWith("/") ? normPattern : normPattern + "/";
97
+ if (touched.startsWith(dirPrefix)) {
98
+ matchedPrefix.push(touched);
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ if (matchedExact.length > 0) {
104
+ return {
105
+ anchor,
106
+ score: 1,
107
+ matchedFiles: Array.from(new Set(matchedExact)),
108
+ reason: "exact-file"
109
+ };
110
+ }
111
+ if (matchedPrefix.length > 0) {
112
+ return {
113
+ anchor,
114
+ score: 0.85,
115
+ matchedFiles: Array.from(new Set(matchedPrefix)),
116
+ reason: "dir-prefix"
117
+ };
118
+ }
119
+ return null;
120
+ }
121
+ function findMatchedAnchors(anchors, touchedFiles) {
122
+ const priorityWeight = { p0: 3, p1: 2, p2: 1 };
123
+ const results = [];
124
+ for (const anchor of anchors) {
125
+ const match = matchAnchorAgainstTouchedFiles(anchor, touchedFiles);
126
+ if (match) {
127
+ results.push(match);
128
+ }
129
+ }
130
+ return results.sort((a, b) => {
131
+ if (b.score !== a.score) {
132
+ return b.score - a.score;
133
+ }
134
+ const weightA = priorityWeight[a.anchor.priority] || 0;
135
+ const weightB = priorityWeight[b.anchor.priority] || 0;
136
+ if (weightB !== weightA) {
137
+ return weightB - weightA;
138
+ }
139
+ return b.anchor.lastTouchedAt - a.anchor.lastTouchedAt;
140
+ });
141
+ }
142
+
143
+ // src/store.ts
144
+ function detectDurability(title) {
145
+ const temporalKeywords = [
146
+ "\u4ECA\u5929",
147
+ "\u4ECA\u65E5",
148
+ "\u665A\u4E0A",
149
+ "\u4ECA\u665A",
150
+ "\u660E\u5929",
151
+ "\u540E\u5929",
152
+ "\u5927\u540E\u5929",
153
+ "\u7A0D\u540E",
154
+ "\u5F85\u4F1A",
155
+ "\u4E0B\u5348",
156
+ "\u4E0A\u5348",
157
+ "\u4E34\u65F6",
158
+ "\u4E00\u4F1A\u513F",
159
+ "\u7B49\u7B49",
160
+ "today",
161
+ "tonight",
162
+ "tomorrow",
163
+ "later",
164
+ "temp",
165
+ "soon"
166
+ ];
167
+ const lower = title.toLowerCase();
168
+ for (const kw of temporalKeywords) {
169
+ if (lower.includes(kw)) {
170
+ return "ephemeral";
171
+ }
172
+ }
173
+ return "durable";
174
+ }
175
+ function getEphemeralDecayPolicy(title) {
176
+ const lower = title.toLowerCase();
177
+ if (lower.includes("\u5927\u540E\u5929")) {
178
+ return { activeDays: 3, sleepDays: 1, graveyardDays: 4 };
179
+ }
180
+ if (lower.includes("\u540E\u5929")) {
181
+ return { activeDays: 2, sleepDays: 1, graveyardDays: 3 };
182
+ }
183
+ return { ...EPHEMERAL_DECAY_POLICY };
184
+ }
185
+ function detectTargetDate(titleOrDate, now = Date.now()) {
186
+ const lower = titleOrDate.toLowerCase().trim();
187
+ const MS_DAY = 24 * 60 * 60 * 1e3;
188
+ if (lower.includes("\u4ECA\u5929") || lower.includes("\u4ECA\u65E5") || lower.includes("\u4ECA\u665A") || lower === "today" || lower === "tonight") {
189
+ return getTodayDateString(now);
190
+ }
191
+ if (lower.includes("\u660E\u5929") || lower === "tomorrow") {
192
+ return getTodayDateString(now + MS_DAY);
193
+ }
194
+ if (lower.includes("\u540E\u5929")) {
195
+ return getTodayDateString(now + 2 * MS_DAY);
196
+ }
197
+ if (lower.includes("\u5927\u540E\u5929")) {
198
+ return getTodayDateString(now + 3 * MS_DAY);
199
+ }
200
+ const weekdayMap = {
201
+ "\u5468\u65E5": 0,
202
+ "\u661F\u671F\u65E5": 0,
203
+ "sunday": 0,
204
+ "sun": 0,
205
+ "\u5468\u4E00": 1,
206
+ "\u661F\u671F\u4E00": 1,
207
+ "monday": 1,
208
+ "mon": 1,
209
+ "\u5468\u4E8C": 2,
210
+ "\u661F\u671F\u4E8C": 2,
211
+ "tuesday": 2,
212
+ "tue": 2,
213
+ "\u5468\u4E09": 3,
214
+ "\u661F\u671F\u4E09": 3,
215
+ "wednesday": 3,
216
+ "wed": 3,
217
+ "\u5468\u56DB": 4,
218
+ "\u661F\u671F\u56DB": 4,
219
+ "thursday": 4,
220
+ "thu": 4,
221
+ "\u5468\u4E94": 5,
222
+ "\u661F\u671F\u4E94": 5,
223
+ "friday": 5,
224
+ "fri": 5,
225
+ "\u5468\u516D": 6,
226
+ "\u661F\u671F\u516D": 6,
227
+ "saturday": 6,
228
+ "sat": 6
229
+ };
230
+ const isNextWeek = lower.includes("\u4E0B\u5468") || lower.includes("next");
231
+ for (const [name, targetDay] of Object.entries(weekdayMap)) {
232
+ if (lower.includes(name)) {
233
+ const curDay = new Date(now).getDay();
234
+ let diff = targetDay - curDay;
235
+ if (diff <= 0 || isNextWeek) {
236
+ diff += 7;
237
+ }
238
+ return getTodayDateString(now + diff * MS_DAY);
239
+ }
240
+ }
241
+ const fullDateMatch = titleOrDate.match(/\b(20\d\d)-(\d{1,2})-(\d{1,2})\b/);
242
+ if (fullDateMatch) {
243
+ const y = fullDateMatch[1];
244
+ const m = fullDateMatch[2].padStart(2, "0");
245
+ const d = fullDateMatch[3].padStart(2, "0");
246
+ return `${y}-${m}-${d}`;
247
+ }
248
+ const mmddMatch = titleOrDate.match(/\b(\d{1,2})-(\d{1,2})\b/);
249
+ if (mmddMatch) {
250
+ const curYear = new Date(now).getFullYear();
251
+ const m = mmddMatch[1].padStart(2, "0");
252
+ const d = mmddMatch[2].padStart(2, "0");
253
+ return `${curYear}-${m}-${d}`;
254
+ }
255
+ return void 0;
256
+ }
257
+ function detectRecurrence(title) {
258
+ const lower = title.toLowerCase();
259
+ if (lower.includes("\u6BCF\u5929") || lower.includes("\u6BCF\u65E5") || lower.includes("daily") || lower.includes("every day")) {
260
+ return "daily";
261
+ }
262
+ return void 0;
263
+ }
264
+ function getTodayDateString(timestamp = Date.now()) {
265
+ const d = new Date(timestamp);
266
+ const year = d.getFullYear();
267
+ const month = String(d.getMonth() + 1).padStart(2, "0");
268
+ const day = String(d.getDate()).padStart(2, "0");
269
+ return `${year}-${month}-${day}`;
270
+ }
271
+ function getDefaultStorageDir() {
272
+ if (process.env.ANCHOR_DIR) {
273
+ return import_node_path2.default.resolve(process.env.ANCHOR_DIR);
274
+ }
275
+ const primaryDir = import_node_path2.default.join(import_node_os.default.homedir(), ".anchor");
276
+ const legacyPiDir = import_node_path2.default.join(import_node_os.default.homedir(), ".pi", "agent", "anchors");
277
+ if (!import_node_fs.default.existsSync(primaryDir) && import_node_fs.default.existsSync(legacyPiDir)) {
278
+ try {
279
+ import_node_fs.default.mkdirSync(primaryDir, { recursive: true });
280
+ for (const file of ["state.json", "archive.jsonl", "graveyard.jsonl"]) {
281
+ const src = import_node_path2.default.join(legacyPiDir, file);
282
+ const dest = import_node_path2.default.join(primaryDir, file);
283
+ if (import_node_fs.default.existsSync(src) && !import_node_fs.default.existsSync(dest)) {
284
+ import_node_fs.default.copyFileSync(src, dest);
285
+ }
286
+ }
287
+ } catch {
288
+ }
289
+ }
290
+ return primaryDir;
291
+ }
292
+ function sleepSync(ms) {
293
+ const end = Date.now() + ms;
294
+ while (Date.now() < end) {
295
+ }
296
+ }
297
+ function isProcessAlive(pid) {
298
+ try {
299
+ process.kill(pid, 0);
300
+ return true;
301
+ } catch (err) {
302
+ return err.code === "EPERM";
303
+ }
304
+ }
305
+ function acquireSyncLock(lockPath, maxWaitMs = 1500, staleTimeoutMs = 5e3) {
306
+ const start = Date.now();
307
+ let attempt = 0;
308
+ while (Date.now() - start < maxWaitMs) {
309
+ attempt++;
310
+ try {
311
+ const fd = import_node_fs.default.openSync(lockPath, "wx");
312
+ const meta = { pid: process.pid, createdAt: Date.now() };
313
+ import_node_fs.default.writeFileSync(fd, JSON.stringify(meta), "utf-8");
314
+ import_node_fs.default.closeSync(fd);
315
+ return () => {
316
+ try {
317
+ if (import_node_fs.default.existsSync(lockPath)) {
318
+ import_node_fs.default.unlinkSync(lockPath);
319
+ }
320
+ } catch {
321
+ }
322
+ };
323
+ } catch (err) {
324
+ if (err.code === "EEXIST") {
325
+ try {
326
+ const content = import_node_fs.default.readFileSync(lockPath, "utf-8");
327
+ const meta = JSON.parse(content);
328
+ const isStale = Date.now() - meta.createdAt > staleTimeoutMs;
329
+ const isDead = meta.pid && !isProcessAlive(meta.pid);
330
+ if (isStale || isDead) {
331
+ try {
332
+ import_node_fs.default.unlinkSync(lockPath);
333
+ continue;
334
+ } catch {
335
+ }
336
+ }
337
+ } catch {
338
+ }
339
+ const delay = Math.min(40, Math.floor(attempt * 4 + Math.random() * 8));
340
+ sleepSync(delay);
341
+ continue;
342
+ }
343
+ break;
344
+ }
345
+ }
346
+ return () => {
347
+ };
348
+ }
349
+ function atomicRenameWithRetry(tempPath, targetPath, maxAttempts = 6) {
350
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
351
+ try {
352
+ import_node_fs.default.renameSync(tempPath, targetPath);
353
+ return;
354
+ } catch (err) {
355
+ const code = err?.code;
356
+ if ((code === "EBUSY" || code === "EPERM" || code === "EACCES") && attempt < maxAttempts) {
357
+ const delay = Math.floor(attempt * 20 + Math.random() * 15);
358
+ sleepSync(delay);
359
+ continue;
360
+ }
361
+ const crashDump = `${tempPath}.failed`;
362
+ try {
363
+ if (import_node_fs.default.existsSync(tempPath)) import_node_fs.default.renameSync(tempPath, crashDump);
364
+ } catch {
365
+ }
366
+ throw err;
367
+ }
368
+ }
369
+ }
370
+ var AnchorStore = class {
371
+ storageDir;
372
+ statePath;
373
+ archivePath;
374
+ graveyardPath;
375
+ constructor(customStorageDir) {
376
+ if (customStorageDir) {
377
+ this.storageDir = import_node_path2.default.resolve(customStorageDir);
378
+ } else {
379
+ this.storageDir = getDefaultStorageDir();
380
+ }
381
+ this.statePath = import_node_path2.default.join(this.storageDir, "state.json");
382
+ this.archivePath = import_node_path2.default.join(this.storageDir, "archive.jsonl");
383
+ this.graveyardPath = import_node_path2.default.join(this.storageDir, "graveyard.jsonl");
384
+ this.ensureDirs();
385
+ }
386
+ get lockPath() {
387
+ return import_node_path2.default.join(this.storageDir, "state.lock");
388
+ }
389
+ /**
390
+ * Execute mutation within a cross-process exclusive lock
391
+ */
392
+ withLock(fn) {
393
+ const release = acquireSyncLock(this.lockPath);
394
+ try {
395
+ return fn();
396
+ } finally {
397
+ release();
398
+ }
399
+ }
400
+ ensureDirs() {
401
+ if (!import_node_fs.default.existsSync(this.storageDir)) {
402
+ try {
403
+ import_node_fs.default.mkdirSync(this.storageDir, { recursive: true });
404
+ } catch {
405
+ }
406
+ }
407
+ }
408
+ /**
409
+ * Load store state. If corrupt or missing, returns safe default.
410
+ */
411
+ loadState() {
412
+ this.ensureDirs();
413
+ if (!import_node_fs.default.existsSync(this.statePath)) {
414
+ return {
415
+ version: 1,
416
+ anchors: [],
417
+ lastSweepAt: Date.now()
418
+ };
419
+ }
420
+ let raw = null;
421
+ for (let attempt = 1; attempt <= 3; attempt++) {
422
+ try {
423
+ raw = import_node_fs.default.readFileSync(this.statePath, "utf-8");
424
+ break;
425
+ } catch (err) {
426
+ if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 3) {
427
+ sleepSync(15);
428
+ continue;
429
+ }
430
+ }
431
+ }
432
+ if (raw === null) {
433
+ return { version: 1, anchors: [], lastSweepAt: Date.now() };
434
+ }
435
+ try {
436
+ const data = JSON.parse(raw);
437
+ if (!Array.isArray(data.anchors)) {
438
+ throw new Error("Invalid state: anchors must be array");
439
+ }
440
+ return data;
441
+ } catch {
442
+ const backupPath = import_node_path2.default.join(this.storageDir, `state.corrupt.${Date.now()}.json`);
443
+ try {
444
+ import_node_fs.default.renameSync(this.statePath, backupPath);
445
+ } catch {
446
+ }
447
+ return {
448
+ version: 1,
449
+ anchors: [],
450
+ lastSweepAt: Date.now()
451
+ };
452
+ }
453
+ }
454
+ /**
455
+ * Atomically save store state via temp file + atomic rename with Windows NTFS spin-retry
456
+ */
457
+ saveState(state) {
458
+ this.ensureDirs();
459
+ const tempPath = import_node_path2.default.join(this.storageDir, `state.tmp.${process.pid}.${Date.now()}`);
460
+ import_node_fs.default.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf-8");
461
+ atomicRenameWithRetry(tempPath, this.statePath);
462
+ }
463
+ generateId(existingAnchors) {
464
+ const numbers = existingAnchors.map((a) => {
465
+ const match = a.id.match(/^anc-(\d+)$/);
466
+ return match ? parseInt(match[1], 10) : 0;
467
+ }).filter((n) => n > 0);
468
+ const nextNum = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
469
+ return `anc-${nextNum}`;
470
+ }
471
+ /**
472
+ * Create a new Anchor with automatic project and cwd tagging
473
+ */
474
+ create(input) {
475
+ return this.withLock(() => {
476
+ const state = this.loadState();
477
+ const now = Date.now();
478
+ const id = this.generateId(state.anchors);
479
+ const cwd = input.cwd ? normalizePath(input.cwd) : "";
480
+ const projectName = input.project ? input.project.trim() : cwd ? import_node_path2.default.basename(cwd) : "global";
481
+ const recurrence = input.recurrence || detectRecurrence(input.title);
482
+ const targetDate = input.targetDate || detectTargetDate(input.title, now);
483
+ const durability = recurrence ? "durable" : input.durability || detectDurability(input.title);
484
+ const decayPolicy = durability === "ephemeral" ? getEphemeralDecayPolicy(input.title) : DURABLE_DECAY_POLICY;
485
+ const anchor = {
486
+ id,
487
+ title: input.title.trim(),
488
+ description: input.description?.trim(),
489
+ priority: input.priority || "p1",
490
+ status: "active",
491
+ durability,
492
+ recurrence,
493
+ targetDate,
494
+ project: projectName,
495
+ cwd,
496
+ createdAt: now,
497
+ updatedAt: now,
498
+ lastTouchedAt: now,
499
+ files: (input.files || []).map((f) => normalizePath(f)).filter(Boolean),
500
+ tags: (input.tags || []).map((t) => t.trim()).filter(Boolean),
501
+ verifyCommand: input.verifyCommand?.trim() || void 0,
502
+ decay: input.decay ? { ...input.decay } : { ...decayPolicy }
503
+ };
504
+ state.anchors.push(anchor);
505
+ this.saveState(state);
506
+ return anchor;
507
+ });
508
+ }
509
+ findAnchorIndex(anchors, id) {
510
+ const clean = id.trim().replace(/^#/, "");
511
+ return anchors.findIndex(
512
+ (a) => a.id === clean || a.id === `anc-${clean}` || (clean.match(/^\d+$/) ? a.id === `anc-${parseInt(clean, 10)}` : false)
513
+ );
514
+ }
515
+ /**
516
+ * Get an anchor by ID (supports 'anc-1', '#anc-1', '1', '01')
517
+ */
518
+ get(id) {
519
+ const state = this.loadState();
520
+ const idx = this.findAnchorIndex(state.anchors, id);
521
+ return idx === -1 ? void 0 : state.anchors[idx];
522
+ }
523
+ /**
524
+ * List anchors. By default, if cwd is provided, only returns anchors
525
+ * belonging to this cwd or marked as global (''). Pass all: true for global view.
526
+ */
527
+ list(filter) {
528
+ const state = this.loadState();
529
+ const targetCwd = filter?.cwd ? normalizePath(filter.cwd) : null;
530
+ const today = getTodayDateString(filter?.now);
531
+ return state.anchors.filter((a) => {
532
+ if (!filter?.all && filter?.status === "active" && a.recurrence === "daily" && a.lastCompletedDate === today) {
533
+ return false;
534
+ }
535
+ if (filter?.status && a.status !== filter.status) return false;
536
+ if (filter?.priority && a.priority !== filter.priority) return false;
537
+ if (!filter?.all && targetCwd) {
538
+ if (a.files.length > 0 && a.cwd && a.cwd !== targetCwd) {
539
+ return false;
540
+ }
541
+ }
542
+ return true;
543
+ });
544
+ }
545
+ /**
546
+ * Update an existing anchor
547
+ */
548
+ update(id, patch) {
549
+ return this.withLock(() => {
550
+ const state = this.loadState();
551
+ const idx = this.findAnchorIndex(state.anchors, id);
552
+ if (idx === -1) {
553
+ throw new Error(`Anchor not found: ${id}`);
554
+ }
555
+ const current = state.anchors[idx];
556
+ const updated = {
557
+ ...current,
558
+ ...patch,
559
+ updatedAt: Date.now()
560
+ };
561
+ if (patch.files) {
562
+ updated.files = patch.files.map((f) => normalizePath(f)).filter(Boolean);
563
+ }
564
+ state.anchors[idx] = updated;
565
+ this.saveState(state);
566
+ return updated;
567
+ });
568
+ }
569
+ /**
570
+ * Touch an anchor to refresh its decay window and reactivate sleeping state
571
+ */
572
+ touch(id, timestamp = Date.now()) {
573
+ return this.withLock(() => {
574
+ const state = this.loadState();
575
+ const idx = this.findAnchorIndex(state.anchors, id);
576
+ if (idx === -1) {
577
+ throw new Error(`Anchor not found: ${id}`);
578
+ }
579
+ const current = state.anchors[idx];
580
+ current.lastTouchedAt = timestamp;
581
+ current.updatedAt = timestamp;
582
+ if (current.status === "sleeping") {
583
+ current.status = "active";
584
+ }
585
+ state.anchors[idx] = current;
586
+ this.saveState(state);
587
+ return current;
588
+ });
589
+ }
590
+ /**
591
+ * Settle an anchor (recurring daily habits complete for today and wake up tomorrow)
592
+ */
593
+ settle(id, evidence = {}, now = Date.now()) {
594
+ return this.withLock(() => {
595
+ const state = this.loadState();
596
+ const idx = this.findAnchorIndex(state.anchors, id);
597
+ if (idx === -1) {
598
+ throw new Error(`Anchor not found: ${id}`);
599
+ }
600
+ const anchor = state.anchors[idx];
601
+ const today = getTodayDateString(now);
602
+ if (anchor.recurrence === "daily") {
603
+ anchor.lastCompletedDate = today;
604
+ anchor.lastTouchedAt = now;
605
+ anchor.updatedAt = now;
606
+ anchor.evidence = {
607
+ ...evidence,
608
+ settledAt: now,
609
+ settledBy: evidence.settledBy || "manual-command"
610
+ };
611
+ this.ensureDirs();
612
+ import_node_fs.default.appendFileSync(this.archivePath, JSON.stringify({ ...anchor, settledForDate: today }) + "\n", "utf-8");
613
+ this.saveState(state);
614
+ return anchor;
615
+ }
616
+ const [settled] = state.anchors.splice(idx, 1);
617
+ settled.status = "settled";
618
+ settled.updatedAt = now;
619
+ settled.evidence = {
620
+ ...evidence,
621
+ settledAt: now,
622
+ settledBy: evidence.settledBy || "manual-command"
623
+ };
624
+ this.ensureDirs();
625
+ import_node_fs.default.appendFileSync(this.archivePath, JSON.stringify(settled) + "\n", "utf-8");
626
+ this.saveState(state);
627
+ return settled;
628
+ });
629
+ }
630
+ /**
631
+ * Reverse/undo the last settled anchor, popping it from archive.jsonl back into state.json
632
+ */
633
+ undoSettle() {
634
+ return this.withLock(() => {
635
+ if (!import_node_fs.default.existsSync(this.archivePath)) {
636
+ throw new Error("No archived anchors to undo");
637
+ }
638
+ const raw = import_node_fs.default.readFileSync(this.archivePath, "utf-8");
639
+ const lines = raw.split("\n").filter(Boolean);
640
+ if (lines.length === 0) {
641
+ throw new Error("Archive is empty, nothing to undo");
642
+ }
643
+ const lastLine = lines.pop();
644
+ const anchor = JSON.parse(lastLine);
645
+ import_node_fs.default.writeFileSync(this.archivePath, lines.length > 0 ? lines.join("\n") + "\n" : "", "utf-8");
646
+ anchor.status = "active";
647
+ anchor.updatedAt = Date.now();
648
+ delete anchor.evidence;
649
+ const state = this.loadState();
650
+ state.anchors.push(anchor);
651
+ this.saveState(state);
652
+ return anchor;
653
+ });
654
+ }
655
+ /**
656
+ * Evict anchor to graveyard.jsonl upon total decay expiry
657
+ */
658
+ dropToGraveyard(id, reason) {
659
+ return this.withLock(() => {
660
+ const state = this.loadState();
661
+ const idx = state.anchors.findIndex((a) => a.id === id);
662
+ if (idx === -1) {
663
+ throw new Error(`Anchor not found: ${id}`);
664
+ }
665
+ const [anchor] = state.anchors.splice(idx, 1);
666
+ anchor.status = "graveyard";
667
+ anchor.updatedAt = Date.now();
668
+ anchor.evidence = {
669
+ summary: reason,
670
+ settledAt: Date.now()
671
+ };
672
+ this.ensureDirs();
673
+ import_node_fs.default.appendFileSync(this.graveyardPath, JSON.stringify(anchor) + "\n", "utf-8");
674
+ this.saveState(state);
675
+ return anchor;
676
+ });
677
+ }
678
+ getArchive(filter) {
679
+ if (!import_node_fs.default.existsSync(this.archivePath)) return [];
680
+ try {
681
+ const raw = import_node_fs.default.readFileSync(this.archivePath, "utf-8");
682
+ const all = raw.split("\n").filter(Boolean).map((l) => JSON.parse(l));
683
+ if (filter?.all || !filter?.cwd) return all;
684
+ const targetCwd = normalizePath(filter.cwd);
685
+ return all.filter((a) => !a.cwd || a.cwd === targetCwd);
686
+ } catch {
687
+ return [];
688
+ }
689
+ }
690
+ getGraveyard(filter) {
691
+ if (!import_node_fs.default.existsSync(this.graveyardPath)) return [];
692
+ try {
693
+ const raw = import_node_fs.default.readFileSync(this.graveyardPath, "utf-8");
694
+ const all = raw.split("\n").filter(Boolean).map((l) => JSON.parse(l));
695
+ if (filter?.all || !filter?.cwd) return all;
696
+ const targetCwd = normalizePath(filter.cwd);
697
+ return all.filter((a) => !a.cwd || a.cwd === targetCwd);
698
+ } catch {
699
+ return [];
700
+ }
701
+ }
702
+ };
703
+
704
+ // src/observer.ts
705
+ var MUTATION_TOOLS = /* @__PURE__ */ new Set(["edit", "write", "patch", "apply_diff", "create_file", "modify"]);
706
+ var SessionTouchObserver = class {
707
+ touchedFiles = /* @__PURE__ */ new Set();
708
+ modifiedFiles = /* @__PURE__ */ new Set();
709
+ inspectedFiles = /* @__PURE__ */ new Set();
710
+ committed = false;
711
+ commitMessages = [];
712
+ clear() {
713
+ this.touchedFiles.clear();
714
+ this.modifiedFiles.clear();
715
+ this.inspectedFiles.clear();
716
+ this.committed = false;
717
+ this.commitMessages = [];
718
+ }
719
+ /**
720
+ * Observe and record a tool call invocation, separating inspection from mutation
721
+ */
722
+ recordToolCall(toolName, input) {
723
+ if (!input || typeof input !== "object") return;
724
+ const lowerTool = (toolName || "").toLowerCase();
725
+ const isMutation = MUTATION_TOOLS.has(lowerTool);
726
+ const paths = [];
727
+ const pathField = input.path || input.filePath || input.file;
728
+ if (typeof pathField === "string") {
729
+ paths.push(normalizePath(pathField));
730
+ }
731
+ if (Array.isArray(input.paths)) {
732
+ for (const p of input.paths) {
733
+ if (typeof p === "string") {
734
+ paths.push(normalizePath(p));
735
+ }
736
+ }
737
+ }
738
+ for (const p of paths) {
739
+ this.touchedFiles.add(p);
740
+ if (isMutation) {
741
+ this.modifiedFiles.add(p);
742
+ } else {
743
+ this.inspectedFiles.add(p);
744
+ }
745
+ }
746
+ if (lowerTool === "bash" || lowerTool === "powershell") {
747
+ const cmd = String(input.command || "");
748
+ if (cmd.includes("git commit")) {
749
+ this.committed = true;
750
+ const msgMatch = cmd.match(/-m\s+["']([^"']+)["']/);
751
+ if (msgMatch && msgMatch[1]) {
752
+ this.commitMessages.push(msgMatch[1]);
753
+ }
754
+ }
755
+ }
756
+ }
757
+ /**
758
+ * Add a file path manually (e.g. from git status diff)
759
+ */
760
+ addTouchedFile(filePath, isModified = true) {
761
+ if (filePath) {
762
+ const norm = normalizePath(filePath);
763
+ this.touchedFiles.add(norm);
764
+ if (isModified) {
765
+ this.modifiedFiles.add(norm);
766
+ } else {
767
+ this.inspectedFiles.add(norm);
768
+ }
769
+ }
770
+ }
771
+ getModifiedFiles() {
772
+ return Array.from(this.modifiedFiles);
773
+ }
774
+ getInspectedFiles() {
775
+ return Array.from(this.inspectedFiles);
776
+ }
777
+ getTouchedFiles() {
778
+ return Array.from(this.touchedFiles);
779
+ }
780
+ hasCommitted() {
781
+ return this.committed;
782
+ }
783
+ getCommitMessages() {
784
+ return [...this.commitMessages];
785
+ }
786
+ /**
787
+ * Check if any commit message explicitly references or resolves an anchor
788
+ */
789
+ matchesCommit(anchor) {
790
+ const idLower = anchor.id.toLowerCase();
791
+ const title = anchor.title.toLowerCase();
792
+ let titleTokens = [];
793
+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
794
+ try {
795
+ const segmenter = new Intl.Segmenter("und", { granularity: "word" });
796
+ titleTokens = Array.from(segmenter.segment(title)).filter((s) => s.isWordLike).map((s) => s.segment.trim().toLowerCase()).filter((t) => t.length >= 2);
797
+ } catch {
798
+ titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
799
+ }
800
+ } else {
801
+ titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
802
+ }
803
+ for (const msg of this.commitMessages) {
804
+ const msgLower = msg.toLowerCase();
805
+ if (msgLower.includes(idLower)) {
806
+ return { matched: true, message: msg };
807
+ }
808
+ if (titleTokens.length > 0) {
809
+ const matchingCount = titleTokens.filter((tok) => msgLower.includes(tok)).length;
810
+ if (matchingCount >= Math.min(2, titleTokens.length)) {
811
+ return { matched: true, message: msg };
812
+ }
813
+ }
814
+ }
815
+ return { matched: false };
816
+ }
817
+ };
818
+
819
+ // src/decay.ts
820
+ var import_node_fs2 = __toESM(require("fs"), 1);
821
+ var MS_PER_DAY = 864e5;
822
+ function evaluateAnchorDecay(anchor, now = Date.now()) {
823
+ if (anchor.status === "settled" || anchor.status === "graveyard") {
824
+ return {
825
+ currentStatus: anchor.status,
826
+ nextStatus: anchor.status,
827
+ daysUntouched: 0,
828
+ remainingActiveDays: 0,
829
+ remainingSleepDays: 0
830
+ };
831
+ }
832
+ const elapsedMs = Math.max(0, now - anchor.lastTouchedAt);
833
+ const daysUntouched = elapsedMs / MS_PER_DAY;
834
+ const activeDays = anchor.decay.activeDays;
835
+ const graveyardDays = anchor.decay.graveyardDays;
836
+ let nextStatus = anchor.status;
837
+ if (daysUntouched <= activeDays) {
838
+ nextStatus = "active";
839
+ } else if (daysUntouched <= graveyardDays) {
840
+ nextStatus = "sleeping";
841
+ } else {
842
+ nextStatus = "graveyard";
843
+ }
844
+ return {
845
+ currentStatus: anchor.status,
846
+ nextStatus,
847
+ daysUntouched: Math.round(daysUntouched * 10) / 10,
848
+ remainingActiveDays: Math.max(0, Math.round((activeDays - daysUntouched) * 10) / 10),
849
+ remainingSleepDays: Math.max(0, Math.round((graveyardDays - daysUntouched) * 10) / 10)
850
+ };
851
+ }
852
+ function sweepStore(store, now = Date.now()) {
853
+ const state = store.loadState();
854
+ const result = {
855
+ transitionedToSleeping: [],
856
+ wokenToActive: [],
857
+ evictedToGraveyard: []
858
+ };
859
+ const toEvict = [];
860
+ let stateModified = false;
861
+ for (const anchor of state.anchors) {
862
+ const evalResult = evaluateAnchorDecay(anchor, now);
863
+ if (evalResult.nextStatus === "graveyard") {
864
+ toEvict.push(anchor);
865
+ } else if (evalResult.nextStatus !== anchor.status) {
866
+ if (evalResult.nextStatus === "sleeping" && anchor.status === "active") {
867
+ anchor.status = "sleeping";
868
+ anchor.updatedAt = now;
869
+ result.transitionedToSleeping.push(anchor.id);
870
+ stateModified = true;
871
+ } else if (evalResult.nextStatus === "active" && anchor.status === "sleeping") {
872
+ anchor.status = "active";
873
+ anchor.updatedAt = now;
874
+ result.wokenToActive.push(anchor.id);
875
+ stateModified = true;
876
+ }
877
+ }
878
+ }
879
+ if (toEvict.length > 0) {
880
+ const evictIds = new Set(toEvict.map((e) => e.id));
881
+ state.anchors = state.anchors.filter((a) => !evictIds.has(a.id));
882
+ stateModified = true;
883
+ const graveyardLines = toEvict.map((exp) => {
884
+ const rec = {
885
+ ...exp,
886
+ status: "graveyard",
887
+ updatedAt: now,
888
+ evictedAt: now,
889
+ evictionReason: `Exceeded decay threshold (${exp.decay.graveyardDays} days untouched)`
890
+ };
891
+ return JSON.stringify(rec);
892
+ }).join("\n") + "\n";
893
+ import_node_fs2.default.appendFileSync(store.graveyardPath, graveyardLines, "utf-8");
894
+ for (const exp of toEvict) {
895
+ result.evictedToGraveyard.push(exp.id);
896
+ }
897
+ }
898
+ state.lastSweepAt = now;
899
+ if (stateModified) {
900
+ store.saveState(state);
901
+ }
902
+ return result;
903
+ }
904
+
905
+ // src/context_injector.ts
906
+ var import_node_path3 = __toESM(require("path"), 1);
907
+ var COMMENT_FORMATS = {
908
+ // Double slash //
909
+ ".ts": (m) => `// ${m}`,
910
+ ".tsx": (m) => `// ${m}`,
911
+ ".js": (m) => `// ${m}`,
912
+ ".jsx": (m) => `// ${m}`,
913
+ ".mjs": (m) => `// ${m}`,
914
+ ".cjs": (m) => `// ${m}`,
915
+ ".java": (m) => `// ${m}`,
916
+ ".kt": (m) => `// ${m}`,
917
+ ".swift": (m) => `// ${m}`,
918
+ ".go": (m) => `// ${m}`,
919
+ ".rs": (m) => `// ${m}`,
920
+ ".c": (m) => `// ${m}`,
921
+ ".cpp": (m) => `// ${m}`,
922
+ ".h": (m) => `// ${m}`,
923
+ ".hpp": (m) => `// ${m}`,
924
+ ".cs": (m) => `// ${m}`,
925
+ ".dart": (m) => `// ${m}`,
926
+ ".scala": (m) => `// ${m}`,
927
+ ".zig": (m) => `// ${m}`,
928
+ ".proto": (m) => `// ${m}`,
929
+ // Hash #
930
+ ".py": (m) => `# ${m}`,
931
+ ".rb": (m) => `# ${m}`,
932
+ ".sh": (m) => `# ${m}`,
933
+ ".bash": (m) => `# ${m}`,
934
+ ".zsh": (m) => `# ${m}`,
935
+ ".yaml": (m) => `# ${m}`,
936
+ ".yml": (m) => `# ${m}`,
937
+ ".toml": (m) => `# ${m}`,
938
+ ".conf": (m) => `# ${m}`,
939
+ ".dockerfile": (m) => `# ${m}`,
940
+ ".ps1": (m) => `# ${m}`,
941
+ ".r": (m) => `# ${m}`,
942
+ // HTML / XML <!-- -->
943
+ ".html": (m) => `<!-- ${m} -->`,
944
+ ".xml": (m) => `<!-- ${m} -->`,
945
+ ".svg": (m) => `<!-- ${m} -->`,
946
+ ".vue": (m) => `<!-- ${m} -->`,
947
+ ".svelte": (m) => `<!-- ${m} -->`,
948
+ // Block comment /* */
949
+ ".css": (m) => `/* ${m} */`,
950
+ ".scss": (m) => `/* ${m} */`,
951
+ ".less": (m) => `/* ${m} */`,
952
+ // SQL / Lua / Haskell --
953
+ ".sql": (m) => `-- ${m}`,
954
+ ".lua": (m) => `-- ${m}`,
955
+ ".hs": (m) => `-- ${m}`,
956
+ // Batch REM
957
+ ".bat": (m) => `REM ${m}`,
958
+ ".cmd": (m) => `REM ${m}`
959
+ };
960
+ function makeSafeTaskAnnotation(filePath, anchor) {
961
+ const ext = import_node_path3.default.extname(filePath).toLowerCase();
962
+ const formatter = COMMENT_FORMATS[ext];
963
+ if (!formatter) {
964
+ return null;
965
+ }
966
+ return "\n\n" + formatter(`\u2316 anchor context: #${anchor.id} ${anchor.title} (${anchor.priority.toUpperCase()})`);
967
+ }
968
+ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
969
+ let cwd;
970
+ let now = Date.now();
971
+ if (typeof cwdOrNow === "number") {
972
+ now = cwdOrNow;
973
+ } else if (typeof cwdOrNow === "string") {
974
+ cwd = cwdOrNow;
975
+ if (typeof nowArg === "number") {
976
+ now = nowArg;
977
+ }
978
+ }
979
+ sweepStore(store, now);
980
+ const activeAnchors = store.list({ status: "active", cwd });
981
+ if (activeAnchors.length === 0) {
982
+ return "";
983
+ }
984
+ const lines = activeAnchors.map((a) => {
985
+ const fileHint = a.files.length > 0 ? ` (${a.files.slice(0, 2).join(", ")})` : "";
986
+ const tagHint = a.tags.length > 0 ? ` [${a.tags.join(", ")}]` : "";
987
+ const projHint = a.cwd ? `[${a.project}]` : "[global]";
988
+ let targetHint = "";
989
+ if (a.recurrence === "daily") {
990
+ targetHint = " [\u4ECA\u65E5\u5FAA\u73AF\xB7Daily]";
991
+ } else if (a.targetDate) {
992
+ const todayStr = getTodayDateString(now);
993
+ if (a.targetDate === todayStr) {
994
+ targetHint = " [\u4ECA\u65E5\u805A\u7126\xB7Due Today]";
995
+ } else if (a.targetDate < todayStr) {
996
+ targetHint = " [\u5DF2\u903E\u671F\xB7Overdue]";
997
+ } else {
998
+ targetHint = ` [\u6392\u671F:${a.targetDate}]`;
999
+ }
1000
+ } else {
1001
+ targetHint = " [\u957F\u671F\u613F\u666F\xB7Someday]";
1002
+ }
1003
+ const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1e3));
1004
+ const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : "";
1005
+ return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
1006
+ });
1007
+ return [
1008
+ `<active-anchors count="${activeAnchors.length}">`,
1009
+ `Unfinished cross-session commitments from previous sessions:`,
1010
+ lines.join("\n"),
1011
+ ``,
1012
+ `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
1013
+ `- If the user opens the session with a casual greeting or continuation ("hi", "\u5728\u5417", "\u7EE7\u7EED", "\u65E9\u4E0A\u597D"), naturally prioritize items tagged [\u4ECA\u65E5\u805A\u7126\xB7Due Today] in a friendly assistant tone (e.g. "\u55E8\uFF01\u4ECA\u5929\u89C4\u5212\u7684\u3010...\u3011\u73B0\u5728\u5904\u7406\u8FD8\u662F\u770B\u522B\u7684\uFF1F").`,
1014
+ `- If the user issues a direct, concrete new instruction (e.g. "\u5E2E\u6211\u67E5\u4E0B\u8FD9\u4E2A\u62A5\u9519"), DO NOT interrupt their flow. Focus directly on their instruction.`,
1015
+ `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
1016
+ `</active-anchors>`
1017
+ ].join("\n");
1018
+ }
1019
+
1020
+ // src/settlement.ts
1021
+ var import_node_child_process = require("child_process");
1022
+ function runPhysicalVerification(anchor, cwd) {
1023
+ if (!anchor.verifyCommand) {
1024
+ return { success: false, output: "No verification command specified" };
1025
+ }
1026
+ try {
1027
+ const stdout = (0, import_node_child_process.execSync)(anchor.verifyCommand, {
1028
+ cwd,
1029
+ encoding: "utf-8",
1030
+ timeout: 3e4,
1031
+ stdio: ["ignore", "pipe", "pipe"]
1032
+ });
1033
+ return { success: true, output: stdout.trim() };
1034
+ } catch (err) {
1035
+ return { success: false, output: String(err.stderr || err.stdout || err.message).trim() };
1036
+ }
1037
+ }
1038
+ function generateSettlementProposals(store, touchedFiles, cwd) {
1039
+ const activeAndSleeping = store.list({ cwd }).filter((a) => a.status === "active" || a.status === "sleeping");
1040
+ const matches = findMatchedAnchors(activeAndSleeping, touchedFiles);
1041
+ const proposals = [];
1042
+ for (const match of matches) {
1043
+ const fileList = match.matchedFiles.slice(0, 3).join(", ");
1044
+ const moreSuffix = match.matchedFiles.length > 3 ? ` \u7B49 ${match.matchedFiles.length} \u4E2A\u6587\u4EF6` : "";
1045
+ proposals.push({
1046
+ anchor: match.anchor,
1047
+ matchedFiles: match.matchedFiles,
1048
+ reason: `\u672C\u6B21\u4F1A\u8BDD\u4FEE\u6539\u4E86 ${fileList}${moreSuffix}`,
1049
+ recommendedAction: match.score >= 0.85 ? "settle" : "defer"
1050
+ });
1051
+ }
1052
+ return proposals;
1053
+ }
1054
+
1055
+ // src/tui.ts
1056
+ var import_node_path4 = __toESM(require("path"), 1);
1057
+ function classifyAnchor(anchor, now = Date.now()) {
1058
+ if (anchor.recurrence === "daily") {
1059
+ return "Habits";
1060
+ }
1061
+ if (!anchor.targetDate) {
1062
+ return "Backlog";
1063
+ }
1064
+ const todayStr = getTodayDateString(now);
1065
+ if (anchor.targetDate <= todayStr) {
1066
+ return "Today";
1067
+ }
1068
+ return "Upcoming";
1069
+ }
1070
+ function groupAnchorsByQuadrant(anchors, now = Date.now()) {
1071
+ const groups = {
1072
+ today: [],
1073
+ upcoming: [],
1074
+ habits: [],
1075
+ backlog: []
1076
+ };
1077
+ for (const a of anchors) {
1078
+ const q = classifyAnchor(a, now);
1079
+ if (q === "Today") groups.today.push(a);
1080
+ else if (q === "Upcoming") groups.upcoming.push(a);
1081
+ else if (q === "Habits") groups.habits.push(a);
1082
+ else groups.backlog.push(a);
1083
+ }
1084
+ return groups;
1085
+ }
1086
+ function stripAnsi(str) {
1087
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1088
+ }
1089
+ function getDisplayWidth(str) {
1090
+ const clean = stripAnsi(str);
1091
+ let width = 0;
1092
+ for (const char of clean) {
1093
+ const code = char.codePointAt(0) || 0;
1094
+ if (code === 8205 || code === 65039 || code === 65038 || code >= 768 && code <= 879 || code >= 8203 && code <= 8207) {
1095
+ continue;
1096
+ }
1097
+ if (code >= 19968 && code <= 40959 || code >= 13312 && code <= 19903 || code >= 131072 && code <= 173791 || code >= 173824 && code <= 177983 || code >= 65281 && code <= 65376 || code >= 12288 && code <= 12351 || code >= 44032 && code <= 55215 || code >= 4352 && code <= 4607 || code >= 12592 && code <= 12687 || code >= 12352 && code <= 12447 || code >= 12448 && code <= 12543 || code >= 127744 && code <= 129535 || code >= 128512 && code <= 128591 || code >= 128640 && code <= 128767 || code >= 9728 && code <= 10175 || code >= 129648 && code <= 129791) {
1098
+ width += 2;
1099
+ } else {
1100
+ width += 1;
1101
+ }
1102
+ }
1103
+ return width;
1104
+ }
1105
+ function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
1106
+ const current = getDisplayWidth(str);
1107
+ if (current <= maxWidth) return str;
1108
+ const ellipsisWidth = getDisplayWidth(ellipsis);
1109
+ const target = maxWidth - ellipsisWidth;
1110
+ if (target <= 0) return ellipsis.slice(0, maxWidth);
1111
+ let accumulated = "";
1112
+ let accumWidth = 0;
1113
+ for (const char of str) {
1114
+ const charWidth = getDisplayWidth(char);
1115
+ if (accumWidth + charWidth > target) {
1116
+ break;
1117
+ }
1118
+ accumulated += char;
1119
+ accumWidth += charWidth;
1120
+ }
1121
+ return accumulated + ellipsis;
1122
+ }
1123
+ function padToWidth(str, targetWidth) {
1124
+ const truncated = truncateToWidth(str, targetWidth);
1125
+ const current = getDisplayWidth(truncated);
1126
+ if (current >= targetWidth) return truncated;
1127
+ return truncated + " ".repeat(targetWidth - current);
1128
+ }
1129
+ function formatTargetDate(anchor, now = Date.now()) {
1130
+ if (anchor.recurrence === "daily") {
1131
+ return "Daily";
1132
+ }
1133
+ if (!anchor.targetDate) {
1134
+ return "Someday";
1135
+ }
1136
+ const todayStr = getTodayDateString(now);
1137
+ if (anchor.targetDate === todayStr) {
1138
+ return "Today";
1139
+ }
1140
+ const MS_DAY = 24 * 60 * 60 * 1e3;
1141
+ const tomorrowStr = getTodayDateString(now + MS_DAY);
1142
+ if (anchor.targetDate === tomorrowStr) {
1143
+ return "Tomorrow";
1144
+ }
1145
+ const in2dStr = getTodayDateString(now + 2 * MS_DAY);
1146
+ if (anchor.targetDate === in2dStr) {
1147
+ return "In 2d";
1148
+ }
1149
+ const in3dStr = getTodayDateString(now + 3 * MS_DAY);
1150
+ if (anchor.targetDate === in3dStr) {
1151
+ return "In 3d";
1152
+ }
1153
+ if (anchor.targetDate < todayStr) {
1154
+ return "Overdue";
1155
+ }
1156
+ const parts = anchor.targetDate.split("-");
1157
+ if (parts.length === 3) {
1158
+ return `${parts[1]}-${parts[2]}`;
1159
+ }
1160
+ return anchor.targetDate;
1161
+ }
1162
+ function formatCreationTime(timestamp, now = Date.now()) {
1163
+ const d = new Date(timestamp);
1164
+ const nowD = new Date(now);
1165
+ const pad = (n) => String(n).padStart(2, "0");
1166
+ const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
1167
+ const todayStr = getTodayDateString(now);
1168
+ const createdDayStr = getTodayDateString(timestamp);
1169
+ if (createdDayStr === todayStr) {
1170
+ return `Today ${timeStr}`;
1171
+ }
1172
+ const yesterdayStr = getTodayDateString(now - 24 * 60 * 60 * 1e3);
1173
+ if (createdDayStr === yesterdayStr) {
1174
+ return `Yesterday ${timeStr}`;
1175
+ }
1176
+ const mmdd = `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
1177
+ if (d.getFullYear() === nowD.getFullYear()) {
1178
+ return `${mmdd} ${timeStr}`;
1179
+ }
1180
+ return `${d.getFullYear()}-${mmdd}`;
1181
+ }
1182
+ function formatOrigin(anchor) {
1183
+ if (typeof anchor === "object") {
1184
+ if (!anchor.cwd) return anchor.project || "global";
1185
+ return import_node_path4.default.basename(anchor.cwd) || "global";
1186
+ }
1187
+ if (!anchor) return "global";
1188
+ return import_node_path4.default.basename(anchor) || "global";
1189
+ }
1190
+ function updateAnchorStatusBar(ctx, store) {
1191
+ if (!ctx.hasUI || !ctx.ui) return;
1192
+ const active = store.list({ status: "active", cwd: ctx.cwd });
1193
+ if (active.length === 0) {
1194
+ ctx.ui.setStatus("anchor", void 0);
1195
+ return;
1196
+ }
1197
+ ctx.ui.setStatus("anchor", `\u2316 ${active.length}`);
1198
+ }
1199
+ function updateStartupBanner(ctx, store) {
1200
+ if (!ctx.hasUI || !ctx.ui) return;
1201
+ const active = store.list({ status: "active", cwd: ctx.cwd });
1202
+ if (active.length === 0) {
1203
+ ctx.ui.setWidget("anchor-startup", void 0);
1204
+ return;
1205
+ }
1206
+ const groups = groupAnchorsByQuadrant(active);
1207
+ const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
1208
+ const lines = [
1209
+ `\u2316 Anchor \xB7 ${active.length} active commitments:`
1210
+ ];
1211
+ const topItems = sorted.slice(0, 3);
1212
+ for (let i = 0; i < topItems.length; i++) {
1213
+ const a = topItems[i];
1214
+ const num = (i + 1).toString().padStart(2, "0");
1215
+ const q = classifyAnchor(a);
1216
+ const tgt = formatTargetDate(a);
1217
+ const titlePadded = a.title.length > 34 ? a.title.slice(0, 32) + ".." : a.title;
1218
+ lines.push(` \u2022 [${q}] ${num} ${titlePadded} (${tgt})`);
1219
+ }
1220
+ if (sorted.length > 3) {
1221
+ lines.push(` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`);
1222
+ }
1223
+ ctx.ui.setWidget("anchor-startup", lines, { placement: "aboveEditor" });
1224
+ }
1225
+ async function openAnchorDashboard(ctx, store) {
1226
+ if (!ctx.hasUI || !ctx.ui) return;
1227
+ const list = store.list({ status: "active", cwd: ctx.cwd });
1228
+ if (list.length === 0) {
1229
+ ctx.ui.notify("\u2316 No active anchors. Use /pin <task> to record.", "info");
1230
+ return;
1231
+ }
1232
+ const groups = groupAnchorsByQuadrant(list);
1233
+ const sortedList = [
1234
+ ...groups.today,
1235
+ ...groups.upcoming,
1236
+ ...groups.habits,
1237
+ ...groups.backlog
1238
+ ];
1239
+ const optionMap = /* @__PURE__ */ new Map();
1240
+ const displayOptions = [];
1241
+ for (let i = 0; i < sortedList.length; i++) {
1242
+ const a = sortedList[i];
1243
+ const num = (i + 1).toString().padStart(2, "0");
1244
+ const category = padToWidth(`[${classifyAnchor(a)}]`, 12);
1245
+ const origin = formatOrigin(a);
1246
+ const target = formatTargetDate(a);
1247
+ const created = formatCreationTime(a.createdAt);
1248
+ const titleWithFiles = a.files && a.files.length > 0 ? `${a.title} [${a.files.slice(0, 1).join(", ")}]` : a.title;
1249
+ const colNum = `${num} `;
1250
+ const colTitle = padToWidth(titleWithFiles, 34);
1251
+ const colOrigin = padToWidth(origin, 10);
1252
+ const colTarget = padToWidth(target, 12);
1253
+ const colCreated = created;
1254
+ const label = `${colNum}${category}${colTitle} ${colOrigin} ${colTarget} ${colCreated}`.trimEnd();
1255
+ optionMap.set(label, a);
1256
+ displayOptions.push(label);
1257
+ }
1258
+ const selected = await ctx.ui.select("\u2316 Anchors (enter to complete):", displayOptions);
1259
+ if (!selected) return;
1260
+ const anchor = optionMap.get(selected);
1261
+ if (!anchor) return;
1262
+ store.settle(anchor.id, { settledBy: "manual-command" });
1263
+ const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled: "${anchor.title}"`;
1264
+ ctx.ui.notify(successMsg, "info");
1265
+ updateAnchorStatusBar(ctx, store);
1266
+ }
1267
+
1268
+ // src/extension.ts
1269
+ var import_node_child_process2 = require("child_process");
1270
+ var MUTATION_TOOLS2 = /* @__PURE__ */ new Set(["edit", "write", "patch", "apply_diff", "create_file", "modify"]);
1271
+ function extension_default(pi) {
1272
+ const store = new AnchorStore();
1273
+ const observer = new SessionTouchObserver();
1274
+ const annotatedThisSession = /* @__PURE__ */ new Set();
1275
+ pi.on("session_start", async (event, ctx) => {
1276
+ observer.clear();
1277
+ annotatedThisSession.clear();
1278
+ const sweep = sweepStore(store);
1279
+ if (sweep.transitionedToSleeping.length > 0) {
1280
+ ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} \u4E2A\u975E\u6D3B\u8DC3\u4EFB\u52A1\u5DF2\u8FDB\u5165\u4F11\u7720`, "info");
1281
+ }
1282
+ updateAnchorStatusBar(ctx, store);
1283
+ if (event.reason !== "resume") {
1284
+ updateStartupBanner(ctx, store);
1285
+ }
1286
+ });
1287
+ pi.on("agent_start", async (_event, ctx) => {
1288
+ if (ctx.hasUI && ctx.ui) {
1289
+ ctx.ui.setWidget("anchor-startup", void 0);
1290
+ }
1291
+ });
1292
+ pi.on("before_agent_start", async (event, ctx) => {
1293
+ const entries = ctx.sessionManager?.getEntries() || [];
1294
+ const messageTurns = entries.filter((e) => e.type === "message");
1295
+ const isColdStart = messageTurns.length <= 1;
1296
+ if (isColdStart) {
1297
+ const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
1298
+ if (contextSnippet) {
1299
+ return {
1300
+ systemPrompt: `${event.systemPrompt}
1301
+
1302
+ ${contextSnippet}`
1303
+ };
1304
+ }
1305
+ }
1306
+ });
1307
+ pi.on("tool_result", async (event, ctx) => {
1308
+ if (!event.isError) {
1309
+ observer.recordToolCall(event.toolName, event.input || {});
1310
+ }
1311
+ const pathInput = event.input?.path;
1312
+ if (typeof pathInput !== "string") return;
1313
+ const touchedPath = normalizePath(pathInput);
1314
+ const anchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active" || a.status === "sleeping");
1315
+ const matches = findMatchedAnchors(anchors, [touchedPath]);
1316
+ if (matches.length > 0) {
1317
+ const isMutation = MUTATION_TOOLS2.has((event.toolName || "").toLowerCase());
1318
+ if (isMutation && !event.isError) {
1319
+ for (const m of matches) {
1320
+ store.touch(m.anchor.id);
1321
+ }
1322
+ updateAnchorStatusBar(ctx, store);
1323
+ }
1324
+ const a = matches[0].anchor;
1325
+ if (!annotatedThisSession.has(a.id)) {
1326
+ annotatedThisSession.add(a.id);
1327
+ const alert = makeSafeTaskAnnotation(touchedPath, a);
1328
+ if (alert) {
1329
+ const contents = [...event.content || []];
1330
+ for (let i = contents.length - 1; i >= 0; i--) {
1331
+ const item = contents[i];
1332
+ if (item && item.type === "text") {
1333
+ contents[i] = { ...item, text: item.text + alert };
1334
+ return { content: contents };
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ }
1340
+ });
1341
+ pi.on("session_shutdown", async (_event, ctx) => {
1342
+ try {
1343
+ const gitStatus = (0, import_node_child_process2.execSync)("git status --porcelain", {
1344
+ cwd: ctx.cwd,
1345
+ encoding: "utf-8",
1346
+ timeout: 3e3,
1347
+ stdio: ["ignore", "pipe", "ignore"]
1348
+ });
1349
+ const changed = gitStatus.split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
1350
+ for (const f of changed) {
1351
+ observer.addTouchedFile(f);
1352
+ }
1353
+ } catch {
1354
+ }
1355
+ const modified = observer.getModifiedFiles();
1356
+ if (modified.length === 0) return;
1357
+ const proposals = generateSettlementProposals(store, modified, ctx.cwd);
1358
+ if (proposals.length === 0) return;
1359
+ for (const prop of proposals) {
1360
+ const a = prop.anchor;
1361
+ const commitMatch = observer.matchesCommit(a);
1362
+ if (commitMatch.matched) {
1363
+ store.settle(a.id, {
1364
+ settledBy: "verification-test",
1365
+ summary: `Auto-settled via Git commit: ${commitMatch.message}`,
1366
+ touchedFiles: prop.matchedFiles
1367
+ });
1368
+ ctx.ui.notify(`\u2316 Auto-settled #${a.id} via Git commit (run /anchor undo to revert)`, "info");
1369
+ continue;
1370
+ }
1371
+ if (a.verifyCommand) {
1372
+ const verifyRes = runPhysicalVerification(a, ctx.cwd);
1373
+ if (verifyRes.success) {
1374
+ store.settle(a.id, {
1375
+ settledBy: "verification-test",
1376
+ summary: `Automated test passed: ${a.verifyCommand}`,
1377
+ touchedFiles: prop.matchedFiles
1378
+ });
1379
+ ctx.ui.notify(`\u2316 Test passed (${a.verifyCommand}), auto-settled #${a.id}`, "info");
1380
+ continue;
1381
+ }
1382
+ }
1383
+ if (!ctx.hasUI || !ctx.ui) continue;
1384
+ const ok = await ctx.ui.confirm(
1385
+ "\u2316 Settle Anchor Task",
1386
+ `Task #${a.id} [${a.title}] touched files (${prop.matchedFiles.slice(0, 2).join(", ")}).
1387
+ Mark as completed and archive?`
1388
+ );
1389
+ if (ok) {
1390
+ store.settle(a.id, {
1391
+ settledBy: "one-tap-settlement",
1392
+ touchedFiles: prop.matchedFiles
1393
+ });
1394
+ ctx.ui.notify(`\u2316 Settled #${a.id}: "${a.title}"`, "info");
1395
+ }
1396
+ }
1397
+ });
1398
+ pi.registerTool({
1399
+ name: "anchor",
1400
+ label: "Anchor (Cross-session Task Protocol)",
1401
+ description: "Manage cross-session persistent tasks that survive terminal restarts and auto-evict upon code changes or settlement. Use when the user asks to retain, pin, remember, or track a multi-session goal across sessions, or when an ongoing commitment must not be forgotten. Actions: pin (create new cross-session anchor), list (view active and sleeping anchors), settle (close and archive a completed anchor), touch (refresh activity), sweep (run decay cleanup). Stored in ~/.anchor/ with zero project repository pollution.",
1402
+ promptSnippet: "Anchor cross-session task contracts that survive terminal restarts and auto-evict",
1403
+ promptGuidelines: [
1404
+ 'Use `anchor` when the user asks to retain a goal across sessions or record a reminder for later/tonight/tomorrow (e.g. "\u665A\u4E0A\u6E05\u7406\u5783\u573E", "\u660E\u5929\u4F18\u5316X", "\u4FDD\u7559\u4EFB\u52A1\u76F4\u5230\u5B8C\u6210").',
1405
+ "BOUNDARY WITH TODO: `todo` is strictly for intra-session active work breakdown (step 1, step 2, step 3 right now). For future reminders or cross-session goals, ONLY use `anchor`. NEVER duplicate a cross-session reminder into both `todo` and `anchor`.",
1406
+ "DO NOT over-engineer or assume automated scheduled tasks unless the user explicitly requests Windows Task Scheduler or cron.",
1407
+ "Tasks are automatically scoped to the current project context without cluttering the project git repository.",
1408
+ 'When code for an anchor is completed and verified, call `anchor` with action "settle" to archive it and free context.',
1409
+ "Active anchors are automatically injected into future sessions in an ultra-compact block."
1410
+ ],
1411
+ parameters: import_typebox.Type.Object({
1412
+ action: import_typebox.Type.Union([
1413
+ import_typebox.Type.Literal("pin"),
1414
+ import_typebox.Type.Literal("list"),
1415
+ import_typebox.Type.Literal("settle"),
1416
+ import_typebox.Type.Literal("touch"),
1417
+ import_typebox.Type.Literal("sweep")
1418
+ ]),
1419
+ title: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Short imperative task title (for pin)" })),
1420
+ description: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Detailed context, acceptance criteria, or technical notes" })),
1421
+ priority: import_typebox.Type.Optional(import_typebox.Type.Union([import_typebox.Type.Literal("p0"), import_typebox.Type.Literal("p1"), import_typebox.Type.Literal("p2")])),
1422
+ project: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Target project name or workspace (defaults to current directory if omitted)" })),
1423
+ targetDate: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Expected completion date (e.g. YYYY-MM-DD, today, tomorrow)" })),
1424
+ files: import_typebox.Type.Optional(import_typebox.Type.Array(import_typebox.Type.String(), { description: "Associated file paths or directory prefixes" })),
1425
+ tags: import_typebox.Type.Optional(import_typebox.Type.Array(import_typebox.Type.String(), { description: "Domain tags" })),
1426
+ verifyCommand: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Optional shell command for automated physical verification" })),
1427
+ id: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Anchor ID, e.g. anc-1 (for settle or touch)" }))
1428
+ }),
1429
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1430
+ if (!ctx) return { content: [{ type: "text", text: "Error: context required" }], isError: true };
1431
+ if (params.action === "pin") {
1432
+ if (!params.title) {
1433
+ return { content: [{ type: "text", text: "Error: title is required for pin action" }], isError: true };
1434
+ }
1435
+ const anc = store.create({
1436
+ title: params.title,
1437
+ description: params.description,
1438
+ priority: params.priority || "p1",
1439
+ project: params.project,
1440
+ targetDate: params.targetDate,
1441
+ cwd: ctx.cwd,
1442
+ files: params.files || [],
1443
+ tags: params.tags || [],
1444
+ verifyCommand: params.verifyCommand
1445
+ });
1446
+ updateAnchorStatusBar(ctx, store);
1447
+ return {
1448
+ content: [{
1449
+ type: "text",
1450
+ text: `Successfully anchored task #${anc.id}: "${anc.title}" [${anc.project}]. Stored in ~/.anchor/.`
1451
+ }],
1452
+ isError: false
1453
+ };
1454
+ }
1455
+ if (params.action === "list") {
1456
+ const active = store.list({ status: "active", cwd: ctx.cwd });
1457
+ const sleeping = store.list({ status: "sleeping", cwd: ctx.cwd });
1458
+ return {
1459
+ content: [{
1460
+ type: "text",
1461
+ text: JSON.stringify({ active, sleeping }, null, 2)
1462
+ }],
1463
+ isError: false
1464
+ };
1465
+ }
1466
+ if (params.action === "settle") {
1467
+ if (!params.id) {
1468
+ return { content: [{ type: "text", text: "Error: id is required for settle action" }], isError: true };
1469
+ }
1470
+ try {
1471
+ const settled = store.settle(params.id, { settledBy: "verification-test" });
1472
+ updateAnchorStatusBar(ctx, store);
1473
+ return {
1474
+ content: [{
1475
+ type: "text",
1476
+ text: `Anchor #${settled.id} successfully settled and evicted from active context.`
1477
+ }],
1478
+ isError: false
1479
+ };
1480
+ } catch (err) {
1481
+ return { content: [{ type: "text", text: err.message }], isError: true };
1482
+ }
1483
+ }
1484
+ if (params.action === "touch") {
1485
+ if (!params.id) {
1486
+ return { content: [{ type: "text", text: "Error: id is required for touch action" }], isError: true };
1487
+ }
1488
+ try {
1489
+ const touched = store.touch(params.id);
1490
+ updateAnchorStatusBar(ctx, store);
1491
+ return {
1492
+ content: [{
1493
+ type: "text",
1494
+ text: `Anchor #${touched.id} touched and decay timer refreshed.`
1495
+ }],
1496
+ isError: false
1497
+ };
1498
+ } catch (err) {
1499
+ return { content: [{ type: "text", text: err.message }], isError: true };
1500
+ }
1501
+ }
1502
+ if (params.action === "sweep") {
1503
+ const res = sweepStore(store);
1504
+ updateAnchorStatusBar(ctx, store);
1505
+ return {
1506
+ content: [{
1507
+ type: "text",
1508
+ text: `Sweep complete: ${res.transitionedToSleeping.length} sleeping, ${res.evictedToGraveyard.length} evicted to graveyard.`
1509
+ }],
1510
+ isError: false
1511
+ };
1512
+ }
1513
+ return { content: [{ type: "text", text: "Unknown action" }], isError: true };
1514
+ }
1515
+ });
1516
+ pi.registerCommand("anchor", {
1517
+ description: "Inspect and settle active anchors",
1518
+ handler: async (args, ctx) => {
1519
+ const input = (args || "").trim();
1520
+ if (!input || input === "list" || input === "ls") {
1521
+ await openAnchorDashboard(ctx, store);
1522
+ return;
1523
+ }
1524
+ if (input === "undo") {
1525
+ try {
1526
+ const restored = store.undoSettle();
1527
+ ctx.ui.notify(`\u2316 Restored #${restored.id}: "${restored.title}"`, "info");
1528
+ updateAnchorStatusBar(ctx, store);
1529
+ } catch (err) {
1530
+ ctx.ui.notify(`Revert failed: ${err.message}`, "error");
1531
+ }
1532
+ return;
1533
+ }
1534
+ const targetId = input.startsWith("#") ? input.slice(1) : input;
1535
+ const item = store.get(targetId);
1536
+ if (item) {
1537
+ store.settle(targetId, { settledBy: "manual-command" });
1538
+ ctx.ui.notify(`\u2316 Settled: "${item.title}"`, "info");
1539
+ updateAnchorStatusBar(ctx, store);
1540
+ return;
1541
+ }
1542
+ const anc = store.create({ title: input, cwd: ctx.cwd });
1543
+ ctx.ui.notify(`\u2316 Pinned #${anc.id}: "${anc.title}"`, "info");
1544
+ updateAnchorStatusBar(ctx, store);
1545
+ }
1546
+ });
1547
+ pi.registerCommand("pin", {
1548
+ description: "Quickly pin a task across sessions",
1549
+ handler: async (args, ctx) => {
1550
+ const title = (args || "").trim();
1551
+ if (!title) {
1552
+ await openAnchorDashboard(ctx, store);
1553
+ return;
1554
+ }
1555
+ const anc = store.create({ title, cwd: ctx.cwd });
1556
+ ctx.ui.notify(`\u2316 Pinned #${anc.id}: "${anc.title}"`, "info");
1557
+ updateAnchorStatusBar(ctx, store);
1558
+ }
1559
+ });
1560
+ }