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.
- package/README.md +29 -14
- package/README_zh.md +29 -14
- package/assets/hero-en.svg +87 -0
- package/assets/hero-zh.svg +87 -0
- package/assets/hero.svg +108 -0
- package/bin/anchor.js +38 -51
- package/dist/{chunk-G72UHPBN.js → chunk-Q7IILJFA.js} +165 -43
- package/dist/extension.cjs +291 -62
- package/dist/extension.d.cts +16 -1
- package/dist/extension.d.ts +16 -1
- package/dist/extension.js +133 -23
- package/dist/index.cjs +169 -43
- package/dist/index.d.cts +63 -242
- package/dist/index.d.ts +63 -242
- package/dist/index.js +9 -1
- package/dist/store-t2_TDhJn.d.cts +210 -0
- package/dist/store-t2_TDhJn.d.ts +210 -0
- package/package.json +21 -5
- package/src/extension.ts +167 -30
- package/src/matcher.ts +51 -0
- package/src/settlement.ts +72 -6
- package/src/store.ts +25 -9
- package/src/tui.ts +77 -29
- package/src/types.ts +7 -7
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
35
|
-
"
|
|
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
|
-
"
|
|
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
|
|
32
|
-
|
|
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(
|
|
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
|
|
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 /
|
|
513
|
+
// 6. User Slash Commands: /anchor, /pin, and /done
|
|
420
514
|
pi.registerCommand('anchor', {
|
|
421
|
-
description: 'Inspect
|
|
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
|
|
517
|
+
const parsed = parseAnchorCommandInput(args);
|
|
424
518
|
|
|
425
519
|
// Case 1: View active anchors (clean list, enter to complete)
|
|
426
|
-
if (
|
|
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 (
|
|
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:
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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:
|
|
454
|
-
const
|
|
455
|
-
|
|
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: '
|
|
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
|
|
465
|
-
if (!
|
|
596
|
+
const target = (args || '').trim();
|
|
597
|
+
if (!target) {
|
|
466
598
|
await openAnchorDashboard(ctx, store);
|
|
467
599
|
return;
|
|
468
600
|
}
|
|
469
|
-
const
|
|
470
|
-
|
|
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.
|