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