pi-anchor 0.1.1 → 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)
@@ -931,16 +931,16 @@ function makeSafeTaskAnnotation(filePath, anchor) {
931
931
  }
932
932
  return "\n\n" + formatter(`\u2316 anchor context: #${anchor.id} ${anchor.title} (${anchor.priority.toUpperCase()})`);
933
933
  }
934
- function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
934
+ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg, preflightFailures) {
935
935
  let cwd;
936
936
  let now = Date.now();
937
937
  if (typeof cwdOrNow === "number") {
938
938
  now = cwdOrNow;
939
939
  } else if (typeof cwdOrNow === "string") {
940
940
  cwd = cwdOrNow;
941
- if (typeof nowArg === "number") {
942
- now = nowArg;
943
- }
941
+ }
942
+ if (typeof nowArg === "number") {
943
+ now = nowArg;
944
944
  }
945
945
  sweepStore(store, now);
946
946
  const activeAnchors = store.list({ status: "active", cwd });
@@ -968,7 +968,9 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
968
968
  }
969
969
  const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1e3));
970
970
  const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : "";
971
- return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
971
+ const failureSnippet = preflightFailures?.get(a.id);
972
+ const verifyHint = failureSnippet ? ` [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25: ${failureSnippet.replace(/\s+/g, " ").slice(-100)}]` : a.verifyCommand ? ` [verify: ${a.verifyCommand}]` : "";
973
+ return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${verifyHint}${ageHint}${tagHint}${fileHint}`;
972
974
  });
973
975
  return [
974
976
  `<active-anchors count="${activeAnchors.length}">`,
@@ -977,6 +979,7 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
977
979
  ``,
978
980
  `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
979
981
  `- If the user opens the session with a casual greeting or continuation ("hi", "\u5728\u5417", "\u7EE7\u7EED", "\u65E9\u4E0A\u597D"), naturally prioritize items tagged [\u4ECA\u65E5\u805A\u7126\xB7Due Today] in a friendly assistant tone (e.g. "\u55E8\uFF01\u4ECA\u5929\u89C4\u5212\u7684\u3010...\u3011\u73B0\u5728\u5904\u7406\u8FD8\u662F\u770B\u522B\u7684\uFF1F").`,
982
+ `- If an anchor has [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25], its verifyCommand failed on session start. Target the failing assertion directly.`,
980
983
  `- If the user issues a direct, concrete new instruction (e.g. "\u5E2E\u6211\u67E5\u4E0B\u8FD9\u4E2A\u62A5\u9519"), DO NOT interrupt their flow. Focus directly on their instruction.`,
981
984
  `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
982
985
  `</active-anchors>`
@@ -1063,8 +1066,9 @@ function groupAnchorsByQuadrant(anchors, now = Date.now()) {
1063
1066
  }
1064
1067
  return groups;
1065
1068
  }
1069
+ var ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
1066
1070
  function stripAnsi(str) {
1067
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1071
+ return str.replace(ANSI_REGEX, "");
1068
1072
  }
1069
1073
  function getDisplayWidth(str) {
1070
1074
  const clean = stripAnsi(str);
@@ -1083,22 +1087,36 @@ function getDisplayWidth(str) {
1083
1087
  return width;
1084
1088
  }
1085
1089
  function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
1086
- const current = getDisplayWidth(str);
1087
- if (current <= maxWidth) return str;
1090
+ if (maxWidth <= 0) return "";
1091
+ const totalWidth = getDisplayWidth(str);
1092
+ if (totalWidth <= maxWidth) return str;
1088
1093
  const ellipsisWidth = getDisplayWidth(ellipsis);
1089
- const target = maxWidth - ellipsisWidth;
1090
- 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);
1091
1096
  let accumulated = "";
1092
1097
  let accumWidth = 0;
1093
- for (const char of str) {
1094
- 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);
1095
1111
  if (accumWidth + charWidth > target) {
1096
1112
  break;
1097
1113
  }
1098
- accumulated += char;
1114
+ accumulated += fullChar;
1099
1115
  accumWidth += charWidth;
1116
+ i += charLen;
1100
1117
  }
1101
- return accumulated + ellipsis;
1118
+ accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, "");
1119
+ return accumulated + ellipsis + (str.includes("\x1B") ? "\x1B[0m" : "");
1102
1120
  }
1103
1121
  function padToWidth(str, targetWidth) {
1104
1122
  const truncated = truncateToWidth(str, targetWidth);
@@ -1251,14 +1269,35 @@ async function openAnchorDashboard(ctx, store) {
1251
1269
  optionMap.set(label, a);
1252
1270
  displayOptions.push(label);
1253
1271
  }
1254
- 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);
1255
1273
  if (!selected) return;
1256
1274
  const anchor = optionMap.get(selected);
1257
1275
  if (!anchor) return;
1258
- store.settle(anchor.id, { settledBy: "manual-command" });
1259
- const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled: "${anchor.title}"`;
1260
- ctx.ui.notify(successMsg, "info");
1261
- 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
+ }
1262
1301
  }
1263
1302
 
1264
1303
  export {
@@ -965,16 +965,16 @@ function makeSafeTaskAnnotation(filePath, anchor) {
965
965
  }
966
966
  return "\n\n" + formatter(`\u2316 anchor context: #${anchor.id} ${anchor.title} (${anchor.priority.toUpperCase()})`);
967
967
  }
968
- function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
968
+ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg, preflightFailures) {
969
969
  let cwd;
970
970
  let now = Date.now();
971
971
  if (typeof cwdOrNow === "number") {
972
972
  now = cwdOrNow;
973
973
  } else if (typeof cwdOrNow === "string") {
974
974
  cwd = cwdOrNow;
975
- if (typeof nowArg === "number") {
976
- now = nowArg;
977
- }
975
+ }
976
+ if (typeof nowArg === "number") {
977
+ now = nowArg;
978
978
  }
979
979
  sweepStore(store, now);
980
980
  const activeAnchors = store.list({ status: "active", cwd });
@@ -1002,7 +1002,9 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1002
1002
  }
1003
1003
  const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1e3));
1004
1004
  const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : "";
1005
- return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
1005
+ const failureSnippet = preflightFailures?.get(a.id);
1006
+ const verifyHint = failureSnippet ? ` [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25: ${failureSnippet.replace(/\s+/g, " ").slice(-100)}]` : a.verifyCommand ? ` [verify: ${a.verifyCommand}]` : "";
1007
+ return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${verifyHint}${ageHint}${tagHint}${fileHint}`;
1006
1008
  });
1007
1009
  return [
1008
1010
  `<active-anchors count="${activeAnchors.length}">`,
@@ -1011,6 +1013,7 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1011
1013
  ``,
1012
1014
  `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
1013
1015
  `- If the user opens the session with a casual greeting or continuation ("hi", "\u5728\u5417", "\u7EE7\u7EED", "\u65E9\u4E0A\u597D"), naturally prioritize items tagged [\u4ECA\u65E5\u805A\u7126\xB7Due Today] in a friendly assistant tone (e.g. "\u55E8\uFF01\u4ECA\u5929\u89C4\u5212\u7684\u3010...\u3011\u73B0\u5728\u5904\u7406\u8FD8\u662F\u770B\u522B\u7684\uFF1F").`,
1016
+ `- If an anchor has [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25], its verifyCommand failed on session start. Target the failing assertion directly.`,
1014
1017
  `- If the user issues a direct, concrete new instruction (e.g. "\u5E2E\u6211\u67E5\u4E0B\u8FD9\u4E2A\u62A5\u9519"), DO NOT interrupt their flow. Focus directly on their instruction.`,
1015
1018
  `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
1016
1019
  `</active-anchors>`
@@ -1083,8 +1086,9 @@ function groupAnchorsByQuadrant(anchors, now = Date.now()) {
1083
1086
  }
1084
1087
  return groups;
1085
1088
  }
1089
+ var ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
1086
1090
  function stripAnsi(str) {
1087
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1091
+ return str.replace(ANSI_REGEX, "");
1088
1092
  }
1089
1093
  function getDisplayWidth(str) {
1090
1094
  const clean = stripAnsi(str);
@@ -1103,22 +1107,36 @@ function getDisplayWidth(str) {
1103
1107
  return width;
1104
1108
  }
1105
1109
  function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
1106
- const current = getDisplayWidth(str);
1107
- if (current <= maxWidth) return str;
1110
+ if (maxWidth <= 0) return "";
1111
+ const totalWidth = getDisplayWidth(str);
1112
+ if (totalWidth <= maxWidth) return str;
1108
1113
  const ellipsisWidth = getDisplayWidth(ellipsis);
1109
- const target = maxWidth - ellipsisWidth;
1110
- 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);
1111
1116
  let accumulated = "";
1112
1117
  let accumWidth = 0;
1113
- for (const char of str) {
1114
- 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);
1115
1131
  if (accumWidth + charWidth > target) {
1116
1132
  break;
1117
1133
  }
1118
- accumulated += char;
1134
+ accumulated += fullChar;
1119
1135
  accumWidth += charWidth;
1136
+ i += charLen;
1120
1137
  }
1121
- return accumulated + ellipsis;
1138
+ accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, "");
1139
+ return accumulated + ellipsis + (str.includes("\x1B") ? "\x1B[0m" : "");
1122
1140
  }
1123
1141
  function padToWidth(str, targetWidth) {
1124
1142
  const truncated = truncateToWidth(str, targetWidth);
@@ -1265,30 +1283,85 @@ async function openAnchorDashboard(ctx, store) {
1265
1283
  optionMap.set(label, a);
1266
1284
  displayOptions.push(label);
1267
1285
  }
1268
- 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);
1269
1287
  if (!selected) return;
1270
1288
  const anchor = optionMap.get(selected);
1271
1289
  if (!anchor) return;
1272
- store.settle(anchor.id, { settledBy: "manual-command" });
1273
- const successMsg = anchor.recurrence === "daily" ? `\u2316 Completed for today: "${anchor.title}" (resets tomorrow)` : `\u2316 Settled: "${anchor.title}"`;
1274
- ctx.ui.notify(successMsg, "info");
1275
- 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
+ }
1276
1315
  }
1277
1316
 
1278
1317
  // src/extension.ts
1279
1318
  var import_node_child_process2 = require("child_process");
1319
+ var import_node_fs3 = __toESM(require("fs"), 1);
1320
+ var import_node_path5 = __toESM(require("path"), 1);
1280
1321
  var MUTATION_TOOLS2 = /* @__PURE__ */ new Set(["edit", "write", "patch", "apply_diff", "create_file", "modify"]);
1281
1322
  function extension_default(pi) {
1282
1323
  const store = new AnchorStore();
1283
1324
  const observer = new SessionTouchObserver();
1284
1325
  const annotatedThisSession = /* @__PURE__ */ new Set();
1326
+ const preflightFailures = /* @__PURE__ */ new Map();
1285
1327
  pi.on("session_start", async (event, ctx) => {
1286
1328
  observer.clear();
1287
1329
  annotatedThisSession.clear();
1330
+ preflightFailures.clear();
1288
1331
  const sweep = sweepStore(store);
1289
1332
  if (sweep.transitionedToSleeping.length > 0) {
1290
1333
  ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} \u4E2A\u975E\u6D3B\u8DC3\u4EFB\u52A1\u5DF2\u8FDB\u5165\u4F11\u7720`, "info");
1291
1334
  }
1335
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active");
1336
+ for (const a of activeAnchors) {
1337
+ if (a.verifyCommand) {
1338
+ try {
1339
+ const res = runPhysicalVerification(a, ctx.cwd);
1340
+ if (res.success) {
1341
+ store.settle(a.id, {
1342
+ settledBy: "verification-test",
1343
+ summary: `Physical pre-flight test passed: ${a.verifyCommand}`
1344
+ });
1345
+ ctx.ui.notify(`\u2693 \u7269\u7406\u9A8C\u8BC1\u901A\u8FC7\uFF01\u951A\u70B9 #${a.id} ("${a.title}") \u5DF2\u81EA\u52A8\u7ED3\u6848\u5F52\u6863\u3002`, "info");
1346
+ } else {
1347
+ preflightFailures.set(a.id, res.output.slice(-250));
1348
+ }
1349
+ } catch (_) {
1350
+ }
1351
+ }
1352
+ }
1353
+ try {
1354
+ const home = process.env.HOME || process.env.USERPROFILE || "";
1355
+ const pendingPath = import_node_path5.default.join(home, ".anchor", "pending_exit.json");
1356
+ if (import_node_fs3.default.existsSync(pendingPath)) {
1357
+ const pending = JSON.parse(import_node_fs3.default.readFileSync(pendingPath, "utf-8"));
1358
+ if (pending.cwd === ctx.cwd && Date.now() - pending.timestamp < 24 * 60 * 60 * 1e3) {
1359
+ ctx.ui.notify(`\u2316 [Anchor \u9000\u51FA\u5B88\u62A4] \u68C0\u6D4B\u5230\u4E0A\u6B21\u5F02\u5E38\u9000\u51FA\u4E14\u6709 ${pending.modifiedCount} \u4E2A\u672A\u63D0\u4EA4\u6539\u52A8\uFF0C\u53EF\u7528 anchor(action="pin") \u56FA\u5316\u4EFB\u52A1\u3002`, "info");
1360
+ }
1361
+ import_node_fs3.default.unlinkSync(pendingPath);
1362
+ }
1363
+ } catch (_) {
1364
+ }
1292
1365
  updateAnchorStatusBar(ctx, store);
1293
1366
  if (event.reason !== "resume") {
1294
1367
  updateStartupBanner(ctx, store);
@@ -1304,7 +1377,7 @@ function extension_default(pi) {
1304
1377
  const messageTurns = entries.filter((e) => e.type === "message");
1305
1378
  const isColdStart = messageTurns.length <= 1;
1306
1379
  if (isColdStart) {
1307
- const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
1380
+ const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd, void 0, preflightFailures);
1308
1381
  if (contextSnippet) {
1309
1382
  return {
1310
1383
  systemPrompt: `${event.systemPrompt}
@@ -1451,7 +1524,7 @@ Mark as completed and archive?`
1451
1524
  if (args.priority) title += ` ${theme.fg("muted", `[${args.priority}]`)}`;
1452
1525
  }
1453
1526
  return {
1454
- render: () => [title],
1527
+ render: (width) => [truncateToWidth(title, width)],
1455
1528
  invalidate: () => {
1456
1529
  }
1457
1530
  };
@@ -1460,14 +1533,14 @@ Mark as completed and archive?`
1460
1533
  if (result.isError) {
1461
1534
  const errText = result.content?.[0]?.text || "Execution failed";
1462
1535
  return {
1463
- render: () => [theme.fg("error", `\u2717 ${errText}`)],
1536
+ render: (width) => [truncateToWidth(theme.fg("error", `\u2717 ${errText}`), width)],
1464
1537
  invalidate: () => {
1465
1538
  }
1466
1539
  };
1467
1540
  }
1468
1541
  const text = result.content?.[0]?.text || "OK";
1469
1542
  return {
1470
- render: () => [theme.fg("success", `\u2714 ${text}`)],
1543
+ render: (width) => [truncateToWidth(theme.fg("success", `\u2714 ${text}`), width)],
1471
1544
  invalidate: () => {
1472
1545
  }
1473
1546
  };
@@ -1603,4 +1676,28 @@ Mark as completed and archive?`
1603
1676
  updateAnchorStatusBar(ctx, store);
1604
1677
  }
1605
1678
  });
1679
+ pi.on("session_shutdown", async (event, ctx) => {
1680
+ if (event.reason === "quit") {
1681
+ try {
1682
+ const gitStatus = (0, import_node_child_process2.execSync)("git status --porcelain", {
1683
+ cwd: ctx.cwd,
1684
+ encoding: "utf-8",
1685
+ timeout: 2e3,
1686
+ stdio: ["ignore", "pipe", "ignore"]
1687
+ }).trim();
1688
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active");
1689
+ if (gitStatus && activeAnchors.length === 0) {
1690
+ const home = process.env.HOME || process.env.USERPROFILE || "";
1691
+ const pendingPath = import_node_path5.default.join(home, ".anchor", "pending_exit.json");
1692
+ const data = {
1693
+ cwd: ctx.cwd,
1694
+ timestamp: Date.now(),
1695
+ modifiedCount: gitStatus.split("\n").filter(Boolean).length
1696
+ };
1697
+ import_node_fs3.default.writeFileSync(pendingPath, JSON.stringify(data, null, 2), "utf-8");
1698
+ }
1699
+ } catch (_) {
1700
+ }
1701
+ }
1702
+ });
1606
1703
  }
package/dist/extension.js CHANGED
@@ -9,25 +9,60 @@ import {
9
9
  renderColdStartAnchorsContext,
10
10
  runPhysicalVerification,
11
11
  sweepStore,
12
+ truncateToWidth,
12
13
  updateAnchorStatusBar,
13
14
  updateStartupBanner
14
- } from "./chunk-QKBUATPO.js";
15
+ } from "./chunk-B652T7QR.js";
15
16
 
16
17
  // src/extension.ts
17
18
  import { Type } from "@sinclair/typebox";
18
19
  import { execSync } from "child_process";
20
+ import fs from "fs";
21
+ import path from "path";
19
22
  var MUTATION_TOOLS = /* @__PURE__ */ new Set(["edit", "write", "patch", "apply_diff", "create_file", "modify"]);
20
23
  function extension_default(pi) {
21
24
  const store = new AnchorStore();
22
25
  const observer = new SessionTouchObserver();
23
26
  const annotatedThisSession = /* @__PURE__ */ new Set();
27
+ const preflightFailures = /* @__PURE__ */ new Map();
24
28
  pi.on("session_start", async (event, ctx) => {
25
29
  observer.clear();
26
30
  annotatedThisSession.clear();
31
+ preflightFailures.clear();
27
32
  const sweep = sweepStore(store);
28
33
  if (sweep.transitionedToSleeping.length > 0) {
29
34
  ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} \u4E2A\u975E\u6D3B\u8DC3\u4EFB\u52A1\u5DF2\u8FDB\u5165\u4F11\u7720`, "info");
30
35
  }
36
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active");
37
+ for (const a of activeAnchors) {
38
+ if (a.verifyCommand) {
39
+ try {
40
+ const res = runPhysicalVerification(a, ctx.cwd);
41
+ if (res.success) {
42
+ store.settle(a.id, {
43
+ settledBy: "verification-test",
44
+ summary: `Physical pre-flight test passed: ${a.verifyCommand}`
45
+ });
46
+ ctx.ui.notify(`\u2693 \u7269\u7406\u9A8C\u8BC1\u901A\u8FC7\uFF01\u951A\u70B9 #${a.id} ("${a.title}") \u5DF2\u81EA\u52A8\u7ED3\u6848\u5F52\u6863\u3002`, "info");
47
+ } else {
48
+ preflightFailures.set(a.id, res.output.slice(-250));
49
+ }
50
+ } catch (_) {
51
+ }
52
+ }
53
+ }
54
+ try {
55
+ const home = process.env.HOME || process.env.USERPROFILE || "";
56
+ const pendingPath = path.join(home, ".anchor", "pending_exit.json");
57
+ if (fs.existsSync(pendingPath)) {
58
+ const pending = JSON.parse(fs.readFileSync(pendingPath, "utf-8"));
59
+ if (pending.cwd === ctx.cwd && Date.now() - pending.timestamp < 24 * 60 * 60 * 1e3) {
60
+ ctx.ui.notify(`\u2316 [Anchor \u9000\u51FA\u5B88\u62A4] \u68C0\u6D4B\u5230\u4E0A\u6B21\u5F02\u5E38\u9000\u51FA\u4E14\u6709 ${pending.modifiedCount} \u4E2A\u672A\u63D0\u4EA4\u6539\u52A8\uFF0C\u53EF\u7528 anchor(action="pin") \u56FA\u5316\u4EFB\u52A1\u3002`, "info");
61
+ }
62
+ fs.unlinkSync(pendingPath);
63
+ }
64
+ } catch (_) {
65
+ }
31
66
  updateAnchorStatusBar(ctx, store);
32
67
  if (event.reason !== "resume") {
33
68
  updateStartupBanner(ctx, store);
@@ -43,7 +78,7 @@ function extension_default(pi) {
43
78
  const messageTurns = entries.filter((e) => e.type === "message");
44
79
  const isColdStart = messageTurns.length <= 1;
45
80
  if (isColdStart) {
46
- const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
81
+ const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd, void 0, preflightFailures);
47
82
  if (contextSnippet) {
48
83
  return {
49
84
  systemPrompt: `${event.systemPrompt}
@@ -190,7 +225,7 @@ Mark as completed and archive?`
190
225
  if (args.priority) title += ` ${theme.fg("muted", `[${args.priority}]`)}`;
191
226
  }
192
227
  return {
193
- render: () => [title],
228
+ render: (width) => [truncateToWidth(title, width)],
194
229
  invalidate: () => {
195
230
  }
196
231
  };
@@ -199,14 +234,14 @@ Mark as completed and archive?`
199
234
  if (result.isError) {
200
235
  const errText = result.content?.[0]?.text || "Execution failed";
201
236
  return {
202
- render: () => [theme.fg("error", `\u2717 ${errText}`)],
237
+ render: (width) => [truncateToWidth(theme.fg("error", `\u2717 ${errText}`), width)],
203
238
  invalidate: () => {
204
239
  }
205
240
  };
206
241
  }
207
242
  const text = result.content?.[0]?.text || "OK";
208
243
  return {
209
- render: () => [theme.fg("success", `\u2714 ${text}`)],
244
+ render: (width) => [truncateToWidth(theme.fg("success", `\u2714 ${text}`), width)],
210
245
  invalidate: () => {
211
246
  }
212
247
  };
@@ -342,6 +377,30 @@ Mark as completed and archive?`
342
377
  updateAnchorStatusBar(ctx, store);
343
378
  }
344
379
  });
380
+ pi.on("session_shutdown", async (event, ctx) => {
381
+ if (event.reason === "quit") {
382
+ try {
383
+ const gitStatus = execSync("git status --porcelain", {
384
+ cwd: ctx.cwd,
385
+ encoding: "utf-8",
386
+ timeout: 2e3,
387
+ stdio: ["ignore", "pipe", "ignore"]
388
+ }).trim();
389
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active");
390
+ if (gitStatus && activeAnchors.length === 0) {
391
+ const home = process.env.HOME || process.env.USERPROFILE || "";
392
+ const pendingPath = path.join(home, ".anchor", "pending_exit.json");
393
+ const data = {
394
+ cwd: ctx.cwd,
395
+ timestamp: Date.now(),
396
+ modifiedCount: gitStatus.split("\n").filter(Boolean).length
397
+ };
398
+ fs.writeFileSync(pendingPath, JSON.stringify(data, null, 2), "utf-8");
399
+ }
400
+ } catch (_) {
401
+ }
402
+ }
403
+ });
345
404
  }
346
405
  export {
347
406
  extension_default as default
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
@@ -1286,16 +1322,16 @@ function makeSafeTaskAnnotation(filePath, anchor) {
1286
1322
  }
1287
1323
  return "\n\n" + formatter(`\u2316 anchor context: #${anchor.id} ${anchor.title} (${anchor.priority.toUpperCase()})`);
1288
1324
  }
1289
- function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1325
+ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg, preflightFailures) {
1290
1326
  let cwd;
1291
1327
  let now = Date.now();
1292
1328
  if (typeof cwdOrNow === "number") {
1293
1329
  now = cwdOrNow;
1294
1330
  } else if (typeof cwdOrNow === "string") {
1295
1331
  cwd = cwdOrNow;
1296
- if (typeof nowArg === "number") {
1297
- now = nowArg;
1298
- }
1332
+ }
1333
+ if (typeof nowArg === "number") {
1334
+ now = nowArg;
1299
1335
  }
1300
1336
  sweepStore(store, now);
1301
1337
  const activeAnchors = store.list({ status: "active", cwd });
@@ -1323,7 +1359,9 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1323
1359
  }
1324
1360
  const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1e3));
1325
1361
  const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : "";
1326
- return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
1362
+ const failureSnippet = preflightFailures?.get(a.id);
1363
+ const verifyHint = failureSnippet ? ` [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25: ${failureSnippet.replace(/\s+/g, " ").slice(-100)}]` : a.verifyCommand ? ` [verify: ${a.verifyCommand}]` : "";
1364
+ return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${verifyHint}${ageHint}${tagHint}${fileHint}`;
1327
1365
  });
1328
1366
  return [
1329
1367
  `<active-anchors count="${activeAnchors.length}">`,
@@ -1332,6 +1370,7 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1332
1370
  ``,
1333
1371
  `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
1334
1372
  `- If the user opens the session with a casual greeting or continuation ("hi", "\u5728\u5417", "\u7EE7\u7EED", "\u65E9\u4E0A\u597D"), naturally prioritize items tagged [\u4ECA\u65E5\u805A\u7126\xB7Due Today] in a friendly assistant tone (e.g. "\u55E8\uFF01\u4ECA\u5929\u89C4\u5212\u7684\u3010...\u3011\u73B0\u5728\u5904\u7406\u8FD8\u662F\u770B\u522B\u7684\uFF1F").`,
1373
+ `- If an anchor has [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25], its verifyCommand failed on session start. Target the failing assertion directly.`,
1335
1374
  `- If the user issues a direct, concrete new instruction (e.g. "\u5E2E\u6211\u67E5\u4E0B\u8FD9\u4E2A\u62A5\u9519"), DO NOT interrupt their flow. Focus directly on their instruction.`,
1336
1375
  `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
1337
1376
  `</active-anchors>`
package/dist/index.d.cts CHANGED
@@ -397,7 +397,7 @@ declare function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): strin
397
397
  * Only active anchors for the current project context are injected.
398
398
  * From turn 2 onwards, this returns empty string (0 tokens).
399
399
  */
400
- declare function renderColdStartAnchorsContext(store: AnchorStore, cwdOrNow?: string | number, nowArg?: number): string;
400
+ declare function renderColdStartAnchorsContext(store: AnchorStore, cwdOrNow?: string | number, nowArg?: number, preflightFailures?: Map<string, string>): string;
401
401
  /** Legacy alias for backwards compatibility */
402
402
  declare const renderActiveAnchorsContext: typeof renderColdStartAnchorsContext;
403
403
 
package/dist/index.d.ts CHANGED
@@ -397,7 +397,7 @@ declare function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): strin
397
397
  * Only active anchors for the current project context are injected.
398
398
  * From turn 2 onwards, this returns empty string (0 tokens).
399
399
  */
400
- declare function renderColdStartAnchorsContext(store: AnchorStore, cwdOrNow?: string | number, nowArg?: number): string;
400
+ declare function renderColdStartAnchorsContext(store: AnchorStore, cwdOrNow?: string | number, nowArg?: number, preflightFailures?: Map<string, string>): string;
401
401
  /** Legacy alias for backwards compatibility */
402
402
  declare const renderActiveAnchorsContext: typeof renderColdStartAnchorsContext;
403
403
 
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  truncateToWidth,
40
40
  updateAnchorStatusBar,
41
41
  updateStartupBanner
42
- } from "./chunk-QKBUATPO.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.1.1",
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
  }
@@ -89,7 +89,8 @@ export function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): string
89
89
  export function renderColdStartAnchorsContext(
90
90
  store: AnchorStore,
91
91
  cwdOrNow?: string | number,
92
- nowArg?: number
92
+ nowArg?: number,
93
+ preflightFailures?: Map<string, string>
93
94
  ): string {
94
95
  let cwd: string | undefined;
95
96
  let now = Date.now();
@@ -98,9 +99,9 @@ export function renderColdStartAnchorsContext(
98
99
  now = cwdOrNow;
99
100
  } else if (typeof cwdOrNow === 'string') {
100
101
  cwd = cwdOrNow;
101
- if (typeof nowArg === 'number') {
102
- now = nowArg;
103
- }
102
+ }
103
+ if (typeof nowArg === 'number') {
104
+ now = nowArg;
104
105
  }
105
106
 
106
107
  // 1. Run opportunistic sweep to ensure expired anchors don't leak into context
@@ -137,7 +138,14 @@ export function renderColdStartAnchorsContext(
137
138
  const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1000));
138
139
  const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : '';
139
140
 
140
- return `• #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
141
+ const failureSnippet = preflightFailures?.get(a.id);
142
+ const verifyHint = failureSnippet
143
+ ? ` [⚡ 物理验证失败: ${failureSnippet.replace(/\s+/g, ' ').slice(-100)}]`
144
+ : a.verifyCommand
145
+ ? ` [verify: ${a.verifyCommand}]`
146
+ : '';
147
+
148
+ return `• #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${verifyHint}${ageHint}${tagHint}${fileHint}`;
141
149
  });
142
150
 
143
151
  return [
@@ -147,6 +155,7 @@ export function renderColdStartAnchorsContext(
147
155
  ``,
148
156
  `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
149
157
  `- If the user opens the session with a casual greeting or continuation ("hi", "在吗", "继续", "早上好"), naturally prioritize items tagged [今日聚焦·Due Today] in a friendly assistant tone (e.g. "嗨!今天规划的【...】现在处理还是看别的?").`,
158
+ `- If an anchor has [⚡ 物理验证失败], its verifyCommand failed on session start. Target the failing assertion directly.`,
150
159
  `- If the user issues a direct, concrete new instruction (e.g. "帮我查下这个报错"), DO NOT interrupt their flow. Focus directly on their instruction.`,
151
160
  `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
152
161
  `</active-anchors>`
package/src/extension.ts CHANGED
@@ -6,8 +6,9 @@ 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
+ import fs from 'node:fs';
11
12
  import path from 'node:path';
12
13
 
13
14
  const MUTATION_TOOLS = new Set(['edit', 'write', 'patch', 'apply_diff', 'create_file', 'modify']);
@@ -17,17 +18,52 @@ export default function (pi: ExtensionAPI) {
17
18
  const store = new AnchorStore();
18
19
  const observer = new SessionTouchObserver();
19
20
  const annotatedThisSession = new Set<string>();
21
+ const preflightFailures = new Map<string, string>();
20
22
 
21
- // 1. Session start: sweep stale tasks, update TUI status bar, and render startup banner
23
+ // 1. Session start: sweep stale tasks, run physical pre-flight tests, update TUI status bar
22
24
  pi.on('session_start', async (event: any, ctx: ExtensionContext) => {
23
25
  observer.clear();
24
26
  annotatedThisSession.clear();
27
+ preflightFailures.clear();
28
+
25
29
  const sweep = sweepStore(store);
26
30
 
27
31
  if (sweep.transitionedToSleeping.length > 0) {
28
32
  ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} 个非活跃任务已进入休眠`, 'info');
29
33
  }
30
34
 
35
+ // Physical Pre-flight Verification: auto-settle passing tasks or record failing output
36
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
37
+ for (const a of activeAnchors) {
38
+ if (a.verifyCommand) {
39
+ try {
40
+ const res = runPhysicalVerification(a, ctx.cwd);
41
+ if (res.success) {
42
+ store.settle(a.id, {
43
+ settledBy: 'verification-test',
44
+ summary: `Physical pre-flight test passed: ${a.verifyCommand}`
45
+ });
46
+ ctx.ui.notify(`⚓ 物理验证通过!锚点 #${a.id} ("${a.title}") 已自动结案归档。`, 'info');
47
+ } else {
48
+ preflightFailures.set(a.id, res.output.slice(-250));
49
+ }
50
+ } catch (_) {}
51
+ }
52
+ }
53
+
54
+ // Exit Guard notification check
55
+ try {
56
+ const home = process.env.HOME || process.env.USERPROFILE || '';
57
+ const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
58
+ if (fs.existsSync(pendingPath)) {
59
+ const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
60
+ if (pending.cwd === ctx.cwd && Date.now() - pending.timestamp < 24 * 60 * 60 * 1000) {
61
+ ctx.ui.notify(`⌖ [Anchor 退出守护] 检测到上次异常退出且有 ${pending.modifiedCount} 个未提交改动,可用 anchor(action="pin") 固化任务。`, 'info');
62
+ }
63
+ fs.unlinkSync(pendingPath);
64
+ }
65
+ } catch (_) {}
66
+
31
67
  updateAnchorStatusBar(ctx, store);
32
68
 
33
69
  // Only render startup banner on initial startup or new session, not on mid-session resume
@@ -50,7 +86,7 @@ export default function (pi: ExtensionAPI) {
50
86
  const isColdStart = messageTurns.length <= 1;
51
87
 
52
88
  if (isColdStart) {
53
- const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
89
+ const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd, undefined, preflightFailures);
54
90
  if (contextSnippet) {
55
91
  return {
56
92
  systemPrompt: `${event.systemPrompt}\n\n${contextSnippet}`
@@ -231,7 +267,7 @@ export default function (pi: ExtensionAPI) {
231
267
  }
232
268
 
233
269
  return {
234
- render: () => [title],
270
+ render: (width: number) => [truncateToWidth(title, width)],
235
271
  invalidate: () => {}
236
272
  };
237
273
  },
@@ -239,14 +275,14 @@ export default function (pi: ExtensionAPI) {
239
275
  if (result.isError) {
240
276
  const errText = result.content?.[0]?.text || 'Execution failed';
241
277
  return {
242
- render: () => [theme.fg('error', `✗ ${errText}`)],
278
+ render: (width: number) => [truncateToWidth(theme.fg('error', `✗ ${errText}`), width)],
243
279
  invalidate: () => {}
244
280
  };
245
281
  }
246
282
 
247
283
  const text = result.content?.[0]?.text || 'OK';
248
284
  return {
249
- render: () => [theme.fg('success', `✔ ${text}`)],
285
+ render: (width: number) => [truncateToWidth(theme.fg('success', `✔ ${text}`), width)],
250
286
  invalidate: () => {}
251
287
  };
252
288
  },
@@ -398,4 +434,30 @@ export default function (pi: ExtensionAPI) {
398
434
  updateAnchorStatusBar(ctx, store);
399
435
  }
400
436
  });
437
+
438
+ // 7. Exit Guard: detect uncommitted changes on abrupt session shutdown
439
+ pi.on('session_shutdown', async (event: any, ctx: ExtensionContext) => {
440
+ if (event.reason === 'quit') {
441
+ try {
442
+ const gitStatus = execSync('git status --porcelain', {
443
+ cwd: ctx.cwd,
444
+ encoding: 'utf-8',
445
+ timeout: 2000,
446
+ stdio: ['ignore', 'pipe', 'ignore']
447
+ }).trim();
448
+
449
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
450
+ if (gitStatus && activeAnchors.length === 0) {
451
+ const home = process.env.HOME || process.env.USERPROFILE || '';
452
+ const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
453
+ const data = {
454
+ cwd: ctx.cwd,
455
+ timestamp: Date.now(),
456
+ modifiedCount: gitStatus.split('\n').filter(Boolean).length
457
+ };
458
+ fs.writeFileSync(pendingPath, JSON.stringify(data, null, 2), 'utf-8');
459
+ }
460
+ } catch (_) {}
461
+ }
462
+ });
401
463
  }
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
  }