pi-anchor 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Anchor Data Contracts & Type Definitions
3
+ * Single Global Source of Truth with Zero Workspace Contamination.
4
+ */
5
+ type AnchorStatus = 'active' | 'sleeping' | 'settled' | 'graveyard';
6
+ type AnchorPriority = 'p0' | 'p1' | 'p2';
7
+ type AnchorDurability = 'ephemeral' | 'durable';
8
+ interface AnchorDecayPolicy {
9
+ /** Days before an untouched active anchor transitions to sleeping */
10
+ activeDays: number;
11
+ /** Days an anchor remains sleeping before dropping to graveyard */
12
+ sleepDays: number;
13
+ /** Total maximum days before permanent graveyard sweep (9999 = never) */
14
+ graveyardDays: number;
15
+ }
16
+ interface AnchorEvidence {
17
+ settledAt?: number;
18
+ settledBy?: 'one-tap-settlement' | 'manual-command' | 'verification-test';
19
+ commitHash?: string;
20
+ summary?: string;
21
+ touchedFiles?: string[];
22
+ }
23
+ interface Anchor {
24
+ /** Unique sequential identifier, e.g., 'anc-1' */
25
+ id: string;
26
+ /** Short imperative title of the task */
27
+ title: string;
28
+ /** Optional detailed context or notes */
29
+ description?: string;
30
+ /** Task priority */
31
+ priority: AnchorPriority;
32
+ /** Current lifecycle status */
33
+ status: AnchorStatus;
34
+ /** Durability tier: ephemeral (short-lived reminder, 48h TTL) or durable (long-term architecture vision) */
35
+ durability: AnchorDurability;
36
+ /** Canonical project name (e.g. 'PPT', 'X', or 'global') */
37
+ project: string;
38
+ /** Canonical root directory where the task was anchored (empty string for global tasks) */
39
+ cwd: string;
40
+ /** Creation timestamp in ms */
41
+ createdAt: number;
42
+ /** Last update timestamp in ms */
43
+ updatedAt: number;
44
+ /** Last touched timestamp in ms (resets on file touch) */
45
+ lastTouchedAt: number;
46
+ /** Associated file paths or globs, e.g. ['src/auth/*.ts'] */
47
+ files: string[];
48
+ /** Expected completion date in format 'YYYY-MM-DD' (e.g. '2026-09-24') */
49
+ targetDate?: string;
50
+ /** Optional shell command for automated physical verification (e.g. 'npm test') */
51
+ verifyCommand?: string;
52
+ /** Topic or domain tags, e.g. ['auth', 'security'] */
53
+ tags: string[];
54
+ /** Optional recurrence pattern: 'daily' for recurring daily habits */
55
+ recurrence?: 'daily';
56
+ /** Last completed date string 'YYYY-MM-DD' for recurring tasks */
57
+ lastCompletedDate?: string;
58
+ /** Decay policy overrides */
59
+ decay: AnchorDecayPolicy;
60
+ /** Settlement evidence metadata upon closure */
61
+ evidence?: AnchorEvidence;
62
+ }
63
+ interface AnchorStoreState {
64
+ version: number;
65
+ anchors: Anchor[];
66
+ lastSweepAt: number;
67
+ nextId?: number;
68
+ }
69
+ interface TouchMatchResult {
70
+ anchor: Anchor;
71
+ score: number;
72
+ matchedFiles: string[];
73
+ reason: 'exact-file' | 'dir-prefix' | 'tag-keyword';
74
+ }
75
+ interface SettlementProposal {
76
+ anchor: Anchor;
77
+ matchedFiles: string[];
78
+ reason: string;
79
+ recommendedAction: 'settle' | 'defer';
80
+ }
81
+ declare const EPHEMERAL_DECAY_POLICY: AnchorDecayPolicy;
82
+ declare const DURABLE_DECAY_POLICY: AnchorDecayPolicy;
83
+ declare const DEFAULT_DECAY_POLICY: AnchorDecayPolicy;
84
+
85
+ /**
86
+ * Detect whether a task is a short-term ephemeral reminder or a long-term architectural vision
87
+ */
88
+ declare function detectDurability(title: string): AnchorDurability;
89
+ /**
90
+ * Get custom ephemeral decay policy based on temporal span.
91
+ * Note: graveyardDays is always 9999 so unfinished tasks are NEVER silently deleted.
92
+ */
93
+ declare function getEphemeralDecayPolicy(title: string): AnchorDecayPolicy;
94
+ /**
95
+ * Detect expected completion date from title keywords or explicit date string, returning 'YYYY-MM-DD'
96
+ */
97
+ declare function detectTargetDate(titleOrDate: string, now?: number): string | undefined;
98
+ /**
99
+ * Detect recurring patterns, e.g. daily habits
100
+ */
101
+ declare function detectRecurrence(title: string): 'daily' | undefined;
102
+ /**
103
+ * Get current date string 'YYYY-MM-DD' in local timezone
104
+ */
105
+ declare function getTodayDateString(timestamp?: number): string;
106
+ /**
107
+ * Resolve the default storage directory for Anchor (~/.anchor).
108
+ * Hierarchy:
109
+ * 1. Process environment: ANCHOR_DIR
110
+ * 2. Canonical user-global store: ~/.anchor/
111
+ * 3. Legacy Pi harness fallback: ~/.pi/agent/anchors/ (migrates automatically if present)
112
+ */
113
+ declare function getDefaultStorageDir(): string;
114
+ /**
115
+ * Acquire exclusive lockfile using atomic 'wx' file creation (zero external dependencies).
116
+ * Features:
117
+ * - 0 async infection (100% synchronous)
118
+ * - Automatic stale lock eviction (>5000ms or dead PID)
119
+ * - Exponential backoff with jitter
120
+ */
121
+ declare function acquireSyncLock(lockPath: string, maxWaitMs?: number, staleTimeoutMs?: number): () => void;
122
+ /**
123
+ * Windows-tolerant atomic rename with exponential spin-retry to combat NTFS EBUSY/EPERM file locks
124
+ */
125
+ declare function atomicRenameWithRetry(tempPath: string, targetPath: string, maxAttempts?: number): void;
126
+ declare class AnchorStore {
127
+ readonly storageDir: string;
128
+ readonly statePath: string;
129
+ readonly archivePath: string;
130
+ readonly graveyardPath: string;
131
+ constructor(customStorageDir?: string);
132
+ get lockPath(): string;
133
+ /**
134
+ * Execute mutation within a cross-process exclusive lock
135
+ */
136
+ withLock<T>(fn: () => T): T;
137
+ private ensureDirs;
138
+ /**
139
+ * Load store state. If corrupt or missing, returns safe default.
140
+ */
141
+ loadState(): AnchorStoreState;
142
+ /**
143
+ * Atomically save store state via temp file + atomic rename with Windows NTFS spin-retry
144
+ */
145
+ saveState(state: AnchorStoreState): void;
146
+ private generateId;
147
+ /**
148
+ * Create a new Anchor with automatic project and cwd tagging
149
+ */
150
+ create(input: {
151
+ title: string;
152
+ description?: string;
153
+ priority?: AnchorPriority;
154
+ durability?: AnchorDurability;
155
+ recurrence?: 'daily';
156
+ cwd?: string;
157
+ project?: string;
158
+ targetDate?: string;
159
+ files?: string[];
160
+ tags?: string[];
161
+ verifyCommand?: string;
162
+ decay?: AnchorDecayPolicy;
163
+ }): Anchor;
164
+ private findAnchorIndex;
165
+ /**
166
+ * Get an anchor by ID (supports 'anc-1', '#anc-1', '1', '01')
167
+ */
168
+ get(id: string): Anchor | undefined;
169
+ /**
170
+ * List anchors. By default, if cwd is provided, only returns anchors
171
+ * belonging to this cwd or marked as global (''). Pass all: true for global view.
172
+ */
173
+ list(filter?: {
174
+ status?: AnchorStatus;
175
+ priority?: AnchorPriority;
176
+ cwd?: string;
177
+ all?: boolean;
178
+ now?: number;
179
+ }): Anchor[];
180
+ /**
181
+ * Update an existing anchor
182
+ */
183
+ update(id: string, patch: Partial<Omit<Anchor, 'id' | 'createdAt'>>): Anchor;
184
+ /**
185
+ * Touch an anchor to refresh its decay window and reactivate sleeping state
186
+ */
187
+ touch(id: string, timestamp?: number): Anchor;
188
+ /**
189
+ * Settle an anchor (recurring daily habits complete for today and wake up tomorrow)
190
+ */
191
+ settle(id: string, evidence?: AnchorEvidence, now?: number): Anchor;
192
+ /**
193
+ * Reverse/undo the last settled anchor, popping it from archive.jsonl back into state.json
194
+ */
195
+ undoSettle(): Anchor;
196
+ /**
197
+ * Evict anchor to graveyard.jsonl upon total decay expiry
198
+ */
199
+ dropToGraveyard(id: string, reason: string): Anchor;
200
+ getArchive(filter?: {
201
+ cwd?: string;
202
+ all?: boolean;
203
+ }): Anchor[];
204
+ getGraveyard(filter?: {
205
+ cwd?: string;
206
+ all?: boolean;
207
+ }): Anchor[];
208
+ }
209
+
210
+ export { AnchorStore as A, DEFAULT_DECAY_POLICY as D, EPHEMERAL_DECAY_POLICY as E, type SettlementProposal as S, type TouchMatchResult as T, type AnchorStatus as a, type Anchor as b, type AnchorDecayPolicy as c, type AnchorDurability as d, type AnchorEvidence as e, type AnchorPriority as f, type AnchorStoreState as g, DURABLE_DECAY_POLICY as h, acquireSyncLock as i, atomicRenameWithRetry as j, detectDurability as k, detectRecurrence as l, detectTargetDate as m, getDefaultStorageDir as n, getEphemeralDecayPolicy as o, getTodayDateString as p };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-anchor",
3
- "version": "0.2.3",
4
- "description": "Cross-session task memory for Pi — pin tasks and reminders across terminal restarts with 0 git repo pollution, 0 idle tokens, and automatic completion on code changes.",
3
+ "version": "0.3.0",
4
+ "description": "Compact, autonomous, persistent task memory for Pi — pin tasks across sessions (/pin), auto-wake on project/file touch, and auto-settle in real time on git commit with 0 TODO.md clutter.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
@@ -10,6 +10,7 @@
10
10
  "dist",
11
11
  "src",
12
12
  "bin",
13
+ "assets",
13
14
  "README.md",
14
15
  "README_zh.md",
15
16
  "LICENSE"
@@ -31,13 +32,28 @@
31
32
  },
32
33
  "keywords": [
33
34
  "pi-package",
34
- "ai-agent",
35
- "coding-agent",
35
+ "pi-extension",
36
+ "anchor",
37
+ "pi-anchor",
38
+ "todo",
39
+ "todos",
40
+ "tasks",
36
41
  "task-management",
42
+ "reminder",
43
+ "reminders",
37
44
  "memory",
45
+ "session",
46
+ "session-memory",
47
+ "handoff",
48
+ "persistence",
49
+ "checkpoint",
50
+ "resume",
51
+ "plan",
52
+ "roadmap",
38
53
  "context-window",
39
54
  "zero-pollution",
40
- "pi-extension"
55
+ "ai-agent",
56
+ "coding-agent"
41
57
  ],
42
58
  "author": "Jason Song <jiaxinsong312@gmail.com>",
43
59
  "license": "MIT",
package/src/extension.ts CHANGED
@@ -1,18 +1,84 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import { Type } from '@sinclair/typebox';
3
- import { AnchorStore } from './store.ts';
3
+ import { AnchorStore, detectTargetDate } from './store.ts';
4
4
  import { SessionTouchObserver } from './observer.ts';
5
5
  import { renderColdStartAnchorsContext, renderActiveAnchorsContext, makeSafeTaskAnnotation } from './context_injector.ts';
6
- import { generateSettlementProposals, runPhysicalVerification } from './settlement.ts';
7
- import { normalizePath, isSameOrSubPath, findMatchedAnchors } from './matcher.ts';
6
+ import { generateSettlementProposals, runPhysicalVerification, tryRealtimeAutoSettle } from './settlement.ts';
7
+ import { normalizePath, isSameOrSubPath, findMatchedAnchors, isAnchorTargetingWorkspace } from './matcher.ts';
8
8
  import { sweepStore } from './decay.ts';
9
- import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner, truncateToWidth, hasActiveUI } from './tui.ts';
9
+ import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner, truncateToWidth, hasActiveUI, sortAnchorsForDisplay } from './tui.ts';
10
10
  import { execSync } from 'node:child_process';
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
 
14
14
  const MUTATION_TOOLS = new Set(['edit', 'write', 'patch', 'apply_diff', 'create_file', 'modify']);
15
15
 
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()): {
21
+ mode: 'dashboard' | 'undo' | 'settle' | 'pin';
22
+ targetId?: string;
23
+ title?: string;
24
+ targetDate?: string;
25
+ } {
26
+ const trimmed = (raw || '').trim();
27
+ if (!trimmed || trimmed === 'list' || trimmed === 'ls') {
28
+ return { mode: 'dashboard' };
29
+ }
30
+ if (trimmed === 'undo') {
31
+ return { mode: 'undo' };
32
+ }
33
+
34
+ // Match explicit settle subcommands: `/anchor done 1`, `/anchor rm anc-2`, `/anchor settle #1`
35
+ const settleMatch = trimmed.match(/^(?:done|rm|close|settle)\s+(.+)$/i);
36
+ if (settleMatch) {
37
+ return {
38
+ mode: 'settle',
39
+ targetId: settleMatch[1].trim().replace(/^#/, '')
40
+ };
41
+ }
42
+
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
+ return {
55
+ mode: 'pin',
56
+ title: cleanTitle || trimmed,
57
+ targetDate
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Resolve either an explicit anchor ID ('anc-2', '2') or a 1-based visual dashboard index ('01', '1')
63
+ */
64
+ export function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?: string): string | undefined {
65
+ 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
+ if (/^\d+$/.test(clean)) {
68
+ const seqIndex = parseInt(clean, 10);
69
+ const visible = store
70
+ .list({ cwd, all: true })
71
+ .filter(a => a.status === 'active' || a.status === 'sleeping');
72
+ const sorted = sortAnchorsForDisplay(visible, cwd);
73
+ if (seqIndex >= 1 && seqIndex <= sorted.length) {
74
+ return sorted[seqIndex - 1].id;
75
+ }
76
+ }
77
+ // 2. Fallback to direct ID lookup in store
78
+ const direct = store.get(clean);
79
+ return direct?.id;
80
+ }
81
+
16
82
  export default function (pi: ExtensionAPI) {
17
83
  // Global authoritative store in ~/.anchor/ (0 workspace clutter)
18
84
  const store = new AnchorStore();
@@ -28,8 +94,16 @@ export default function (pi: ExtensionAPI) {
28
94
 
29
95
  const sweep = sweepStore(store);
30
96
 
31
- if (sweep.transitionedToSleeping.length > 0) {
32
- ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} 个非活跃任务已进入休眠`, 'info');
97
+ // Smart Workspace Wakeup: if a sleeping anchor specifically targets the workspace we just opened, wake it automatically!
98
+ const sleepingForWorkspace = store
99
+ .list({ status: 'sleeping', all: true })
100
+ .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');
33
107
  }
34
108
 
35
109
  // Physical Pre-flight Verification: auto-settle passing tasks or record failing output (scoped strictly to current cwd)
@@ -43,7 +117,7 @@ export default function (pi: ExtensionAPI) {
43
117
  settledBy: 'verification-test',
44
118
  summary: `Physical pre-flight test passed: ${a.verifyCommand}`
45
119
  });
46
- ctx.ui.notify(`⚓ 物理验证通过!锚点 #${a.id} ("${a.title}") 已自动结案归档。`, 'info');
120
+ ctx.ui.notify(`⌖ Verification passed! Auto-settled #${a.id}: "${a.title}"`, 'info');
47
121
  } else {
48
122
  preflightFailures.set(a.id, res.output.slice(-250));
49
123
  }
@@ -104,11 +178,31 @@ export default function (pi: ExtensionAPI) {
104
178
  }
105
179
  });
106
180
 
107
- // 4. JIT Path-Triggered Context Alert: When agent successfully touches an intersecting file
181
+ // 4. JIT Path-Triggered Context Alert & Real-Time Autonomous Commit/Test Settlement
108
182
  pi.on('tool_result', async (event: any, ctx: ExtensionContext) => {
183
+ const lowerTool = String(event.toolName || '').toLowerCase();
184
+
109
185
  // Only verified, non-error tool executions are recorded to prevent false-positive settlement
110
186
  if (!event.isError) {
111
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) {
198
+ try {
199
+ ctx.ui.notify(`⌖ Auto-settled #${item.anchor.id} ("${item.anchor.title}") via ${item.reason}`, 'info');
200
+ } catch {}
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
112
206
  }
113
207
 
114
208
  const pathInput = (event.input as any)?.path;
@@ -189,9 +283,9 @@ export default function (pi: ExtensionAPI) {
189
283
  }
190
284
 
191
285
  const modified = observer.getModifiedFiles();
192
- if (modified.length === 0) return;
286
+ if (modified.length === 0 && !observer.hasCommitted()) return;
193
287
 
194
- const proposals = generateSettlementProposals(store, modified, ctx.cwd);
288
+ const proposals = generateSettlementProposals(store, modified, ctx.cwd, observer);
195
289
  if (proposals.length === 0) return;
196
290
 
197
291
  // Check proposals for automated verification or one-tap settlement
@@ -416,20 +510,20 @@ export default function (pi: ExtensionAPI) {
416
510
  }
417
511
  });
418
512
 
419
- // 6. User Slash Commands: /anchor and /pin
513
+ // 6. User Slash Commands: /anchor, /pin, and /done
420
514
  pi.registerCommand('anchor', {
421
- description: 'Inspect and settle active anchors',
515
+ description: 'Inspect, pin, or settle active anchors (e.g. /anchor, /anchor done 1, /anchor undo)',
422
516
  handler: async (args: string, ctx: ExtensionContext) => {
423
- const input = (args || '').trim();
517
+ const parsed = parseAnchorCommandInput(args);
424
518
 
425
519
  // Case 1: View active anchors (clean list, enter to complete)
426
- if (!input || input === 'list' || input === 'ls') {
520
+ if (parsed.mode === 'dashboard') {
427
521
  await openAnchorDashboard(ctx, store);
428
522
  return;
429
523
  }
430
524
 
431
525
  // Case 2: Revert settlement
432
- if (input === 'undo') {
526
+ if (parsed.mode === 'undo') {
433
527
  try {
434
528
  const restored = store.undoSettle();
435
529
  ctx.ui.notify(`⌖ Restored #${restored.id}: "${restored.title}"`, 'info');
@@ -440,34 +534,77 @@ export default function (pi: ExtensionAPI) {
440
534
  return;
441
535
  }
442
536
 
443
- // Case 3: Complete directly by ID (e.g. /anchor anc-1)
444
- const targetId = input.startsWith('#') ? input.slice(1) : input;
445
- const item = store.get(targetId);
446
- if (item) {
447
- store.settle(targetId, { settledBy: 'manual-command' });
448
- ctx.ui.notify(`⌖ Settled: "${item.title}"`, 'info');
537
+ // Case 3: Explicit settle subcommand (`/anchor done 1` or `/anchor done anc-1`)
538
+ if (parsed.mode === 'settle' && parsed.targetId) {
539
+ const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
540
+ if (!resolvedId) {
541
+ ctx.ui.notify(`⌖ Anchor not found: ${parsed.targetId}`, 'error');
542
+ return;
543
+ }
544
+ const settled = store.settle(resolvedId, { settledBy: 'manual-command' });
545
+ ctx.ui.notify(`⌖ Settled #${settled.id}: "${settled.title}" (/anchor undo to revert)`, 'info');
449
546
  updateAnchorStatusBar(ctx, store);
450
547
  return;
451
548
  }
452
549
 
453
- // Case 4: Freeform text directly creates an anchor
454
- const anc = store.create({ title: input, cwd: ctx.cwd });
455
- ctx.ui.notify(`⌖ Pinned #${anc.id}: "${anc.title}"`, 'info');
550
+ // Case 4: Direct ID completion if input matches an existing ID (e.g. `/anchor anc-1` or `/anchor #1`)
551
+ const rawTrimmed = (args || '').trim();
552
+ if (/^(?:#?anc-\d+|#\d+)$/i.test(rawTrimmed)) {
553
+ const resolvedId = resolveAnchorTarget(store, rawTrimmed, ctx.cwd);
554
+ 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');
557
+ updateAnchorStatusBar(ctx, store);
558
+ return;
559
+ }
560
+ }
561
+
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');
456
569
  updateAnchorStatusBar(ctx, store);
457
570
  }
458
571
  });
459
572
 
460
- // Alias /pin to instant-add
573
+ // Alias /pin to instant-add (supports `--due <date>`)
461
574
  pi.registerCommand('pin', {
462
- description: 'Quickly pin a task across sessions',
575
+ description: 'Pin a task across sessions (e.g. /pin Refactor auth --due tomorrow)',
576
+ handler: async (args: string, ctx: ExtensionContext) => {
577
+ const parsed = parseAnchorCommandInput(args);
578
+ if (parsed.mode === 'dashboard' || !parsed.title) {
579
+ await openAnchorDashboard(ctx, store);
580
+ return;
581
+ }
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');
588
+ updateAnchorStatusBar(ctx, store);
589
+ }
590
+ });
591
+
592
+ // Alias /done for 1-step completion by display number or ID (e.g. `/done 1`)
593
+ pi.registerCommand('done', {
594
+ description: 'Complete an active anchor by row number or ID (e.g. /done 1)',
463
595
  handler: async (args: string, ctx: ExtensionContext) => {
464
- const title = (args || '').trim();
465
- if (!title) {
596
+ const target = (args || '').trim();
597
+ if (!target) {
466
598
  await openAnchorDashboard(ctx, store);
467
599
  return;
468
600
  }
469
- const anc = store.create({ title, cwd: ctx.cwd });
470
- ctx.ui.notify(`⌖ Pinned #${anc.id}: "${anc.title}"`, 'info');
601
+ const resolvedId = resolveAnchorTarget(store, target, ctx.cwd);
602
+ if (!resolvedId) {
603
+ ctx.ui.notify(`⌖ Anchor not found: ${target}`, 'error');
604
+ return;
605
+ }
606
+ const settled = store.settle(resolvedId, { settledBy: 'manual-command' });
607
+ ctx.ui.notify(`⌖ Settled #${settled.id}: "${settled.title}" (/anchor undo to revert)`, 'info');
471
608
  updateAnchorStatusBar(ctx, store);
472
609
  }
473
610
  });
package/src/matcher.ts CHANGED
@@ -51,6 +51,57 @@ export function isSameOrSubPath(anchorCwd?: string, targetCwd?: string): boolean
51
51
  return cmpA === cmpT || cmpT.startsWith(cmpA + '/') || cmpA.startsWith(cmpT + '/');
52
52
  }
53
53
 
54
+ /**
55
+ * Extract explicit file or directory paths mentioned in natural language title/description
56
+ * (e.g. "D:/College/优化/EXTERNAL_ARCHITECTURE_INSPIRATIONS.md", "src/auth/jwt.ts")
57
+ */
58
+ export function extractPathsFromText(text?: string): string[] {
59
+ if (!text) return [];
60
+ const results = new Set<string>();
61
+ // Match absolute Windows/POSIX paths or relative project paths with extensions/slashes
62
+ const regex = /(?:[a-zA-Z]:[\\/][^\s"'`,。;!?、,;)>\]]+|(?:\b(?:src|lib|dist|tests|test|docs|scripts|packages|extensions|bin|assets)[\\/][^\s"'`,。;!?、,;)>\]]+)|(?:\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|c|cpp|h|cs|md|json|yaml|yml|toml|html|css|sql|sh)\b))/g;
63
+ let match: RegExpExecArray | null;
64
+ while ((match = regex.exec(text)) !== null) {
65
+ const cleaned = normalizePath(match[0].replace(/[.:;,'")\]}>]+$/, ''));
66
+ if (cleaned && cleaned.length >= 3) {
67
+ results.add(cleaned);
68
+ }
69
+ }
70
+ return Array.from(results);
71
+ }
72
+
73
+ /**
74
+ * Determine whether an anchor specifically targets a given workspace directory (`targetCwd`),
75
+ * checking `anchor.cwd`, `anchor.project`, and `anchor.files`.
76
+ */
77
+ export function isAnchorTargetingWorkspace(anchor: Anchor, targetCwd?: string): boolean {
78
+ if (!targetCwd) return true;
79
+ const normTarget = targetCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
80
+ const targetBase = path.basename(normTarget).toLowerCase();
81
+
82
+ // 1. Check explicit project path or project folder name
83
+ if (anchor.project && anchor.project !== 'global') {
84
+ const normProj = anchor.project.trim().replace(/\\/g, '/').replace(/\/+$/, '');
85
+ if (isSameOrSubPath(normProj, normTarget)) return true;
86
+ const projBase = path.basename(normProj).toLowerCase();
87
+ if (projBase && projBase === targetBase) return true;
88
+ }
89
+
90
+ // 2. Check associated file paths
91
+ if (anchor.files && anchor.files.length > 0) {
92
+ for (const f of anchor.files) {
93
+ if (isSameOrSubPath(f, normTarget)) return true;
94
+ }
95
+ }
96
+
97
+ // 3. Check creation cwd (only if files are relative or empty, or if no conflicting absolute project path was given)
98
+ if (anchor.cwd && isSameOrSubPath(anchor.cwd, normTarget)) {
99
+ return true;
100
+ }
101
+
102
+ return false;
103
+ }
104
+
54
105
  /**
55
106
  * Match touched files against an anchor's registered physical patterns
56
107
  * Only anchors with explicit files/globs/prefixes are matched against file touches.