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 +46 -28
- package/README_zh.md +54 -32
- package/dist/{chunk-QKBUATPO.js → chunk-B652T7QR.js} +58 -19
- package/dist/extension.cjs +120 -23
- package/dist/extension.js +64 -5
- package/dist/index.cjs +58 -19
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +5 -4
- package/src/context_injector.ts +14 -5
- package/src/extension.ts +68 -6
- package/src/tui.ts +58 -16
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
|
+
<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 (
|
|
237
|
+
npm test # Native Node test runner (26/26 passing)
|
|
238
238
|
```
|
|
239
239
|
|
|
240
240
|
```text
|
|
241
|
-
✔
|
|
242
|
-
✔
|
|
243
|
-
✔
|
|
244
|
-
✔
|
|
245
|
-
✔
|
|
246
|
-
✔
|
|
247
|
-
✔
|
|
248
|
-
✔
|
|
249
|
-
✔
|
|
250
|
-
✔
|
|
251
|
-
✔
|
|
252
|
-
✔
|
|
253
|
-
✔
|
|
254
|
-
✔
|
|
255
|
-
✔
|
|
256
|
-
✔ AnchorStore -
|
|
257
|
-
✔ AnchorStore -
|
|
258
|
-
✔ AnchorStore -
|
|
259
|
-
✔ AnchorStore -
|
|
260
|
-
✔
|
|
261
|
-
✔ AnchorTUI -
|
|
262
|
-
✔ AnchorTUI -
|
|
263
|
-
✔ AnchorTUI -
|
|
264
|
-
✔ AnchorTUI -
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
+
<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
|
|
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
|
-
|
|
|
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 # 全套自动化单元测试 (
|
|
236
|
+
npm test # 全套自动化单元测试 (26/26 全绿通过)
|
|
237
237
|
```
|
|
238
238
|
|
|
239
239
|
```text
|
|
240
|
-
✔
|
|
241
|
-
✔
|
|
242
|
-
✔
|
|
243
|
-
✔
|
|
244
|
-
✔
|
|
245
|
-
✔
|
|
246
|
-
✔
|
|
247
|
-
✔
|
|
248
|
-
✔
|
|
249
|
-
✔
|
|
250
|
-
✔
|
|
251
|
-
✔
|
|
252
|
-
✔
|
|
253
|
-
✔
|
|
254
|
-
✔
|
|
255
|
-
✔ AnchorStore -
|
|
256
|
-
✔ AnchorStore -
|
|
257
|
-
✔ AnchorStore -
|
|
258
|
-
✔ AnchorStore -
|
|
259
|
-
✔
|
|
260
|
-
✔ AnchorTUI -
|
|
261
|
-
✔ AnchorTUI -
|
|
262
|
-
✔ AnchorTUI -
|
|
263
|
-
✔ AnchorTUI -
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1087
|
-
|
|
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
|
|
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
|
-
|
|
1094
|
-
|
|
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 +=
|
|
1114
|
+
accumulated += fullChar;
|
|
1099
1115
|
accumWidth += charWidth;
|
|
1116
|
+
i += charLen;
|
|
1100
1117
|
}
|
|
1101
|
-
|
|
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 (
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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 {
|
package/dist/extension.cjs
CHANGED
|
@@ -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
|
-
|
|
976
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1107
|
-
|
|
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
|
|
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
|
-
|
|
1114
|
-
|
|
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 +=
|
|
1134
|
+
accumulated += fullChar;
|
|
1119
1135
|
accumWidth += charWidth;
|
|
1136
|
+
i += charLen;
|
|
1120
1137
|
}
|
|
1121
|
-
|
|
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 (
|
|
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
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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-
|
|
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(
|
|
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
|
-
|
|
934
|
-
|
|
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
|
|
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
|
-
|
|
941
|
-
|
|
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 +=
|
|
958
|
+
accumulated += fullChar;
|
|
946
959
|
accumWidth += charWidth;
|
|
960
|
+
i += charLen;
|
|
947
961
|
}
|
|
948
|
-
|
|
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 (
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anchor",
|
|
3
|
-
"version": "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/context_injector.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
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 +=
|
|
158
|
+
accumulated += fullChar;
|
|
143
159
|
accumWidth += charWidth;
|
|
160
|
+
i += charLen;
|
|
144
161
|
}
|
|
145
162
|
|
|
146
|
-
|
|
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 (
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
}
|