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/extension.ts CHANGED
@@ -1,27 +1,30 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import { Type } from '@sinclair/typebox';
3
- import { AnchorStore, detectTargetDate } from './store.ts';
4
- import { SessionTouchObserver } from './observer.ts';
5
- import { renderColdStartAnchorsContext, renderActiveAnchorsContext, makeSafeTaskAnnotation } from './context_injector.ts';
6
- import { generateSettlementProposals, runPhysicalVerification } from './settlement.ts';
7
- import { normalizePath, isSameOrSubPath, findMatchedAnchors } from './matcher.ts';
8
- import { sweepStore } from './decay.ts';
9
- import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner, truncateToWidth, hasActiveUI, groupAnchorsByQuadrant } from './tui.ts';
10
- import { execSync } from 'node:child_process';
11
- import fs from 'node:fs';
12
- import path from 'node:path';
3
+ import {
4
+ AnchorStore,
5
+ findMatchedAnchors,
6
+ isAnchorTargetingWorkspace,
7
+ makeSafeTaskAnnotation,
8
+ matchesCommitMessage,
9
+ normalizePath,
10
+ renderColdStartAnchorsContext,
11
+ sweepQuietNotes
12
+ } from './store.ts';
13
+ import {
14
+ hasActiveUI,
15
+ openAnchorDashboard,
16
+ sortAnchorsForDisplay,
17
+ truncateToWidth,
18
+ updateAnchorStatusBar,
19
+ updateStartupBanner
20
+ } from './tui.ts';
13
21
 
14
22
  const MUTATION_TOOLS = new Set(['edit', 'write', 'patch', 'apply_diff', 'create_file', 'modify']);
15
23
 
16
- /**
17
- * Parse inline `--due <date>` / `-d <date>` flags and `done`/`rm`/`close`/`settle` subcommands
18
- * for `/anchor` and `/pin` slash commands.
19
- */
20
- export function parseAnchorCommandInput(raw: string, now: number = Date.now()): {
24
+ export function parseAnchorCommandInput(raw: string): {
21
25
  mode: 'dashboard' | 'undo' | 'settle' | 'pin';
22
26
  targetId?: string;
23
27
  title?: string;
24
- targetDate?: string;
25
28
  } {
26
29
  const trimmed = (raw || '').trim();
27
30
  if (!trimmed || trimmed === 'list' || trimmed === 'ls') {
@@ -31,7 +34,6 @@ export function parseAnchorCommandInput(raw: string, now: number = Date.now()):
31
34
  return { mode: 'undo' };
32
35
  }
33
36
 
34
- // Match explicit settle subcommands: `/anchor done 1`, `/anchor rm anc-2`, `/anchor settle #1`
35
37
  const settleMatch = trimmed.match(/^(?:done|rm|close|settle)\s+(.+)$/i);
36
38
  if (settleMatch) {
37
39
  return {
@@ -40,106 +42,46 @@ export function parseAnchorCommandInput(raw: string, now: number = Date.now()):
40
42
  };
41
43
  }
42
44
 
43
- // Extract optional `--due <val>` or `-d <val>` flag
44
- let targetDate: string | undefined;
45
- const tokens = trimmed.split(/\s+/);
46
- const dueIdx = tokens.findIndex(t => t === '--due' || t === '-d');
47
- if (dueIdx !== -1 && tokens[dueIdx + 1]) {
48
- const rawDue = tokens[dueIdx + 1];
49
- targetDate = detectTargetDate(rawDue, now) || rawDue;
50
- tokens.splice(dueIdx, 2);
51
- }
52
- const cleanTitle = tokens.join(' ').trim();
53
-
54
45
  return {
55
46
  mode: 'pin',
56
- title: cleanTitle || trimmed,
57
- targetDate
47
+ title: trimmed
58
48
  };
59
49
  }
60
50
 
61
- /**
62
- * Resolve either an explicit anchor ID ('anc-2', '2') or a 1-based visual dashboard index ('01', '1')
63
- */
64
51
  export function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?: string): string | undefined {
65
52
  const clean = idOrIndex.trim().replace(/^#/, '');
66
- // 1. Check 1-based sequential index from the active quadrant-sorted list first when user passes a plain number
67
53
  if (/^\d+$/.test(clean)) {
68
54
  const seqIndex = parseInt(clean, 10);
69
- const active = store.list({ status: 'active', cwd });
70
- const groups = groupAnchorsByQuadrant(active);
71
- const sortedActive = [
72
- ...groups.today,
73
- ...groups.upcoming,
74
- ...groups.habits,
75
- ...groups.backlog
76
- ];
77
- if (seqIndex >= 1 && seqIndex <= sortedActive.length) {
78
- return sortedActive[seqIndex - 1].id;
55
+ const visible = store
56
+ .list({ cwd, all: true })
57
+ .filter(a => a.status === 'active' || a.status === 'sleeping');
58
+ const sorted = sortAnchorsForDisplay(visible, cwd);
59
+ if (seqIndex >= 1 && seqIndex <= sorted.length) {
60
+ return sorted[seqIndex - 1].id;
79
61
  }
80
62
  }
81
- // 2. Fallback to direct ID lookup in store
82
- const direct = store.get(clean);
83
- return direct?.id;
63
+ return store.get(clean)?.id;
84
64
  }
85
65
 
86
66
  export default function (pi: ExtensionAPI) {
87
- // Global authoritative store in ~/.anchor/ (0 workspace clutter)
88
67
  const store = new AnchorStore();
89
- const observer = new SessionTouchObserver();
90
68
  const annotatedThisSession = new Set<string>();
91
- const preflightFailures = new Map<string, string>();
92
69
 
93
- // 1. Session start: sweep stale tasks, run physical pre-flight tests, update TUI status bar
70
+ // 1. Session start: quiet old notes (>7d), wake notes belonging to this workspace, show sticky note banner
94
71
  pi.on('session_start', async (event: any, ctx: ExtensionContext) => {
95
- observer.clear();
96
72
  annotatedThisSession.clear();
97
- preflightFailures.clear();
98
-
99
- const sweep = sweepStore(store);
100
-
101
- if (sweep.transitionedToSleeping.length > 0) {
102
- ctx.ui.notify(`⌖ Anchor: ${sweep.transitionedToSleeping.length} inactive tasks transitioned to sleep`, 'info');
103
- }
104
-
105
- // Physical Pre-flight Verification: auto-settle passing tasks or record failing output (scoped strictly to current cwd)
106
- const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
107
- for (const a of activeAnchors) {
108
- if (a.verifyCommand && (!a.cwd || isSameOrSubPath(a.cwd, ctx.cwd))) {
109
- try {
110
- const res = runPhysicalVerification(a, ctx.cwd);
111
- if (res.success) {
112
- store.settle(a.id, {
113
- settledBy: 'verification-test',
114
- summary: `Physical pre-flight test passed: ${a.verifyCommand}`
115
- });
116
- ctx.ui.notify(`⌖ Verification passed! Auto-settled #${a.id}: "${a.title}"`, 'info');
117
- } else {
118
- preflightFailures.set(a.id, res.output.slice(-250));
119
- }
120
- } catch (_) {}
121
- }
73
+ sweepQuietNotes(store);
74
+
75
+ // Clever Touch: If a quiet note belongs to the workspace we just opened, wake it up automatically
76
+ const quietForHere = store
77
+ .list({ status: 'sleeping', all: true })
78
+ .filter(a => (a.files.length > 0 || (a.project && a.project !== 'global') || a.cwd) && isAnchorTargetingWorkspace(a, ctx.cwd));
79
+ for (const q of quietForHere) {
80
+ store.touch(q.id);
122
81
  }
123
82
 
124
- // Exit Guard notification check
125
- try {
126
- const home = process.env.HOME || process.env.USERPROFILE || '';
127
- const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
128
- if (fs.existsSync(pendingPath)) {
129
- const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
130
- const isExpired = Date.now() - pending.timestamp >= 24 * 60 * 60 * 1000;
131
- if (!isExpired && isSameOrSubPath(pending.cwd, ctx.cwd)) {
132
- ctx.ui.notify(`⌖ [Exit Guard] Detected ${pending.modifiedCount} uncommitted changes from previous session exit. Run /pin to record.`, 'info');
133
- fs.unlinkSync(pendingPath);
134
- } else if (isExpired) {
135
- fs.unlinkSync(pendingPath);
136
- }
137
- }
138
- } catch (_) {}
139
-
140
83
  updateAnchorStatusBar(ctx, store);
141
84
 
142
- // Only render startup banner on initial startup or new session, not on mid-session resume
143
85
  if (event.reason !== 'resume') {
144
86
  updateStartupBanner(ctx, store);
145
87
  } else if (hasActiveUI(ctx)) {
@@ -149,7 +91,7 @@ export default function (pi: ExtensionAPI) {
149
91
  }
150
92
  });
151
93
 
152
- // 2. Auto-clear startup banner when agent starts first turn to keep screen clean
94
+ // 2. Clear sticky note banner as soon as the user sends the first prompt
153
95
  pi.on('agent_start', async (_event: any, ctx: ExtensionContext) => {
154
96
  if (hasActiveUI(ctx)) {
155
97
  try {
@@ -158,184 +100,91 @@ export default function (pi: ExtensionAPI) {
158
100
  }
159
101
  });
160
102
 
161
- // 3. JIT Cold-Start Injection: Only fires on Turn 1 of a session. From turn 2 onwards, consumes 0 tokens!
103
+ // 3. Turn 1 Cold-Start Brief (0 tokens on Turn 2+)
162
104
  pi.on('before_agent_start', async (event: any, ctx: ExtensionContext) => {
163
105
  const entries = (ctx as any).sessionManager?.getEntries() || [];
164
106
  const messageTurns = entries.filter((e: any) => e.type === 'message');
165
- const isColdStart = messageTurns.length <= 1;
166
-
167
- if (isColdStart) {
168
- const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd, undefined, preflightFailures);
169
- if (contextSnippet) {
170
- return {
171
- systemPrompt: `${event.systemPrompt}\n\n${contextSnippet}`
172
- };
107
+ if (messageTurns.length <= 1) {
108
+ const snippet = renderColdStartAnchorsContext(store, ctx.cwd);
109
+ if (snippet) {
110
+ return { systemPrompt: `${event.systemPrompt}\n\n${snippet}` };
173
111
  }
174
112
  }
175
113
  });
176
114
 
177
- // 4. JIT Path-Triggered Context Alert: When agent successfully touches an intersecting file
115
+ // 4. Clever Touches on Tool Execution:
116
+ // (a) Auto-tear off sticky notes on matching `git commit`
117
+ // (b) Slip a 1-line comment whisper when the AI reads/edits a matching file
178
118
  pi.on('tool_result', async (event: any, ctx: ExtensionContext) => {
179
- // Only verified, non-error tool executions are recorded to prevent false-positive settlement
180
- if (!event.isError) {
181
- observer.recordToolCall(event.toolName, event.input || {});
182
- }
183
-
184
- const pathInput = (event.input as any)?.path;
185
- if (typeof pathInput !== 'string') return;
186
-
187
- const touchedPath = normalizePath(pathInput, ctx.cwd);
188
- const anchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
189
- const matches = findMatchedAnchors(anchors, [touchedPath]);
190
-
191
- if (matches.length > 0) {
192
- const isMutation = MUTATION_TOOLS.has((event.toolName || '').toLowerCase());
193
-
194
- // Only physical mutations (edit, write) refresh decay timer and wake sleeping anchors!
195
- // Read-only inspection (read, grep) provides JIT context without fake lifecycle extension.
196
- if (isMutation && !event.isError) {
197
- for (const m of matches) {
198
- store.touch(m.anchor.id);
199
- }
200
- updateAnchorStatusBar(ctx, store);
201
- }
202
-
203
- const a = matches[0].anchor;
204
-
205
- // Deduplicate annotations and ensure syntax safety (e.g. skip .json/.env)
206
- if (!annotatedThisSession.has(a.id)) {
207
- annotatedThisSession.add(a.id);
208
-
209
- const alert = makeSafeTaskAnnotation(touchedPath, a);
210
- if (alert) {
211
- const contents = [...(event.content || [])];
212
- for (let i = contents.length - 1; i >= 0; i--) {
213
- const item = contents[i];
214
- if (item && item.type === 'text') {
215
- contents[i] = { ...item, text: item.text + alert };
216
- return { content: contents };
119
+ if (event.isError) return;
120
+
121
+ const lowerTool = String(event.toolName || '').toLowerCase();
122
+
123
+ // (a) Real-time auto-tear on `git commit -m "..."`
124
+ if (lowerTool === 'bash' || lowerTool === 'powershell') {
125
+ const cmd = String((event.input as any)?.command || '');
126
+ if (cmd.includes('git commit')) {
127
+ const msgMatch = cmd.match(/-m\s+["']([^"']+)["']/);
128
+ if (msgMatch && msgMatch[1]) {
129
+ const commitMsg = msgMatch[1];
130
+ const candidates = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
131
+ for (const a of candidates) {
132
+ if (matchesCommitMessage(a, [commitMsg]).matched && isAnchorTargetingWorkspace(a, ctx.cwd)) {
133
+ store.settle(a.id, { settledBy: 'commit', summary: commitMsg });
134
+ updateAnchorStatusBar(ctx, store);
135
+ if (hasActiveUI(ctx)) {
136
+ try {
137
+ ctx.ui.notify(`⌖ Auto-torn off #${a.id.replace(/^anc-/, '')} ("${a.title}") on git commit`, 'info');
138
+ } catch {}
139
+ }
217
140
  }
218
141
  }
219
142
  }
220
143
  }
221
144
  }
222
- });
223
-
224
- // 4. Session shutdown: Single-pass git inspection, one-tap settlement check & Exit Guard
225
- pi.on('session_shutdown', async (event: any, ctx: ExtensionContext) => {
226
- let gitStatus = '';
227
- try {
228
- gitStatus = execSync('git status --porcelain', {
229
- cwd: ctx.cwd,
230
- encoding: 'utf-8',
231
- timeout: 2000,
232
- stdio: ['ignore', 'pipe', 'ignore']
233
- }).replace(/[\r\n]+$/, '');
234
- const changed = gitStatus
235
- .split(/\r?\n/)
236
- .filter(Boolean)
237
- .map((l: string) => l.slice(3).replace(/^"(.*)"$/, '$1').trim())
238
- .filter(Boolean);
239
- for (const f of changed) {
240
- observer.addTouchedFile(f);
241
- }
242
- } catch {}
243
-
244
- // Exit Guard: detect uncommitted changes on abrupt session shutdown
245
- if (event?.reason === 'quit' && gitStatus) {
246
- try {
247
- const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
248
- if (activeAnchors.length === 0) {
249
- const home = process.env.HOME || process.env.USERPROFILE || '';
250
- const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
251
- const data = {
252
- cwd: ctx.cwd,
253
- timestamp: Date.now(),
254
- modifiedCount: gitStatus.split('\n').filter(Boolean).length
255
- };
256
- fs.writeFileSync(pendingPath, JSON.stringify(data, null, 2), 'utf-8');
257
- }
258
- } catch {}
259
- }
260
-
261
- const modified = observer.getModifiedFiles();
262
- if (modified.length === 0) return;
263
145
 
264
- const proposals = generateSettlementProposals(store, modified, ctx.cwd);
265
- if (proposals.length === 0) return;
146
+ // (b) 1-line syntax-safe file whisper
147
+ const pathInput = (event.input as any)?.path;
148
+ if (typeof pathInput !== 'string') return;
266
149
 
267
- // Check proposals for automated verification or one-tap settlement
268
- for (const prop of proposals) {
269
- const a = prop.anchor;
150
+ const touchedPath = normalizePath(pathInput, ctx.cwd);
151
+ const notes = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
152
+ const matches = findMatchedAnchors(notes, [touchedPath]);
153
+ if (matches.length === 0) return;
270
154
 
271
- // 1. Zero-friction Git Commit evidence matching
272
- const commitMatch = observer.matchesCommit(a);
273
- if (commitMatch.matched) {
274
- store.settle(a.id, {
275
- settledBy: 'verification-test',
276
- summary: `Auto-settled via Git commit: ${commitMatch.message}`,
277
- touchedFiles: prop.matchedFiles
278
- });
279
- if (hasActiveUI(ctx)) {
280
- try {
281
- ctx.ui.notify(`⌖ Auto-settled #${a.id} via Git commit (run /anchor undo to revert)`, 'info');
282
- } catch {}
283
- }
284
- continue;
285
- }
155
+ if (MUTATION_TOOLS.has(lowerTool)) {
156
+ for (const m of matches) store.touch(m.anchor.id);
157
+ updateAnchorStatusBar(ctx, store);
158
+ }
286
159
 
287
- // 2. Automated physical verification command
288
- if (a.verifyCommand) {
289
- const verifyRes = runPhysicalVerification(a, ctx.cwd);
290
- if (verifyRes.success) {
291
- store.settle(a.id, {
292
- settledBy: 'verification-test',
293
- summary: `Automated test passed: ${a.verifyCommand}`,
294
- touchedFiles: prop.matchedFiles
295
- });
296
- if (hasActiveUI(ctx)) {
297
- try {
298
- ctx.ui.notify(`⌖ Test passed (${a.verifyCommand}), auto-settled #${a.id}`, 'info');
299
- } catch {}
160
+ const targetNote = matches[0].anchor;
161
+ if (!annotatedThisSession.has(targetNote.id)) {
162
+ annotatedThisSession.add(targetNote.id);
163
+ const whisper = makeSafeTaskAnnotation(touchedPath, targetNote);
164
+ if (whisper) {
165
+ const contents = [...(event.content || [])];
166
+ for (let i = contents.length - 1; i >= 0; i--) {
167
+ const item = contents[i];
168
+ if (item && item.type === 'text') {
169
+ contents[i] = { ...item, text: item.text + whisper };
170
+ return { content: contents };
300
171
  }
301
- continue;
302
172
  }
303
173
  }
304
-
305
- // 3. Fallback: Prompt user for one-tap settlement on exit
306
- if (!hasActiveUI(ctx)) continue;
307
-
308
- try {
309
- const ok = await ctx.ui.confirm(
310
- '⌖ Settle Anchor Task',
311
- `Task #${a.id} [${a.title}] touched files (${prop.matchedFiles.slice(0, 2).join(', ')}).\nMark as completed and archive?`
312
- );
313
-
314
- if (ok) {
315
- store.settle(a.id, {
316
- settledBy: 'one-tap-settlement',
317
- touchedFiles: prop.matchedFiles
318
- });
319
- ctx.ui.notify(`⌖ Settled #${a.id}: "${a.title}"`, 'info');
320
- }
321
- } catch {}
322
174
  }
323
175
  });
324
176
 
325
177
  // 5. Register LLM Tool: anchor
326
178
  pi.registerTool({
327
179
  name: 'anchor',
328
- label: 'Anchor (Cross-session Task Protocol)',
180
+ label: 'Anchor (Sticky Note)',
329
181
  description:
330
- '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.',
331
- promptSnippet: 'Anchor cross-session task contracts that survive terminal restarts and auto-evict',
182
+ 'Pin, list, or tear off cross-session sticky notes stored in ~/.anchor/ (zero git repo clutter). Actions: pin (stick a new note), list (view notes), settle (tear off completed note).',
183
+ promptSnippet: 'Pin or tear off cross-session sticky notes that survive terminal restarts',
332
184
  promptGuidelines: [
333
- 'Use `anchor` when the user asks to retain a goal across sessions or record a reminder for later/tonight/tomorrow (e.g. "晚上清理垃圾", "明天优化X", "保留任务直到完成").',
334
- '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`.',
335
- 'DO NOT over-engineer or assume automated scheduled tasks unless the user explicitly requests Windows Task Scheduler or cron.',
336
- 'Tasks are automatically scoped to the current project context without cluttering the project git repository.',
337
- 'When code for an anchor is completed and verified, call `anchor` with action "settle" to archive it and free context.',
338
- 'Active anchors are automatically injected into future sessions in an ultra-compact block.'
185
+ 'Use `anchor` when the user asks to remember something for later/tomorrow or across sessions (e.g. "记一下明天重构鉴权", "晚上清理缓存").',
186
+ 'BOUNDARY WITH TODO: `todo` is for multi-step work right now in this session; `anchor` is a sticky note for future sessions.',
187
+ 'When a pinned note is completed, call `anchor` with action "settle" to tear it off.'
339
188
  ],
340
189
  parameters: Type.Object({
341
190
  action: Type.Union([
@@ -345,105 +194,73 @@ export default function (pi: ExtensionAPI) {
345
194
  Type.Literal('touch'),
346
195
  Type.Literal('sweep')
347
196
  ]),
348
- title: Type.Optional(Type.String({ description: 'Short imperative task title (for pin)' })),
349
- description: Type.Optional(Type.String({ description: 'Detailed context, acceptance criteria, or technical notes' })),
350
- priority: Type.Optional(Type.Union([Type.Literal('p0'), Type.Literal('p1'), Type.Literal('p2')])),
351
- project: Type.Optional(Type.String({ description: 'Target project name or workspace (defaults to current directory if omitted)' })),
352
- targetDate: Type.Optional(Type.String({ description: 'Expected completion date (e.g. YYYY-MM-DD, today, tomorrow)' })),
353
- files: Type.Optional(Type.Array(Type.String(), { description: 'Associated file paths or directory prefixes' })),
354
- tags: Type.Optional(Type.Array(Type.String(), { description: 'Domain tags' })),
355
- verifyCommand: Type.Optional(Type.String({ description: 'Optional shell command for automated physical verification' })),
356
- id: Type.Optional(Type.String({ description: 'Anchor ID, e.g. anc-1 (for settle or touch)' }))
197
+ title: Type.Optional(Type.String({ description: 'Sticky note text (for pin)' })),
198
+ description: Type.Optional(Type.String({ description: 'Optional extra details' })),
199
+ project: Type.Optional(Type.String({ description: 'Optional project name or folder' })),
200
+ files: Type.Optional(Type.Array(Type.String(), { description: 'Optional associated file paths' })),
201
+ id: Type.Optional(Type.String({ description: 'Note ID, e.g. anc-1 or 1 (for settle)' }))
357
202
  }),
358
203
  renderCall(args: any, theme: any) {
359
204
  const action = args.action || 'list';
360
205
  let title = theme.fg('toolTitle', theme.bold('⌖ anchor ')) + theme.fg('accent', action);
361
-
362
206
  if (action === 'pin' && args.title) {
363
- const due = args.targetDate ? ` ${theme.fg('dim', `[${args.targetDate}]`)}` : '';
364
- const files = (args.files && args.files.length > 0)
365
- ? ` ${theme.fg('muted', `(${args.files.slice(0, 2).join(', ')})`)}`
366
- : '';
367
- title += ` "${args.title}"${due}${files}`;
368
- } else if (action === 'settle' && args.id) {
369
- title += ` ${theme.fg('dim', `#${args.id}`)}`;
370
- } else if (action === 'touch' && args.id) {
371
- title += ` ${theme.fg('dim', `#${args.id}`)}`;
372
- } else if (action === 'list') {
373
- if (args.priority) title += ` ${theme.fg('muted', `[${args.priority}]`)}`;
207
+ title += ` "${args.title}"`;
208
+ } else if ((action === 'settle' || action === 'touch') && args.id) {
209
+ title += ` ${theme.fg('dim', `#${String(args.id).replace(/^anc-/, '')}`)}`;
374
210
  }
375
-
376
211
  return {
377
212
  render: (width: number) => [truncateToWidth(title, width)],
378
213
  invalidate: () => {}
379
214
  };
380
215
  },
381
216
  renderResult(result: any, _options: any, theme: any) {
382
- if (result.isError) {
383
- const errText = result.content?.[0]?.text || 'Execution failed';
384
- return {
385
- render: (width: number) => [truncateToWidth(theme.fg('error', `✗ ${errText}`), width)],
386
- invalidate: () => {}
387
- };
388
- }
389
-
390
- const text = result.content?.[0]?.text || 'OK';
217
+ const text = result.content?.[0]?.text || (result.isError ? 'Failed' : 'OK');
218
+ const color = result.isError ? 'error' : 'success';
219
+ const icon = result.isError ? '✗' : '✔';
391
220
  return {
392
- render: (width: number) => [truncateToWidth(theme.fg('success', `✔ ${text}`), width)],
221
+ render: (width: number) => [truncateToWidth(theme.fg(color, `${icon} ${text}`), width)],
393
222
  invalidate: () => {}
394
223
  };
395
224
  },
396
225
  async execute(_toolCallId: string, params: any, _signal?: AbortSignal, _onUpdate?: (partial: any) => void, ctx?: ExtensionContext) {
397
226
  if (!ctx) return { content: [{ type: 'text', text: 'Error: context required' }], isError: true };
227
+
398
228
  if (params.action === 'pin') {
399
229
  if (!params.title) {
400
- return { content: [{ type: 'text', text: 'Error: title is required for pin action' }], isError: true };
230
+ return { content: [{ type: 'text', text: 'Error: title is required for pin' }], isError: true };
401
231
  }
402
232
  const anc = store.create({
403
233
  title: params.title,
404
234
  description: params.description,
405
- priority: params.priority || 'p1',
406
235
  project: params.project,
407
- targetDate: params.targetDate,
408
236
  cwd: ctx.cwd,
409
- files: params.files || [],
410
- tags: params.tags || [],
411
- verifyCommand: params.verifyCommand
237
+ files: params.files || []
412
238
  });
413
239
  updateAnchorStatusBar(ctx, store);
414
240
  return {
415
- content: [{
416
- type: 'text',
417
- text: `Successfully anchored task #${anc.id}: "${anc.title}" [${anc.project}]. Stored in ~/.anchor/.`
418
- }],
241
+ content: [{ type: 'text', text: `Pinned note #${anc.id.replace(/^anc-/, '')}: "${anc.title}"` }],
419
242
  isError: false
420
243
  };
421
244
  }
422
245
 
423
246
  if (params.action === 'list') {
424
- const active = store.list({ status: 'active', cwd: ctx.cwd });
425
- const sleeping = store.list({ status: 'sleeping', cwd: ctx.cwd });
247
+ const notes = store.list({ cwd: ctx.cwd, all: true }).filter(a => a.status === 'active' || a.status === 'sleeping');
426
248
  return {
427
- content: [{
428
- type: 'text',
429
- text: JSON.stringify({ active, sleeping }, null, 2)
430
- }],
249
+ content: [{ type: 'text', text: JSON.stringify(notes, null, 2) }],
431
250
  isError: false
432
251
  };
433
252
  }
434
253
 
435
254
  if (params.action === 'settle') {
436
255
  if (!params.id) {
437
- return { content: [{ type: 'text', text: 'Error: id is required for settle action' }], isError: true };
256
+ return { content: [{ type: 'text', text: 'Error: id is required for settle' }], isError: true };
438
257
  }
439
258
  try {
440
- const settled = store.settle(params.id, { settledBy: 'verification-test' });
259
+ const targetId = resolveAnchorTarget(store, String(params.id), ctx.cwd) || String(params.id);
260
+ const settled = store.settle(targetId, { settledBy: 'manual' });
441
261
  updateAnchorStatusBar(ctx, store);
442
262
  return {
443
- content: [{
444
- type: 'text',
445
- text: `Anchor #${settled.id} successfully settled and evicted from active context.`
446
- }],
263
+ content: [{ type: 'text', text: `Torn off note #${settled.id.replace(/^anc-/, '')}: "${settled.title}"` }],
447
264
  isError: false
448
265
  };
449
266
  } catch (err: any) {
@@ -451,123 +268,93 @@ export default function (pi: ExtensionAPI) {
451
268
  }
452
269
  }
453
270
 
454
- if (params.action === 'touch') {
455
- if (!params.id) {
456
- return { content: [{ type: 'text', text: 'Error: id is required for touch action' }], isError: true };
457
- }
271
+ if (params.action === 'touch' && params.id) {
458
272
  try {
459
- const touched = store.touch(params.id);
273
+ const touched = store.touch(String(params.id));
460
274
  updateAnchorStatusBar(ctx, store);
461
- return {
462
- content: [{
463
- type: 'text',
464
- text: `Anchor #${touched.id} touched and decay timer refreshed.`
465
- }],
466
- isError: false
467
- };
275
+ return { content: [{ type: 'text', text: `Refreshed note #${touched.id}` }], isError: false };
468
276
  } catch (err: any) {
469
277
  return { content: [{ type: 'text', text: err.message }], isError: true };
470
278
  }
471
279
  }
472
280
 
473
281
  if (params.action === 'sweep') {
474
- const res = sweepStore(store);
282
+ const res = sweepQuietNotes(store);
475
283
  updateAnchorStatusBar(ctx, store);
476
- return {
477
- content: [{
478
- type: 'text',
479
- text: `Sweep complete: ${res.transitionedToSleeping.length} sleeping, ${res.evictedToGraveyard.length} evicted to graveyard.`
480
- }],
481
- isError: false
482
- };
284
+ return { content: [{ type: 'text', text: `Quieted ${res.quieted.length} old note(s).` }], isError: false };
483
285
  }
484
286
 
485
287
  return { content: [{ type: 'text', text: 'Unknown action' }], isError: true };
486
288
  }
487
289
  });
488
290
 
489
- // 6. User Slash Commands: /anchor, /pin, and /done
291
+ // 6. Slash Commands: /anchor, /pin, /done
490
292
  pi.registerCommand('anchor', {
491
- description: 'Inspect, pin, or settle active anchors (e.g. /anchor, /anchor done 1, /anchor undo)',
293
+ description: 'View, pin, or tear off sticky notes (/anchor, /anchor done 1, /anchor undo)',
492
294
  handler: async (args: string, ctx: ExtensionContext) => {
493
295
  const parsed = parseAnchorCommandInput(args);
494
296
 
495
- // Case 1: View active anchors (clean list, enter to complete)
496
297
  if (parsed.mode === 'dashboard') {
497
298
  await openAnchorDashboard(ctx, store);
498
299
  return;
499
300
  }
500
301
 
501
- // Case 2: Revert settlement
502
302
  if (parsed.mode === 'undo') {
503
303
  try {
504
304
  const restored = store.undoSettle();
505
- ctx.ui.notify(`⌖ Restored #${restored.id}: "${restored.title}"`, 'info');
305
+ ctx.ui.notify(`⌖ Put back #${restored.id.replace(/^anc-/, '')}: "${restored.title}"`, 'info');
506
306
  updateAnchorStatusBar(ctx, store);
507
307
  } catch (err: any) {
508
- ctx.ui.notify(`Revert failed: ${err.message}`, 'error');
308
+ ctx.ui.notify(`Undo failed: ${err.message}`, 'error');
509
309
  }
510
310
  return;
511
311
  }
512
312
 
513
- // Case 3: Explicit settle subcommand (`/anchor done 1` or `/anchor done anc-1`)
514
313
  if (parsed.mode === 'settle' && parsed.targetId) {
515
314
  const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
516
315
  if (!resolvedId) {
517
- ctx.ui.notify(`⌖ Anchor not found: ${parsed.targetId}`, 'error');
316
+ ctx.ui.notify(`⌖ Note not found: ${parsed.targetId}`, 'error');
518
317
  return;
519
318
  }
520
- const settled = store.settle(resolvedId, { settledBy: 'manual-command' });
521
- ctx.ui.notify(`⌖ Settled #${settled.id}: "${settled.title}" (/anchor undo to revert)`, 'info');
319
+ const settled = store.settle(resolvedId, { settledBy: 'manual' });
320
+ ctx.ui.notify(`⌖ Torn off #${settled.id.replace(/^anc-/, '')}: "${settled.title}" (/anchor undo to put back)`, 'info');
522
321
  updateAnchorStatusBar(ctx, store);
523
322
  return;
524
323
  }
525
324
 
526
- // Case 4: Direct ID completion if input matches an existing ID (e.g. `/anchor anc-1` or `/anchor #1`)
527
325
  const rawTrimmed = (args || '').trim();
528
326
  if (/^(?:#?anc-\d+|#\d+)$/i.test(rawTrimmed)) {
529
327
  const resolvedId = resolveAnchorTarget(store, rawTrimmed, ctx.cwd);
530
328
  if (resolvedId) {
531
- const settled = store.settle(resolvedId, { settledBy: 'manual-command' });
532
- ctx.ui.notify(`⌖ Settled #${settled.id}: "${settled.title}" (/anchor undo to revert)`, 'info');
329
+ const settled = store.settle(resolvedId, { settledBy: 'manual' });
330
+ ctx.ui.notify(`⌖ Torn off #${settled.id.replace(/^anc-/, '')}: "${settled.title}" (/anchor undo to put back)`, 'info');
533
331
  updateAnchorStatusBar(ctx, store);
534
332
  return;
535
333
  }
536
334
  }
537
335
 
538
- // Case 5: Freeform text creates an anchor (supports `--due <date>`)
539
- const anc = store.create({
540
- title: parsed.title || rawTrimmed,
541
- targetDate: parsed.targetDate,
542
- cwd: ctx.cwd
543
- });
544
- ctx.ui.notify(`⌖ Pinned #${anc.id}: "${anc.title}"${anc.targetDate ? ` (due ${anc.targetDate})` : ''}`, 'info');
336
+ const anc = store.create({ title: parsed.title || rawTrimmed, cwd: ctx.cwd });
337
+ ctx.ui.notify(`⌖ Pinned #${anc.id.replace(/^anc-/, '')}: "${anc.title}"`, 'info');
545
338
  updateAnchorStatusBar(ctx, store);
546
339
  }
547
340
  });
548
341
 
549
- // Alias /pin to instant-add (supports `--due <date>`)
550
342
  pi.registerCommand('pin', {
551
- description: 'Pin a task across sessions (e.g. /pin Refactor auth --due tomorrow)',
343
+ description: 'Stick a quick note across sessions (e.g. /pin Check auth token expiry)',
552
344
  handler: async (args: string, ctx: ExtensionContext) => {
553
- const parsed = parseAnchorCommandInput(args);
554
- if (parsed.mode === 'dashboard' || !parsed.title) {
345
+ const text = (args || '').trim();
346
+ if (!text) {
555
347
  await openAnchorDashboard(ctx, store);
556
348
  return;
557
349
  }
558
- const anc = store.create({
559
- title: parsed.title,
560
- targetDate: parsed.targetDate,
561
- cwd: ctx.cwd
562
- });
563
- ctx.ui.notify(`⌖ Pinned #${anc.id}: "${anc.title}"${anc.targetDate ? ` (due ${anc.targetDate})` : ''}`, 'info');
350
+ const anc = store.create({ title: text, cwd: ctx.cwd });
351
+ ctx.ui.notify(`⌖ Pinned #${anc.id.replace(/^anc-/, '')}: "${anc.title}"`, 'info');
564
352
  updateAnchorStatusBar(ctx, store);
565
353
  }
566
354
  });
567
355
 
568
- // Alias /done for 1-step completion by display number or ID (e.g. `/done 1`)
569
356
  pi.registerCommand('done', {
570
- description: 'Complete an active anchor by row number or ID (e.g. /done 1)',
357
+ description: 'Tear off a sticky note by number or ID (e.g. /done 1)',
571
358
  handler: async (args: string, ctx: ExtensionContext) => {
572
359
  const target = (args || '').trim();
573
360
  if (!target) {
@@ -576,18 +363,17 @@ export default function (pi: ExtensionAPI) {
576
363
  }
577
364
  const resolvedId = resolveAnchorTarget(store, target, ctx.cwd);
578
365
  if (!resolvedId) {
579
- ctx.ui.notify(`⌖ Anchor not found: ${target}`, 'error');
366
+ ctx.ui.notify(`⌖ Note not found: ${target}`, 'error');
580
367
  return;
581
368
  }
582
- const settled = store.settle(resolvedId, { settledBy: 'manual-command' });
583
- ctx.ui.notify(`⌖ Settled #${settled.id}: "${settled.title}" (/anchor undo to revert)`, 'info');
369
+ const settled = store.settle(resolvedId, { settledBy: 'manual' });
370
+ ctx.ui.notify(`⌖ Torn off #${settled.id.replace(/^anc-/, '')}: "${settled.title}" (/anchor undo to put back)`, 'info');
584
371
  updateAnchorStatusBar(ctx, store);
585
372
  }
586
373
  });
587
374
 
588
- // Alias /anchors to open dashboard
589
375
  pi.registerCommand('anchors', {
590
- description: 'Inspect and settle active anchors',
376
+ description: 'View sticky notes',
591
377
  handler: async (_args: string, ctx: ExtensionContext) => {
592
378
  await openAnchorDashboard(ctx, store);
593
379
  }