pi-anchor 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.ts CHANGED
@@ -1,80 +1,16 @@
1
1
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import { AnchorStore, getTodayDateString } from './store.ts';
2
+ import { AnchorStore, isAnchorTargetingWorkspace } from './store.ts';
3
3
  import type { Anchor } from './types.ts';
4
4
  import path from 'node:path';
5
5
 
6
- export type AnchorQuadrant = 'Today' | 'Upcoming' | 'Habits' | 'Backlog';
7
-
8
- export interface GroupedAnchors {
9
- today: Anchor[];
10
- upcoming: Anchor[];
11
- habits: Anchor[];
12
- backlog: Anchor[];
13
- }
14
-
15
- /**
16
- * Classify anchor into one of four cognitive quadrants:
17
- * - 'Today': Due today or overdue
18
- * - 'Upcoming': Due in the future (tomorrow, in 2d, specific dates)
19
- * - 'Habits': Daily recurring habits
20
- * - 'Backlog': Open-ended long-term vision
21
- */
22
- export function classifyAnchor(anchor: Anchor, now: number = Date.now()): AnchorQuadrant {
23
- if (anchor.recurrence === 'daily') {
24
- return 'Habits';
25
- }
26
- if (!anchor.targetDate) {
27
- return 'Backlog';
28
- }
29
-
30
- const todayStr = getTodayDateString(now);
31
- if (anchor.targetDate <= todayStr) {
32
- return 'Today';
33
- }
34
-
35
- return 'Upcoming';
36
- }
37
-
38
- /**
39
- * Group active anchors by cognitive quadrant
40
- */
41
- export function groupAnchorsByQuadrant(anchors: Anchor[], now: number = Date.now()): GroupedAnchors {
42
- const groups: GroupedAnchors = {
43
- today: [],
44
- upcoming: [],
45
- habits: [],
46
- backlog: []
47
- };
48
-
49
- for (const a of anchors) {
50
- const q = classifyAnchor(a, now);
51
- if (q === 'Today') groups.today.push(a);
52
- else if (q === 'Upcoming') groups.upcoming.push(a);
53
- else if (q === 'Habits') groups.habits.push(a);
54
- else groups.backlog.push(a);
55
- }
56
-
57
- return groups;
58
- }
59
-
60
6
  const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
61
7
 
62
- /**
63
- * Strip ANSI escape codes from string
64
- */
65
8
  export function stripAnsi(str: string): string {
66
9
  return str.replace(ANSI_REGEX, '');
67
10
  }
68
11
 
69
12
  /**
70
- * Calculate display width in terminal columns, accounting for:
71
- * - ANSI escape codes (width 0)
72
- * - CJK ideographs & fullwidth forms (width 2)
73
- * - Japanese Kana (width 2)
74
- * - Korean Hangul syllables & Jamo (width 2)
75
- * - Emojis & Pictographs (width 2)
76
- * - Zero-width characters & joiners (width 0)
77
- * - Standard ASCII (width 1)
13
+ * Calculate visual terminal column width (CJK/Kana/Hangul/Emoji = 2 cells, ASCII = 1 cell)
78
14
  */
79
15
  export function getDisplayWidth(str: string): number {
80
16
  const clean = stripAnsi(str);
@@ -82,8 +18,6 @@ export function getDisplayWidth(str: string): number {
82
18
 
83
19
  for (const char of clean) {
84
20
  const code = char.codePointAt(0) || 0;
85
-
86
- // Zero-width characters (ZWJ, variation selectors, combining marks)
87
21
  if (
88
22
  code === 0x200d ||
89
23
  code === 0xfe0f ||
@@ -94,7 +28,6 @@ export function getDisplayWidth(str: string): number {
94
28
  continue;
95
29
  }
96
30
 
97
- // Double-width characters: CJK, Hangul, Kana, Emojis
98
31
  if (
99
32
  (code >= 0x4e00 && code <= 0x9fff) ||
100
33
  (code >= 0x3400 && code <= 0x4dbf) ||
@@ -131,9 +64,6 @@ export function hasActiveUI(ctx: ExtensionContext | null | undefined): ctx is Ex
131
64
  }
132
65
  }
133
66
 
134
- /**
135
- * Truncate string to target terminal visual width with ellipsis
136
- */
137
67
  export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…'): string {
138
68
  if (maxWidth <= 0) return '';
139
69
  const singleLine = str.replace(/[\r\n\t]+/g, ' ');
@@ -162,23 +92,17 @@ export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…')
162
92
  const fullChar = singleLine.slice(i, i + charLen);
163
93
  const charWidth = getDisplayWidth(fullChar);
164
94
 
165
- if (accumWidth + charWidth > target) {
166
- break;
167
- }
95
+ if (accumWidth + charWidth > target) break;
168
96
  accumulated += fullChar;
169
97
  accumWidth += charWidth;
170
98
  i += charLen;
171
99
  }
172
100
 
173
- // Invariant 11: Guard against trailing open brackets right before ellipsis (e.g. "Fix auth (…" -> "Fix auth…")
174
- accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, '');
175
-
101
+ // Invariant 11: Strip any trailing unclosed parenthesis/bracket fragment right before ellipsis
102
+ accumulated = accumulated.replace(/\s*[\(\[\{(【][^)\]})】]*$/, '');
176
103
  return accumulated + ellipsis + (singleLine.includes('\x1b') ? '\x1b[0m' : '');
177
104
  }
178
105
 
179
- /**
180
- * Pad and/or truncate string to exact visual column width, eliminating column tearing
181
- */
182
106
  export function padToWidth(str: string, targetWidth: number): string {
183
107
  const truncated = truncateToWidth(str, targetWidth);
184
108
  const current = getDisplayWidth(truncated);
@@ -187,139 +111,84 @@ export function padToWidth(str: string, targetWidth: number): string {
187
111
  }
188
112
 
189
113
  /**
190
- * Format expected completion date in human-intuitive terms:
191
- * - 'Daily' for daily recurring habits
192
- * - 'Today' if targetDate is today
193
- * - 'Tomorrow' if targetDate is tomorrow
194
- * - 'In 2d' / 'In 3d' for near-term dates
195
- * - 'MM-DD' for future dates
196
- * - 'Overdue' if targetDate is past
197
- * - 'Someday' for open-ended architecture/long-term vision
114
+ * Format relative sticky-note age cleanly: 'just now', '15m ago', '3h ago', '2d ago'
198
115
  */
199
- export function formatTargetDate(anchor: Anchor, now: number = Date.now()): string {
200
- if (anchor.recurrence === 'daily') {
201
- return 'Daily';
202
- }
203
- if (!anchor.targetDate) {
204
- return 'Someday';
205
- }
206
-
207
- const todayStr = getTodayDateString(now);
208
- if (anchor.targetDate === todayStr) {
209
- return 'Today';
210
- }
211
-
212
- const MS_DAY = 24 * 60 * 60 * 1000;
213
- const tomorrowStr = getTodayDateString(now + MS_DAY);
214
- if (anchor.targetDate === tomorrowStr) {
215
- return 'Tomorrow';
216
- }
217
-
218
- const in2dStr = getTodayDateString(now + 2 * MS_DAY);
219
- if (anchor.targetDate === in2dStr) {
220
- return 'In 2d';
221
- }
222
-
223
- const in3dStr = getTodayDateString(now + 3 * MS_DAY);
224
- if (anchor.targetDate === in3dStr) {
225
- return 'In 3d';
226
- }
227
-
228
- if (anchor.targetDate < todayStr) {
229
- return 'Overdue';
230
- }
231
-
232
- const parts = anchor.targetDate.split('-');
233
- if (parts.length === 3) {
234
- return `${parts[1]}-${parts[2]}`;
235
- }
236
-
237
- return anchor.targetDate;
116
+ export function formatRelativeAge(timestamp: number, now: number = Date.now()): string {
117
+ const diffSec = Math.max(0, Math.floor((now - timestamp) / 1000));
118
+ if (diffSec < 60) return 'just now';
119
+ const diffMin = Math.floor(diffSec / 60);
120
+ if (diffMin < 60) return `${diffMin}m ago`;
121
+ const diffHours = Math.floor(diffMin / 60);
122
+ if (diffHours < 24) return `${diffHours}h ago`;
123
+ const diffDays = Math.floor(diffHours / 24);
124
+ return `${diffDays}d ago`;
238
125
  }
239
126
 
240
127
  /**
241
- * Format creation timestamp as concise date/time:
242
- * - 'Today HH:MM' if created today
243
- * - 'Yesterday HH:MM' if created yesterday
244
- * - 'MM-DD HH:MM' if created this year
245
- * - 'YYYY-MM-DD' if older
128
+ * Format workspace & first file context concisely (e.g. 'anchor · src/auth.ts' or 'College/优化')
246
129
  */
247
- export function formatCreationTime(timestamp: number, now: number = Date.now()): string {
248
- const d = new Date(timestamp);
249
- const nowD = new Date(now);
250
-
251
- const pad = (n: number) => String(n).padStart(2, '0');
252
- const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
253
-
254
- const todayStr = getTodayDateString(now);
255
- const createdDayStr = getTodayDateString(timestamp);
256
-
257
- if (createdDayStr === todayStr) {
258
- return `Today ${timeStr}`;
130
+ export function formatOrigin(anchor: Anchor | string): string {
131
+ if (typeof anchor === 'string') {
132
+ if (!anchor) return 'global';
133
+ return path.basename(anchor.replace(/\\/g, '/')) || 'global';
259
134
  }
260
135
 
261
- const yesterdayStr = getTodayDateString(now - 24 * 60 * 60 * 1000);
262
- if (createdDayStr === yesterdayStr) {
263
- return `Yesterday ${timeStr}`;
136
+ let proj = 'global';
137
+ if (anchor.project && anchor.project !== 'global') {
138
+ const normProj = anchor.project.replace(/\\/g, '/').replace(/\/+$/, '');
139
+ if (normProj.includes('/')) {
140
+ const parts = normProj.split('/').filter(Boolean);
141
+ proj = parts.slice(-2).join('/');
142
+ } else {
143
+ proj = normProj;
144
+ }
145
+ } else if (anchor.cwd) {
146
+ proj = path.basename(anchor.cwd.replace(/\\/g, '/')) || 'global';
264
147
  }
265
148
 
266
- const mmdd = `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
267
- if (d.getFullYear() === nowD.getFullYear()) {
268
- return `${mmdd} ${timeStr}`;
149
+ if (anchor.files && anchor.files.length > 0) {
150
+ const shortFile = path.basename(anchor.files[0].replace(/\\/g, '/'));
151
+ if (shortFile && !proj.endsWith(shortFile)) {
152
+ return `${proj} · ${shortFile}`;
153
+ }
269
154
  }
270
-
271
- return `${d.getFullYear()}-${mmdd}`;
155
+ return proj;
272
156
  }
273
157
 
274
158
  /**
275
- * Backward compatibility alias
159
+ * Sort sticky notes naturally:
160
+ * 1. Active notes before quiet (>7d untouched) notes
161
+ * 2. Notes belonging to current workspace first
162
+ * 3. Most recently touched first
276
163
  */
277
- export function formatRelativeTime(timestamp: number, now: number = Date.now()): string {
278
- return formatCreationTime(timestamp, now);
279
- }
280
-
281
- /**
282
- * Backward compatibility alias (now returns target date representation)
283
- */
284
- export function formatRemainingTtl(anchor: Anchor, now: number = Date.now()): string | undefined {
285
- return formatTargetDate(anchor, now);
286
- }
287
-
288
- /**
289
- * Format origin creation workspace folder (provenance: where the task was born/created)
290
- */
291
- export function formatOrigin(anchor: Anchor | string): string {
292
- if (typeof anchor === 'object') {
293
- if (!anchor.cwd) return anchor.project || 'global';
294
- return path.basename(anchor.cwd) || 'global';
295
- }
296
- if (!anchor) return 'global';
297
- return path.basename(anchor) || 'global';
164
+ export function sortAnchorsForDisplay(anchors: Anchor[], cwd?: string): Anchor[] {
165
+ return [...anchors].sort((a, b) => {
166
+ if (a.status !== b.status) {
167
+ return a.status === 'active' ? -1 : 1;
168
+ }
169
+ if (cwd) {
170
+ const aMatch = isAnchorTargetingWorkspace(a, cwd) ? 0 : 1;
171
+ const bMatch = isAnchorTargetingWorkspace(b, cwd) ? 0 : 1;
172
+ if (aMatch !== bMatch) return aMatch - bMatch;
173
+ }
174
+ return b.lastTouchedAt - a.lastTouchedAt;
175
+ });
298
176
  }
299
177
 
300
178
  /**
301
- * Update footer status bar capsule: `⌖ 1`
179
+ * Update status bar badge: `⌖ 2` (invisible when 0 active notes)
302
180
  */
303
181
  export function updateAnchorStatusBar(ctx: ExtensionContext, store: AnchorStore): void {
304
182
  if (!hasActiveUI(ctx)) return;
305
-
306
183
  try {
307
184
  const active = store.list({ status: 'active', cwd: ctx.cwd });
308
- if (active.length === 0) {
309
- ctx.ui.setStatus('anchor', undefined);
310
- return;
311
- }
312
-
313
- ctx.ui.setStatus('anchor', `⌖ ${active.length}`);
314
- } catch {
315
- // Ignore stale context errors during session replacement
316
- }
185
+ ctx.ui.setStatus('anchor', active.length > 0 ? `⌖ ${active.length}` : undefined);
186
+ } catch {}
317
187
  }
318
188
 
319
189
  /**
320
- * Render lightweight startup banner widget above the editor.
321
- * Features 3-4 lines with quadrant badges and target date hints.
322
- * Automatically cleared on agent_start (when user sends first message).
190
+ * Render clean sticky-note card above editor on session startup.
191
+ * Automatically disappears as soon as the user sends their first message.
323
192
  */
324
193
  export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore): void {
325
194
  if (!hasActiveUI(ctx)) return;
@@ -331,30 +200,34 @@ export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore):
331
200
  return;
332
201
  }
333
202
 
334
- const groups = groupAnchorsByQuadrant(active);
335
- const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
203
+ const sorted = sortAnchorsForDisplay(active, ctx.cwd);
336
204
  const topItems = sorted.slice(0, 3);
337
205
 
338
206
  const factory = (_tui?: any, theme?: any) => ({
339
207
  render: (width: number) => {
340
- const maxTitleWidth = Math.max(12, width - 36);
208
+ const maxTitleWidth = Math.max(14, width - 34);
341
209
  const fg = theme?.fg ? theme.fg.bind(theme) : (_c: string, text: string) => text;
210
+ const label = active.length === 1 ? '1 note' : `${active.length} notes`;
342
211
 
343
212
  const lines: string[] = [
344
- truncateToWidth(fg('accent', `⌖ Anchor · ${active.length} active commitments:`), width)
213
+ truncateToWidth(fg('accent', `⌖ ${label}`), width)
345
214
  ];
346
215
 
347
- for (let i = 0; i < topItems.length; i++) {
348
- const a = topItems[i];
216
+ for (const a of topItems) {
349
217
  const num = a.id.replace(/^anc-/, '').padStart(2, '0');
350
- const q = classifyAnchor(a);
351
- const tgt = formatTargetDate(a);
352
- const titlePadded = truncateToWidth(a.title, maxTitleWidth);
353
- lines.push(truncateToWidth(` • ${fg('muted', `[${q}]`)} ${fg('dim', num)} ${titlePadded} ${fg('dim', `(${tgt})`)}`, width));
218
+ const origin = formatOrigin(a);
219
+ const age = formatRelativeAge(a.createdAt);
220
+ const titleClipped = truncateToWidth(a.title, maxTitleWidth);
221
+ lines.push(
222
+ truncateToWidth(
223
+ ` ${fg('dim', num)} ${titleClipped} ${fg('muted', `· ${origin}`)} ${fg('dim', `(${age})`)}`,
224
+ width
225
+ )
226
+ );
354
227
  }
355
228
 
356
229
  if (sorted.length > 3) {
357
- lines.push(truncateToWidth(fg('dim', ` (+${sorted.length - 3} more · run /anchor to inspect)`), width));
230
+ lines.push(truncateToWidth(fg('dim', ` +${sorted.length - 3} more · /anchor`), width));
358
231
  }
359
232
 
360
233
  return lines;
@@ -362,102 +235,82 @@ export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore):
362
235
  invalidate: () => {}
363
236
  });
364
237
 
365
- // Reactive component factory in real TUI mode, static string[] in mock tests
366
238
  const content = (ctx as any).ui?.theme ? factory : factory().render(80);
367
-
368
239
  ctx.ui.setWidget('anchor-startup', content as any, { placement: 'aboveEditor' });
369
- } catch {
370
- // Ignore stale context errors during session replacement
371
- }
240
+ } catch {}
372
241
  }
373
242
 
374
243
  /**
375
- * Clean, columnar-aligned checklist with Target Date and Creation Time columns
244
+ * Interactive `/anchor` sticky-note board
376
245
  */
377
- export async function openAnchorDashboard(
378
- ctx: ExtensionContext,
379
- store: AnchorStore
380
- ): Promise<void> {
246
+ export async function openAnchorDashboard(ctx: ExtensionContext, store: AnchorStore): Promise<void> {
381
247
  if (!hasActiveUI(ctx)) return;
382
248
 
383
- const list = store.list({ status: 'active', cwd: ctx.cwd });
384
- if (list.length === 0) {
385
- ctx.ui.notify('⌖ No active anchors. Use /pin <task> to record.', 'info');
249
+ const allNotes = store
250
+ .list({ cwd: ctx.cwd, all: true })
251
+ .filter(a => a.status === 'active' || a.status === 'sleeping');
252
+
253
+ if (allNotes.length === 0) {
254
+ ctx.ui.notify('⌖ No sticky notes. Use /pin <note> to stick one.', 'info');
386
255
  return;
387
256
  }
388
257
 
389
- const groups = groupAnchorsByQuadrant(list);
390
- const sortedList = [
391
- ...groups.today,
392
- ...groups.upcoming,
393
- ...groups.habits,
394
- ...groups.backlog
395
- ];
396
-
258
+ const sorted = sortAnchorsForDisplay(allNotes, ctx.cwd);
397
259
  const optionMap = new Map<string, Anchor>();
398
260
  const displayOptions: string[] = [];
399
261
  const termCols = process.stdout?.columns || 96;
400
- const isNarrow = termCols < 88;
401
- const titleWidth = isNarrow ? Math.max(18, termCols - 36) : 34;
262
+ const isNarrow = termCols < 84;
263
+ const titleWidth = isNarrow ? Math.max(20, termCols - 30) : 42;
402
264
 
403
- for (let i = 0; i < sortedList.length; i++) {
404
- const a = sortedList[i];
265
+ for (const a of sorted) {
405
266
  const num = a.id.replace(/^anc-/, '').padStart(2, '0');
406
- const category = padToWidth(`[${classifyAnchor(a)}]`, 12);
407
267
  const origin = formatOrigin(a);
408
- const target = formatTargetDate(a);
409
- const created = formatCreationTime(a.createdAt);
268
+ const age = a.status === 'sleeping'
269
+ ? `${formatRelativeAge(a.createdAt)} · quiet`
270
+ : formatRelativeAge(a.createdAt);
410
271
 
411
- const titleWithFiles = (a.files && a.files.length > 0)
412
- ? `${a.title} [${a.files.slice(0, 1).join(', ')}]`
413
- : a.title;
414
-
415
- // Clean tabular column alignment: ID, Category, Title, Project, Target, Created
416
272
  const colNum = `${num} `;
417
- const colTitle = padToWidth(titleWithFiles, titleWidth);
418
- const colOrigin = padToWidth(origin, 10);
419
- const colTarget = padToWidth(target, 12);
420
- const colCreated = created;
273
+ const colTitle = padToWidth(a.title, titleWidth);
274
+ const colOrigin = padToWidth(origin, 22);
421
275
 
422
276
  const label = isNarrow
423
- ? `${colNum}${category}${colTitle} ${colTarget}`.trimEnd()
424
- : `${colNum}${category}${colTitle} ${colOrigin} ${colTarget} ${colCreated}`.trimEnd();
277
+ ? `${colNum}${colTitle} ${age}`.trimEnd()
278
+ : `${colNum}${colTitle} ${colOrigin} ${age}`.trimEnd();
425
279
 
426
280
  optionMap.set(label, a);
427
281
  displayOptions.push(label);
428
282
  }
429
283
 
430
- const selected = await ctx.ui.select('⌖ Anchors (select to inspect or settle):', displayOptions);
284
+ const selected = await ctx.ui.select('⌖ Notes (select a note):', displayOptions);
431
285
  if (!selected) return;
432
286
 
433
287
  const anchor = optionMap.get(selected);
434
288
  if (!anchor) return;
435
289
 
436
- const action = await ctx.ui.select(
437
- `⌖ Anchor #${anchor.id}: "${anchor.title}"`,
438
- [
439
- '✓ Settle (Mark completed and archive)',
440
- 'ℹ View details & associated files',
441
- '↩ Cancel'
442
- ]
443
- );
290
+ const actions = [
291
+ '✓ Done (tear off note)',
292
+ ...(anchor.status === 'sleeping' ? ['⚡ Wake up (mark active)'] : []),
293
+ 'ℹ Read full note',
294
+ '↩ Back'
295
+ ];
296
+
297
+ const action = await ctx.ui.select(`⌖ #${anchor.id.replace(/^anc-/, '')}: "${anchor.title}"`, actions);
444
298
  if (!action || action.startsWith('↩')) return;
445
299
 
446
300
  if (action.startsWith('✓')) {
447
- store.settle(anchor.id, { settledBy: 'manual-command' });
448
- const successMsg = anchor.recurrence === 'daily'
449
- ? `⌖ Completed for today: "${anchor.title}" (resets tomorrow)`
450
- : `⌖ Settled #${anchor.id}: "${anchor.title}" (/anchor undo to revert)`;
451
- ctx.ui.notify(successMsg, 'info');
301
+ store.settle(anchor.id, { settledBy: 'manual' });
302
+ ctx.ui.notify(`⌖ Torn off #${anchor.id.replace(/^anc-/, '')}: "${anchor.title}" (/anchor undo to put back)`, 'info');
303
+ updateAnchorStatusBar(ctx, store);
304
+ } else if (action.startsWith('⚡')) {
305
+ store.touch(anchor.id);
306
+ ctx.ui.notify(`⌖ Woke up #${anchor.id.replace(/^anc-/, '')}: "${anchor.title}"`, 'info');
452
307
  updateAnchorStatusBar(ctx, store);
453
308
  } else if (action.startsWith('ℹ')) {
454
309
  const details = [
455
- `#${anchor.id} [${anchor.priority.toUpperCase()}] ${anchor.title}`,
456
- anchor.description ? `Description: ${anchor.description}` : null,
457
- anchor.targetDate ? `Target Date: ${anchor.targetDate} (${formatTargetDate(anchor)})` : null,
458
- anchor.files && anchor.files.length > 0 ? `Files: ${anchor.files.join(', ')}` : null,
459
- anchor.verifyCommand ? `Verify Command: ${anchor.verifyCommand}` : null,
460
- `Created: ${formatCreationTime(anchor.createdAt)} (in ${anchor.project || 'global'})`
310
+ `⌖ Note #${anchor.id.replace(/^anc-/, '')}: ${anchor.title}`,
311
+ anchor.description ? `Details: ${anchor.description}` : null,
312
+ anchor.files.length > 0 ? `Files: ${anchor.files.join(', ')}` : null,
313
+ `Workspace: ${anchor.project || 'global'} · Pinned ${formatRelativeAge(anchor.createdAt)}`
461
314
  ].filter(Boolean).join('\n');
462
315
  ctx.ui.notify(details, 'info');
463
316
  }
package/src/types.ts CHANGED
@@ -1,67 +1,47 @@
1
1
  /**
2
- * Anchor Data Contracts & Type Definitions
3
- * Single Global Source of Truth with Zero Workspace Contamination.
2
+ * Anchor Data Contracts
3
+ * Minimalist cross-session sticky notes stored in ~/.anchor/state.json
4
4
  */
5
5
 
6
- export type AnchorStatus = 'active' | 'sleeping' | 'settled' | 'graveyard';
6
+ export type AnchorStatus = 'active' | 'sleeping' | 'settled';
7
7
  export type AnchorPriority = 'p0' | 'p1' | 'p2';
8
- export type AnchorDurability = 'ephemeral' | 'durable';
9
-
10
- export interface AnchorDecayPolicy {
11
- /** Days before an untouched active anchor transitions to sleeping */
12
- activeDays: number;
13
- /** Days an anchor remains sleeping before dropping to graveyard */
14
- sleepDays: number;
15
- /** Total maximum days before permanent graveyard sweep (9999 = never) */
16
- graveyardDays: number;
17
- }
18
8
 
19
9
  export interface AnchorEvidence {
20
10
  settledAt?: number;
21
- settledBy?: 'one-tap-settlement' | 'manual-command' | 'verification-test';
11
+ settledBy?: 'commit' | 'manual' | 'one-tap-settlement' | 'manual-command' | 'verification-test';
22
12
  commitHash?: string;
23
13
  summary?: string;
24
14
  touchedFiles?: string[];
25
15
  }
26
16
 
27
17
  export interface Anchor {
28
- /** Unique sequential identifier, e.g., 'anc-1' */
18
+ /** Sequential identifier, e.g. 'anc-1' */
29
19
  id: string;
30
- /** Short imperative title of the task */
20
+ /** Sticky note text */
31
21
  title: string;
32
- /** Optional detailed context or notes */
22
+ /** Optional extra context */
33
23
  description?: string;
34
- /** Task priority */
35
- priority: AnchorPriority;
36
- /** Current lifecycle status */
24
+ /** Lifecycle status: 'active' (recent) or 'sleeping' (>7d untouched, 0 prompt tokens) */
37
25
  status: AnchorStatus;
38
- /** Durability tier: ephemeral (short-lived reminder, 48h TTL) or durable (long-term architecture vision) */
39
- durability: AnchorDurability;
40
- /** Canonical project name (e.g. 'PPT', 'X', or 'global') */
26
+ /** Short workspace label (e.g. 'anchor', 'College/优化', or 'global') */
41
27
  project: string;
42
- /** Canonical root directory where the task was anchored (empty string for global tasks) */
28
+ /** Workspace directory path where the note applies */
43
29
  cwd: string;
44
- /** Creation timestamp in ms */
30
+ /** Creation timestamp (ms) */
45
31
  createdAt: number;
46
- /** Last update timestamp in ms */
32
+ /** Last update timestamp (ms) */
47
33
  updatedAt: number;
48
- /** Last touched timestamp in ms (resets on file touch) */
34
+ /** Last activity timestamp (ms) */
49
35
  lastTouchedAt: number;
50
- /** Associated file paths or globs, e.g. ['src/auth/*.ts'] */
36
+ /** Associated file paths or globs (explicit or auto-extracted from note text) */
51
37
  files: string[];
52
- /** Expected completion date in format 'YYYY-MM-DD' (e.g. '2026-09-24') */
53
- targetDate?: string;
54
- /** Optional shell command for automated physical verification (e.g. 'npm test') */
55
- verifyCommand?: string;
56
- /** Topic or domain tags, e.g. ['auth', 'security'] */
38
+ /** Optional tags */
57
39
  tags: string[];
58
- /** Optional recurrence pattern: 'daily' for recurring daily habits */
59
- recurrence?: 'daily';
60
- /** Last completed date string 'YYYY-MM-DD' for recurring tasks */
61
- lastCompletedDate?: string;
62
- /** Decay policy overrides */
63
- decay: AnchorDecayPolicy;
64
- /** Settlement evidence metadata upon closure */
40
+ /** Optional due date ('YYYY-MM-DD') */
41
+ targetDate?: string;
42
+ /** Optional priority kept for backward compatibility */
43
+ priority?: AnchorPriority;
44
+ /** Settlement metadata when archived */
65
45
  evidence?: AnchorEvidence;
66
46
  }
67
47
 
@@ -76,30 +56,9 @@ export interface TouchMatchResult {
76
56
  anchor: Anchor;
77
57
  score: number;
78
58
  matchedFiles: string[];
79
- reason: 'exact-file' | 'dir-prefix' | 'tag-keyword';
80
- }
81
-
82
- export interface SettlementProposal {
83
- anchor: Anchor;
84
- matchedFiles: string[];
85
- reason: string;
86
- recommendedAction: 'settle' | 'defer';
59
+ reason: 'exact-file' | 'dir-prefix';
87
60
  }
88
61
 
89
- export const EPHEMERAL_DECAY_POLICY: AnchorDecayPolicy = {
90
- activeDays: 1, // 24 hours active
91
- sleepDays: 1, // 24 hours sleeping
92
- graveyardDays: 2 // 48 hours total before auto-clearing
93
- };
94
-
95
- export const DURABLE_DECAY_POLICY: AnchorDecayPolicy = {
96
- activeDays: 3,
97
- sleepDays: 30,
98
- graveyardDays: 9999 // Never permanently auto-dropped; preserved indefinitely
99
- };
100
-
101
- export const DEFAULT_DECAY_POLICY: AnchorDecayPolicy = {
102
- activeDays: 3,
103
- sleepDays: 7,
104
- graveyardDays: 14
105
- };
62
+ /** 7 days without activity moves a note to quiet mode (still visible in UI, 0 prompt tokens in unrelated workspaces) */
63
+ export const QUIET_AFTER_DAYS = 7;
64
+ export const MS_PER_DAY = 86_400_000;