pi-anchor 0.2.1 → 0.2.3

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.cjs CHANGED
@@ -58,6 +58,8 @@ __export(index_exports, {
58
58
  getTodayDateString: () => getTodayDateString,
59
59
  globToRegExp: () => globToRegExp,
60
60
  groupAnchorsByQuadrant: () => groupAnchorsByQuadrant,
61
+ hasActiveUI: () => hasActiveUI,
62
+ isSameOrSubPath: () => isSameOrSubPath,
61
63
  makeSafeTaskAnnotation: () => makeSafeTaskAnnotation,
62
64
  matchAnchorAgainstTouchedFiles: () => matchAnchorAgainstTouchedFiles,
63
65
  normalizePath: () => normalizePath,
@@ -103,20 +105,34 @@ var import_node_os = __toESM(require("os"), 1);
103
105
  // src/matcher.ts
104
106
  var import_node_path = __toESM(require("path"), 1);
105
107
  function globToRegExp(pattern) {
106
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\/\*\*\//g, "___GLOB_DIR_SLASH___").replace(/\*\*/g, "___GLOB_STAR_STAR___").replace(/\*/g, "[^/]*").replace(/___GLOB_DIR_SLASH___/g, "(?:/|/.+/)").replace(/___GLOB_STAR_STAR___/g, ".*");
108
+ const collapsed = pattern.slice(0, 512).replace(/\/+(\*\*\/+)+/g, "/**/").replace(/\*{3,}/g, "**");
109
+ const escaped = collapsed.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\/\*\*\//g, "___GLOB_DIR_SLASH___").replace(/\*\*/g, "___GLOB_STAR_STAR___").replace(/\*/g, "[^/]*").replace(/___GLOB_DIR_SLASH___/g, "(?:/|/(?:[^/]+/)+)").replace(/___GLOB_STAR_STAR___/g, ".*");
107
110
  return new RegExp(`^${escaped}$`);
108
111
  }
109
112
  function normalizePath(filePath, baseCwd) {
110
113
  if (typeof filePath !== "string") return "";
111
114
  let norm = filePath.trim().replace(/\\/g, "/").replace(/^\.\//, "");
112
- if (baseCwd && (import_node_path.default.isAbsolute(filePath) || norm.startsWith("/"))) {
113
- const normBase = baseCwd.trim().replace(/\\/g, "/");
114
- if (norm.toLowerCase().startsWith(normBase.toLowerCase())) {
115
- norm = norm.slice(normBase.length).replace(/^\/+/, "");
115
+ if (baseCwd && (import_node_path.default.isAbsolute(filePath) || norm.startsWith("/") || /^[a-zA-Z]:\//.test(norm))) {
116
+ const normBase = baseCwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
117
+ const lowerNorm = norm.toLowerCase();
118
+ const lowerBase = normBase.toLowerCase();
119
+ if (lowerNorm === lowerBase) {
120
+ norm = "";
121
+ } else if (lowerNorm.startsWith(lowerBase + "/")) {
122
+ norm = norm.slice(normBase.length + 1).replace(/^\/+/, "");
116
123
  }
117
124
  }
118
125
  return norm;
119
126
  }
127
+ function isSameOrSubPath(anchorCwd, targetCwd) {
128
+ if (!anchorCwd || !targetCwd) return false;
129
+ const normA = anchorCwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
130
+ const normT = targetCwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
131
+ const isWinStyle = process.platform === "win32" || /^[a-zA-Z]:\//.test(normA) || /^[a-zA-Z]:\//.test(normT);
132
+ const cmpA = isWinStyle ? normA.toLowerCase() : normA;
133
+ const cmpT = isWinStyle ? normT.toLowerCase() : normT;
134
+ return cmpA === cmpT || cmpT.startsWith(cmpA + "/") || cmpA.startsWith(cmpT + "/");
135
+ }
120
136
  function matchAnchorAgainstTouchedFiles(anchor, touchedFiles) {
121
137
  if (!anchor.files || anchor.files.length === 0) return null;
122
138
  const normTouched = touchedFiles.map((f) => normalizePath(f, anchor.cwd)).filter(Boolean);
@@ -478,9 +494,19 @@ var AnchorStore = class {
478
494
  }
479
495
  try {
480
496
  const data = JSON.parse(raw);
481
- if (!Array.isArray(data.anchors)) {
497
+ if (!data || typeof data !== "object" || !Array.isArray(data.anchors)) {
482
498
  throw new Error("Invalid state: anchors must be array");
483
499
  }
500
+ data.anchors = data.anchors.filter((a) => a && typeof a === "object" && typeof a.id === "string" && typeof a.title === "string").map((a) => ({
501
+ ...a,
502
+ files: Array.isArray(a.files) ? a.files.filter((f) => typeof f === "string") : [],
503
+ tags: Array.isArray(a.tags) ? a.tags.filter((t) => typeof t === "string") : [],
504
+ decay: {
505
+ activeDays: Number.isFinite(a.decay?.activeDays) && a.decay.activeDays > 0 ? a.decay.activeDays : DURABLE_DECAY_POLICY.activeDays,
506
+ sleepDays: Number.isFinite(a.decay?.sleepDays) && a.decay.sleepDays > 0 ? a.decay.sleepDays : DURABLE_DECAY_POLICY.sleepDays,
507
+ graveyardDays: Number.isFinite(a.decay?.graveyardDays) && a.decay.graveyardDays > 0 ? a.decay.graveyardDays : DURABLE_DECAY_POLICY.graveyardDays
508
+ }
509
+ }));
484
510
  return data;
485
511
  } catch {
486
512
  const backupPath = import_node_path2.default.join(this.storageDir, `state.corrupt.${Date.now()}.json`);
@@ -500,16 +526,19 @@ var AnchorStore = class {
500
526
  */
501
527
  saveState(state) {
502
528
  this.ensureDirs();
503
- const tempPath = import_node_path2.default.join(this.storageDir, `state.tmp.${process.pid}.${Date.now()}`);
529
+ const rand = Math.random().toString(36).slice(2, 7);
530
+ const tempPath = import_node_path2.default.join(this.storageDir, `state.tmp.${process.pid}.${Date.now()}.${rand}`);
504
531
  import_node_fs.default.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf-8");
505
532
  atomicRenameWithRetry(tempPath, this.statePath);
506
533
  }
507
- generateId(existingAnchors) {
508
- const numbers = existingAnchors.map((a) => {
534
+ generateId(state) {
535
+ const numbers = state.anchors.map((a) => {
509
536
  const match = a.id.match(/^anc-(\d+)$/);
510
537
  return match ? parseInt(match[1], 10) : 0;
511
538
  }).filter((n) => n > 0);
512
- const nextNum = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
539
+ const maxExisting = numbers.length > 0 ? Math.max(...numbers) : 0;
540
+ const nextNum = Math.max(maxExisting + 1, state.nextId || 1);
541
+ state.nextId = nextNum + 1;
513
542
  return `anc-${nextNum}`;
514
543
  }
515
544
  /**
@@ -519,7 +548,7 @@ var AnchorStore = class {
519
548
  return this.withLock(() => {
520
549
  const state = this.loadState();
521
550
  const now = Date.now();
522
- const id = this.generateId(state.anchors);
551
+ const id = this.generateId(state);
523
552
  const cwd = input.cwd ? normalizePath(input.cwd) : "";
524
553
  const projectName = input.project ? input.project.trim() : cwd ? import_node_path2.default.basename(cwd) : "global";
525
554
  const recurrence = input.recurrence || detectRecurrence(input.title);
@@ -579,7 +608,7 @@ var AnchorStore = class {
579
608
  if (filter?.status && a.status !== filter.status) return false;
580
609
  if (filter?.priority && a.priority !== filter.priority) return false;
581
610
  if (!filter?.all && targetCwd) {
582
- if (a.files.length > 0 && a.cwd && a.cwd !== targetCwd) {
611
+ if (a.files.length > 0 && a.cwd && !isSameOrSubPath(a.cwd, targetCwd)) {
583
612
  return false;
584
613
  }
585
614
  }
@@ -680,18 +709,46 @@ var AnchorStore = class {
680
709
  throw new Error("No archived anchors to undo");
681
710
  }
682
711
  const raw = import_node_fs.default.readFileSync(this.archivePath, "utf-8");
683
- const lines = raw.split("\n").filter(Boolean);
684
- if (lines.length === 0) {
712
+ const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
713
+ let anchor = null;
714
+ while (lines.length > 0 && !anchor) {
715
+ const candidate = lines.pop();
716
+ try {
717
+ const parsed = JSON.parse(candidate);
718
+ if (parsed && typeof parsed === "object" && typeof parsed.id === "string" && typeof parsed.title === "string") {
719
+ anchor = parsed;
720
+ }
721
+ } catch {
722
+ }
723
+ }
724
+ if (!anchor) {
685
725
  throw new Error("Archive is empty, nothing to undo");
686
726
  }
687
- const lastLine = lines.pop();
688
- const anchor = JSON.parse(lastLine);
689
727
  import_node_fs.default.writeFileSync(this.archivePath, lines.length > 0 ? lines.join("\n") + "\n" : "", "utf-8");
690
728
  anchor.status = "active";
691
729
  anchor.updatedAt = Date.now();
692
730
  delete anchor.evidence;
731
+ delete anchor.settledForDate;
732
+ if (anchor.recurrence === "daily") {
733
+ delete anchor.lastCompletedDate;
734
+ }
693
735
  const state = this.loadState();
694
- state.anchors.push(anchor);
736
+ const existingIdx = state.anchors.findIndex((a) => a.id === anchor.id);
737
+ if (existingIdx !== -1) {
738
+ if (state.anchors[existingIdx].title === anchor.title) {
739
+ state.anchors[existingIdx] = anchor;
740
+ } else {
741
+ anchor.id = this.generateId(state);
742
+ state.anchors.push(anchor);
743
+ }
744
+ } else {
745
+ const match = anchor.id.match(/^anc-(\d+)$/);
746
+ if (match) {
747
+ const restoredNum = parseInt(match[1], 10);
748
+ state.nextId = Math.max(state.nextId || 1, restoredNum + 1);
749
+ }
750
+ state.anchors.push(anchor);
751
+ }
695
752
  this.saveState(state);
696
753
  return anchor;
697
754
  });
@@ -723,10 +780,19 @@ var AnchorStore = class {
723
780
  if (!import_node_fs.default.existsSync(this.archivePath)) return [];
724
781
  try {
725
782
  const raw = import_node_fs.default.readFileSync(this.archivePath, "utf-8");
726
- const all = raw.split("\n").filter(Boolean).map((l) => JSON.parse(l));
783
+ const all = [];
784
+ for (const l of raw.split(/\r?\n/)) {
785
+ const trimmed = l.trim();
786
+ if (!trimmed) continue;
787
+ try {
788
+ const parsed = JSON.parse(trimmed);
789
+ if (parsed && typeof parsed === "object" && typeof parsed.id === "string") all.push(parsed);
790
+ } catch {
791
+ }
792
+ }
727
793
  if (filter?.all || !filter?.cwd) return all;
728
794
  const targetCwd = normalizePath(filter.cwd);
729
- return all.filter((a) => !a.cwd || a.cwd === targetCwd);
795
+ return all.filter((a) => !a.cwd || isSameOrSubPath(a.cwd, targetCwd));
730
796
  } catch {
731
797
  return [];
732
798
  }
@@ -735,10 +801,19 @@ var AnchorStore = class {
735
801
  if (!import_node_fs.default.existsSync(this.graveyardPath)) return [];
736
802
  try {
737
803
  const raw = import_node_fs.default.readFileSync(this.graveyardPath, "utf-8");
738
- const all = raw.split("\n").filter(Boolean).map((l) => JSON.parse(l));
804
+ const all = [];
805
+ for (const l of raw.split(/\r?\n/)) {
806
+ const trimmed = l.trim();
807
+ if (!trimmed) continue;
808
+ try {
809
+ const parsed = JSON.parse(trimmed);
810
+ if (parsed && typeof parsed === "object" && typeof parsed.id === "string") all.push(parsed);
811
+ } catch {
812
+ }
813
+ }
739
814
  if (filter?.all || !filter?.cwd) return all;
740
815
  const targetCwd = normalizePath(filter.cwd);
741
- return all.filter((a) => !a.cwd || a.cwd === targetCwd);
816
+ return all.filter((a) => !a.cwd || isSameOrSubPath(a.cwd, targetCwd));
742
817
  } catch {
743
818
  return [];
744
819
  }
@@ -779,69 +854,73 @@ function evaluateAnchorDecay(anchor, now = Date.now()) {
779
854
  };
780
855
  }
781
856
  function sweepStore(store, now = Date.now()) {
782
- const state = store.loadState();
783
- const result = {
784
- transitionedToSleeping: [],
785
- wokenToActive: [],
786
- evictedToGraveyard: []
787
- };
788
- const toEvict = [];
789
- let stateModified = false;
790
- for (const anchor of state.anchors) {
791
- const evalResult = evaluateAnchorDecay(anchor, now);
792
- if (evalResult.nextStatus === "graveyard") {
793
- toEvict.push(anchor);
794
- } else if (evalResult.nextStatus !== anchor.status) {
795
- if (evalResult.nextStatus === "sleeping" && anchor.status === "active") {
796
- anchor.status = "sleeping";
797
- anchor.updatedAt = now;
798
- result.transitionedToSleeping.push(anchor.id);
799
- stateModified = true;
800
- } else if (evalResult.nextStatus === "active" && anchor.status === "sleeping") {
801
- anchor.status = "active";
802
- anchor.updatedAt = now;
803
- result.wokenToActive.push(anchor.id);
804
- stateModified = true;
857
+ return store.withLock(() => {
858
+ const state = store.loadState();
859
+ const result = {
860
+ transitionedToSleeping: [],
861
+ wokenToActive: [],
862
+ evictedToGraveyard: []
863
+ };
864
+ const toEvict = [];
865
+ let stateModified = false;
866
+ for (const anchor of state.anchors) {
867
+ const evalResult = evaluateAnchorDecay(anchor, now);
868
+ if (evalResult.nextStatus === "graveyard") {
869
+ toEvict.push(anchor);
870
+ } else if (evalResult.nextStatus !== anchor.status) {
871
+ if (evalResult.nextStatus === "sleeping" && anchor.status === "active") {
872
+ anchor.status = "sleeping";
873
+ anchor.updatedAt = now;
874
+ result.transitionedToSleeping.push(anchor.id);
875
+ stateModified = true;
876
+ } else if (evalResult.nextStatus === "active" && anchor.status === "sleeping") {
877
+ anchor.status = "active";
878
+ anchor.updatedAt = now;
879
+ result.wokenToActive.push(anchor.id);
880
+ stateModified = true;
881
+ }
805
882
  }
806
883
  }
807
- }
808
- if (toEvict.length > 0) {
809
- const evictIds = new Set(toEvict.map((e) => e.id));
810
- state.anchors = state.anchors.filter((a) => !evictIds.has(a.id));
811
- stateModified = true;
812
- const graveyardLines = toEvict.map((exp) => {
813
- const rec = {
814
- ...exp,
815
- status: "graveyard",
816
- updatedAt: now,
817
- evictedAt: now,
818
- evictionReason: `Exceeded decay threshold (${exp.decay.graveyardDays} days untouched)`
819
- };
820
- return JSON.stringify(rec);
821
- }).join("\n") + "\n";
822
- import_node_fs2.default.appendFileSync(store.graveyardPath, graveyardLines, "utf-8");
823
- for (const exp of toEvict) {
824
- result.evictedToGraveyard.push(exp.id);
884
+ if (toEvict.length > 0) {
885
+ const evictIds = new Set(toEvict.map((e) => e.id));
886
+ state.anchors = state.anchors.filter((a) => !evictIds.has(a.id));
887
+ stateModified = true;
888
+ const graveyardLines = toEvict.map((exp) => {
889
+ const rec = {
890
+ ...exp,
891
+ status: "graveyard",
892
+ updatedAt: now,
893
+ evictedAt: now,
894
+ evictionReason: `Exceeded decay threshold (${exp.decay.graveyardDays} days untouched)`
895
+ };
896
+ return JSON.stringify(rec);
897
+ }).join("\n") + "\n";
898
+ import_node_fs2.default.appendFileSync(store.graveyardPath, graveyardLines, "utf-8");
899
+ for (const exp of toEvict) {
900
+ result.evictedToGraveyard.push(exp.id);
901
+ }
825
902
  }
826
- }
827
- state.lastSweepAt = now;
828
- if (stateModified) {
829
- store.saveState(state);
830
- }
831
- return result;
903
+ state.lastSweepAt = now;
904
+ if (stateModified) {
905
+ store.saveState(state);
906
+ }
907
+ return result;
908
+ });
832
909
  }
833
910
 
834
911
  // src/settlement.ts
835
912
  var import_node_child_process = require("child_process");
836
913
  function runPhysicalVerification(anchor, cwd) {
837
- if (!anchor.verifyCommand) {
914
+ if (!anchor.verifyCommand?.trim()) {
838
915
  return { success: false, output: "No verification command specified" };
839
916
  }
917
+ const targetCwd = anchor.cwd || cwd;
840
918
  try {
841
919
  const stdout = (0, import_node_child_process.execSync)(anchor.verifyCommand, {
842
- cwd,
920
+ cwd: targetCwd,
843
921
  encoding: "utf-8",
844
- timeout: 3e4,
922
+ timeout: 8e3,
923
+ maxBuffer: 4 * 1024 * 1024,
845
924
  stdio: ["ignore", "pipe", "pipe"]
846
925
  });
847
926
  return { success: true, output: stdout.trim() };
@@ -930,27 +1009,36 @@ function getDisplayWidth(str) {
930
1009
  }
931
1010
  return width;
932
1011
  }
1012
+ function hasActiveUI(ctx) {
1013
+ if (!ctx) return false;
1014
+ try {
1015
+ return Boolean(ctx.hasUI && ctx.ui);
1016
+ } catch {
1017
+ return false;
1018
+ }
1019
+ }
933
1020
  function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
934
1021
  if (maxWidth <= 0) return "";
935
- const totalWidth = getDisplayWidth(str);
936
- if (totalWidth <= maxWidth) return str;
1022
+ const singleLine = str.replace(/[\r\n\t]+/g, " ");
1023
+ const totalWidth = getDisplayWidth(singleLine);
1024
+ if (totalWidth <= maxWidth) return singleLine;
937
1025
  const ellipsisWidth = getDisplayWidth(ellipsis);
938
1026
  const target = Math.max(0, maxWidth - ellipsisWidth);
939
1027
  if (target === 0) return ellipsis.slice(0, maxWidth);
940
1028
  let accumulated = "";
941
1029
  let accumWidth = 0;
942
1030
  let i = 0;
943
- while (i < str.length) {
1031
+ while (i < singleLine.length) {
944
1032
  ANSI_REGEX.lastIndex = i;
945
- const match = ANSI_REGEX.exec(str);
1033
+ const match = ANSI_REGEX.exec(singleLine);
946
1034
  if (match && match.index === i) {
947
1035
  accumulated += match[0];
948
1036
  i += match[0].length;
949
1037
  continue;
950
1038
  }
951
- const codePoint = str.codePointAt(i) || 0;
1039
+ const codePoint = singleLine.codePointAt(i) || 0;
952
1040
  const charLen = codePoint > 65535 ? 2 : 1;
953
- const fullChar = str.slice(i, i + charLen);
1041
+ const fullChar = singleLine.slice(i, i + charLen);
954
1042
  const charWidth = getDisplayWidth(fullChar);
955
1043
  if (accumWidth + charWidth > target) {
956
1044
  break;
@@ -960,7 +1048,7 @@ function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
960
1048
  i += charLen;
961
1049
  }
962
1050
  accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, "");
963
- return accumulated + ellipsis + (str.includes("\x1B") ? "\x1B[0m" : "");
1051
+ return accumulated + ellipsis + (singleLine.includes("\x1B") ? "\x1B[0m" : "");
964
1052
  }
965
1053
  function padToWidth(str, targetWidth) {
966
1054
  const truncated = truncateToWidth(str, targetWidth);
@@ -1036,52 +1124,58 @@ function formatOrigin(anchor) {
1036
1124
  return import_node_path3.default.basename(anchor) || "global";
1037
1125
  }
1038
1126
  function updateAnchorStatusBar(ctx, store) {
1039
- if (!ctx.hasUI || !ctx.ui) return;
1040
- const active = store.list({ status: "active", cwd: ctx.cwd });
1041
- if (active.length === 0) {
1042
- ctx.ui.setStatus("anchor", void 0);
1043
- return;
1127
+ if (!hasActiveUI(ctx)) return;
1128
+ try {
1129
+ const active = store.list({ status: "active", cwd: ctx.cwd });
1130
+ if (active.length === 0) {
1131
+ ctx.ui.setStatus("anchor", void 0);
1132
+ return;
1133
+ }
1134
+ ctx.ui.setStatus("anchor", `\u2316 ${active.length}`);
1135
+ } catch {
1044
1136
  }
1045
- ctx.ui.setStatus("anchor", `\u2316 ${active.length}`);
1046
1137
  }
1047
1138
  function updateStartupBanner(ctx, store) {
1048
- if (!ctx.hasUI || !ctx.ui) return;
1049
- const active = store.list({ status: "active", cwd: ctx.cwd });
1050
- if (active.length === 0) {
1051
- ctx.ui.setWidget("anchor-startup", void 0);
1052
- return;
1053
- }
1054
- const groups = groupAnchorsByQuadrant(active);
1055
- const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
1056
- const topItems = sorted.slice(0, 3);
1057
- const factory = (_tui, theme) => ({
1058
- render: (width) => {
1059
- const maxTitleWidth = Math.max(16, width - 36);
1060
- const fg = theme?.fg ? theme.fg.bind(theme) : (_c, text) => text;
1061
- const lines = [
1062
- fg("accent", `\u2316 Anchor \xB7 ${active.length} active commitments:`)
1063
- ];
1064
- for (let i = 0; i < topItems.length; i++) {
1065
- const a = topItems[i];
1066
- const num = (i + 1).toString().padStart(2, "0");
1067
- const q = classifyAnchor(a);
1068
- const tgt = formatTargetDate(a);
1069
- const titlePadded = truncateToWidth(a.title, maxTitleWidth);
1070
- lines.push(` \u2022 ${fg("muted", `[${q}]`)} ${fg("dim", num)} ${titlePadded} ${fg("dim", `(${tgt})`)}`);
1071
- }
1072
- if (sorted.length > 3) {
1073
- lines.push(fg("dim", ` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`));
1074
- }
1075
- return lines;
1076
- },
1077
- invalidate: () => {
1139
+ if (!hasActiveUI(ctx)) return;
1140
+ try {
1141
+ const active = store.list({ status: "active", cwd: ctx.cwd });
1142
+ if (active.length === 0) {
1143
+ ctx.ui.setWidget("anchor-startup", void 0);
1144
+ return;
1078
1145
  }
1079
- });
1080
- const content = ctx.ui?.theme ? factory : factory().render(80);
1081
- ctx.ui.setWidget("anchor-startup", content, { placement: "aboveEditor" });
1146
+ const groups = groupAnchorsByQuadrant(active);
1147
+ const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
1148
+ const topItems = sorted.slice(0, 3);
1149
+ const factory = (_tui, theme) => ({
1150
+ render: (width) => {
1151
+ const maxTitleWidth = Math.max(12, width - 36);
1152
+ const fg = theme?.fg ? theme.fg.bind(theme) : (_c, text) => text;
1153
+ const lines = [
1154
+ truncateToWidth(fg("accent", `\u2316 Anchor \xB7 ${active.length} active commitments:`), width)
1155
+ ];
1156
+ for (let i = 0; i < topItems.length; i++) {
1157
+ const a = topItems[i];
1158
+ const num = a.id.replace(/^anc-/, "").padStart(2, "0");
1159
+ const q = classifyAnchor(a);
1160
+ const tgt = formatTargetDate(a);
1161
+ const titlePadded = truncateToWidth(a.title, maxTitleWidth);
1162
+ lines.push(truncateToWidth(` \u2022 ${fg("muted", `[${q}]`)} ${fg("dim", num)} ${titlePadded} ${fg("dim", `(${tgt})`)}`, width));
1163
+ }
1164
+ if (sorted.length > 3) {
1165
+ lines.push(truncateToWidth(fg("dim", ` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`), width));
1166
+ }
1167
+ return lines;
1168
+ },
1169
+ invalidate: () => {
1170
+ }
1171
+ });
1172
+ const content = ctx.ui?.theme ? factory : factory().render(80);
1173
+ ctx.ui.setWidget("anchor-startup", content, { placement: "aboveEditor" });
1174
+ } catch {
1175
+ }
1082
1176
  }
1083
1177
  async function openAnchorDashboard(ctx, store) {
1084
- if (!ctx.hasUI || !ctx.ui) return;
1178
+ if (!hasActiveUI(ctx)) return;
1085
1179
  const list = store.list({ status: "active", cwd: ctx.cwd });
1086
1180
  if (list.length === 0) {
1087
1181
  ctx.ui.notify("\u2316 No active anchors. Use /pin <task> to record.", "info");
@@ -1096,20 +1190,23 @@ async function openAnchorDashboard(ctx, store) {
1096
1190
  ];
1097
1191
  const optionMap = /* @__PURE__ */ new Map();
1098
1192
  const displayOptions = [];
1193
+ const termCols = process.stdout?.columns || 96;
1194
+ const isNarrow = termCols < 88;
1195
+ const titleWidth = isNarrow ? Math.max(18, termCols - 36) : 34;
1099
1196
  for (let i = 0; i < sortedList.length; i++) {
1100
1197
  const a = sortedList[i];
1101
- const num = (i + 1).toString().padStart(2, "0");
1198
+ const num = a.id.replace(/^anc-/, "").padStart(2, "0");
1102
1199
  const category = padToWidth(`[${classifyAnchor(a)}]`, 12);
1103
1200
  const origin = formatOrigin(a);
1104
1201
  const target = formatTargetDate(a);
1105
1202
  const created = formatCreationTime(a.createdAt);
1106
1203
  const titleWithFiles = a.files && a.files.length > 0 ? `${a.title} [${a.files.slice(0, 1).join(", ")}]` : a.title;
1107
1204
  const colNum = `${num} `;
1108
- const colTitle = padToWidth(titleWithFiles, 34);
1205
+ const colTitle = padToWidth(titleWithFiles, titleWidth);
1109
1206
  const colOrigin = padToWidth(origin, 10);
1110
1207
  const colTarget = padToWidth(target, 12);
1111
1208
  const colCreated = created;
1112
- const label = `${colNum}${category}${colTitle} ${colOrigin} ${colTarget} ${colCreated}`.trimEnd();
1209
+ const label = isNarrow ? `${colNum}${category}${colTitle} ${colTarget}`.trimEnd() : `${colNum}${category}${colTitle} ${colOrigin} ${colTarget} ${colCreated}`.trimEnd();
1113
1210
  optionMap.set(label, a);
1114
1211
  displayOptions.push(label);
1115
1212
  }
@@ -1466,6 +1563,8 @@ var AnchorProtocol = class {
1466
1563
  getTodayDateString,
1467
1564
  globToRegExp,
1468
1565
  groupAnchorsByQuadrant,
1566
+ hasActiveUI,
1567
+ isSameOrSubPath,
1469
1568
  makeSafeTaskAnnotation,
1470
1569
  matchAnchorAgainstTouchedFiles,
1471
1570
  normalizePath,
package/dist/index.d.cts CHANGED
@@ -66,6 +66,7 @@ interface AnchorStoreState {
66
66
  version: number;
67
67
  anchors: Anchor[];
68
68
  lastSweepAt: number;
69
+ nextId?: number;
69
70
  }
70
71
  interface TouchMatchResult {
71
72
  anchor: Anchor;
@@ -236,6 +237,10 @@ declare function globToRegExp(pattern: string): RegExp;
236
237
  * Normalize path separators to POSIX forward slashes and optionally relative to baseCwd
237
238
  */
238
239
  declare function normalizePath(filePath: string, baseCwd?: string): string;
240
+ /**
241
+ * Compare two workspace CWDs safely (case-insensitive on Windows, supports subdirectory scoping)
242
+ */
243
+ declare function isSameOrSubPath(anchorCwd?: string, targetCwd?: string): boolean;
239
244
  /**
240
245
  * Match touched files against an anchor's registered physical patterns
241
246
  * Only anchors with explicit files/globs/prefixes are matched against file touches.
@@ -298,6 +303,7 @@ declare function stripAnsi(str: string): string;
298
303
  * - Standard ASCII (width 1)
299
304
  */
300
305
  declare function getDisplayWidth(str: string): number;
306
+ declare function hasActiveUI(ctx: ExtensionContext | null | undefined): ctx is ExtensionContext;
301
307
  /**
302
308
  * Truncate string to target terminal visual width with ellipsis
303
309
  */
@@ -445,4 +451,4 @@ declare class AnchorProtocol {
445
451
  };
446
452
  }
447
453
 
448
- export { type AgentAdapter, type Anchor, type AnchorDecayPolicy, type AnchorDurability, type AnchorEvidence, type AnchorPriority, AnchorProtocol, type AnchorQuadrant, type AnchorStatus, AnchorStore, type AnchorStoreState, COMMENT_FORMATS, DEFAULT_DECAY_POLICY, DURABLE_DECAY_POLICY, type DecayEvaluation, EPHEMERAL_DECAY_POLICY, type GroupedAnchors, SessionTouchObserver, type SettlementProposal, type SweepResult, type TouchMatchResult, acquireSyncLock, atomicRenameWithRetry, classifyAnchor, detectDurability, detectRecurrence, detectTargetDate, evaluateAnchorDecay, findMatchedAnchors, formatCreationTime, formatOrigin, formatRelativeTime, formatRemainingTtl, formatSettlementCard, formatTargetDate, generateSettlementProposals, getDefaultStorageDir, getDisplayWidth, getEphemeralDecayPolicy, getTodayDateString, globToRegExp, groupAnchorsByQuadrant, makeSafeTaskAnnotation, matchAnchorAgainstTouchedFiles, normalizePath, openAnchorDashboard, padToWidth, renderActiveAnchorsContext, renderColdStartAnchorsContext, runPhysicalVerification, stripAnsi, sweepStore, truncateToWidth, updateAnchorStatusBar, updateStartupBanner };
454
+ export { type AgentAdapter, type Anchor, type AnchorDecayPolicy, type AnchorDurability, type AnchorEvidence, type AnchorPriority, AnchorProtocol, type AnchorQuadrant, type AnchorStatus, AnchorStore, type AnchorStoreState, COMMENT_FORMATS, DEFAULT_DECAY_POLICY, DURABLE_DECAY_POLICY, type DecayEvaluation, EPHEMERAL_DECAY_POLICY, type GroupedAnchors, SessionTouchObserver, type SettlementProposal, type SweepResult, type TouchMatchResult, acquireSyncLock, atomicRenameWithRetry, classifyAnchor, detectDurability, detectRecurrence, detectTargetDate, evaluateAnchorDecay, findMatchedAnchors, formatCreationTime, formatOrigin, formatRelativeTime, formatRemainingTtl, formatSettlementCard, formatTargetDate, generateSettlementProposals, getDefaultStorageDir, getDisplayWidth, getEphemeralDecayPolicy, getTodayDateString, globToRegExp, groupAnchorsByQuadrant, hasActiveUI, isSameOrSubPath, makeSafeTaskAnnotation, matchAnchorAgainstTouchedFiles, normalizePath, openAnchorDashboard, padToWidth, renderActiveAnchorsContext, renderColdStartAnchorsContext, runPhysicalVerification, stripAnsi, sweepStore, truncateToWidth, updateAnchorStatusBar, updateStartupBanner };
package/dist/index.d.ts CHANGED
@@ -66,6 +66,7 @@ interface AnchorStoreState {
66
66
  version: number;
67
67
  anchors: Anchor[];
68
68
  lastSweepAt: number;
69
+ nextId?: number;
69
70
  }
70
71
  interface TouchMatchResult {
71
72
  anchor: Anchor;
@@ -236,6 +237,10 @@ declare function globToRegExp(pattern: string): RegExp;
236
237
  * Normalize path separators to POSIX forward slashes and optionally relative to baseCwd
237
238
  */
238
239
  declare function normalizePath(filePath: string, baseCwd?: string): string;
240
+ /**
241
+ * Compare two workspace CWDs safely (case-insensitive on Windows, supports subdirectory scoping)
242
+ */
243
+ declare function isSameOrSubPath(anchorCwd?: string, targetCwd?: string): boolean;
239
244
  /**
240
245
  * Match touched files against an anchor's registered physical patterns
241
246
  * Only anchors with explicit files/globs/prefixes are matched against file touches.
@@ -298,6 +303,7 @@ declare function stripAnsi(str: string): string;
298
303
  * - Standard ASCII (width 1)
299
304
  */
300
305
  declare function getDisplayWidth(str: string): number;
306
+ declare function hasActiveUI(ctx: ExtensionContext | null | undefined): ctx is ExtensionContext;
301
307
  /**
302
308
  * Truncate string to target terminal visual width with ellipsis
303
309
  */
@@ -445,4 +451,4 @@ declare class AnchorProtocol {
445
451
  };
446
452
  }
447
453
 
448
- export { type AgentAdapter, type Anchor, type AnchorDecayPolicy, type AnchorDurability, type AnchorEvidence, type AnchorPriority, AnchorProtocol, type AnchorQuadrant, type AnchorStatus, AnchorStore, type AnchorStoreState, COMMENT_FORMATS, DEFAULT_DECAY_POLICY, DURABLE_DECAY_POLICY, type DecayEvaluation, EPHEMERAL_DECAY_POLICY, type GroupedAnchors, SessionTouchObserver, type SettlementProposal, type SweepResult, type TouchMatchResult, acquireSyncLock, atomicRenameWithRetry, classifyAnchor, detectDurability, detectRecurrence, detectTargetDate, evaluateAnchorDecay, findMatchedAnchors, formatCreationTime, formatOrigin, formatRelativeTime, formatRemainingTtl, formatSettlementCard, formatTargetDate, generateSettlementProposals, getDefaultStorageDir, getDisplayWidth, getEphemeralDecayPolicy, getTodayDateString, globToRegExp, groupAnchorsByQuadrant, makeSafeTaskAnnotation, matchAnchorAgainstTouchedFiles, normalizePath, openAnchorDashboard, padToWidth, renderActiveAnchorsContext, renderColdStartAnchorsContext, runPhysicalVerification, stripAnsi, sweepStore, truncateToWidth, updateAnchorStatusBar, updateStartupBanner };
454
+ export { type AgentAdapter, type Anchor, type AnchorDecayPolicy, type AnchorDurability, type AnchorEvidence, type AnchorPriority, AnchorProtocol, type AnchorQuadrant, type AnchorStatus, AnchorStore, type AnchorStoreState, COMMENT_FORMATS, DEFAULT_DECAY_POLICY, DURABLE_DECAY_POLICY, type DecayEvaluation, EPHEMERAL_DECAY_POLICY, type GroupedAnchors, SessionTouchObserver, type SettlementProposal, type SweepResult, type TouchMatchResult, acquireSyncLock, atomicRenameWithRetry, classifyAnchor, detectDurability, detectRecurrence, detectTargetDate, evaluateAnchorDecay, findMatchedAnchors, formatCreationTime, formatOrigin, formatRelativeTime, formatRemainingTtl, formatSettlementCard, formatTargetDate, generateSettlementProposals, getDefaultStorageDir, getDisplayWidth, getEphemeralDecayPolicy, getTodayDateString, globToRegExp, groupAnchorsByQuadrant, hasActiveUI, isSameOrSubPath, makeSafeTaskAnnotation, matchAnchorAgainstTouchedFiles, normalizePath, openAnchorDashboard, padToWidth, renderActiveAnchorsContext, renderColdStartAnchorsContext, runPhysicalVerification, stripAnsi, sweepStore, truncateToWidth, updateAnchorStatusBar, updateStartupBanner };
package/dist/index.js CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  getTodayDateString,
27
27
  globToRegExp,
28
28
  groupAnchorsByQuadrant,
29
+ hasActiveUI,
30
+ isSameOrSubPath,
29
31
  makeSafeTaskAnnotation,
30
32
  matchAnchorAgainstTouchedFiles,
31
33
  normalizePath,
@@ -39,7 +41,7 @@ import {
39
41
  truncateToWidth,
40
42
  updateAnchorStatusBar,
41
43
  updateStartupBanner
42
- } from "./chunk-B652T7QR.js";
44
+ } from "./chunk-G72UHPBN.js";
43
45
 
44
46
  // src/protocol.ts
45
47
  var AnchorProtocol = class {
@@ -128,6 +130,8 @@ export {
128
130
  getTodayDateString,
129
131
  globToRegExp,
130
132
  groupAnchorsByQuadrant,
133
+ hasActiveUI,
134
+ isSameOrSubPath,
131
135
  makeSafeTaskAnnotation,
132
136
  matchAnchorAgainstTouchedFiles,
133
137
  normalizePath,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-anchor",
3
- "version": "0.2.1",
4
- "description": "Zero-pollution, 0-idle-token task memory for AI coding agents",
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.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "extensions": [
22
22
  "dist/extension.js"
23
23
  ],
24
- "image": "https://raw.githubusercontent.com/3ZEROS12/anchor/main/assets/hero.svg"
24
+ "image": "https://raw.githubusercontent.com/3ZEROS12/anchor/main/assets/hero-en.svg"
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsup src/index.ts src/extension.ts --format esm,cjs --dts --clean",