pi-anchor 0.2.0 → 0.2.1

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 CHANGED
@@ -21,7 +21,7 @@
21
21
  </a>
22
22
  <img src="https://img.shields.io/badge/Node-v20+-22c55e.svg" alt="Node v20+">
23
23
  <img src="https://img.shields.io/badge/TypeScript-Strict-3b82f6.svg" alt="TypeScript Strict">
24
- <img src="https://img.shields.io/badge/Tests-24%20Passed-22c55e.svg" alt="Tests: 24 Passed">
24
+ <img src="https://img.shields.io/badge/Tests-26%20Passed-22c55e.svg" alt="Tests: 26 Passed">
25
25
  <img src="https://img.shields.io/badge/Startup-%3C%2090ms-success.svg" alt="Startup: < 90ms">
26
26
  <img src="https://img.shields.io/badge/Storage-Zero%20Repo%20Pollution-success.svg" alt="Zero Repo Pollution">
27
27
  <img src="https://img.shields.io/badge/License-MIT-f97316.svg" alt="License: MIT">
@@ -234,40 +234,58 @@ Engineered with strict TypeScript and 100% standard library test coverage using
234
234
  ```bash
235
235
  npm run build # Dual ESM/CJS compilation via tsup + declaration emit
236
236
  npm run typecheck # Strict tsc --noEmit check (0 errors)
237
- npm test # Native Node test runner (24/24 passing)
237
+ npm test # Native Node test runner (26/26 passing)
238
238
  ```
239
239
 
240
240
  ```text
241
- ✔ Lock - acquireSyncLock acquires, holds, and releases exclusive lockfile (32ms)
242
- ✔ Lock - acquireSyncLock safely reclaims stale lock from dead PID (18ms)
243
- ✔ Context - makeSafeTaskAnnotation produces language-accurate comments and skips JSON (3.1ms)
244
- ✔ Protocol - AnchorProtocol lifecycle handles cold-start and safe JIT (21ms)
245
- ✔ ContextInjector - renders only active anchors, sleeping consume 0 tokens (59ms)
246
- ✔ ContextInjector - renders targetDate and task aging in prompt (10ms)
247
- ✔ AnchorDecay - status evaluation transitions (1.2ms)
248
- ✔ AnchorDecay - sweepStore transitions and graveyard eviction (102ms)
249
- ✔ AnchorMatcher - exact, prefix, and tag matching (2.5ms)
250
- ✔ AnchorMatcher - glob pattern matching (*.ts, src/**/*.ts) (0.7ms)
251
- ✔ AnchorMatcher - findMatchedAnchors prioritizes high-confidence & high-priority (0.4ms)
252
- ✔ SessionTouchObserver - records read, edit, write and commits (25ms)
253
- ✔ SessionTouchObserver - matches CJK commit messages with segmentation (2.0ms)
254
- ✔ SessionTouchObserver - separates read inspection from edit mutation (12ms)
255
- ✔ AnchorStore - basic CRUD & atomic writes (77ms)
256
- ✔ AnchorStore - corrupt state recovery (9.1ms)
257
- ✔ AnchorStore - daily recurring task completes for today and wakes tomorrow (19ms)
258
- ✔ AnchorStore - temporal durability keywords and project detection (10ms)
259
- ✔ AnchorStore - atomicRenameWithRetry successfully replaces files atomically (22ms)
260
- ✔ AnchorTUI - status bar reflects active state cleanly with ⌖ N (23ms)
261
- ✔ AnchorTUI - formatTargetDate and formatCreationTime render clear dual-timeline (38ms)
262
- ✔ AnchorTUI - openAnchorDashboard renders Plan 2 Neovim/Geek layout with folder metadata (26ms)
263
- ✔ AnchorTUI - getDisplayWidth and padToWidth properly align CJK full-width columns (0.3ms)
264
- ✔ AnchorTUI - updateStartupBanner renders clean widget above editor (8.7ms)
265
-
266
- ℹ pass 24, fail 0 (416ms total runtime)
241
+ ✔ ContextInjector - renders only active anchors, sleeping consume 0 tokens
242
+ ✔ ContextInjector - renders targetDate and task aging in prompt
243
+ ✔ ContextInjector - renders preflight failure hints into cold-start context
244
+ ✔ AnchorDecay - status evaluation transitions
245
+ ✔ AnchorDecay - sweepStore transitions and graveyard eviction
246
+ ✔ AnchorMatcher - exact, prefix, and tag matching
247
+ ✔ AnchorMatcher - glob pattern matching (*.ts, src/**/*.ts)
248
+ ✔ AnchorMatcher - findMatchedAnchors prioritizes high-confidence & high-priority
249
+ ✔ SessionTouchObserver - records read, edit, write and commits
250
+ ✔ SessionTouchObserver - matches CJK commit messages with segmentation
251
+ ✔ SessionTouchObserver - separates read inspection from edit mutation
252
+ ✔ Lock - acquireSyncLock acquires, holds, and releases exclusive lockfile
253
+ ✔ Lock - acquireSyncLock safely reclaims stale lock from dead PID
254
+ ✔ Context - makeSafeTaskAnnotation produces language-accurate comments and skips JSON
255
+ ✔ Protocol - AnchorProtocol lifecycle handles cold-start and safe JIT
256
+ ✔ AnchorStore - basic CRUD & atomic writes
257
+ ✔ AnchorStore - corrupt state recovery
258
+ ✔ AnchorStore - daily recurring task completes for today and wakes tomorrow
259
+ ✔ AnchorStore - temporal durability keywords and project detection
260
+ ✔ AnchorStore - atomicRenameWithRetry successfully replaces files atomically
261
+ ✔ AnchorTUI - status bar reflects active state cleanly with ⌖ N
262
+ ✔ AnchorTUI - formatTargetDate and formatCreationTime render clear dual-timeline
263
+ ✔ AnchorTUI - openAnchorDashboard renders Plan 2 Neovim/Geek layout with folder metadata
264
+ ✔ AnchorTUI - getDisplayWidth and padToWidth properly align CJK full-width columns
265
+ ✔ AnchorTUI - updateStartupBanner renders clean widget above editor
266
+ ✔ AnchorTUI - truncateToWidth properly handles ANSI escape sequences and bounds width
267
+
268
+ ℹ pass 26, fail 0
267
269
  ```
268
270
 
269
271
  ---
270
272
 
273
+ ## Author's Note
274
+
275
+ The motivation behind Anchor came from a recurring headache during pair-programming with AI coding agents: watching `TODO.md` and `AGENTS.md` rot inside my git trees.
276
+
277
+ Every time a task remained unfinished across sessions, writing it to a project markdown file felt harmless at first. But over weeks of work, two frictions became unbearable:
278
+ 1. In multi-turn sessions, the AI dragged dozens of stagnant task lines into the prompt on every single turn, burning hundreds of tokens while diluting the model's focus on the actual code being edited.
279
+ 2. Merge conflicts on task checklists in Git commits polluted the repository's history with irrelevant bookkeeping noise.
280
+
281
+ When I looked at existing solutions, the trade-offs felt disproportionate. Stuffing a 200MB Dolt SQL relational database into the repository root (`beads`), or running a background Go daemon with SQLite and continuous vector retrieval (`engram`), inverted the relationship between tool and developer. A developer task tracker should not demand more system resources than the application being built.
282
+
283
+ Anchor takes the opposite approach: store state outside the project tree in a lean global ledger (`~/.anchor/state.json`), inject tasks only on Turn 1 of a cold start, keep Turn 2+ at exactly 0 tokens, and auto-settle upon git commits or session exit.
284
+
285
+ I hope Anchor helps keep your project trees clean and your focus where it belongs: on the code.
286
+
287
+ ---
288
+
271
289
  ## License
272
290
 
273
291
  MIT License © 2025 [Jason Song (@3ZEROS12)](https://github.com/3ZEROS12)
package/README_zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p align="center">
4
4
  <strong>面向 AI 编程智能体的零污染、零闲置 Token 跨会话任务记忆。</strong><br>
5
- <em>告别发霉的 TODO.md。零 Git 历史污染,0 闲置 Token 损耗,写完代码自动闭环核销。</em>
5
+ <em>告别发霉的 TODO.md。零 Git 历史污染,0 闲置 Token 损耗,代码提交自动核销结算。</em>
6
6
  </p>
7
7
 
8
8
  <p align="center">
@@ -21,7 +21,7 @@
21
21
  </a>
22
22
  <img src="https://img.shields.io/badge/Node-v20+-22c55e.svg" alt="Node v20+">
23
23
  <img src="https://img.shields.io/badge/TypeScript-Strict-3b82f6.svg" alt="TypeScript Strict">
24
- <img src="https://img.shields.io/badge/Tests-24%20通过-22c55e.svg" alt="Tests: 24 Passed">
24
+ <img src="https://img.shields.io/badge/Tests-26%20通过-22c55e.svg" alt="Tests: 26 Passed">
25
25
  <img src="https://img.shields.io/badge/启动耗时-%3C%2090ms-success.svg" alt="启动耗时: < 90ms">
26
26
  <img src="https://img.shields.io/badge/存储-零仓库污染-success.svg" alt="零仓库污染">
27
27
  <img src="https://img.shields.io/badge/开源协议-MIT-f97316.svg" alt="License: MIT">
@@ -38,7 +38,7 @@
38
38
  使用终端编程智能体(Claude Code、Pi、Aider 等)进行实际项目开发时,开发者普遍面临三个摩擦:
39
39
 
40
40
  1. **终端退出即失忆**:按下 `Ctrl+C` 退出终端,Agent 内存中的上下文被全部清空。未完成的代码重构计划、临时交代的排期与多步约定瞬间丢失。
41
- 2. **TODO.md 污染与 Token 泥潭**:直接在工程根目录写 `TODO.md` 或 `AGENTS.md`,不仅会产生大量 Git 杂质提交,而且**每一轮对话都要把几十行陈旧清单塞入 System Prompt**,白白消耗大量 Token 预算,并分散模型对当前核心代码的注意力。
41
+ 2. **TODO.md 污染与 Token 泥潭**:直接在工程根目录写 `TODO.md` 或 `AGENTS.md`,会产生大量 Git 杂质提交,且**每一轮对话都要把几十行陈旧清单塞入 System Prompt**,白白消耗大量 Token 预算,并分散模型对当前核心代码的注意力。
42
42
  3. **静态清单无限腐烂**:写完代码后,工程师极少会专程打开 Markdown 逐个打勾。未勾选的旧任务烂在仓库里,越积越多。
43
43
 
44
44
  现有的解决方案大多走向了两个重型极端:
@@ -122,7 +122,7 @@ Anchor 从两个正交的时间维度管理任务,兼顾“何时要交”与
122
122
  * **`[Habits]`(每日循环)**:日常例行循环任务。当天打勾后隐身,明日零点自动复苏。
123
123
  * **`[Backlog]`(长期备忘)**:无指定日期的宏观架构愿景(`Someday`)。
124
124
 
125
- ### 5. 零心智负担的自动核销闭环
125
+ ### 5. 零心智负担的自动核销与生命周期结算
126
126
  * **Git Commit 自动语义匹配**:内置基于标准 `Intl.Segmenter` 的多语系分词引擎。代码提交信息(如 `git commit -m "fix(auth): migrate to HttpOnly cookies"`)命中任务描述时自动核销结案。
127
127
  * **退出终端一键确认**:检测到本会话修改了任务关联的文件时,退出终端前弹出极简单键确认:
128
128
  ```text
@@ -170,7 +170,7 @@ Anchor 从两个正交的时间维度管理任务,兼顾“何时要交”与
170
170
  | **Token 损耗** | 每轮中/高消耗 | 高(注入长文历史) | 极高(陈旧文本成倍累积) | **0 Token 闲置(仅文件触碰 JIT 唤醒)** |
171
171
  | **JIT 语法安全** | 不支持文件注释 | 无语法感知 | 静态文本无防护 | **精确匹配 30+ 语言注释,跳过 JSON** |
172
172
  | **任务时效** | 单层平铺 | 单层平铺 | 静态复选框 | **双时间维度(交付排期 + 立项时间)** |
173
- | **核销闭环** | 手动命令 `close` | 无闭环机制 | 人工修改文本打勾 | **Git Commit 自动识别 + 退出前一键核销** |
173
+ | **任务结算与核销** | 手动命令 `close` | 无自动结算机制 | 人工修改文本打勾 | **Git Commit 自动识别 + 退出前一键核销** |
174
174
  | **衰减清理** | 手动 prune | 无衰退机制 | 长期堆积发霉 | **静默衰退(临时任务 48h 自动清理)** |
175
175
  | **启动耗时** | 重型 CLI 初始化 | Go 守护进程常驻 | 无 | **< 90ms 预打包瞬时秒开** |
176
176
  | **环境依赖** | 外部 Dolt 二进制 | 外部 Go 编译产物 | 无 | **零原生外部依赖(纯 TypeScript)** |
@@ -233,40 +233,62 @@ pi install npm:pi-anchor
233
233
  ```bash
234
234
  npm run build # tsup 双格式打包 (ESM/CJS) 与 .d.ts 类型生成
235
235
  npm run typecheck # TypeScript 严格类型检查 (0 错误)
236
- npm test # 全套自动化单元测试 (24/24 全绿通过)
236
+ npm test # 全套自动化单元测试 (26/26 全绿通过)
237
237
  ```
238
238
 
239
239
  ```text
240
- ✔ Lock - acquireSyncLock acquires, holds, and releases exclusive lockfile (32ms)
241
- ✔ Lock - acquireSyncLock safely reclaims stale lock from dead PID (18ms)
242
- ✔ Context - makeSafeTaskAnnotation produces language-accurate comments and skips JSON (3.1ms)
243
- ✔ Protocol - AnchorProtocol lifecycle handles cold-start and safe JIT (21ms)
244
- ✔ ContextInjector - renders only active anchors, sleeping consume 0 tokens (59ms)
245
- ✔ ContextInjector - renders targetDate and task aging in prompt (10ms)
246
- ✔ AnchorDecay - status evaluation transitions (1.2ms)
247
- ✔ AnchorDecay - sweepStore transitions and graveyard eviction (102ms)
248
- ✔ AnchorMatcher - exact, prefix, and tag matching (2.5ms)
249
- ✔ AnchorMatcher - glob pattern matching (*.ts, src/**/*.ts) (0.7ms)
250
- ✔ AnchorMatcher - findMatchedAnchors prioritizes high-confidence & high-priority (0.4ms)
251
- ✔ SessionTouchObserver - records read, edit, write and commits (25ms)
252
- ✔ SessionTouchObserver - matches CJK commit messages with segmentation (2.0ms)
253
- ✔ SessionTouchObserver - separates read inspection from edit mutation (12ms)
254
- ✔ AnchorStore - basic CRUD & atomic writes (77ms)
255
- ✔ AnchorStore - corrupt state recovery (9.1ms)
256
- ✔ AnchorStore - daily recurring task completes for today and wakes tomorrow (19ms)
257
- ✔ AnchorStore - temporal durability keywords and project detection (10ms)
258
- ✔ AnchorStore - atomicRenameWithRetry successfully replaces files atomically (22ms)
259
- ✔ AnchorTUI - status bar reflects active state cleanly with ⌖ N (23ms)
260
- ✔ AnchorTUI - formatTargetDate and formatCreationTime render clear dual-timeline (38ms)
261
- ✔ AnchorTUI - openAnchorDashboard renders Plan 2 Neovim/Geek layout with folder metadata (26ms)
262
- ✔ AnchorTUI - getDisplayWidth and padToWidth properly align CJK full-width columns (0.3ms)
263
- ✔ AnchorTUI - updateStartupBanner renders clean widget above editor (8.7ms)
264
-
265
- ℹ pass 24, fail 0 (416ms total runtime)
240
+ ✔ ContextInjector - renders only active anchors, sleeping consume 0 tokens
241
+ ✔ ContextInjector - renders targetDate and task aging in prompt
242
+ ✔ ContextInjector - renders preflight failure hints into cold-start context
243
+ ✔ AnchorDecay - status evaluation transitions
244
+ ✔ AnchorDecay - sweepStore transitions and graveyard eviction
245
+ ✔ AnchorMatcher - exact, prefix, and tag matching
246
+ ✔ AnchorMatcher - glob pattern matching (*.ts, src/**/*.ts)
247
+ ✔ AnchorMatcher - findMatchedAnchors prioritizes high-confidence & high-priority
248
+ ✔ SessionTouchObserver - records read, edit, write and commits
249
+ ✔ SessionTouchObserver - matches CJK commit messages with segmentation
250
+ ✔ SessionTouchObserver - separates read inspection from edit mutation
251
+ ✔ Lock - acquireSyncLock acquires, holds, and releases exclusive lockfile
252
+ ✔ Lock - acquireSyncLock safely reclaims stale lock from dead PID
253
+ ✔ Context - makeSafeTaskAnnotation produces language-accurate comments and skips JSON
254
+ ✔ Protocol - AnchorProtocol lifecycle handles cold-start and safe JIT
255
+ ✔ AnchorStore - basic CRUD & atomic writes
256
+ ✔ AnchorStore - corrupt state recovery
257
+ ✔ AnchorStore - daily recurring task completes for today and wakes tomorrow
258
+ ✔ AnchorStore - temporal durability keywords and project detection
259
+ ✔ AnchorStore - atomicRenameWithRetry successfully replaces files atomically
260
+ ✔ AnchorTUI - status bar reflects active state cleanly with ⌖ N
261
+ ✔ AnchorTUI - formatTargetDate and formatCreationTime render clear dual-timeline
262
+ ✔ AnchorTUI - openAnchorDashboard renders Plan 2 Neovim/Geek layout with folder metadata
263
+ ✔ AnchorTUI - getDisplayWidth and padToWidth properly align CJK full-width columns
264
+ ✔ AnchorTUI - updateStartupBanner renders clean widget above editor
265
+ ✔ AnchorTUI - truncateToWidth properly handles ANSI escape sequences and bounds width
266
+
267
+ ℹ pass 26, fail 0
266
268
  ```
267
269
 
268
270
  ---
269
271
 
272
+ ## 作者手记 (Author's Note)
273
+
274
+ 写 Anchor 的最初冲动,来自于我用终端 AI 编程智能体结对开发时经历的一场持续阵痛:看着项目根目录下的 `TODO.md` 与 `AGENTS.md` 在 Git 历史里发霉。
275
+
276
+ 很多时候,写完一段代码已经是深夜,跨会话未完成的任务随手记在项目的 Markdown 里,看起来省事。然而几个月下来,两点物理摩擦让我极其痛苦:
277
+ 1. 每一轮对话交互,AI 都要把陈旧的任务清单整段塞进系统提示词,白白烧掉几十上百行 Token,还会分散模型对当前正在修改的代码的注意力;
278
+ 2. 团队多人协作或分支合并时,这类任务清单文件频发 Git 合并冲突,无端污染了核心代码的提交历史。
279
+
280
+ 调研现存工具时,我发现社区给出的方案走向了沉重的极端:为了管理几个简单的任务,有的方案直接在代码仓库根目录塞入一个超过 200MB 的 Dolt SQL 关系型数据库(`beads`),有的方案在后台常驻一个用 Go 编写的 SQLite 守护进程并做持续向量检索(`engram`)。一个任务追踪工具消耗的系统资源与认知负担,甚至超过了开发者正在编写的业务代码本身。
281
+
282
+ 这颠倒了工具与生产者的关系。
283
+
284
+ Anchor 选择了一条极度克制的工程路径:把状态移出代码仓库,托管在用户主目录下的单文件轻量状态表(`~/.anchor/state.json`);首轮对话注入冷启动上下文,第 2 轮起保持 0 Token 损耗;通过语言对应的语法安全注释在触碰代码时精准唤醒,并在代码提交或会话退出时自动核销。
285
+
286
+ 在这次更新中,我们还为 `/anchor` 增加了防误触详情面板,按回车可以从容查看关联文件与执行命令,彻底消灭误敲回车直接结算的焦虑。
287
+
288
+ 希望 Anchor 能让你的代码仓库保持干净,把专注力还给代码本身。
289
+
290
+ ---
291
+
270
292
  ## 开源协议
271
293
 
272
294
  MIT License © 2025 [Jason Song (@3ZEROS12)](https://github.com/3ZEROS12)
@@ -1066,8 +1066,9 @@ function groupAnchorsByQuadrant(anchors, now = Date.now()) {
1066
1066
  }
1067
1067
  return groups;
1068
1068
  }
1069
+ var ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
1069
1070
  function stripAnsi(str) {
1070
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1071
+ return str.replace(ANSI_REGEX, "");
1071
1072
  }
1072
1073
  function getDisplayWidth(str) {
1073
1074
  const clean = stripAnsi(str);
@@ -1086,22 +1087,36 @@ function getDisplayWidth(str) {
1086
1087
  return width;
1087
1088
  }
1088
1089
  function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
1089
- const current = getDisplayWidth(str);
1090
- if (current <= maxWidth) return str;
1090
+ if (maxWidth <= 0) return "";
1091
+ const totalWidth = getDisplayWidth(str);
1092
+ if (totalWidth <= maxWidth) return str;
1091
1093
  const ellipsisWidth = getDisplayWidth(ellipsis);
1092
- const target = maxWidth - ellipsisWidth;
1093
- if (target <= 0) return ellipsis.slice(0, maxWidth);
1094
+ const target = Math.max(0, maxWidth - ellipsisWidth);
1095
+ if (target === 0) return ellipsis.slice(0, maxWidth);
1094
1096
  let accumulated = "";
1095
1097
  let accumWidth = 0;
1096
- for (const char of str) {
1097
- const charWidth = getDisplayWidth(char);
1098
+ let i = 0;
1099
+ while (i < str.length) {
1100
+ ANSI_REGEX.lastIndex = i;
1101
+ const match = ANSI_REGEX.exec(str);
1102
+ if (match && match.index === i) {
1103
+ accumulated += match[0];
1104
+ i += match[0].length;
1105
+ continue;
1106
+ }
1107
+ const codePoint = str.codePointAt(i) || 0;
1108
+ const charLen = codePoint > 65535 ? 2 : 1;
1109
+ const fullChar = str.slice(i, i + charLen);
1110
+ const charWidth = getDisplayWidth(fullChar);
1098
1111
  if (accumWidth + charWidth > target) {
1099
1112
  break;
1100
1113
  }
1101
- accumulated += char;
1114
+ accumulated += fullChar;
1102
1115
  accumWidth += charWidth;
1116
+ i += charLen;
1103
1117
  }
1104
- return accumulated + ellipsis;
1118
+ accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, "");
1119
+ return accumulated + ellipsis + (str.includes("\x1B") ? "\x1B[0m" : "");
1105
1120
  }
1106
1121
  function padToWidth(str, targetWidth) {
1107
1122
  const truncated = truncateToWidth(str, targetWidth);
@@ -1254,14 +1269,35 @@ async function openAnchorDashboard(ctx, store) {
1254
1269
  optionMap.set(label, a);
1255
1270
  displayOptions.push(label);
1256
1271
  }
1257
- const selected = await ctx.ui.select("\u2316 Anchors (enter to complete):", displayOptions);
1272
+ const selected = await ctx.ui.select("\u2316 Anchors (select to inspect or settle):", displayOptions);
1258
1273
  if (!selected) return;
1259
1274
  const anchor = optionMap.get(selected);
1260
1275
  if (!anchor) return;
1261
- store.settle(anchor.id, { settledBy: "manual-command" });
1262
- const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled: "${anchor.title}"`;
1263
- ctx.ui.notify(successMsg, "info");
1264
- updateAnchorStatusBar(ctx, store);
1276
+ const action = await ctx.ui.select(
1277
+ `\u2316 Anchor #${anchor.id}: "${anchor.title}"`,
1278
+ [
1279
+ "\u2713 Settle (Mark completed and archive)",
1280
+ "\u2139 View details & associated files",
1281
+ "\u21A9 Cancel"
1282
+ ]
1283
+ );
1284
+ if (!action || action.startsWith("\u21A9")) return;
1285
+ if (action.startsWith("\u2713")) {
1286
+ store.settle(anchor.id, { settledBy: "manual-command" });
1287
+ const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled #${anchor.id}: "${anchor.title}" (/anchor undo to revert)`;
1288
+ ctx.ui.notify(successMsg, "info");
1289
+ updateAnchorStatusBar(ctx, store);
1290
+ } else if (action.startsWith("\u2139")) {
1291
+ const details = [
1292
+ `#${anchor.id} [${anchor.priority.toUpperCase()}] ${anchor.title}`,
1293
+ anchor.description ? `Description: ${anchor.description}` : null,
1294
+ anchor.targetDate ? `Target Date: ${anchor.targetDate} (${formatTargetDate(anchor)})` : null,
1295
+ anchor.files && anchor.files.length > 0 ? `Files: ${anchor.files.join(", ")}` : null,
1296
+ anchor.verifyCommand ? `Verify Command: ${anchor.verifyCommand}` : null,
1297
+ `Created: ${formatCreationTime(anchor.createdAt)} (in ${anchor.project || "global"})`
1298
+ ].filter(Boolean).join("\n");
1299
+ ctx.ui.notify(details, "info");
1300
+ }
1265
1301
  }
1266
1302
 
1267
1303
  export {
@@ -1086,8 +1086,9 @@ function groupAnchorsByQuadrant(anchors, now = Date.now()) {
1086
1086
  }
1087
1087
  return groups;
1088
1088
  }
1089
+ var ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
1089
1090
  function stripAnsi(str) {
1090
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1091
+ return str.replace(ANSI_REGEX, "");
1091
1092
  }
1092
1093
  function getDisplayWidth(str) {
1093
1094
  const clean = stripAnsi(str);
@@ -1106,22 +1107,36 @@ function getDisplayWidth(str) {
1106
1107
  return width;
1107
1108
  }
1108
1109
  function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
1109
- const current = getDisplayWidth(str);
1110
- if (current <= maxWidth) return str;
1110
+ if (maxWidth <= 0) return "";
1111
+ const totalWidth = getDisplayWidth(str);
1112
+ if (totalWidth <= maxWidth) return str;
1111
1113
  const ellipsisWidth = getDisplayWidth(ellipsis);
1112
- const target = maxWidth - ellipsisWidth;
1113
- if (target <= 0) return ellipsis.slice(0, maxWidth);
1114
+ const target = Math.max(0, maxWidth - ellipsisWidth);
1115
+ if (target === 0) return ellipsis.slice(0, maxWidth);
1114
1116
  let accumulated = "";
1115
1117
  let accumWidth = 0;
1116
- for (const char of str) {
1117
- const charWidth = getDisplayWidth(char);
1118
+ let i = 0;
1119
+ while (i < str.length) {
1120
+ ANSI_REGEX.lastIndex = i;
1121
+ const match = ANSI_REGEX.exec(str);
1122
+ if (match && match.index === i) {
1123
+ accumulated += match[0];
1124
+ i += match[0].length;
1125
+ continue;
1126
+ }
1127
+ const codePoint = str.codePointAt(i) || 0;
1128
+ const charLen = codePoint > 65535 ? 2 : 1;
1129
+ const fullChar = str.slice(i, i + charLen);
1130
+ const charWidth = getDisplayWidth(fullChar);
1118
1131
  if (accumWidth + charWidth > target) {
1119
1132
  break;
1120
1133
  }
1121
- accumulated += char;
1134
+ accumulated += fullChar;
1122
1135
  accumWidth += charWidth;
1136
+ i += charLen;
1123
1137
  }
1124
- return accumulated + ellipsis;
1138
+ accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, "");
1139
+ return accumulated + ellipsis + (str.includes("\x1B") ? "\x1B[0m" : "");
1125
1140
  }
1126
1141
  function padToWidth(str, targetWidth) {
1127
1142
  const truncated = truncateToWidth(str, targetWidth);
@@ -1268,14 +1283,35 @@ async function openAnchorDashboard(ctx, store) {
1268
1283
  optionMap.set(label, a);
1269
1284
  displayOptions.push(label);
1270
1285
  }
1271
- const selected = await ctx.ui.select("\u2316 Anchors (enter to complete):", displayOptions);
1286
+ const selected = await ctx.ui.select("\u2316 Anchors (select to inspect or settle):", displayOptions);
1272
1287
  if (!selected) return;
1273
1288
  const anchor = optionMap.get(selected);
1274
1289
  if (!anchor) return;
1275
- store.settle(anchor.id, { settledBy: "manual-command" });
1276
- const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled: "${anchor.title}"`;
1277
- ctx.ui.notify(successMsg, "info");
1278
- updateAnchorStatusBar(ctx, store);
1290
+ const action = await ctx.ui.select(
1291
+ `\u2316 Anchor #${anchor.id}: "${anchor.title}"`,
1292
+ [
1293
+ "\u2713 Settle (Mark completed and archive)",
1294
+ "\u2139 View details & associated files",
1295
+ "\u21A9 Cancel"
1296
+ ]
1297
+ );
1298
+ if (!action || action.startsWith("\u21A9")) return;
1299
+ if (action.startsWith("\u2713")) {
1300
+ store.settle(anchor.id, { settledBy: "manual-command" });
1301
+ const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled #${anchor.id}: "${anchor.title}" (/anchor undo to revert)`;
1302
+ ctx.ui.notify(successMsg, "info");
1303
+ updateAnchorStatusBar(ctx, store);
1304
+ } else if (action.startsWith("\u2139")) {
1305
+ const details = [
1306
+ `#${anchor.id} [${anchor.priority.toUpperCase()}] ${anchor.title}`,
1307
+ anchor.description ? `Description: ${anchor.description}` : null,
1308
+ anchor.targetDate ? `Target Date: ${anchor.targetDate} (${formatTargetDate(anchor)})` : null,
1309
+ anchor.files && anchor.files.length > 0 ? `Files: ${anchor.files.join(", ")}` : null,
1310
+ anchor.verifyCommand ? `Verify Command: ${anchor.verifyCommand}` : null,
1311
+ `Created: ${formatCreationTime(anchor.createdAt)} (in ${anchor.project || "global"})`
1312
+ ].filter(Boolean).join("\n");
1313
+ ctx.ui.notify(details, "info");
1314
+ }
1279
1315
  }
1280
1316
 
1281
1317
  // src/extension.ts
@@ -1488,7 +1524,7 @@ Mark as completed and archive?`
1488
1524
  if (args.priority) title += ` ${theme.fg("muted", `[${args.priority}]`)}`;
1489
1525
  }
1490
1526
  return {
1491
- render: () => [title],
1527
+ render: (width) => [truncateToWidth(title, width)],
1492
1528
  invalidate: () => {
1493
1529
  }
1494
1530
  };
@@ -1497,14 +1533,14 @@ Mark as completed and archive?`
1497
1533
  if (result.isError) {
1498
1534
  const errText = result.content?.[0]?.text || "Execution failed";
1499
1535
  return {
1500
- render: () => [theme.fg("error", `\u2717 ${errText}`)],
1536
+ render: (width) => [truncateToWidth(theme.fg("error", `\u2717 ${errText}`), width)],
1501
1537
  invalidate: () => {
1502
1538
  }
1503
1539
  };
1504
1540
  }
1505
1541
  const text = result.content?.[0]?.text || "OK";
1506
1542
  return {
1507
- render: () => [theme.fg("success", `\u2714 ${text}`)],
1543
+ render: (width) => [truncateToWidth(theme.fg("success", `\u2714 ${text}`), width)],
1508
1544
  invalidate: () => {
1509
1545
  }
1510
1546
  };
package/dist/extension.js CHANGED
@@ -9,9 +9,10 @@ import {
9
9
  renderColdStartAnchorsContext,
10
10
  runPhysicalVerification,
11
11
  sweepStore,
12
+ truncateToWidth,
12
13
  updateAnchorStatusBar,
13
14
  updateStartupBanner
14
- } from "./chunk-T62PHIQI.js";
15
+ } from "./chunk-B652T7QR.js";
15
16
 
16
17
  // src/extension.ts
17
18
  import { Type } from "@sinclair/typebox";
@@ -224,7 +225,7 @@ Mark as completed and archive?`
224
225
  if (args.priority) title += ` ${theme.fg("muted", `[${args.priority}]`)}`;
225
226
  }
226
227
  return {
227
- render: () => [title],
228
+ render: (width) => [truncateToWidth(title, width)],
228
229
  invalidate: () => {
229
230
  }
230
231
  };
@@ -233,14 +234,14 @@ Mark as completed and archive?`
233
234
  if (result.isError) {
234
235
  const errText = result.content?.[0]?.text || "Execution failed";
235
236
  return {
236
- render: () => [theme.fg("error", `\u2717 ${errText}`)],
237
+ render: (width) => [truncateToWidth(theme.fg("error", `\u2717 ${errText}`), width)],
237
238
  invalidate: () => {
238
239
  }
239
240
  };
240
241
  }
241
242
  const text = result.content?.[0]?.text || "OK";
242
243
  return {
243
- render: () => [theme.fg("success", `\u2714 ${text}`)],
244
+ render: (width) => [truncateToWidth(theme.fg("success", `\u2714 ${text}`), width)],
244
245
  invalidate: () => {
245
246
  }
246
247
  };
package/dist/index.cjs CHANGED
@@ -910,8 +910,9 @@ function groupAnchorsByQuadrant(anchors, now = Date.now()) {
910
910
  }
911
911
  return groups;
912
912
  }
913
+ var ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
913
914
  function stripAnsi(str) {
914
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
915
+ return str.replace(ANSI_REGEX, "");
915
916
  }
916
917
  function getDisplayWidth(str) {
917
918
  const clean = stripAnsi(str);
@@ -930,22 +931,36 @@ function getDisplayWidth(str) {
930
931
  return width;
931
932
  }
932
933
  function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
933
- const current = getDisplayWidth(str);
934
- if (current <= maxWidth) return str;
934
+ if (maxWidth <= 0) return "";
935
+ const totalWidth = getDisplayWidth(str);
936
+ if (totalWidth <= maxWidth) return str;
935
937
  const ellipsisWidth = getDisplayWidth(ellipsis);
936
- const target = maxWidth - ellipsisWidth;
937
- if (target <= 0) return ellipsis.slice(0, maxWidth);
938
+ const target = Math.max(0, maxWidth - ellipsisWidth);
939
+ if (target === 0) return ellipsis.slice(0, maxWidth);
938
940
  let accumulated = "";
939
941
  let accumWidth = 0;
940
- for (const char of str) {
941
- const charWidth = getDisplayWidth(char);
942
+ let i = 0;
943
+ while (i < str.length) {
944
+ ANSI_REGEX.lastIndex = i;
945
+ const match = ANSI_REGEX.exec(str);
946
+ if (match && match.index === i) {
947
+ accumulated += match[0];
948
+ i += match[0].length;
949
+ continue;
950
+ }
951
+ const codePoint = str.codePointAt(i) || 0;
952
+ const charLen = codePoint > 65535 ? 2 : 1;
953
+ const fullChar = str.slice(i, i + charLen);
954
+ const charWidth = getDisplayWidth(fullChar);
942
955
  if (accumWidth + charWidth > target) {
943
956
  break;
944
957
  }
945
- accumulated += char;
958
+ accumulated += fullChar;
946
959
  accumWidth += charWidth;
960
+ i += charLen;
947
961
  }
948
- return accumulated + ellipsis;
962
+ accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, "");
963
+ return accumulated + ellipsis + (str.includes("\x1B") ? "\x1B[0m" : "");
949
964
  }
950
965
  function padToWidth(str, targetWidth) {
951
966
  const truncated = truncateToWidth(str, targetWidth);
@@ -1098,14 +1113,35 @@ async function openAnchorDashboard(ctx, store) {
1098
1113
  optionMap.set(label, a);
1099
1114
  displayOptions.push(label);
1100
1115
  }
1101
- const selected = await ctx.ui.select("\u2316 Anchors (enter to complete):", displayOptions);
1116
+ const selected = await ctx.ui.select("\u2316 Anchors (select to inspect or settle):", displayOptions);
1102
1117
  if (!selected) return;
1103
1118
  const anchor = optionMap.get(selected);
1104
1119
  if (!anchor) return;
1105
- store.settle(anchor.id, { settledBy: "manual-command" });
1106
- const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled: "${anchor.title}"`;
1107
- ctx.ui.notify(successMsg, "info");
1108
- updateAnchorStatusBar(ctx, store);
1120
+ const action = await ctx.ui.select(
1121
+ `\u2316 Anchor #${anchor.id}: "${anchor.title}"`,
1122
+ [
1123
+ "\u2713 Settle (Mark completed and archive)",
1124
+ "\u2139 View details & associated files",
1125
+ "\u21A9 Cancel"
1126
+ ]
1127
+ );
1128
+ if (!action || action.startsWith("\u21A9")) return;
1129
+ if (action.startsWith("\u2713")) {
1130
+ store.settle(anchor.id, { settledBy: "manual-command" });
1131
+ const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled #${anchor.id}: "${anchor.title}" (/anchor undo to revert)`;
1132
+ ctx.ui.notify(successMsg, "info");
1133
+ updateAnchorStatusBar(ctx, store);
1134
+ } else if (action.startsWith("\u2139")) {
1135
+ const details = [
1136
+ `#${anchor.id} [${anchor.priority.toUpperCase()}] ${anchor.title}`,
1137
+ anchor.description ? `Description: ${anchor.description}` : null,
1138
+ anchor.targetDate ? `Target Date: ${anchor.targetDate} (${formatTargetDate(anchor)})` : null,
1139
+ anchor.files && anchor.files.length > 0 ? `Files: ${anchor.files.join(", ")}` : null,
1140
+ anchor.verifyCommand ? `Verify Command: ${anchor.verifyCommand}` : null,
1141
+ `Created: ${formatCreationTime(anchor.createdAt)} (in ${anchor.project || "global"})`
1142
+ ].filter(Boolean).join("\n");
1143
+ ctx.ui.notify(details, "info");
1144
+ }
1109
1145
  }
1110
1146
 
1111
1147
  // src/observer.ts
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  truncateToWidth,
40
40
  updateAnchorStatusBar,
41
41
  updateStartupBanner
42
- } from "./chunk-T62PHIQI.js";
42
+ } from "./chunk-B652T7QR.js";
43
43
 
44
44
  // src/protocol.ts
45
45
  var AnchorProtocol = class {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anchor",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Zero-pollution, 0-idle-token task memory for AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,13 +52,14 @@
52
52
  "publishConfig": {
53
53
  "access": "public"
54
54
  },
55
+ "peerDependencies": {
56
+ "@sinclair/typebox": "*"
57
+ },
55
58
  "devDependencies": {
59
+ "@sinclair/typebox": "^0.34.52",
56
60
  "@types/node": "^26.6.2",
57
61
  "tsup": "^8.5.1",
58
62
  "tsx": "^4.23.15",
59
63
  "typescript": "^5.8.2"
60
- },
61
- "dependencies": {
62
- "@sinclair/typebox": "^0.34.52"
63
64
  }
64
65
  }
package/src/extension.ts CHANGED
@@ -6,7 +6,7 @@ import { renderColdStartAnchorsContext, renderActiveAnchorsContext, makeSafeTask
6
6
  import { generateSettlementProposals, runPhysicalVerification } from './settlement.ts';
7
7
  import { normalizePath, findMatchedAnchors } from './matcher.ts';
8
8
  import { sweepStore } from './decay.ts';
9
- import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner } from './tui.ts';
9
+ import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner, truncateToWidth } 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';
@@ -267,7 +267,7 @@ export default function (pi: ExtensionAPI) {
267
267
  }
268
268
 
269
269
  return {
270
- render: () => [title],
270
+ render: (width: number) => [truncateToWidth(title, width)],
271
271
  invalidate: () => {}
272
272
  };
273
273
  },
@@ -275,14 +275,14 @@ export default function (pi: ExtensionAPI) {
275
275
  if (result.isError) {
276
276
  const errText = result.content?.[0]?.text || 'Execution failed';
277
277
  return {
278
- render: () => [theme.fg('error', `✗ ${errText}`)],
278
+ render: (width: number) => [truncateToWidth(theme.fg('error', `✗ ${errText}`), width)],
279
279
  invalidate: () => {}
280
280
  };
281
281
  }
282
282
 
283
283
  const text = result.content?.[0]?.text || 'OK';
284
284
  return {
285
- render: () => [theme.fg('success', `✔ ${text}`)],
285
+ render: (width: number) => [truncateToWidth(theme.fg('success', `✔ ${text}`), width)],
286
286
  invalidate: () => {}
287
287
  };
288
288
  },
package/src/tui.ts CHANGED
@@ -57,11 +57,13 @@ export function groupAnchorsByQuadrant(anchors: Anchor[], now: number = Date.now
57
57
  return groups;
58
58
  }
59
59
 
60
+ const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
61
+
60
62
  /**
61
63
  * Strip ANSI escape codes from string
62
64
  */
63
65
  export function stripAnsi(str: string): string {
64
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
66
+ return str.replace(ANSI_REGEX, '');
65
67
  }
66
68
 
67
69
  /**
@@ -124,26 +126,44 @@ export function getDisplayWidth(str: string): number {
124
126
  * Truncate string to target terminal visual width with ellipsis
125
127
  */
126
128
  export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…'): string {
127
- const current = getDisplayWidth(str);
128
- if (current <= maxWidth) return str;
129
+ if (maxWidth <= 0) return '';
130
+ const totalWidth = getDisplayWidth(str);
131
+ if (totalWidth <= maxWidth) return str;
129
132
 
130
133
  const ellipsisWidth = getDisplayWidth(ellipsis);
131
- const target = maxWidth - ellipsisWidth;
132
- if (target <= 0) return ellipsis.slice(0, maxWidth);
134
+ const target = Math.max(0, maxWidth - ellipsisWidth);
135
+ if (target === 0) return ellipsis.slice(0, maxWidth);
133
136
 
134
137
  let accumulated = '';
135
138
  let accumWidth = 0;
139
+ let i = 0;
140
+
141
+ while (i < str.length) {
142
+ ANSI_REGEX.lastIndex = i;
143
+ const match = ANSI_REGEX.exec(str);
144
+ if (match && match.index === i) {
145
+ accumulated += match[0];
146
+ i += match[0].length;
147
+ continue;
148
+ }
149
+
150
+ const codePoint = str.codePointAt(i) || 0;
151
+ const charLen = codePoint > 0xffff ? 2 : 1;
152
+ const fullChar = str.slice(i, i + charLen);
153
+ const charWidth = getDisplayWidth(fullChar);
136
154
 
137
- for (const char of str) {
138
- const charWidth = getDisplayWidth(char);
139
155
  if (accumWidth + charWidth > target) {
140
156
  break;
141
157
  }
142
- accumulated += char;
158
+ accumulated += fullChar;
143
159
  accumWidth += charWidth;
160
+ i += charLen;
144
161
  }
145
162
 
146
- return accumulated + ellipsis;
163
+ // Invariant 11: Guard against trailing open brackets right before ellipsis (e.g. "Fix auth (…" -> "Fix auth…")
164
+ accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, '');
165
+
166
+ return accumulated + ellipsis + (str.includes('\x1b') ? '\x1b[0m' : '');
147
167
  }
148
168
 
149
169
  /**
@@ -384,16 +404,38 @@ export async function openAnchorDashboard(
384
404
  displayOptions.push(label);
385
405
  }
386
406
 
387
- const selected = await ctx.ui.select('⌖ Anchors (enter to complete):', displayOptions);
407
+ const selected = await ctx.ui.select('⌖ Anchors (select to inspect or settle):', displayOptions);
388
408
  if (!selected) return;
389
409
 
390
410
  const anchor = optionMap.get(selected);
391
411
  if (!anchor) return;
392
412
 
393
- store.settle(anchor.id, { settledBy: 'manual-command' });
394
- const successMsg = anchor.recurrence === 'daily'
395
- ? `⌖ Completed for today: "${anchor.title}" (resets tomorrow)`
396
- : `⌖ Settled: "${anchor.title}"`;
397
- ctx.ui.notify(successMsg, 'info');
398
- updateAnchorStatusBar(ctx, store);
413
+ const action = await ctx.ui.select(
414
+ `⌖ Anchor #${anchor.id}: "${anchor.title}"`,
415
+ [
416
+ '✓ Settle (Mark completed and archive)',
417
+ 'ℹ View details & associated files',
418
+ '↩ Cancel'
419
+ ]
420
+ );
421
+ if (!action || action.startsWith('↩')) return;
422
+
423
+ if (action.startsWith('✓')) {
424
+ store.settle(anchor.id, { settledBy: 'manual-command' });
425
+ const successMsg = anchor.recurrence === 'daily'
426
+ ? `⌖ Completed for today: "${anchor.title}" (resets tomorrow)`
427
+ : `⌖ Settled #${anchor.id}: "${anchor.title}" (/anchor undo to revert)`;
428
+ ctx.ui.notify(successMsg, 'info');
429
+ updateAnchorStatusBar(ctx, store);
430
+ } else if (action.startsWith('ℹ')) {
431
+ const details = [
432
+ `#${anchor.id} [${anchor.priority.toUpperCase()}] ${anchor.title}`,
433
+ anchor.description ? `Description: ${anchor.description}` : null,
434
+ anchor.targetDate ? `Target Date: ${anchor.targetDate} (${formatTargetDate(anchor)})` : null,
435
+ anchor.files && anchor.files.length > 0 ? `Files: ${anchor.files.join(', ')}` : null,
436
+ anchor.verifyCommand ? `Verify Command: ${anchor.verifyCommand}` : null,
437
+ `Created: ${formatCreationTime(anchor.createdAt)} (in ${anchor.project || 'global'})`
438
+ ].filter(Boolean).join('\n');
439
+ ctx.ui.notify(details, 'info');
440
+ }
399
441
  }