pi-anchor 0.2.4 → 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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as AnchorStatus, b as Anchor, A as AnchorStore, T as TouchMatchResult, S as SettlementProposal } from './store-BNKjzhS_.js';
2
- export { c as AnchorDecayPolicy, d as AnchorDurability, e as AnchorEvidence, f as AnchorPriority, g as AnchorStoreState, D as DEFAULT_DECAY_POLICY, h as DURABLE_DECAY_POLICY, E as EPHEMERAL_DECAY_POLICY, i as acquireSyncLock, j as atomicRenameWithRetry, k as detectDurability, l as detectRecurrence, m as detectTargetDate, n as getDefaultStorageDir, o as getEphemeralDecayPolicy, p as getTodayDateString } from './store-BNKjzhS_.js';
1
+ import { a as AnchorStatus, b as Anchor, A as AnchorStore, T as TouchMatchResult, S as SettlementProposal } from './store-t2_TDhJn.js';
2
+ export { c as AnchorDecayPolicy, d as AnchorDurability, e as AnchorEvidence, f as AnchorPriority, g as AnchorStoreState, D as DEFAULT_DECAY_POLICY, h as DURABLE_DECAY_POLICY, E as EPHEMERAL_DECAY_POLICY, i as acquireSyncLock, j as atomicRenameWithRetry, k as detectDurability, l as detectRecurrence, m as detectTargetDate, n as getDefaultStorageDir, o as getEphemeralDecayPolicy, p as getTodayDateString } from './store-t2_TDhJn.js';
3
3
  import { ExtensionContext } from '@earendil-works/pi-coding-agent';
4
4
 
5
5
  interface DecayEvaluation {
@@ -35,6 +35,16 @@ declare function normalizePath(filePath: string, baseCwd?: string): string;
35
35
  * Compare two workspace CWDs safely (case-insensitive on Windows, supports subdirectory scoping)
36
36
  */
37
37
  declare function isSameOrSubPath(anchorCwd?: string, targetCwd?: string): boolean;
38
+ /**
39
+ * Extract explicit file or directory paths mentioned in natural language title/description
40
+ * (e.g. "D:/College/优化/EXTERNAL_ARCHITECTURE_INSPIRATIONS.md", "src/auth/jwt.ts")
41
+ */
42
+ declare function extractPathsFromText(text?: string): string[];
43
+ /**
44
+ * Determine whether an anchor specifically targets a given workspace directory (`targetCwd`),
45
+ * checking `anchor.cwd`, `anchor.project`, and `anchor.files`.
46
+ */
47
+ declare function isAnchorTargetingWorkspace(anchor: Anchor, targetCwd?: string): boolean;
38
48
  /**
39
49
  * Match touched files against an anchor's registered physical patterns
40
50
  * Only anchors with explicit files/globs/prefixes are matched against file touches.
@@ -47,6 +57,35 @@ declare function matchAnchorAgainstTouchedFiles(anchor: Anchor, touchedFiles: st
47
57
  */
48
58
  declare function findMatchedAnchors(anchors: Anchor[], touchedFiles: string[]): TouchMatchResult[];
49
59
 
60
+ declare class SessionTouchObserver {
61
+ private touchedFiles;
62
+ private modifiedFiles;
63
+ private inspectedFiles;
64
+ private committed;
65
+ private commitMessages;
66
+ clear(): void;
67
+ /**
68
+ * Observe and record a tool call invocation, separating inspection from mutation
69
+ */
70
+ recordToolCall(toolName: string, input: Record<string, unknown>): void;
71
+ /**
72
+ * Add a file path manually (e.g. from git status diff)
73
+ */
74
+ addTouchedFile(filePath: string, isModified?: boolean): void;
75
+ getModifiedFiles(): string[];
76
+ getInspectedFiles(): string[];
77
+ getTouchedFiles(): string[];
78
+ hasCommitted(): boolean;
79
+ getCommitMessages(): string[];
80
+ /**
81
+ * Check if any commit message explicitly references or resolves an anchor
82
+ */
83
+ matchesCommit(anchor: Anchor): {
84
+ matched: boolean;
85
+ message?: string;
86
+ };
87
+ }
88
+
50
89
  /**
51
90
  * Execute physical verification test command for an anchor
52
91
  */
@@ -55,9 +94,18 @@ declare function runPhysicalVerification(anchor: Anchor, cwd: string): {
55
94
  output: string;
56
95
  };
57
96
  /**
58
- * Generate settlement candidates based on files touched during the session
97
+ * Real-time autonomous settlement during active session:
98
+ * Triggered immediately when a non-error shell command runs `git commit` or a matching `verifyCommand`.
99
+ * Works for BOTH file-bound and file-less (`files: []`) anchors!
59
100
  */
60
- declare function generateSettlementProposals(store: AnchorStore, touchedFiles: string[], cwd?: string): SettlementProposal[];
101
+ declare function tryRealtimeAutoSettle(store: AnchorStore, observer: SessionTouchObserver, cwd?: string, executedCommand?: string): Array<{
102
+ anchor: Anchor;
103
+ reason: string;
104
+ }>;
105
+ /**
106
+ * Generate settlement candidates based on files touched or commits made during the session
107
+ */
108
+ declare function generateSettlementProposals(store: AnchorStore, touchedFiles: string[], cwd?: string, observer?: SessionTouchObserver): SettlementProposal[];
61
109
  /**
62
110
  * Format settlement proposal into terminal-ready card
63
111
  */
@@ -82,6 +130,14 @@ declare function classifyAnchor(anchor: Anchor, now?: number): AnchorQuadrant;
82
130
  * Group active anchors by cognitive quadrant
83
131
  */
84
132
  declare function groupAnchorsByQuadrant(anchors: Anchor[], now?: number): GroupedAnchors;
133
+ /**
134
+ * Sort unfinished anchors into a single compact, priority-driven engineering list:
135
+ * 1. Active before Sleeping (quiet)
136
+ * 2. Matching current workspace before other workspaces
137
+ * 3. Due Today / Overdue before open-ended
138
+ * 4. Priority (P0 -> P1 -> P2)
139
+ */
140
+ declare function sortAnchorsForDisplay(anchors: Anchor[], cwd?: string, now?: number): Anchor[];
85
141
  /**
86
142
  * Strip ANSI escape codes from string
87
143
  */
@@ -134,7 +190,7 @@ declare function formatRelativeTime(timestamp: number, now?: number): string;
134
190
  */
135
191
  declare function formatRemainingTtl(anchor: Anchor, now?: number): string | undefined;
136
192
  /**
137
- * Format origin creation workspace folder (provenance: where the task was born/created)
193
+ * Format origin workspace / project label concisely
138
194
  */
139
195
  declare function formatOrigin(anchor: Anchor | string): string;
140
196
  /**
@@ -148,39 +204,10 @@ declare function updateAnchorStatusBar(ctx: ExtensionContext, store: AnchorStore
148
204
  */
149
205
  declare function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore): void;
150
206
  /**
151
- * Clean, columnar-aligned checklist with Target Date and Creation Time columns
207
+ * Clean, flat priority-sorted engineering checklist (includes both active and quiet/sleeping tasks so nothing is ever lost)
152
208
  */
153
209
  declare function openAnchorDashboard(ctx: ExtensionContext, store: AnchorStore): Promise<void>;
154
210
 
155
- declare class SessionTouchObserver {
156
- private touchedFiles;
157
- private modifiedFiles;
158
- private inspectedFiles;
159
- private committed;
160
- private commitMessages;
161
- clear(): void;
162
- /**
163
- * Observe and record a tool call invocation, separating inspection from mutation
164
- */
165
- recordToolCall(toolName: string, input: Record<string, unknown>): void;
166
- /**
167
- * Add a file path manually (e.g. from git status diff)
168
- */
169
- addTouchedFile(filePath: string, isModified?: boolean): void;
170
- getModifiedFiles(): string[];
171
- getInspectedFiles(): string[];
172
- getTouchedFiles(): string[];
173
- hasCommitted(): boolean;
174
- getCommitMessages(): string[];
175
- /**
176
- * Check if any commit message explicitly references or resolves an anchor
177
- */
178
- matchesCommit(anchor: Anchor): {
179
- matched: boolean;
180
- message?: string;
181
- };
182
- }
183
-
184
211
  /**
185
212
  * Universal multi-language comment generator.
186
213
  * Maps file extension to safe comment syntax.
@@ -245,4 +272,4 @@ declare class AnchorProtocol {
245
272
  };
246
273
  }
247
274
 
248
- export { type AgentAdapter, Anchor, AnchorProtocol, type AnchorQuadrant, AnchorStatus, AnchorStore, COMMENT_FORMATS, type DecayEvaluation, type GroupedAnchors, SessionTouchObserver, SettlementProposal, type SweepResult, TouchMatchResult, classifyAnchor, evaluateAnchorDecay, findMatchedAnchors, formatCreationTime, formatOrigin, formatRelativeTime, formatRemainingTtl, formatSettlementCard, formatTargetDate, generateSettlementProposals, getDisplayWidth, globToRegExp, groupAnchorsByQuadrant, hasActiveUI, isSameOrSubPath, makeSafeTaskAnnotation, matchAnchorAgainstTouchedFiles, normalizePath, openAnchorDashboard, padToWidth, renderActiveAnchorsContext, renderColdStartAnchorsContext, runPhysicalVerification, stripAnsi, sweepStore, truncateToWidth, updateAnchorStatusBar, updateStartupBanner };
275
+ export { type AgentAdapter, Anchor, AnchorProtocol, type AnchorQuadrant, AnchorStatus, AnchorStore, COMMENT_FORMATS, type DecayEvaluation, type GroupedAnchors, SessionTouchObserver, SettlementProposal, type SweepResult, TouchMatchResult, classifyAnchor, evaluateAnchorDecay, extractPathsFromText, findMatchedAnchors, formatCreationTime, formatOrigin, formatRelativeTime, formatRemainingTtl, formatSettlementCard, formatTargetDate, generateSettlementProposals, getDisplayWidth, globToRegExp, groupAnchorsByQuadrant, hasActiveUI, isAnchorTargetingWorkspace, isSameOrSubPath, makeSafeTaskAnnotation, matchAnchorAgainstTouchedFiles, normalizePath, openAnchorDashboard, padToWidth, renderActiveAnchorsContext, renderColdStartAnchorsContext, runPhysicalVerification, sortAnchorsForDisplay, stripAnsi, sweepStore, truncateToWidth, tryRealtimeAutoSettle, updateAnchorStatusBar, updateStartupBanner };
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  detectRecurrence,
13
13
  detectTargetDate,
14
14
  evaluateAnchorDecay,
15
+ extractPathsFromText,
15
16
  findMatchedAnchors,
16
17
  formatCreationTime,
17
18
  formatOrigin,
@@ -27,6 +28,7 @@ import {
27
28
  globToRegExp,
28
29
  groupAnchorsByQuadrant,
29
30
  hasActiveUI,
31
+ isAnchorTargetingWorkspace,
30
32
  isSameOrSubPath,
31
33
  makeSafeTaskAnnotation,
32
34
  matchAnchorAgainstTouchedFiles,
@@ -36,12 +38,14 @@ import {
36
38
  renderActiveAnchorsContext,
37
39
  renderColdStartAnchorsContext,
38
40
  runPhysicalVerification,
41
+ sortAnchorsForDisplay,
39
42
  stripAnsi,
40
43
  sweepStore,
41
44
  truncateToWidth,
45
+ tryRealtimeAutoSettle,
42
46
  updateAnchorStatusBar,
43
47
  updateStartupBanner
44
- } from "./chunk-G72UHPBN.js";
48
+ } from "./chunk-Q7IILJFA.js";
45
49
 
46
50
  // src/protocol.ts
47
51
  var AnchorProtocol = class {
@@ -116,6 +120,7 @@ export {
116
120
  detectRecurrence,
117
121
  detectTargetDate,
118
122
  evaluateAnchorDecay,
123
+ extractPathsFromText,
119
124
  findMatchedAnchors,
120
125
  formatCreationTime,
121
126
  formatOrigin,
@@ -131,6 +136,7 @@ export {
131
136
  globToRegExp,
132
137
  groupAnchorsByQuadrant,
133
138
  hasActiveUI,
139
+ isAnchorTargetingWorkspace,
134
140
  isSameOrSubPath,
135
141
  makeSafeTaskAnnotation,
136
142
  matchAnchorAgainstTouchedFiles,
@@ -140,9 +146,11 @@ export {
140
146
  renderActiveAnchorsContext,
141
147
  renderColdStartAnchorsContext,
142
148
  runPhysicalVerification,
149
+ sortAnchorsForDisplay,
143
150
  stripAnsi,
144
151
  sweepStore,
145
152
  truncateToWidth,
153
+ tryRealtimeAutoSettle,
146
154
  updateAnchorStatusBar,
147
155
  updateStartupBanner
148
156
  };
@@ -87,7 +87,8 @@ declare const DEFAULT_DECAY_POLICY: AnchorDecayPolicy;
87
87
  */
88
88
  declare function detectDurability(title: string): AnchorDurability;
89
89
  /**
90
- * Get custom ephemeral decay policy based on temporal span (e.g. 后天, 大后天)
90
+ * Get custom ephemeral decay policy based on temporal span.
91
+ * Note: graveyardDays is always 9999 so unfinished tasks are NEVER silently deleted.
91
92
  */
92
93
  declare function getEphemeralDecayPolicy(title: string): AnchorDecayPolicy;
93
94
  /**
@@ -87,7 +87,8 @@ declare const DEFAULT_DECAY_POLICY: AnchorDecayPolicy;
87
87
  */
88
88
  declare function detectDurability(title: string): AnchorDurability;
89
89
  /**
90
- * Get custom ephemeral decay policy based on temporal span (e.g. 后天, 大后天)
90
+ * Get custom ephemeral decay policy based on temporal span.
91
+ * Note: graveyardDays is always 9999 so unfinished tasks are NEVER silently deleted.
91
92
  */
92
93
  declare function getEphemeralDecayPolicy(title: string): AnchorDecayPolicy;
93
94
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-anchor",
3
- "version": "0.2.4",
4
- "description": "Never lose unfinished tasks when closing Pi — pin reminders across sessions (/pin), auto-remind when touching matching files, and auto-complete on git commit with 0 TODO.md clutter.",
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",
package/src/extension.ts CHANGED
@@ -3,10 +3,10 @@ import { Type } from '@sinclair/typebox';
3
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, groupAnchorsByQuadrant } 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';
@@ -63,19 +63,15 @@ export function parseAnchorCommandInput(raw: string, now: number = Date.now()):
63
63
  */
64
64
  export function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?: string): string | undefined {
65
65
  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
66
+ // 1. Check 1-based sequential index from the sorted display list first when user passes a plain number
67
67
  if (/^\d+$/.test(clean)) {
68
68
  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;
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;
79
75
  }
80
76
  }
81
77
  // 2. Fallback to direct ID lookup in store
@@ -98,8 +94,16 @@ export default function (pi: ExtensionAPI) {
98
94
 
99
95
  const sweep = sweepStore(store);
100
96
 
101
- if (sweep.transitionedToSleeping.length > 0) {
102
- ctx.ui.notify(`⌖ Anchor: ${sweep.transitionedToSleeping.length} inactive tasks transitioned to sleep`, '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');
103
107
  }
104
108
 
105
109
  // Physical Pre-flight Verification: auto-settle passing tasks or record failing output (scoped strictly to current cwd)
@@ -174,11 +178,31 @@ export default function (pi: ExtensionAPI) {
174
178
  }
175
179
  });
176
180
 
177
- // 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
178
182
  pi.on('tool_result', async (event: any, ctx: ExtensionContext) => {
183
+ const lowerTool = String(event.toolName || '').toLowerCase();
184
+
179
185
  // Only verified, non-error tool executions are recorded to prevent false-positive settlement
180
186
  if (!event.isError) {
181
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
+ }
182
206
  }
183
207
 
184
208
  const pathInput = (event.input as any)?.path;
@@ -259,9 +283,9 @@ export default function (pi: ExtensionAPI) {
259
283
  }
260
284
 
261
285
  const modified = observer.getModifiedFiles();
262
- if (modified.length === 0) return;
286
+ if (modified.length === 0 && !observer.hasCommitted()) return;
263
287
 
264
- const proposals = generateSettlementProposals(store, modified, ctx.cwd);
288
+ const proposals = generateSettlementProposals(store, modified, ctx.cwd, observer);
265
289
  if (proposals.length === 0) return;
266
290
 
267
291
  // Check proposals for automated verification or one-tap settlement
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.
package/src/settlement.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AnchorStore } from './store.ts';
2
- import { findMatchedAnchors } from './matcher.ts';
2
+ import { findMatchedAnchors, isAnchorTargetingWorkspace } from './matcher.ts';
3
+ import type { SessionTouchObserver } from './observer.ts';
3
4
  import type { Anchor, SettlementProposal } from './types.ts';
4
5
  import { execSync } from 'node:child_process';
5
6
 
@@ -27,31 +28,96 @@ export function runPhysicalVerification(anchor: Anchor, cwd: string): { success:
27
28
  }
28
29
 
29
30
  /**
30
- * Generate settlement candidates based on files touched during the session
31
+ * Real-time autonomous settlement during active session:
32
+ * Triggered immediately when a non-error shell command runs `git commit` or a matching `verifyCommand`.
33
+ * Works for BOTH file-bound and file-less (`files: []`) anchors!
34
+ */
35
+ export function tryRealtimeAutoSettle(
36
+ store: AnchorStore,
37
+ observer: SessionTouchObserver,
38
+ cwd?: string,
39
+ executedCommand?: string
40
+ ): Array<{ anchor: Anchor; reason: string }> {
41
+ const candidates = store.list({ cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
42
+ if (candidates.length === 0) return [];
43
+
44
+ const settledList: Array<{ anchor: Anchor; reason: string }> = [];
45
+ const cmdTrimmed = (executedCommand || '').trim();
46
+
47
+ for (const a of candidates) {
48
+ // 1. Check real-time Git Commit semantic match (works even when a.files is empty!)
49
+ if (observer.hasCommitted()) {
50
+ const commitMatch = observer.matchesCommit(a);
51
+ if (commitMatch.matched && (!cwd || isAnchorTargetingWorkspace(a, cwd))) {
52
+ const settled = store.settle(a.id, {
53
+ settledBy: 'verification-test',
54
+ summary: `Auto-settled via Git commit: ${commitMatch.message}`,
55
+ touchedFiles: observer.getModifiedFiles()
56
+ });
57
+ settledList.push({ anchor: settled, reason: `git commit ("${commitMatch.message}")` });
58
+ continue;
59
+ }
60
+ }
61
+
62
+ // 2. Check if the executed shell command directly ran and passed the anchor's verifyCommand
63
+ if (a.verifyCommand && cmdTrimmed && cmdTrimmed.includes(a.verifyCommand.trim())) {
64
+ const settled = store.settle(a.id, {
65
+ settledBy: 'verification-test',
66
+ summary: `Auto-settled via passing command: ${a.verifyCommand}`,
67
+ touchedFiles: observer.getModifiedFiles()
68
+ });
69
+ settledList.push({ anchor: settled, reason: `verifyCommand (${a.verifyCommand})` });
70
+ }
71
+ }
72
+
73
+ return settledList;
74
+ }
75
+
76
+ /**
77
+ * Generate settlement candidates based on files touched or commits made during the session
31
78
  */
32
79
  export function generateSettlementProposals(
33
80
  store: AnchorStore,
34
81
  touchedFiles: string[],
35
- cwd?: string
82
+ cwd?: string,
83
+ observer?: SessionTouchObserver
36
84
  ): SettlementProposal[] {
37
- // Only match active/sleeping anchors belonging to current workspace
38
85
  const activeAndSleeping = store.list({ cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
39
86
  const matches = findMatchedAnchors(activeAndSleeping, touchedFiles);
87
+ const seenIds = new Set<string>();
40
88
 
41
89
  const proposals: SettlementProposal[] = [];
42
90
 
43
91
  for (const match of matches) {
92
+ seenIds.add(match.anchor.id);
44
93
  const fileList = match.matchedFiles.slice(0, 3).join(', ');
45
- const moreSuffix = match.matchedFiles.length > 3 ? ` 等 ${match.matchedFiles.length} 个文件` : '';
94
+ const moreSuffix = match.matchedFiles.length > 3 ? ` (+${match.matchedFiles.length - 3} more)` : '';
46
95
 
47
96
  proposals.push({
48
97
  anchor: match.anchor,
49
98
  matchedFiles: match.matchedFiles,
50
- reason: `本次会话修改了 ${fileList}${moreSuffix}`,
99
+ reason: `Modified ${fileList}${moreSuffix}`,
51
100
  recommendedAction: match.score >= 0.85 ? 'settle' : 'defer'
52
101
  });
53
102
  }
54
103
 
104
+ // Also include file-less (or un-matched) anchors if a git commit message matched them
105
+ if (observer && observer.hasCommitted()) {
106
+ for (const a of activeAndSleeping) {
107
+ if (seenIds.has(a.id)) continue;
108
+ const cm = observer.matchesCommit(a);
109
+ if (cm.matched) {
110
+ seenIds.add(a.id);
111
+ proposals.push({
112
+ anchor: a,
113
+ matchedFiles: touchedFiles.slice(0, 3),
114
+ reason: `Matched git commit: "${cm.message}"`,
115
+ recommendedAction: 'settle'
116
+ });
117
+ }
118
+ }
119
+ }
120
+
55
121
  return proposals;
56
122
  }
57
123
 
package/src/store.ts CHANGED
@@ -11,7 +11,7 @@ import type {
11
11
  AnchorStoreState
12
12
  } from './types.ts';
13
13
  import { DEFAULT_DECAY_POLICY, DURABLE_DECAY_POLICY, EPHEMERAL_DECAY_POLICY } from './types.ts';
14
- import { normalizePath, isSameOrSubPath } from './matcher.ts';
14
+ import { normalizePath, isSameOrSubPath, extractPathsFromText, isAnchorTargetingWorkspace } from './matcher.ts';
15
15
 
16
16
  /**
17
17
  * Detect whether a task is a short-term ephemeral reminder or a long-term architectural vision
@@ -31,15 +31,16 @@ export function detectDurability(title: string): AnchorDurability {
31
31
  }
32
32
 
33
33
  /**
34
- * Get custom ephemeral decay policy based on temporal span (e.g. 后天, 大后天)
34
+ * Get custom ephemeral decay policy based on temporal span.
35
+ * Note: graveyardDays is always 9999 so unfinished tasks are NEVER silently deleted.
35
36
  */
36
37
  export function getEphemeralDecayPolicy(title: string): AnchorDecayPolicy {
37
38
  const lower = title.toLowerCase();
38
39
  if (lower.includes('大后天')) {
39
- return { activeDays: 3, sleepDays: 1, graveyardDays: 4 };
40
+ return { activeDays: 7, sleepDays: 30, graveyardDays: 9999 };
40
41
  }
41
42
  if (lower.includes('后天')) {
42
- return { activeDays: 2, sleepDays: 1, graveyardDays: 3 };
43
+ return { activeDays: 7, sleepDays: 30, graveyardDays: 9999 };
43
44
  }
44
45
  return { ...EPHEMERAL_DECAY_POLICY };
45
46
  }
@@ -415,7 +416,22 @@ export class AnchorStore {
415
416
  const now = Date.now();
416
417
  const id = this.generateId(state);
417
418
 
418
- const cwd = input.cwd ? normalizePath(input.cwd) : '';
419
+ const explicitFiles = (input.files || []).map(f => normalizePath(f)).filter(Boolean);
420
+ const inferredFiles = explicitFiles.length > 0
421
+ ? explicitFiles
422
+ : extractPathsFromText(`${input.title} ${input.description || ''}`);
423
+
424
+ let cwd = input.cwd ? normalizePath(input.cwd) : '';
425
+ // If project is an explicit absolute path (e.g. "D:/College/优化"), align cwd to it so workspace matching is never split
426
+ if (input.project && (/^[a-zA-Z]:[\\/]/.test(input.project.trim()) || input.project.trim().startsWith('/'))) {
427
+ cwd = normalizePath(input.project.trim());
428
+ } else if (inferredFiles.length > 0 && (/^[a-zA-Z]:\//.test(inferredFiles[0]) || inferredFiles[0].startsWith('/'))) {
429
+ const firstDir = path.dirname(inferredFiles[0]);
430
+ if (firstDir && firstDir !== '.') {
431
+ cwd = normalizePath(firstDir);
432
+ }
433
+ }
434
+
419
435
  const projectName = input.project
420
436
  ? input.project.trim()
421
437
  : (cwd ? path.basename(cwd) : 'global');
@@ -442,7 +458,7 @@ export class AnchorStore {
442
458
  createdAt: now,
443
459
  updatedAt: now,
444
460
  lastTouchedAt: now,
445
- files: (input.files || []).map(f => normalizePath(f)).filter(Boolean),
461
+ files: inferredFiles,
446
462
  tags: (input.tags || []).map(t => t.trim()).filter(Boolean),
447
463
  verifyCommand: input.verifyCommand?.trim() || undefined,
448
464
  decay: input.decay ? { ...input.decay } : { ...decayPolicy }
@@ -497,10 +513,10 @@ export class AnchorStore {
497
513
  if (filter?.priority && a.priority !== filter.priority) return false;
498
514
 
499
515
  // Project context filtering:
500
- // If an anchor has specific code files attached, scope it to that cwd.
501
- // If an anchor has NO code files attached (habits, general tasks, study), it is universal and visible everywhere!
516
+ // If an anchor has specific code files attached, match against cwd, project path/name, or file paths.
517
+ // If an anchor has NO code files attached (general tasks, memos), it is universal and visible everywhere!
502
518
  if (!filter?.all && targetCwd) {
503
- if (a.files.length > 0 && a.cwd && !isSameOrSubPath(a.cwd, targetCwd)) {
519
+ if (a.files.length > 0 && (a.cwd || a.project) && !isAnchorTargetingWorkspace(a, targetCwd)) {
504
520
  return false;
505
521
  }
506
522
  }