@getpipher/armory-todo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/assets/hero.svg +180 -0
- package/docs/todo-SPEC.md +161 -0
- package/extensions/todo.ts +203 -0
- package/package.json +56 -0
- package/src/todo-store.ts +252 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RECTOR
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/hero.svg" alt="armory-todo — global, cross-session TODO for pi" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">armory-todo</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
A TODO list that <strong>survives across all your pi sessions</strong> — not just within one.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/@getpipher/armory-todo"><img src="https://img.shields.io/npm/v/@getpipher/armory-todo?color=cb3837&logo=npm" alt="npm"></a>
|
|
13
|
+
<img src="https://img.shields.io/badge/pi-package-34d399" alt="pi-package">
|
|
14
|
+
<img src="https://img.shields.io/badge/license-MIT-22d3ee" alt="MIT">
|
|
15
|
+
<img src="https://img.shields.io/badge/dependencies-0-9aa7a1" alt="no dependencies">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## The problem
|
|
21
|
+
|
|
22
|
+
pi sessions are ephemeral conversation branches. A TODO you tell to session A is invisible to session B unless you manually write it to a notes file *and* remember to read it next time. Every existing pi todo extension (`@juicesharp/rpiv-todo`, `@xynogen/pix-todo`, `@gonrocca/zero-pi-todo`, …) is **conversation-branch-scoped** — they persist via pi's `appendEntry()` and survive compaction + `/reload` *within a single session*. None bridge across separate sessions, and none make a fresh session aware of pending work on its own.
|
|
23
|
+
|
|
24
|
+
`armory-todo` is the other shape: a single disk file that **every** session reads, plus an **auto-injected** `## Open TODOs` block in the system prompt so a fresh session starts already aware.
|
|
25
|
+
|
|
26
|
+
| | survives compaction/reload *within* a session | survives across *separate* sessions | auto-surfaced in every new session |
|
|
27
|
+
|---|:---:|:---:|:---:|
|
|
28
|
+
| branch-scoped todo extensions | ✅ | ❌ | ❌ |
|
|
29
|
+
| **armory-todo** | ✅ | ✅ | ✅ |
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install git:github.com/getpipher/armory-todo # from git
|
|
35
|
+
pi install npm:@getpipher/armory-todo # from npm (scoped)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then restart pi (or `/reload`). Or add to `~/.pi/agent/settings.json`:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{ "packages": ["npm:@getpipher/armory-todo"] }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
**Say it naturally** — the model calls the `todo` tool:
|
|
47
|
+
|
|
48
|
+
> “put this in our TODO: decouple global rules into AGENTS.md”
|
|
49
|
+
> “show me the TODO” → “mark td-… done”
|
|
50
|
+
|
|
51
|
+
**Slash command** for quick human triage:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
/todo list open + in-progress TODOs
|
|
55
|
+
/todo all include done/cancelled
|
|
56
|
+
/todo add <text> quick add (priority: med)
|
|
57
|
+
/todo done <id> mark done
|
|
58
|
+
/todo rm <id> cancel (tombstone)
|
|
59
|
+
/todo clean clear all done
|
|
60
|
+
/todo path show the store file path
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**The `todo` tool** (model-callable):
|
|
64
|
+
|
|
65
|
+
| action | params | effect |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `list` | `statusFilter?`, `projectFilter?`, `tagFilter?` | matching TODOs (default: open + in_progress) |
|
|
68
|
+
| `add` | `text`, `project?`, `tags?`, `priority?`, `source?` | create a TODO |
|
|
69
|
+
| `update` | `id`, `text?`, `priority?`, `status?`, `project?`, `tags?` | edit a TODO |
|
|
70
|
+
| `complete` | `id` | mark done |
|
|
71
|
+
| `delete` | `id` | cancel (tombstone) |
|
|
72
|
+
| `clear` | `status?` (default `done`) | bulk-clear a status |
|
|
73
|
+
|
|
74
|
+
Each TODO carries `id, text, project, tags, priority (low|med|high|critical), status (open|in_progress|done|cancelled), source, createdAt, updatedAt, closedAt`.
|
|
75
|
+
|
|
76
|
+
## How it works
|
|
77
|
+
|
|
78
|
+
- **Disk store** — `~/.pi/agent/todo.json`, atomic `0600` writes, corrupt-file auto-recovery, `version: 1` schema. Not pi session entries, so it outlives any conversation.
|
|
79
|
+
- **`todo` tool** — model CRUD (above).
|
|
80
|
+
- **`/todo` command** — human triage (above).
|
|
81
|
+
- **Auto-inject** — on every `before_agent_start`, a compact `## Open TODOs (N)` block (titles + ids, capped at 15, sorted by priority) is appended to the system prompt, so the agent starts every turn already aware of pending work. Mutations refresh it on the next turn.
|
|
82
|
+
|
|
83
|
+
Full design + decisions: [`docs/todo-SPEC.md`](docs/todo-SPEC.md).
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
| env var | default | purpose |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `TODO_STORE_PATH` | `~/.pi/agent/todo.json` | override the store location (tests / multiple profiles) |
|
|
90
|
+
|
|
91
|
+
Run the store tests: `npm test` (24/24).
|
|
92
|
+
|
|
93
|
+
## Security
|
|
94
|
+
|
|
95
|
+
- Store file is `0600`. Atomic write (temp + rename); a corrupt file is backed up to `todo.json.bad-<ts>` and a fresh store starts — the extension never crashes your session.
|
|
96
|
+
- **Never put secrets in a TODO.** TODO text is injected into the system prompt and therefore reaches your model provider — same rule as `AGENTS.md` / context files.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT.
|
package/assets/hero.svg
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 440" width="1600" height="440" role="img" aria-label="armory — global, cross-session TODO for pi">
|
|
2
|
+
<defs>
|
|
3
|
+
<!-- refined background gradient (deeper, layered) -->
|
|
4
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
5
|
+
<stop offset="0" stop-color="#070a09"/>
|
|
6
|
+
<stop offset="0.55" stop-color="#0b1014"/>
|
|
7
|
+
<stop offset="1" stop-color="#080c0b"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
<!-- accent gradient (emerald → cyan) -->
|
|
10
|
+
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
|
11
|
+
<stop offset="0" stop-color="#34d399"/>
|
|
12
|
+
<stop offset="1" stop-color="#22d3ee"/>
|
|
13
|
+
</linearGradient>
|
|
14
|
+
<!-- logo mark gradient stroke -->
|
|
15
|
+
<linearGradient id="markStroke" x1="0" y1="0" x2="1" y2="1">
|
|
16
|
+
<stop offset="0" stop-color="#34d399"/>
|
|
17
|
+
<stop offset="1" stop-color="#22d3ee"/>
|
|
18
|
+
</linearGradient>
|
|
19
|
+
<!-- soft directional glows -->
|
|
20
|
+
<radialGradient id="glowA" cx="0.20" cy="0.16" r="0.5">
|
|
21
|
+
<stop offset="0" stop-color="#1f6f5c" stop-opacity="0.30"/>
|
|
22
|
+
<stop offset="1" stop-color="#1f6f5c" stop-opacity="0"/>
|
|
23
|
+
</radialGradient>
|
|
24
|
+
<radialGradient id="glowB" cx="0.08" cy="0.94" r="0.5">
|
|
25
|
+
<stop offset="0" stop-color="#0e7490" stop-opacity="0.20"/>
|
|
26
|
+
<stop offset="1" stop-color="#0e7490" stop-opacity="0"/>
|
|
27
|
+
</radialGradient>
|
|
28
|
+
<!-- dot grid -->
|
|
29
|
+
<pattern id="dots" x="0" y="0" width="26" height="26" patternUnits="userSpaceOnUse">
|
|
30
|
+
<circle cx="1.5" cy="1.5" r="1" fill="#16201d" fill-opacity="0.5"/>
|
|
31
|
+
</pattern>
|
|
32
|
+
<!-- fine grain texture (tactile depth, kills banding) -->
|
|
33
|
+
<filter id="grain" x="0" y="0" width="100%" height="100%">
|
|
34
|
+
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" stitchTiles="stitch"/>
|
|
35
|
+
<feColorMatrix type="saturate" values="0"/>
|
|
36
|
+
<feComponentTransfer><feFuncA type="linear" slope="0.05"/></feComponentTransfer>
|
|
37
|
+
</filter>
|
|
38
|
+
<!-- directional drop shadow beneath the card (crisp, cast downward) -->
|
|
39
|
+
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="170%">
|
|
40
|
+
<feGaussianBlur in="SourceAlpha" stdDeviation="14"/>
|
|
41
|
+
<feOffset dy="24" result="off"/>
|
|
42
|
+
<feFlood flood-color="#000000" flood-opacity="0.68"/>
|
|
43
|
+
<feComposite in2="off" operator="in"/>
|
|
44
|
+
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
45
|
+
</filter>
|
|
46
|
+
<!-- soft floor falloff -->
|
|
47
|
+
<radialGradient id="floor" cx="0.5" cy="0.5" r="0.5">
|
|
48
|
+
<stop offset="0" stop-color="#000000" stop-opacity="0.5"/>
|
|
49
|
+
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
|
|
50
|
+
</radialGradient>
|
|
51
|
+
<!-- hard contact shadow gradient (darkest at edge, fading down) -->
|
|
52
|
+
<linearGradient id="contactEdge" x1="0" y1="0" x2="0" y2="1">
|
|
53
|
+
<stop offset="0" stop-color="#000000" stop-opacity="0.8"/>
|
|
54
|
+
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
|
|
55
|
+
</linearGradient>
|
|
56
|
+
</defs>
|
|
57
|
+
|
|
58
|
+
<!-- background layers -->
|
|
59
|
+
<rect width="1600" height="440" fill="url(#bg)"/>
|
|
60
|
+
<rect width="1600" height="440" fill="url(#dots)" opacity="0.5"/>
|
|
61
|
+
<rect width="1600" height="440" fill="url(#glowA)"/>
|
|
62
|
+
<rect width="1600" height="440" fill="url(#glowB)"/>
|
|
63
|
+
<rect width="1600" height="440" fill="url(#grain)" opacity="0.55"/>
|
|
64
|
+
<rect x="0" y="437" width="1600" height="3" fill="#13201c" opacity="0.9"/>
|
|
65
|
+
|
|
66
|
+
<!-- ================= LEFT: logo + wordmark ================= -->
|
|
67
|
+
<g transform="translate(88, 92)">
|
|
68
|
+
<!-- logo mark: bracket vault + stored slots + injection arrow -->
|
|
69
|
+
<g transform="translate(2, -2)">
|
|
70
|
+
<rect x="0" y="0" rx="16" ry="16" width="72" height="72" fill="#0e1714" stroke="url(#markStroke)" stroke-width="2.6"/>
|
|
71
|
+
<!-- top rim light (shared light source with the card) -->
|
|
72
|
+
<line x1="14" y1="3.5" x2="58" y2="3.5" stroke="#3a4a44" stroke-width="1.4" opacity="0.55" stroke-linecap="round"/>
|
|
73
|
+
<!-- stored TODO slots (top one active) -->
|
|
74
|
+
<rect x="14" y="17" width="36" height="7" rx="3.5" fill="#34d399"/>
|
|
75
|
+
<rect x="14" y="30" width="26" height="6" rx="3" fill="#2c3a35"/>
|
|
76
|
+
<rect x="14" y="42" width="31" height="6" rx="3" fill="#2c3a35"/>
|
|
77
|
+
<!-- persistence node: signals cross-session linkage -->
|
|
78
|
+
<circle cx="55" cy="55" r="5" fill="#0e1714" stroke="#22d3ee" stroke-width="1.8"/>
|
|
79
|
+
<circle cx="55" cy="55" r="1.8" fill="#22d3ee"/>
|
|
80
|
+
</g>
|
|
81
|
+
|
|
82
|
+
<!-- wordmark (no tacked-on underline; the green period carries the accent) -->
|
|
83
|
+
<text x="92" y="58" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
|
|
84
|
+
font-size="76" font-weight="800" fill="#eef5f1" letter-spacing="-2.5">armory<tspan fill="#34d399">.</tspan></text>
|
|
85
|
+
|
|
86
|
+
<!-- tagline -->
|
|
87
|
+
<text x="92" y="102" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
|
|
88
|
+
font-size="26" font-weight="600" fill="#d4ddd8">global, cross-session TODO for pi</text>
|
|
89
|
+
<text x="92" y="130" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
|
|
90
|
+
font-size="16.5" fill="#8a9791">persists across all sessions · auto-injected into every prompt</text>
|
|
91
|
+
|
|
92
|
+
<!-- chips row (tightened, brighter borders, icons) -->
|
|
93
|
+
<g transform="translate(92, 152)" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-size="12.5" font-weight="600">
|
|
94
|
+
<!-- pi extension -->
|
|
95
|
+
<g>
|
|
96
|
+
<rect x="0" y="0" rx="13" ry="13" width="120" height="26" fill="#10171a" stroke="#3a4a44" stroke-width="1"/>
|
|
97
|
+
<circle cx="14" cy="13" r="4" fill="#34d399"/>
|
|
98
|
+
<text x="26" y="17.5" fill="#c2cfc9" letter-spacing="0.4">pi extension</text>
|
|
99
|
+
</g>
|
|
100
|
+
<!-- MIT -->
|
|
101
|
+
<g transform="translate(132, 0)">
|
|
102
|
+
<rect x="0" y="0" rx="13" ry="13" width="92" height="26" fill="#10171a" stroke="#3a4a44" stroke-width="1"/>
|
|
103
|
+
<path d="M14,8 L14,18 M11,8 L17,8 M11,18 L17,18" fill="none" stroke="#8a9791" stroke-width="1.4" stroke-linecap="round"/>
|
|
104
|
+
<text x="27" y="17.5" fill="#c2cfc9" letter-spacing="0.4">MIT</text>
|
|
105
|
+
</g>
|
|
106
|
+
<!-- v0.1.0 -->
|
|
107
|
+
<g transform="translate(234, 0)">
|
|
108
|
+
<rect x="0" y="0" rx="13" ry="13" width="98" height="26" fill="#10171a" stroke="#3a4a44" stroke-width="1"/>
|
|
109
|
+
<path d="M14,9 L14,17 M14,9 L11,12 M14,9 L17,12" fill="none" stroke="#8a9791" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
110
|
+
<text x="27" y="17.5" fill="#c2cfc9" letter-spacing="0.4">v0.1.0</text>
|
|
111
|
+
</g>
|
|
112
|
+
<!-- no dependencies -->
|
|
113
|
+
<g transform="translate(342, 0)">
|
|
114
|
+
<rect x="0" y="0" rx="13" ry="13" width="170" height="26" fill="#10171a" stroke="#3a4a44" stroke-width="1"/>
|
|
115
|
+
<rect x="10" y="9" width="8" height="8" rx="1.5" fill="none" stroke="#8a9791" stroke-width="1.4"/>
|
|
116
|
+
<text x="27" y="17.5" fill="#c2cfc9" letter-spacing="0.4">no dependencies</text>
|
|
117
|
+
</g>
|
|
118
|
+
</g>
|
|
119
|
+
</g>
|
|
120
|
+
|
|
121
|
+
<!-- ================= RIGHT: live "Open TODOs" preview card ================= -->
|
|
122
|
+
<g transform="translate(900, 56)">
|
|
123
|
+
<!-- soft floor falloff -->
|
|
124
|
+
<ellipse cx="306" cy="358" rx="290" ry="22" fill="url(#floor)" opacity="0.65"/>
|
|
125
|
+
<!-- directional drop shadow (cast downward, crisper) -->
|
|
126
|
+
<rect x="0" y="0" rx="18" ry="18" width="612" height="320" fill="#000000" opacity="0.6" filter="url(#cardShadow)"/>
|
|
127
|
+
<!-- panel (darker for contrast) -->
|
|
128
|
+
<rect x="0" y="0" rx="18" ry="18" width="612" height="320" fill="#0a1110" stroke="#2c3a35" stroke-width="1.3"/>
|
|
129
|
+
<!-- hard contact shadow hugging the bottom edge -->
|
|
130
|
+
<rect x="22" y="320" width="568" height="12" fill="url(#contactEdge)"/>
|
|
131
|
+
<!-- crisp 3px contact line (the hard touchdown, drawn on top) -->
|
|
132
|
+
<rect x="20" y="317" width="572" height="3" fill="#000000" opacity="0.95"/>
|
|
133
|
+
<!-- top rim light (single light source from above) -->
|
|
134
|
+
<line x1="16" y1="2.5" x2="596" y2="2.5" stroke="#3a4a44" stroke-width="1.2" opacity="0.6" stroke-linecap="round"/>
|
|
135
|
+
|
|
136
|
+
<!-- titlebar -->
|
|
137
|
+
<g transform="translate(20, 22)">
|
|
138
|
+
<circle cx="7" cy="7" r="6" fill="#ff5f57"/>
|
|
139
|
+
<circle cx="25" cy="7" r="6" fill="#febc2e"/>
|
|
140
|
+
<circle cx="43" cy="7" r="6" fill="#28c840"/>
|
|
141
|
+
<text x="572" y="11.5" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12" fill="#e8efea" text-anchor="end">system prompt · injected each turn</text>
|
|
142
|
+
</g>
|
|
143
|
+
<line x1="20" y1="46" x2="592" y2="46" stroke="#1e2a26" stroke-width="1"/>
|
|
144
|
+
|
|
145
|
+
<!-- injected block content -->
|
|
146
|
+
<g transform="translate(28, 68)" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
|
|
147
|
+
<!-- heading -->
|
|
148
|
+
<text x="0" y="14" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="14.5" font-weight="700" fill="#34d399">## Open TODOs (3)</text>
|
|
149
|
+
|
|
150
|
+
<!-- row 1: high -->
|
|
151
|
+
<g transform="translate(0, 40)">
|
|
152
|
+
<rect x="0" y="0" rx="6" ry="6" width="40" height="20" fill="#3a2a0e" stroke="#fbbf24" stroke-width="1"/>
|
|
153
|
+
<text x="20" y="14.5" font-size="11" font-weight="700" fill="#fcd34d" text-anchor="middle" letter-spacing="0.4">high</text>
|
|
154
|
+
<text x="52" y="14.5" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12" fill="#b8c4be">[td-…m6pjxhj]</text>
|
|
155
|
+
<text x="184" y="14.5" font-size="13" fill="#f4f7f4">⏵ Decouple global rules → AGENTS.md</text>
|
|
156
|
+
<rect x="408" y="2" rx="8" ry="8" width="44" height="16" fill="#13202b" stroke="#2b4250"/>
|
|
157
|
+
<text x="430" y="13.5" font-size="10.5" fill="#a8b5af" text-anchor="middle">(pi)</text>
|
|
158
|
+
</g>
|
|
159
|
+
|
|
160
|
+
<!-- row 2: med -->
|
|
161
|
+
<g transform="translate(0, 78)">
|
|
162
|
+
<rect x="0" y="0" rx="6" ry="6" width="34" height="20" fill="#0e1f33" stroke="#60a5fa" stroke-width="1"/>
|
|
163
|
+
<text x="17" y="14.5" font-size="11" font-weight="700" fill="#93c5fd" text-anchor="middle" letter-spacing="0.4">med</text>
|
|
164
|
+
<text x="46" y="14.5" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12" fill="#b8c4be">[td-…mzr0qs9]</text>
|
|
165
|
+
<text x="178" y="14.5" font-size="13" fill="#f4f7f4">Research browser-use vs Chrome MCP</text>
|
|
166
|
+
</g>
|
|
167
|
+
|
|
168
|
+
<!-- row 3: low -->
|
|
169
|
+
<g transform="translate(0, 116)">
|
|
170
|
+
<rect x="0" y="0" rx="6" ry="6" width="32" height="20" fill="#1f252a" stroke="#cbd5e1" stroke-width="1"/>
|
|
171
|
+
<text x="16" y="14.5" font-size="11" font-weight="700" fill="#e2e8f0" text-anchor="middle" letter-spacing="0.4">low</text>
|
|
172
|
+
<text x="44" y="14.5" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12" fill="#b8c4be">[td-…nke3etn]</text>
|
|
173
|
+
<text x="176" y="14.5" font-size="13" fill="#f4f7f4">.claude/ path normalization (skills)</text>
|
|
174
|
+
</g>
|
|
175
|
+
|
|
176
|
+
<!-- footer hint -->
|
|
177
|
+
<text x="0" y="180" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12" fill="#9aa7a1">… +3 more · use <tspan fill="#34d399">todo list</tspan> to see all</text>
|
|
178
|
+
</g>
|
|
179
|
+
</g>
|
|
180
|
+
</svg>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# SPEC — `armory-todo` (global, cross-session TODO for pi)
|
|
2
|
+
|
|
3
|
+
**Repo:** `getpipher/armory` (`~/local-dev/armory`) · **Extension file:** `extensions/todo.ts`
|
|
4
|
+
**Status:** Draft (2026-06-23) — pending RECTOR sign-off on the flagged Decisions.
|
|
5
|
+
**Related:** closes the "cross-session TODO" pain recorded in `~/Documents/secret/claude-strategy/pi/session-handoff-2026-06-23.md` (Next steps #7 is the canonical TODO this spec exists to never lose again).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Problem
|
|
10
|
+
|
|
11
|
+
pi sessions are ephemeral conversation branches. A TODO told to session A is **invisible to session B** unless a human manually writes it to a strategy/handoff file *and* the next session remembers to read it. This happened live this week: the "AGENTS.md decoupling" TODO given in `[pi_setup_2]` was unknown to a later session because it lived only in that session's log + a handoff doc the next session hadn't opened.
|
|
12
|
+
|
|
13
|
+
Existing pi todo extensions (`@juicesharp/rpiv-todo`, `@xynogen/pix-todo`, `@gonrocca/zero-pi-todo`, …) are **conversation-branch-scoped** — they persist via pi's `appendEntry()`/`getBranch()` and survive compaction + `/reload` *within one session*. **None persist across separate sessions**, and none auto-surface open TODOs to a fresh session. (Confirmed by reading their READMEs: *"tasks replay from the conversation branch, not disk."*)
|
|
14
|
+
|
|
15
|
+
## 2. Goal / non-goals
|
|
16
|
+
|
|
17
|
+
**Goal:** a persistent, **cross-session**, **agent-accessible** TODO store so that:
|
|
18
|
+
- any session can write ("put this in our TODO") and it survives forever (on disk),
|
|
19
|
+
- any session can read ("show me the TODO") and get the full global list,
|
|
20
|
+
- **every new session is proactively made aware** of open TODOs without being asked (auto-injected into the system prompt).
|
|
21
|
+
|
|
22
|
+
**Non-goals (v1):**
|
|
23
|
+
- Not a project-manager / Gantt / dependency graph. Flat list with tags + priority is enough.
|
|
24
|
+
- Not a replacement for per-project `ROADMAP.md` / GitHub issues — those stay the source of truth for *project* scope. `armory-todo` is for **cross-cutting, agent-internal** reminders that don't belong to one repo.
|
|
25
|
+
- Not branch-aware replay (that's what existing extensions do; we deliberately use a disk store instead).
|
|
26
|
+
- No TUI overlay widget in v1 (a `/todo` command + system-prompt injection suffices; overlay is a later enhancement).
|
|
27
|
+
|
|
28
|
+
## 3. Architecture
|
|
29
|
+
|
|
30
|
+
A single pi extension, `extensions/todo.ts`, four parts:
|
|
31
|
+
|
|
32
|
+
1. **Disk store** — a JSON file, the single source of truth, read/written by every session. NOT pi session entries.
|
|
33
|
+
2. **`todo` tool** — LLM-callable; CRUD + filtered list. The model uses this when RECTOR says "put this in our TODO" / "show me the TODO" / "mark X done".
|
|
34
|
+
3. **`/todo` slash command** — human-facing triage/view.
|
|
35
|
+
4. **Auto-inject** — on `before_agent_start`, append a compact **"Open TODOs"** block to the system prompt so the agent starts every turn already aware of pending work.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
┌──────────────────────────────────────────────┐
|
|
39
|
+
session A ──▶ │ todo tool ──┐ │
|
|
40
|
+
session B ──▶ │ /todo cmd ──┼──▶ read/write ──▶ todo.json ◀── │ ──▶ before_agent_start
|
|
41
|
+
session C ──▶ │ │ (disk) │ appends "## Open TODOs"
|
|
42
|
+
│ └───────────┘ │ to system prompt
|
|
43
|
+
└──────────────────────────────────────────────┘
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Hooks used (pi extension API, verified):**
|
|
47
|
+
- `pi.on("session_start", …)` — load store into memory cache; log a notify with the open count.
|
|
48
|
+
- `pi.on("before_agent_start", …)` — return `{ systemPrompt: event.systemPrompt + openTodosBlock }` so every agent turn sees the current open list. (Cheap: capped, titles only.)
|
|
49
|
+
- `pi.registerTool({ name: "todo", … })` — model CRUD.
|
|
50
|
+
- `pi.registerCommand("todo", …)` — human view/filter.
|
|
51
|
+
|
|
52
|
+
**Why `before_agent_start` (per-prompt) and not `session_start` (once)?** `before_agent_start` is the documented channel to mutate the system prompt (`event.systemPromptOptions.appendSystemPrompt`), and it re-runs after mutations, so the injected list is always current. `session_start` is used only to warm the in-memory cache + notify. We inject on every prompt but the block is tiny (titles + ids, capped at 15) — acceptable overhead. (Decision point: see §7.)
|
|
53
|
+
|
|
54
|
+
## 4. Data model
|
|
55
|
+
|
|
56
|
+
Store file: **`~/.pi/agent/todo.json`** (Decision 1). Format:
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
{
|
|
60
|
+
"version": 1,
|
|
61
|
+
"updatedAt": "2026-06-23T23:41:16Z",
|
|
62
|
+
"todos": [
|
|
63
|
+
{
|
|
64
|
+
"id": "td-01JEXAMPLE", // ULID-ish, monotonically unique
|
|
65
|
+
"text": "Decouple global rules from CLAUDE.md → per-host AGENTS.md",
|
|
66
|
+
"project": "pi", // optional, free-form tag (e.g. "pi", "sip", "profizo", or "" for global)
|
|
67
|
+
"tags": ["dotfiles","config"], // optional
|
|
68
|
+
"priority": "high", // "low" | "med" | "high" | "critical"
|
|
69
|
+
"status": "open", // "open" | "in_progress" | "done" | "cancelled"
|
|
70
|
+
"source": "pi_setup_2", // free-form origin hint (session name / repo)
|
|
71
|
+
"createdAt": "2026-06-23T10:05:00Z",
|
|
72
|
+
"updatedAt": "2026-06-23T10:05:00Z",
|
|
73
|
+
"closedAt": null
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **Scope:** one global list; `project`/`tags` are filter dimensions (Decision 2). "show me the TODO" returns all; `?project=pi`/`?tag=dotfiles`/`?status=open` filter.
|
|
80
|
+
- IDs: `td-` + a short timestamp-base32 unique id (no dep — generate from `Date.now()` + random).
|
|
81
|
+
- Append-only history is **not** kept in v1 (status transitions overwrite `status`/`updatedAt`). A separate audit log is a non-goal.
|
|
82
|
+
|
|
83
|
+
## 5. Tool API (`todo`)
|
|
84
|
+
|
|
85
|
+
`pi.registerTool({ name: "todo", description: "Global cross-session TODO store. Use when the user says 'put in our TODO' / 'show me the TODO' / 'mark done'.", inputSchema, handler })`.
|
|
86
|
+
|
|
87
|
+
Single tool, `action`-driven (mirrors the rpiv/zero-pi-todo convention so it's familiar):
|
|
88
|
+
|
|
89
|
+
| action | params | returns |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `list` | `status?`, `project?`, `tag?` | matching todos (id, text, priority, status, project, tags) |
|
|
92
|
+
| `add` | `text` (req), `project?`, `tags?`, `priority?`, `source?` | created todo |
|
|
93
|
+
| `update` | `id`, `text?`, `priority?`, `status?`, `project?`, `tags?` | updated todo |
|
|
94
|
+
| `complete` | `id` | sets `status: done`, `closedAt` |
|
|
95
|
+
| `delete` | `id` | sets `status: cancelled` (tombstone, not hard-delete) |
|
|
96
|
+
| `clear` | `status?` (default `done`) | cancels all of that status |
|
|
97
|
+
|
|
98
|
+
Notes:
|
|
99
|
+
- Tool handler **writes through to disk** on every mutation and updates the in-memory cache + re-renders the injected block.
|
|
100
|
+
- `list` with no filter returns **open + in_progress only** by default (the actionable set); pass `status: all` to see done/cancelled too.
|
|
101
|
+
- The model is instructed (in the tool description + injected block) to prefer this tool for TODO-style asks and to include a `source` hint when known.
|
|
102
|
+
|
|
103
|
+
## 6. Slash command (`/todo`)
|
|
104
|
+
|
|
105
|
+
`pi.registerCommand("todo", { … })` — human triage. Subcommands via args:
|
|
106
|
+
|
|
107
|
+
- `/todo` — print open + in_progress, grouped by project.
|
|
108
|
+
- `/todo all` — include done/cancelled.
|
|
109
|
+
- `/todo add <text>` — quick add (priority med, project from cwd-derived guess, tags []).
|
|
110
|
+
- `/todo done <id>` — mark done.
|
|
111
|
+
- `/todo rm <id>` — cancel/tombstone.
|
|
112
|
+
- `/todo clean` — cancel all `done` older than 30 days.
|
|
113
|
+
|
|
114
|
+
Output is a `ctx.ui.notify(...)` (or a message) — a compact, readable list, not an overlay (v1).
|
|
115
|
+
|
|
116
|
+
## 7. Decisions (flagged for RECTOR — baked-in recommendations)
|
|
117
|
+
|
|
118
|
+
1. **Store location** → `~/.pi/agent/todo.json` (agent-local; TODOs aren't secrets per the `~/Documents/secret/` convention). *Alt: `~/Documents/secret/todo.json` for iCloud sync.* **Recommend: `~/.pi/agent/todo.json`.**
|
|
119
|
+
2. **Scope** → one global list with `project`/`tags`/`priority` filter dimensions. **Recommend: global.**
|
|
120
|
+
3. **Auto-inject** → ON: append a capped "## Open TODOs (N)" block (titles + ids + priority, max 15, sorted priority→createdAt) to the system prompt on every `before_agent_start`. **Recommend: ON** (this is the whole point — closes the "next session is blind" gap).
|
|
121
|
+
4. **Name** → extension module `todo` (file `extensions/todo.ts`), tool `todo`, command `/todo`. Identity string for logs/notify: `armory-todo`. **Recommend as stated.**
|
|
122
|
+
5. **Seed import** → provide a **`/todo import`** command that scans the scattered existing TODOs (pi handoff "Next steps" #1–9 + each project `MEMORY.md`'s pending lines) and adds them as `status: open`, `source: "imported"`. **Not auto-run**; RECTOR invokes it once. **Recommend: provide, don't auto-run.**
|
|
123
|
+
|
|
124
|
+
## 8. Edge cases & failure modes
|
|
125
|
+
|
|
126
|
+
- **Concurrent sessions writing the same file** — two pi sessions mutating `todo.json` simultaneously could race. v1 mitigation: read-modify-write under a simple `flock`-style atomic write (write to `todo.json.tmp` + `rename`). Last-write-wins is acceptable for TODOs; an in-memory cache per session means a *different* session's write isn't seen until reload — the `before_agent_start` re-reads disk each prompt, so staleness is bounded to "one prompt behind". **Flag:** if RECTOR multi-rooms constantly, add `flock` (v1.1).
|
|
127
|
+
- **Corrupt / missing file** — on parse failure, back up to `todo.json.bad-<ts>` and start fresh with `{version:1,todos:[]}`; notify the user. Never crash the session.
|
|
128
|
+
- **Schema migration** — `version: 1`; on load, if `version` missing, best-effort migrate or reset.
|
|
129
|
+
- **Unbounded growth** — `done`/`cancelled` tombstones accumulate; `/todo clean` cancels-purges old ones. Injection block caps at 15 (overflow → "… +N more, use `todo list`").
|
|
130
|
+
- **Injection size** — keep the system-prompt block tiny (ids + ≤80-char titles). If >15 open, show top 15 by priority + a count.
|
|
131
|
+
- **No `ctx.ui` (non-interactive run, `pi -p`)** — guard all `ctx.ui.notify`/`confirm` behind `ctx.hasUI`; the tool + store still work headless.
|
|
132
|
+
- **Secrets leak** — TODO `text` is user-provided; it gets injected into the system prompt (sent to the LLM provider). Document this: **don't put secrets in TODOs**. (Matches the existing "don't put secrets in AGENTS.md/context" rule.)
|
|
133
|
+
|
|
134
|
+
## 9. Security / safety
|
|
135
|
+
|
|
136
|
+
- File perms `0600` on `todo.json` (it may contain project-internal references).
|
|
137
|
+
- Atomic write (tmp + rename). Never `JSON.stringify` partial.
|
|
138
|
+
- Validate tool input (text non-empty, priority enum, id exists for update/complete/delete → return a clear error otherwise).
|
|
139
|
+
- No network. No dependency on anything but `node:fs` + the pi extension API. (Mirrors `zero-pi-todo`'s "no dependencies" stance.)
|
|
140
|
+
|
|
141
|
+
## 10. Testing
|
|
142
|
+
|
|
143
|
+
- `tsx` unit tests for the store (add/update/complete/delete/clear, filtering, atomic write, corrupt-file recovery, schema migration).
|
|
144
|
+
- A `--self-test` path: the extension logs its loaded state on `session_start` (open count) so we can eyeball correctness in a real session.
|
|
145
|
+
- Manual gate: in a real pi session — `/todo add test`, restart pi, confirm the new session's system prompt contains the open TODO (verify via a throwaway `before_agent_start` log or by asking the agent "what are my open TODOs?").
|
|
146
|
+
|
|
147
|
+
## 11. Rollout / install
|
|
148
|
+
|
|
149
|
+
- Add `extensions/todo.ts` to `armory`; armory's `pi.extensions: ["./extensions"]` already loads all `.ts` there, so no `package.json` change needed.
|
|
150
|
+
- RECTOR installs locally: `pi install /Users/rector/local-dev/armory` (or the settings.json local-path entry already present for armory).
|
|
151
|
+
- Verify: `pi list` shows armory; `/todo` responds; a TODO added in session A is visible in a fresh session B.
|
|
152
|
+
- Commit to `getpipher/armory` (one commit: `feat(todo): global cross-session TODO extension`). GitLab mirror via existing `mirror-gitlab.yml`.
|
|
153
|
+
|
|
154
|
+
## 12. Open questions for RECTOR
|
|
155
|
+
|
|
156
|
+
- Confirm Decisions 1–5 (defaults above).
|
|
157
|
+
- Should the auto-inject block also list **due-today / critical-only** instead of all open? (v1: all open, capped 15.)
|
|
158
|
+
- Do you want a `/todo focus <id>` that sets one TODO as "in_progress" and pins just that one in the system prompt (less noise)? (v1.1 candidate.)
|
|
159
|
+
|
|
160
|
+
─────────────────────────────────────────────────────────────
|
|
161
|
+
End of SPEC. Awaiting your review + decision confirmations before I implement `extensions/todo.ts`.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* armory-todo — global, cross-session TODO for pi.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the existing pi todo extensions (which are conversation-branch-scoped:
|
|
5
|
+
* they survive compaction/reload *within one session* via appendEntry), this
|
|
6
|
+
* one is backed by a single disk file (~/.pi/agent/todo.json) so a TODO added
|
|
7
|
+
* in session A is visible in any session B. It also auto-injects an "Open
|
|
8
|
+
* TODOs" block into the system prompt on every before_agent_start, so a fresh
|
|
9
|
+
* session is proactively aware of pending work instead of starting blind.
|
|
10
|
+
*
|
|
11
|
+
* Surface: `todo` tool (model CRUD), `/todo` slash command (human triage).
|
|
12
|
+
* See docs/todo-SPEC.md for the design.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
18
|
+
import {
|
|
19
|
+
addTodo,
|
|
20
|
+
completeTodo,
|
|
21
|
+
deleteTodo,
|
|
22
|
+
clearTodos,
|
|
23
|
+
listTodos,
|
|
24
|
+
renderOpenBlock,
|
|
25
|
+
updateTodo,
|
|
26
|
+
getStorePath,
|
|
27
|
+
} from "../src/todo-store";
|
|
28
|
+
|
|
29
|
+
const ACTIONS = ["list", "add", "update", "complete", "delete", "clear"] as const;
|
|
30
|
+
|
|
31
|
+
function fmt(t: ReturnType<typeof listTodos>[number]): string {
|
|
32
|
+
const tag = t.project ? ` (${t.project})` : "";
|
|
33
|
+
const pins = t.tags.length ? ` #${t.tags.join(" #")}` : "";
|
|
34
|
+
return `- [${t.id}] (${t.priority}/${t.status}) ${t.text}${tag}${pins}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function (pi: ExtensionAPI) {
|
|
38
|
+
// Warm + report on session start (every new/resume/fork/reload).
|
|
39
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
40
|
+
try {
|
|
41
|
+
const open = listTodos();
|
|
42
|
+
if (ctx.hasUI) {
|
|
43
|
+
ctx.ui.notify(`armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`, "info");
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// store unavailable — never crash the session
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Auto-inject the open-TODO block into the system prompt every turn so the
|
|
51
|
+
// agent is always aware of pending cross-session work.
|
|
52
|
+
pi.on("before_agent_start", async (event: any) => {
|
|
53
|
+
try {
|
|
54
|
+
const base = (event?.systemPrompt as string | undefined) ?? "";
|
|
55
|
+
const block = renderOpenBlock();
|
|
56
|
+
return { systemPrompt: base + "\n\n" + block };
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Model-callable tool.
|
|
63
|
+
pi.registerTool({
|
|
64
|
+
name: "todo",
|
|
65
|
+
label: "TODO",
|
|
66
|
+
description:
|
|
67
|
+
"Global cross-session TODO store (persists across ALL pi sessions, not just this one). " +
|
|
68
|
+
"Use when the user says 'put this in our TODO', 'show me the TODO', 'mark <id> done', etc. " +
|
|
69
|
+
"Open TODOs are also auto-injected into your context each turn. " +
|
|
70
|
+
"Never put secrets in a TODO — the text reaches the model provider.",
|
|
71
|
+
promptSnippet: "Read/update the global cross-session TODO list",
|
|
72
|
+
promptGuidelines: [
|
|
73
|
+
"Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending'.",
|
|
74
|
+
"Use todo (action:'add', text, project?, tags?, priority?, source?) when the user says 'put this in our TODO'.",
|
|
75
|
+
"Use todo (action:'complete', id) to mark a TODO done, and (action:'update', id, …) to edit one.",
|
|
76
|
+
],
|
|
77
|
+
parameters: Type.Object({
|
|
78
|
+
action: StringEnum(ACTIONS),
|
|
79
|
+
id: Type.Optional(Type.String({ description: "Todo id (for update/complete/delete)" })),
|
|
80
|
+
text: Type.Optional(Type.String({ description: "Todo text (add) or new text (update)" })),
|
|
81
|
+
project: Type.Optional(Type.String({ description: "Project tag, e.g. 'pi', 'sip', or '' for global" })),
|
|
82
|
+
tags: Type.Optional(Type.Array(Type.String())),
|
|
83
|
+
priority: Type.Optional(StringEnum(["low", "med", "high", "critical"] as const)),
|
|
84
|
+
status: Type.Optional(StringEnum(["open", "in_progress", "done", "cancelled"] as const)),
|
|
85
|
+
// list filters
|
|
86
|
+
statusFilter: Type.Optional(StringEnum(["open", "in_progress", "done", "cancelled", "all"] as const)),
|
|
87
|
+
projectFilter: Type.Optional(Type.String()),
|
|
88
|
+
tagFilter: Type.Optional(Type.String()),
|
|
89
|
+
}),
|
|
90
|
+
async execute(_toolCallId, params) {
|
|
91
|
+
try {
|
|
92
|
+
switch (params.action) {
|
|
93
|
+
case "list": {
|
|
94
|
+
const todos = listTodos({
|
|
95
|
+
status: params.statusFilter as any,
|
|
96
|
+
project: params.projectFilter,
|
|
97
|
+
tag: params.tagFilter,
|
|
98
|
+
});
|
|
99
|
+
if (todos.length === 0) {
|
|
100
|
+
return { content: [{ type: "text" as const, text: "No matching TODOs." }] };
|
|
101
|
+
}
|
|
102
|
+
return { content: [{ type: "text" as const, text: todos.map(fmt).join("\n") }] };
|
|
103
|
+
}
|
|
104
|
+
case "add": {
|
|
105
|
+
if (!params.text) {
|
|
106
|
+
return { content: [{ type: "text" as const, text: "Error: `text` is required for add." }] };
|
|
107
|
+
}
|
|
108
|
+
const t = addTodo({
|
|
109
|
+
text: params.text,
|
|
110
|
+
project: params.project,
|
|
111
|
+
tags: params.tags,
|
|
112
|
+
priority: params.priority as any,
|
|
113
|
+
source: params.source as any,
|
|
114
|
+
});
|
|
115
|
+
return { content: [{ type: "text" as const, text: `Added ${t.id}: ${t.text}` }] };
|
|
116
|
+
}
|
|
117
|
+
case "update": {
|
|
118
|
+
if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for update." }] };
|
|
119
|
+
const t = updateTodo(params.id, {
|
|
120
|
+
text: params.text,
|
|
121
|
+
project: params.project,
|
|
122
|
+
tags: params.tags,
|
|
123
|
+
priority: params.priority as any,
|
|
124
|
+
status: params.status as any,
|
|
125
|
+
});
|
|
126
|
+
return { content: [{ type: "text" as const, text: `Updated ${t.id}: ${t.text} [${t.status}]` }] };
|
|
127
|
+
}
|
|
128
|
+
case "complete": {
|
|
129
|
+
if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for complete." }] };
|
|
130
|
+
const t = completeTodo(params.id);
|
|
131
|
+
return { content: [{ type: "text" as const, text: `Completed ${t.id}: ${t.text}` }] };
|
|
132
|
+
}
|
|
133
|
+
case "delete": {
|
|
134
|
+
if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for delete." }] };
|
|
135
|
+
const t = deleteTodo(params.id);
|
|
136
|
+
return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.text}` }] };
|
|
137
|
+
}
|
|
138
|
+
case "clear": {
|
|
139
|
+
const n = clearTodos((params.status as any) ?? "done");
|
|
140
|
+
return { content: [{ type: "text" as const, text: `Cleared ${n} '${params.status ?? "done"}' TODOs.` }] };
|
|
141
|
+
}
|
|
142
|
+
default:
|
|
143
|
+
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }] };
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }] };
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Human slash command: /todo [all|add <text>|done <id>|rm <id>|clean|path]
|
|
152
|
+
pi.registerCommand("todo", {
|
|
153
|
+
description: "Global cross-session TODO list. /todo · /todo all · /todo add <text> · /todo done <id> · /todo rm <id> · /todo clean · /todo path",
|
|
154
|
+
handler: async (args, ctx) => {
|
|
155
|
+
const a = (args ?? "").trim();
|
|
156
|
+
const [sub, ...rest] = a.split(/\s+/);
|
|
157
|
+
try {
|
|
158
|
+
if (sub === "all") {
|
|
159
|
+
const todos = listTodos({ status: "all" });
|
|
160
|
+
const msg = todos.length ? todos.map(fmt).join("\n") : "(no TODOs at all)";
|
|
161
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (sub === "add") {
|
|
165
|
+
const text = rest.join(" ").trim();
|
|
166
|
+
if (!text) { if (ctx.hasUI) ctx.ui.notify("usage: /todo add <text>", "warning"); return; }
|
|
167
|
+
const t = addTodo({ text, source: "slash" });
|
|
168
|
+
if (ctx.hasUI) ctx.ui.notify(`Added ${t.id}: ${t.text}`, "info");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (sub === "done") {
|
|
172
|
+
const id = rest[0];
|
|
173
|
+
if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo done <id>", "warning"); return; }
|
|
174
|
+
const t = completeTodo(id);
|
|
175
|
+
if (ctx.hasUI) ctx.ui.notify(`Completed ${t.id}`, "info");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (sub === "rm") {
|
|
179
|
+
const id = rest[0];
|
|
180
|
+
if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo rm <id>", "warning"); return; }
|
|
181
|
+
const t = deleteTodo(id);
|
|
182
|
+
if (ctx.hasUI) ctx.ui.notify(`Cancelled ${t.id}`, "info");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (sub === "clean") {
|
|
186
|
+
const n = clearTodos("done");
|
|
187
|
+
if (ctx.hasUI) ctx.ui.notify(`Cleared ${n} done TODOs.`, "info");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (sub === "path") {
|
|
191
|
+
if (ctx.hasUI) ctx.ui.notify(`store: ${getStorePath()}`, "info");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// default: list open
|
|
195
|
+
const todos = listTodos();
|
|
196
|
+
const msg = todos.length ? todos.map(fmt).join("\n") : "(no open TODOs)";
|
|
197
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (ctx.hasUI) ctx.ui.notify(`todo error: ${(err as Error).message}`, "warning");
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getpipher/armory-todo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Global, cross-session TODO for pi — persists across all sessions and is auto-injected into every prompt. The disk-backed counterpart to branch-scoped pi todo extensions.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"todo",
|
|
9
|
+
"task",
|
|
10
|
+
"persistent",
|
|
11
|
+
"cross-session"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"author": "RECTOR (https://github.com/rz1989s)",
|
|
16
|
+
"homepage": "https://github.com/getpipher/armory-todo",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/getpipher/armory-todo.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/getpipher/armory-todo/issues"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"extensions",
|
|
26
|
+
"src",
|
|
27
|
+
"assets",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"docs"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./extensions"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node test/todo-store.test.mts"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-ai": "*",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
43
|
+
"typebox": "*"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@earendil-works/pi-ai": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@earendil-works/pi-coding-agent": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"typebox": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Pure, pi-independent TODO store for armory-todo.
|
|
2
|
+
//
|
|
3
|
+
// A global, cross-session TODO list backed by a single JSON file on disk
|
|
4
|
+
// (default ~/.pi/agent/todo.json; override with TODO_STORE_PATH for tests).
|
|
5
|
+
// Deliberately NOT pi session-entries — this survives across all sessions.
|
|
6
|
+
//
|
|
7
|
+
// Kept free of any pi/typebox imports so it can be unit-tested standalone.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PATH = join(homedir(), ".pi", "agent", "todo.json");
|
|
21
|
+
const STORE_PATH = process.env.TODO_STORE_PATH || DEFAULT_PATH;
|
|
22
|
+
|
|
23
|
+
export type Priority = "low" | "med" | "high" | "critical";
|
|
24
|
+
export type Status = "open" | "in_progress" | "done" | "cancelled";
|
|
25
|
+
|
|
26
|
+
const PRIO_ORDER: Record<Priority, number> = { critical: 0, high: 1, med: 2, low: 3 };
|
|
27
|
+
const PRIORITIES: Priority[] = ["low", "med", "high", "critical"];
|
|
28
|
+
const STATUSES: Status[] = ["open", "in_progress", "done", "cancelled"];
|
|
29
|
+
const PRIO_SET = new Set(PRIORITIES);
|
|
30
|
+
const STATUS_SET = new Set(STATUSES);
|
|
31
|
+
|
|
32
|
+
export interface Todo {
|
|
33
|
+
id: string;
|
|
34
|
+
text: string;
|
|
35
|
+
project: string;
|
|
36
|
+
tags: string[];
|
|
37
|
+
priority: Priority;
|
|
38
|
+
status: Status;
|
|
39
|
+
source: string;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
updatedAt: string;
|
|
42
|
+
closedAt: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Store {
|
|
46
|
+
version: 1;
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
todos: Todo[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AddInput {
|
|
52
|
+
text: string;
|
|
53
|
+
project?: string;
|
|
54
|
+
tags?: string[];
|
|
55
|
+
priority?: Priority;
|
|
56
|
+
source?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UpdateInput {
|
|
60
|
+
text?: string;
|
|
61
|
+
project?: string;
|
|
62
|
+
tags?: string[];
|
|
63
|
+
priority?: Priority;
|
|
64
|
+
status?: Status;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ListFilter {
|
|
68
|
+
status?: Status | "all";
|
|
69
|
+
project?: string;
|
|
70
|
+
tag?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class TodoError extends Error {}
|
|
74
|
+
|
|
75
|
+
function now(): string {
|
|
76
|
+
return new Date().toISOString();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Monotonic-ish unique id: td-<base36 ms>-<6 random>. */
|
|
80
|
+
function genId(): string {
|
|
81
|
+
return "td-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emptyStore(): Store {
|
|
85
|
+
return { version: 1, updatedAt: now(), todos: [] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getStorePath(): string {
|
|
89
|
+
return STORE_PATH;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Load the store from disk. On corruption, back up the bad file and start fresh. */
|
|
93
|
+
export function loadStore(): Store {
|
|
94
|
+
if (!existsSync(STORE_PATH)) return emptyStore();
|
|
95
|
+
try {
|
|
96
|
+
const raw = readFileSync(STORE_PATH, "utf8");
|
|
97
|
+
const parsed = JSON.parse(raw) as Store;
|
|
98
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
|
|
99
|
+
throw new Error("invalid store shape");
|
|
100
|
+
}
|
|
101
|
+
if (parsed.version !== 1) {
|
|
102
|
+
// Future: migrate. v1 only — reset on unknown version with backup.
|
|
103
|
+
throw new Error("unsupported store version: " + String(parsed.version));
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
106
|
+
} catch {
|
|
107
|
+
try {
|
|
108
|
+
renameSync(STORE_PATH, `${STORE_PATH}.bad-${Date.now()}`);
|
|
109
|
+
} catch {
|
|
110
|
+
// best-effort backup; swallow
|
|
111
|
+
}
|
|
112
|
+
return emptyStore();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Atomic, 0600 write. */
|
|
117
|
+
export function saveStore(store: Store): void {
|
|
118
|
+
store.updatedAt = now();
|
|
119
|
+
const dir = dirname(STORE_PATH);
|
|
120
|
+
mkdirSync(dir, { recursive: true });
|
|
121
|
+
const tmp = `${STORE_PATH}.tmp`;
|
|
122
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
123
|
+
try {
|
|
124
|
+
chmodSync(tmp, 0o600);
|
|
125
|
+
} catch {
|
|
126
|
+
// some filesystems ignore mode bits; not fatal
|
|
127
|
+
}
|
|
128
|
+
renameSync(tmp, STORE_PATH);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertPriority(p: unknown): asserts p is Priority {
|
|
132
|
+
if (typeof p !== "string" || !PRIO_SET.has(p as Priority)) {
|
|
133
|
+
throw new TodoError(`invalid priority: ${String(p)} (expected ${PRIORITIES.join("|")})`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function assertStatus(s: unknown): asserts s is Status {
|
|
138
|
+
if (typeof s !== "string" || !STATUS_SET.has(s as Status)) {
|
|
139
|
+
throw new TodoError(`invalid status: ${String(s)} (expected ${STATUSES.join("|")})`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function findOrFail(store: Store, id: string): Todo {
|
|
144
|
+
const t = store.todos.find((x) => x.id === id);
|
|
145
|
+
if (!t) throw new TodoError(`no todo with id ${id}`);
|
|
146
|
+
return t;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function addTodo(input: AddInput): Todo {
|
|
150
|
+
const text = (input.text ?? "").trim();
|
|
151
|
+
if (!text) throw new TodoError("text is required");
|
|
152
|
+
if (input.priority) assertPriority(input.priority);
|
|
153
|
+
const store = loadStore();
|
|
154
|
+
const todo: Todo = {
|
|
155
|
+
id: genId(),
|
|
156
|
+
text,
|
|
157
|
+
project: (input.project ?? "").trim(),
|
|
158
|
+
tags: (input.tags ?? []).map((t) => t.trim()).filter(Boolean),
|
|
159
|
+
priority: input.priority ?? "med",
|
|
160
|
+
status: "open",
|
|
161
|
+
source: (input.source ?? "").trim(),
|
|
162
|
+
createdAt: now(),
|
|
163
|
+
updatedAt: now(),
|
|
164
|
+
closedAt: null,
|
|
165
|
+
};
|
|
166
|
+
store.todos.push(todo);
|
|
167
|
+
saveStore(store);
|
|
168
|
+
return todo;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function listTodos(filter: ListFilter = {}): Todo[] {
|
|
172
|
+
const store = loadStore();
|
|
173
|
+
let out = store.todos;
|
|
174
|
+
if (filter.status && filter.status !== "all") {
|
|
175
|
+
assertStatus(filter.status);
|
|
176
|
+
out = out.filter((t) => t.status === filter.status);
|
|
177
|
+
} else if (!filter.status) {
|
|
178
|
+
// default: actionable set only
|
|
179
|
+
out = out.filter((t) => t.status === "open" || t.status === "in_progress");
|
|
180
|
+
}
|
|
181
|
+
if (filter.project) out = out.filter((t) => t.project === filter.project);
|
|
182
|
+
if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
|
|
183
|
+
return out.slice().sort((a, b) => {
|
|
184
|
+
if (a.status !== b.status) {
|
|
185
|
+
// in_progress before open (actionable ordering)
|
|
186
|
+
return a.status === "in_progress" ? -1 : b.status === "in_progress" ? 1 : 0;
|
|
187
|
+
}
|
|
188
|
+
if (PRIO_ORDER[a.priority] !== PRIO_ORDER[b.priority]) {
|
|
189
|
+
return PRIO_ORDER[a.priority] - PRIO_ORDER[b.priority];
|
|
190
|
+
}
|
|
191
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function updateTodo(id: string, patch: UpdateInput): Todo {
|
|
196
|
+
const store = loadStore();
|
|
197
|
+
const todo = findOrFail(store, id);
|
|
198
|
+
if (patch.text !== undefined) {
|
|
199
|
+
const text = patch.text.trim();
|
|
200
|
+
if (!text) throw new TodoError("text must not be empty");
|
|
201
|
+
todo.text = text;
|
|
202
|
+
}
|
|
203
|
+
if (patch.project !== undefined) todo.project = patch.project.trim();
|
|
204
|
+
if (patch.tags !== undefined) todo.tags = patch.tags.map((t) => t.trim()).filter(Boolean);
|
|
205
|
+
if (patch.priority !== undefined) {
|
|
206
|
+
assertPriority(patch.priority);
|
|
207
|
+
todo.priority = patch.priority;
|
|
208
|
+
}
|
|
209
|
+
if (patch.status !== undefined) {
|
|
210
|
+
assertStatus(patch.status);
|
|
211
|
+
const wasOpen = todo.status === "open" || todo.status === "in_progress";
|
|
212
|
+
const nowDone = patch.status === "done" || patch.status === "cancelled";
|
|
213
|
+
todo.status = patch.status;
|
|
214
|
+
if (wasOpen && nowDone) todo.closedAt = now();
|
|
215
|
+
if (!nowDone) todo.closedAt = null;
|
|
216
|
+
}
|
|
217
|
+
todo.updatedAt = now();
|
|
218
|
+
saveStore(store);
|
|
219
|
+
return todo;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function completeTodo(id: string): Todo {
|
|
223
|
+
return updateTodo(id, { status: "done" });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function deleteTodo(id: string): Todo {
|
|
227
|
+
return updateTodo(id, { status: "cancelled" });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function clearTodos(status: Status = "done"): number {
|
|
231
|
+
assertStatus(status);
|
|
232
|
+
const store = loadStore();
|
|
233
|
+
const before = store.todos.length;
|
|
234
|
+
store.todos = store.todos.filter((t) => t.status !== status);
|
|
235
|
+
const removed = before - store.todos.length;
|
|
236
|
+
if (removed > 0) saveStore(store);
|
|
237
|
+
return removed;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Compact markdown summary of open + in_progress TODOs for system-prompt injection. */
|
|
241
|
+
export function renderOpenBlock(max = 15): string {
|
|
242
|
+
const todos = listTodos(); // actionable set, sorted
|
|
243
|
+
if (todos.length === 0) return "## Open TODOs\n(none — no pending cross-session TODOs)\n";
|
|
244
|
+
const shown = todos.slice(0, max);
|
|
245
|
+
const lines = shown.map((t) => {
|
|
246
|
+
const tag = t.project ? ` (${t.project})` : "";
|
|
247
|
+
const pin = t.status === "in_progress" ? " ⏵" : "";
|
|
248
|
+
return `- [${t.id}] (${t.priority})${pin} ${t.text}${tag}`;
|
|
249
|
+
});
|
|
250
|
+
const overflow = todos.length > max ? `\n- … +${todos.length - max} more (use \`todo list\`)` : "";
|
|
251
|
+
return `## Open TODOs (${todos.length})\n${lines.join("\n")}${overflow}\n`;
|
|
252
|
+
}
|