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/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
  }
package/src/tui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import { AnchorStore, getTodayDateString } from './store.ts';
3
+ import { isAnchorTargetingWorkspace } from './matcher.ts';
3
4
  import type { Anchor } from './types.ts';
4
5
  import path from 'node:path';
5
6
 
@@ -57,6 +58,40 @@ export function groupAnchorsByQuadrant(anchors: Anchor[], now: number = Date.now
57
58
  return groups;
58
59
  }
59
60
 
61
+ const PRIORITY_RANK: Record<string, number> = { p0: 0, p1: 1, p2: 2 };
62
+
63
+ /**
64
+ * Sort unfinished anchors into a single compact, priority-driven engineering list:
65
+ * 1. Active before Sleeping (quiet)
66
+ * 2. Matching current workspace before other workspaces
67
+ * 3. Due Today / Overdue before open-ended
68
+ * 4. Priority (P0 -> P1 -> P2)
69
+ */
70
+ export function sortAnchorsForDisplay(anchors: Anchor[], cwd?: string, now: number = Date.now()): Anchor[] {
71
+ const todayStr = getTodayDateString(now);
72
+ return [...anchors].sort((a, b) => {
73
+ // 1. Active before sleeping
74
+ if (a.status !== b.status) {
75
+ return a.status === 'active' ? -1 : 1;
76
+ }
77
+ // 2. Current workspace match first
78
+ if (cwd) {
79
+ const aMatch = isAnchorTargetingWorkspace(a, cwd) ? 0 : 1;
80
+ const bMatch = isAnchorTargetingWorkspace(b, cwd) ? 0 : 1;
81
+ if (aMatch !== bMatch) return aMatch - bMatch;
82
+ }
83
+ // 3. Due today / overdue first
84
+ const aDue = a.targetDate && a.targetDate <= todayStr ? 0 : 1;
85
+ const bDue = b.targetDate && b.targetDate <= todayStr ? 0 : 1;
86
+ if (aDue !== bDue) return aDue - bDue;
87
+ // 4. Priority P0 -> P1 -> P2
88
+ const pA = PRIORITY_RANK[a.priority] ?? 1;
89
+ const pB = PRIORITY_RANK[b.priority] ?? 1;
90
+ if (pA !== pB) return pA - pB;
91
+ return a.createdAt - b.createdAt;
92
+ });
93
+ }
94
+
60
95
  const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
61
96
 
62
97
  /**
@@ -286,15 +321,23 @@ export function formatRemainingTtl(anchor: Anchor, now: number = Date.now()): st
286
321
  }
287
322
 
288
323
  /**
289
- * Format origin creation workspace folder (provenance: where the task was born/created)
324
+ * Format origin workspace / project label concisely
290
325
  */
291
326
  export function formatOrigin(anchor: Anchor | string): string {
292
327
  if (typeof anchor === 'object') {
293
- if (!anchor.cwd) return anchor.project || 'global';
294
- return path.basename(anchor.cwd) || 'global';
328
+ if (anchor.project && anchor.project !== 'global') {
329
+ const normProj = anchor.project.replace(/\\/g, '/').replace(/\/+$/, '');
330
+ if (normProj.includes('/')) {
331
+ const parts = normProj.split('/').filter(Boolean);
332
+ return parts.slice(-2).join('/');
333
+ }
334
+ return normProj;
335
+ }
336
+ if (!anchor.cwd) return 'global';
337
+ return path.basename(anchor.cwd.replace(/\\/g, '/')) || 'global';
295
338
  }
296
339
  if (!anchor) return 'global';
297
- return path.basename(anchor) || 'global';
340
+ return path.basename(anchor.replace(/\\/g, '/')) || 'global';
298
341
  }
299
342
 
300
343
  /**
@@ -372,7 +415,7 @@ export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore):
372
415
  }
373
416
 
374
417
  /**
375
- * Clean, columnar-aligned checklist with Target Date and Creation Time columns
418
+ * Clean, flat priority-sorted engineering checklist (includes both active and quiet/sleeping tasks so nothing is ever lost)
376
419
  */
377
420
  export async function openAnchorDashboard(
378
421
  ctx: ExtensionContext,
@@ -380,66 +423,67 @@ export async function openAnchorDashboard(
380
423
  ): Promise<void> {
381
424
  if (!hasActiveUI(ctx)) return;
382
425
 
383
- const list = store.list({ status: 'active', cwd: ctx.cwd });
384
- if (list.length === 0) {
426
+ const allUnfinished = store
427
+ .list({ cwd: ctx.cwd, all: true })
428
+ .filter(a => a.status === 'active' || a.status === 'sleeping');
429
+
430
+ if (allUnfinished.length === 0) {
385
431
  ctx.ui.notify('⌖ No active anchors. Use /pin <task> to record.', 'info');
386
432
  return;
387
433
  }
388
434
 
389
- const groups = groupAnchorsByQuadrant(list);
390
- const sortedList = [
391
- ...groups.today,
392
- ...groups.upcoming,
393
- ...groups.habits,
394
- ...groups.backlog
395
- ];
435
+ const sortedList = sortAnchorsForDisplay(allUnfinished, ctx.cwd);
396
436
 
397
437
  const optionMap = new Map<string, Anchor>();
398
438
  const displayOptions: string[] = [];
399
439
  const termCols = process.stdout?.columns || 96;
400
440
  const isNarrow = termCols < 88;
401
- const titleWidth = isNarrow ? Math.max(18, termCols - 36) : 34;
441
+ const titleWidth = isNarrow ? Math.max(18, termCols - 36) : 36;
402
442
 
403
443
  for (let i = 0; i < sortedList.length; i++) {
404
444
  const a = sortedList[i];
405
445
  const num = a.id.replace(/^anc-/, '').padStart(2, '0');
406
- const category = padToWidth(`[${classifyAnchor(a)}]`, 12);
446
+ const badge = a.status === 'sleeping'
447
+ ? padToWidth('[Quiet]', 10)
448
+ : padToWidth(`[${a.priority.toUpperCase()}]`, 10);
407
449
  const origin = formatOrigin(a);
408
- const target = formatTargetDate(a);
450
+ const target = a.status === 'sleeping' ? 'Quiet (0 tok)' : formatTargetDate(a);
409
451
  const created = formatCreationTime(a.createdAt);
410
452
 
411
453
  const titleWithFiles = (a.files && a.files.length > 0)
412
454
  ? `${a.title} [${a.files.slice(0, 1).join(', ')}]`
413
455
  : a.title;
414
456
 
415
- // Clean tabular column alignment: ID, Category, Title, Project, Target, Created
416
457
  const colNum = `${num} `;
417
458
  const colTitle = padToWidth(titleWithFiles, titleWidth);
418
- const colOrigin = padToWidth(origin, 10);
419
- const colTarget = padToWidth(target, 12);
459
+ const colOrigin = padToWidth(origin, 12);
460
+ const colTarget = padToWidth(target, 14);
420
461
  const colCreated = created;
421
462
 
422
463
  const label = isNarrow
423
- ? `${colNum}${category}${colTitle} ${colTarget}`.trimEnd()
424
- : `${colNum}${category}${colTitle} ${colOrigin} ${colTarget} ${colCreated}`.trimEnd();
464
+ ? `${colNum}${badge}${colTitle} ${colTarget}`.trimEnd()
465
+ : `${colNum}${badge}${colTitle} ${colOrigin} ${colTarget} ${colCreated}`.trimEnd();
425
466
 
426
467
  optionMap.set(label, a);
427
468
  displayOptions.push(label);
428
469
  }
429
470
 
430
- const selected = await ctx.ui.select('⌖ Anchors (select to inspect or settle):', displayOptions);
471
+ const selected = await ctx.ui.select('⌖ Anchors (select to settle, wake, or inspect):', displayOptions);
431
472
  if (!selected) return;
432
473
 
433
474
  const anchor = optionMap.get(selected);
434
475
  if (!anchor) return;
435
476
 
477
+ const actions = [
478
+ '✓ Settle (Mark completed and archive)',
479
+ ...(anchor.status === 'sleeping' ? ['⚡ Wake up (Activate for current session)'] : []),
480
+ 'ℹ View details & associated files',
481
+ '↩ Cancel'
482
+ ];
483
+
436
484
  const action = await ctx.ui.select(
437
485
  `⌖ Anchor #${anchor.id}: "${anchor.title}"`,
438
- [
439
- '✓ Settle (Mark completed and archive)',
440
- 'ℹ View details & associated files',
441
- '↩ Cancel'
442
- ]
486
+ actions
443
487
  );
444
488
  if (!action || action.startsWith('↩')) return;
445
489
 
@@ -450,9 +494,13 @@ export async function openAnchorDashboard(
450
494
  : `⌖ Settled #${anchor.id}: "${anchor.title}" (/anchor undo to revert)`;
451
495
  ctx.ui.notify(successMsg, 'info');
452
496
  updateAnchorStatusBar(ctx, store);
497
+ } else if (action.startsWith('⚡')) {
498
+ store.touch(anchor.id);
499
+ ctx.ui.notify(`⌖ Woke up #${anchor.id}: "${anchor.title}"`, 'info');
500
+ updateAnchorStatusBar(ctx, store);
453
501
  } else if (action.startsWith('ℹ')) {
454
502
  const details = [
455
- `#${anchor.id} [${anchor.priority.toUpperCase()}] ${anchor.title}`,
503
+ `#${anchor.id} [${anchor.priority.toUpperCase()} · ${anchor.status.toUpperCase()}] ${anchor.title}`,
456
504
  anchor.description ? `Description: ${anchor.description}` : null,
457
505
  anchor.targetDate ? `Target Date: ${anchor.targetDate} (${formatTargetDate(anchor)})` : null,
458
506
  anchor.files && anchor.files.length > 0 ? `Files: ${anchor.files.join(', ')}` : null,
package/src/types.ts CHANGED
@@ -87,19 +87,19 @@ export interface SettlementProposal {
87
87
  }
88
88
 
89
89
  export const EPHEMERAL_DECAY_POLICY: AnchorDecayPolicy = {
90
- activeDays: 1, // 24 hours active
91
- sleepDays: 1, // 24 hours sleeping
92
- graveyardDays: 2 // 48 hours total before auto-clearing
90
+ activeDays: 7, // 7 days active before quiet sleep
91
+ sleepDays: 30,
92
+ graveyardDays: 9999 // Never silently auto-delete unfinished tasks
93
93
  };
94
94
 
95
95
  export const DURABLE_DECAY_POLICY: AnchorDecayPolicy = {
96
- activeDays: 3,
96
+ activeDays: 7, // 7 days active before quiet sleep
97
97
  sleepDays: 30,
98
98
  graveyardDays: 9999 // Never permanently auto-dropped; preserved indefinitely
99
99
  };
100
100
 
101
101
  export const DEFAULT_DECAY_POLICY: AnchorDecayPolicy = {
102
- activeDays: 3,
103
- sleepDays: 7,
104
- graveyardDays: 14
102
+ activeDays: 7,
103
+ sleepDays: 30,
104
+ graveyardDays: 9999
105
105
  };