talking-stick 0.2.0 → 0.3.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/README.md +34 -50
- package/dist/cli/install-commands.js +76 -36
- package/dist/cli/output.js +2 -2
- package/dist/cli/registry.js +13 -32
- package/dist/cli/room-commands.js +1 -1
- package/dist/cli/startup-maintenance.js +27 -1
- package/dist/cli.js +2 -2
- package/dist/config.js +2 -2
- package/dist/identity.js +4 -4
- package/dist/index.js +2 -2
- package/dist/install-audit.js +21 -0
- package/dist/install-migration.js +84 -0
- package/dist/install.js +0 -69
- package/dist/update-migration.js +135 -0
- package/docs/plans/2026-05-04-diff-walker-design.md +585 -0
- package/docs/plans/2026-05-05-cli-only-coordination.md +224 -0
- package/docs/plans/out-of-band-signaling-implementation.md +5 -5
- package/docs/receive-consumer-contract.md +8 -6
- package/docs/releases/0.3.0.md +77 -0
- package/docs/talking-stick-plan.md +3 -2
- package/package.json +4 -3
- package/scripts/postinstall-mcp-cleanup.cjs +25 -0
- package/skills/talking-stick/SKILL.md +124 -103
- package/dist/mcp-server.js +0 -244
- package/dist/server.js +0 -3
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
# Diff Walker Design — Live Radiologist UX for Agent Edits
|
|
2
|
+
|
|
3
|
+
**Status:** design draft, pre-implementation. Authors: claude:45688d4d, codex:d4bc2492. Operator: Wojtek.
|
|
4
|
+
|
|
5
|
+
**Inspiration:** [umputun/revdiff](https://github.com/umputun/revdiff) — keyboard-first diff navigation. We extend the model from one-shot review to a live, persistent companion process that watches a workspace while agents work in it.
|
|
6
|
+
|
|
7
|
+
## Goals
|
|
8
|
+
|
|
9
|
+
- A separate operator-facing process (`tt walk`) that displays diffs as agents make them, navigable like a radiologist scrubs a film stack — live follow by default, keys for up/down/left/right, line/block selection, enter to annotate.
|
|
10
|
+
- Annotations route as messages back to the agent who made the change (or, if they are no longer reachable, the current stick holder, falling back to a durable note). Every annotation is persisted before any delivery attempt.
|
|
11
|
+
- Resilient under noisy editor saves, rapid sequential edits, atomic rename writes, and external/manual edits. The watcher captures every stable state it observes, periodically reconciles to recover from dropped filesystem events, never claims a torn read as a real change, and never blocks the agents.
|
|
12
|
+
- Fresh by default. Opening `tt walk` for a folder makes that moment the new baseline and permanently scrubs prior walker-local history for that folder unless the operator explicitly asks to resume.
|
|
13
|
+
- Harness-neutral. v1 must work uniformly whether the agent is Claude Code, Codex CLI, Gemini, OpenCode, or a human at the keyboard. No hook integration required.
|
|
14
|
+
- **Scoped to human-readable source code.** The walker is for examining source diffs a human can read. Files with a known source extension get full content tracking and rendered diffs. Files with unknown extensions get hash-only tracking — the operator sees that they changed, but the walker does not store or render their bodies. Files past a hard size ceiling are not tracked at all.
|
|
15
|
+
|
|
16
|
+
## Non-goals (v1)
|
|
17
|
+
|
|
18
|
+
- Hook-driven exact attribution per tool call. Schema leaves room (see §Attribution); v2 enriches.
|
|
19
|
+
- Inline editing of agent code from the walker UI. The walker is read-mostly with annotation as the only mutation.
|
|
20
|
+
- Multi-workspace aggregation. One walker pane = one room. Operator opens multiple panes for multiple rooms.
|
|
21
|
+
- Long-term local diff archives. The default invocation is intentionally destructive for old walker history. Operators who need an archive must request one before the reset.
|
|
22
|
+
- Conflict resolution / merging. We surface what changed; we do not arbitrate.
|
|
23
|
+
- Replacing `git diff` for review of merged work. The walker is for the live coordination phase, not historical review.
|
|
24
|
+
|
|
25
|
+
## UX loop
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
┌──────────────────────────────┬──────────────────────────────────┐
|
|
29
|
+
│ Change feed (left pane) │ Diff body (right pane) │
|
|
30
|
+
│ ▸ src/service.ts +12 −3 │ @@ line 412 │
|
|
31
|
+
│ codex 14:02:11 ↩ │ - throw new Error("not_found") │
|
|
32
|
+
│ src/db.ts +1 −0 │ + throw new TypedError(...) │
|
|
33
|
+
│ codex 14:02:09 │ … │
|
|
34
|
+
│ ▸ tests/x.test.ts +8 −0 │ │
|
|
35
|
+
│ claude 13:58:44 ✎ note │ │
|
|
36
|
+
└──────────────────────────────┴──────────────────────────────────┘
|
|
37
|
+
w/s scroll feed a/d prev-next change space hold pause
|
|
38
|
+
m mark line enter annotate esc live follow
|
|
39
|
+
tab toggle pane / search ? help
|
|
40
|
+
bksp / ^O return to last review position from live follow
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- The feed (left) is reverse-chronological by visible `change_seq`. Active edits appear at the top with a live cursor; resolved-and-quiet items dim.
|
|
44
|
+
- The diff body (right) shows the unified diff for the selected `change_seq`. `m` marks line ranges; `enter` opens the annotation modal pre-targeted at the attributed agent.
|
|
45
|
+
- In follow mode, the selected row automatically advances to each new completed batch after a short settle delay, so the operator can watch complete diffs appear at agent pace without chasing the feed manually.
|
|
46
|
+
- Any deliberate viewer interaction other than `Esc` switches out of passive follow. Navigation, mouse scroll, line mark, and pane toggle enter review mode; search, annotation entry, and help open compose/modal layers over the preserved selection. New changes continue collecting above it.
|
|
47
|
+
- `Esc` exits annotation/search/selection state first; repeated `Esc` returns all the way to follow mode. When not editing text, `Esc` is "show me live again."
|
|
48
|
+
- Review mode automatically returns to follow mode after `follow_idle_timeout_ms` with no key, mouse, or annotation activity. **Any key press the viewer recognizes resets the idle timer** — not just navigation, but search, marking, annotation entry, help, and pane toggle alike. Holding the follow-pause key keeps review mode active; releasing it lets the idle timer resume.
|
|
49
|
+
- When review/compose state returns to live follow, the viewer keeps a single back-step anchor. `Backspace` (or `Ctrl+O` for vim/less muscle memory) restores that last review position. See §Return-to-review below.
|
|
50
|
+
- A status bar shows the current room owner, the watcher's lag (events behind real time), follow/review state, and the snapshot store size.
|
|
51
|
+
|
|
52
|
+
### Follow / review state machine
|
|
53
|
+
|
|
54
|
+
The default viewing behavior is closer to `tail -f` than to a static review list.
|
|
55
|
+
|
|
56
|
+
| State | Entry | Behavior | Exit |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `follow` | initial `tt walk`, repeated `Esc`, idle timeout | after `follow_settle_delay_ms`, select the newest visible change and scroll the diff body to top | navigation, mouse scroll, search, mark, annotation entry, help, pane toggle, hold-pause, or back-step |
|
|
59
|
+
| `review` | navigation, mouse scroll, mark, pane toggle, or back-step | keep current selection stable while new changes accumulate; show a "N new" indicator | repeated `Esc` or idle timeout, saving current selection as `back_anchor` before returning to `follow` |
|
|
60
|
+
| `hold` | hold the pause key, default `Space` | freeze selection and suppress idle return while key is held | key release returns to `review` and restarts idle timer |
|
|
61
|
+
| `compose` | annotation modal, search input, or help | preserve draft text and selection; never auto-follow while text entry is active or modal help is open | `Esc` closes current layer; repeated `Esc` eventually saves current selection as `back_anchor` and returns to `follow` |
|
|
62
|
+
|
|
63
|
+
`follow_settle_delay_ms` defaults to 1500 ms. This avoids dragging the UI through every intermediate save event and lets a completed batch land before the viewer advances. `follow_idle_timeout_ms` defaults to 30000 ms. The idle timer is reset by *any* key press the viewer recognizes and by mouse movement or scroll — not only navigation. The only exception is the hold-pause key, which is itself the explicit "freeze for as long as I'm holding this" gesture.
|
|
64
|
+
|
|
65
|
+
### Return-to-review
|
|
66
|
+
|
|
67
|
+
When the idle timer or repeated `Esc` returns the viewer to live follow, the operator may have been mid-thought on an older `change_seq`. Backspace returns them there.
|
|
68
|
+
|
|
69
|
+
- **Single-slot anchor.** The state machine captures one `back_anchor: change_seq` when it leaves `review` or `compose` for `follow`. It is *not* a multi-step history stack — YAGNI for v1.
|
|
70
|
+
- **Trigger.** `Backspace` (primary) or `Ctrl+O` (alias, for vim/less muscle memory) is active in `follow` mode outside text entry. It jumps the selection to `back_anchor`, consumes the anchor, and re-enters `review` mode with the idle timer restarted.
|
|
71
|
+
- **Inside text entry.** When the annotation modal or search input has focus, `Backspace` is delete-char as expected. The back-step binding is not active inside text entry.
|
|
72
|
+
- **Anchor lifecycle.** Deliberate navigation inside `review` does not update `back_anchor` immediately; the anchor is replaced only when the viewer leaves review/compose for follow. This keeps Backspace meaning "return to where I was before live follow," not local undo.
|
|
73
|
+
- **No anchor case.** If the operator has no saved anchor, or already consumed it, `Backspace` is a no-op and the status bar briefly shows "no back step" — a quieter affordance than a beep.
|
|
74
|
+
|
|
75
|
+
## Storage model
|
|
76
|
+
|
|
77
|
+
The watcher's data lives **outside the repository**.
|
|
78
|
+
|
|
79
|
+
**Default location:** `${TALKING_STICK_DATA_DIR:-$XDG_DATA_HOME/talking-stick}/watch/workspaces/<workspace_key>/` — sqlite at `watch.sqlite`, blobs in `blobs/<sha256-prefix>/<sha256>`, projection cache at `projection.git/`, and a small `manifest.json`. A separate non-resettable registry lives at `${...}/watch/registry.sqlite`.
|
|
80
|
+
|
|
81
|
+
`workspace_key = sha256(realpath(canonical_workspace_path))[:32]`. The manifest stores the canonical path, the active room id, the active watcher session id, and the last reset timestamp. The store is keyed by canonical folder, not by room id, because the operator's mental model is "this current folder starts fresh when I open the walker." If the same folder gets a new Talking Stick room later, the old folder-local walker lint must not survive under an obsolete room id.
|
|
82
|
+
|
|
83
|
+
**Override:** `--store <path>` flag on `tt watch` and `tt walk` for explicit repo-local mode (debugging, ephemeral worktrees). When repo-local mode is used, the implementation must add the store path to `.git/info/exclude` automatically so it never appears in `git status`.
|
|
84
|
+
|
|
85
|
+
**Why outside the repo by default:** snapshot blobs are high-churn and would dirty `git status`, defeat `git clean`, balloon backups, and force every consuming repo to add a `.gitignore` entry. The CAS journal is operator infrastructure, not project artifact.
|
|
86
|
+
|
|
87
|
+
**Why folder-scoped instead of room-scoped:** room ids are coordination epochs, not storage ownership boundaries. A folder can accumulate multiple rooms during planning, implementation, and review. Resetting by canonical folder makes `tt walk` a reliable "start watching from now" command and keeps old room stores from growing forever.
|
|
88
|
+
|
|
89
|
+
### Watch registry (v1)
|
|
90
|
+
|
|
91
|
+
The registry is the only watcher state that survives default resets. It is small, path-indexed, and exists so a destructive session reset can safely delete the per-workspace journal without deleting the writer lease that protects that reset.
|
|
92
|
+
|
|
93
|
+
```sql
|
|
94
|
+
CREATE TABLE watch_workspaces (
|
|
95
|
+
workspace_key TEXT PRIMARY KEY,
|
|
96
|
+
canonical_path TEXT NOT NULL UNIQUE,
|
|
97
|
+
store_path TEXT NOT NULL,
|
|
98
|
+
schema_version INTEGER NOT NULL,
|
|
99
|
+
current_room_id TEXT,
|
|
100
|
+
current_session_id TEXT,
|
|
101
|
+
last_reset_at TEXT
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
CREATE TABLE watcher_leases (
|
|
105
|
+
workspace_key TEXT PRIMARY KEY REFERENCES watch_workspaces(workspace_key),
|
|
106
|
+
holder_id TEXT NOT NULL,
|
|
107
|
+
host_id TEXT,
|
|
108
|
+
pid INTEGER,
|
|
109
|
+
heartbeat_at TEXT NOT NULL,
|
|
110
|
+
lease_expires_at TEXT NOT NULL
|
|
111
|
+
);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The lease lives in `registry.sqlite`, not in `watch.sqlite`. That boundary is load-bearing: the reset path can atomically rename and recreate the entire workspace store while the registry lease remains valid.
|
|
115
|
+
|
|
116
|
+
On startup, the registry must compare `workspace_key` against the stored `canonical_path`. A hash collision or manifest mismatch is a hard error, not a reason to reuse another folder's store.
|
|
117
|
+
|
|
118
|
+
### Session schema (v1)
|
|
119
|
+
|
|
120
|
+
This schema lives in the resettable per-workspace `watch.sqlite`.
|
|
121
|
+
|
|
122
|
+
```sql
|
|
123
|
+
CREATE TABLE watch_session (
|
|
124
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
125
|
+
session_id TEXT NOT NULL, -- uuid for the current fresh baseline
|
|
126
|
+
canonical_path TEXT NOT NULL,
|
|
127
|
+
room_id TEXT NOT NULL,
|
|
128
|
+
started_at TEXT NOT NULL,
|
|
129
|
+
schema_version INTEGER NOT NULL,
|
|
130
|
+
reset_mode TEXT NOT NULL, -- 'fresh' | 'resume'
|
|
131
|
+
baseline_event_seq INTEGER,
|
|
132
|
+
baseline_git_head TEXT
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
CREATE TABLE file_versions (
|
|
136
|
+
version_id TEXT PRIMARY KEY, -- "<sha256>:<size>"
|
|
137
|
+
sha256 TEXT NOT NULL,
|
|
138
|
+
size_bytes INTEGER NOT NULL,
|
|
139
|
+
blob_path TEXT, -- relative to watch dir; NULL until/no body stored
|
|
140
|
+
first_seen_at TEXT NOT NULL
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE TABLE path_heads (
|
|
144
|
+
path TEXT PRIMARY KEY, -- workspace-relative
|
|
145
|
+
version_id TEXT REFERENCES file_versions(version_id),
|
|
146
|
+
class TEXT, -- 'source' | 'opaque' | 'skipped'; NULL when deleted
|
|
147
|
+
exists_now INTEGER NOT NULL,
|
|
148
|
+
updated_at TEXT NOT NULL
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
CREATE TABLE change_batches (
|
|
152
|
+
batch_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
153
|
+
opened_at TEXT NOT NULL,
|
|
154
|
+
closed_at TEXT NOT NULL,
|
|
155
|
+
attributed_to TEXT, -- agent_id at batch open; NULL if room idle
|
|
156
|
+
attribution_kind TEXT NOT NULL, -- 'owner' | 'multi_owner' | 'none'
|
|
157
|
+
room_event_seq_lo INTEGER, -- first room event seq inside batch window
|
|
158
|
+
room_event_seq_hi INTEGER, -- last room event seq inside batch window
|
|
159
|
+
source TEXT NOT NULL DEFAULT 'fs_watch' -- 'fs_watch' | 'reconcile'; v2: 'hook'
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE TABLE file_changes (
|
|
163
|
+
change_seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
164
|
+
batch_id INTEGER NOT NULL REFERENCES change_batches(batch_id),
|
|
165
|
+
path TEXT NOT NULL, -- workspace-relative
|
|
166
|
+
rename_from TEXT, -- non-null if detected rename
|
|
167
|
+
class TEXT NOT NULL, -- render/projection class: 'source' | 'opaque' | 'skipped'
|
|
168
|
+
visible INTEGER NOT NULL DEFAULT 1, -- 0 for internal projection-maintenance rows
|
|
169
|
+
before_version_id TEXT REFERENCES file_versions(version_id), -- NULL on add
|
|
170
|
+
after_version_id TEXT REFERENCES file_versions(version_id), -- NULL on delete
|
|
171
|
+
observed_at TEXT NOT NULL,
|
|
172
|
+
tool_call_id TEXT, -- v2 hook attribution
|
|
173
|
+
harness_event_id TEXT -- v2 hook attribution
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
CREATE TABLE annotations (
|
|
177
|
+
annotation_id TEXT PRIMARY KEY, -- uuid
|
|
178
|
+
change_seq INTEGER NOT NULL REFERENCES file_changes(change_seq),
|
|
179
|
+
before_version_id TEXT, -- snapshot of versions at annotation time
|
|
180
|
+
after_version_id TEXT,
|
|
181
|
+
line_side TEXT NOT NULL DEFAULT 'after', -- 'before' | 'after'
|
|
182
|
+
line_start INTEGER, -- inclusive, in selected side
|
|
183
|
+
line_end INTEGER, -- inclusive
|
|
184
|
+
selected_text TEXT, -- short excerpt for offline review / delivery
|
|
185
|
+
body TEXT NOT NULL,
|
|
186
|
+
author TEXT NOT NULL, -- 'human:<user>' or harness id
|
|
187
|
+
created_at TEXT NOT NULL,
|
|
188
|
+
delivery_status TEXT NOT NULL, -- 'pending' | 'sent' | 'noted' | 'failed'
|
|
189
|
+
delivered_to TEXT, -- agent_id once delivered
|
|
190
|
+
delivery_attempted_at TEXT,
|
|
191
|
+
message_event_seq INTEGER, -- talking-stick event seq if message route
|
|
192
|
+
note_id TEXT -- talking-stick note_id if note route
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
CREATE INDEX idx_file_changes_batch ON file_changes(batch_id);
|
|
196
|
+
CREATE INDEX idx_file_changes_path ON file_changes(path);
|
|
197
|
+
CREATE INDEX idx_file_changes_feed ON file_changes(change_seq) WHERE visible = 1;
|
|
198
|
+
CREATE INDEX idx_path_heads_version ON path_heads(version_id);
|
|
199
|
+
CREATE INDEX idx_annotations_change ON annotations(change_seq);
|
|
200
|
+
CREATE INDEX idx_annotations_pending ON annotations(delivery_status) WHERE delivery_status = 'pending';
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The database represents one live watcher session for the folder. In the default `fresh` mode, startup deletes and recreates `watch.sqlite`, `blobs/`, and `projection.git/` before inserting a new `watch_session` row, so old `change_batches`, `file_changes`, `path_heads`, `file_versions`, and local annotations cannot leak into the new operator view. `reset_mode='resume'` exists only when the operator explicitly opts out of the default wipe.
|
|
204
|
+
|
|
205
|
+
`file_versions` is content-addressed; it does not own classification because classification is path- and scan-context-dependent. The same bytes may be a rendered source diff at `README.md` and an opaque attachment under another path. `file_changes.class` records how this change is reviewed and projected, while `path_heads.class` records the current path state. For adds and modifications, `file_changes.class` is the after-state class. For deletions, it is the previous `path_heads.class`, because the UI still needs to know whether the delete is renderable as a source diff. If content is first observed as opaque and later appears as source, the implementation may backfill `blob_path` for the existing `version_id`.
|
|
206
|
+
|
|
207
|
+
`file_changes.visible=0` exists for internal maintenance only. The main case is a previously projected source path becoming skipped because it crossed `max_track_bytes`: the operator should not see a huge-file diff item, but the projection still needs a tombstone so rebuilds do not keep stale source content.
|
|
208
|
+
|
|
209
|
+
`path_heads` is the fast "what did we last believe this path contained?" table. `file_changes` is the immutable audit trail. This avoids deriving current state by scanning the tail of `file_changes` on every filesystem event, makes deletes/re-adds explicit, and lets `version_id = NULL, class = 'skipped', exists_now = 1` represent "the file exists but is intentionally outside the walker's tracking budget."
|
|
210
|
+
|
|
211
|
+
**Invariant:** deleting the shadow git cache (see §Diff projection) never loses current-session review history. Deleting `watch.sqlite` and `blobs/` does, and that deletion is now the normal default when a fresh walker session starts. Operator-facing: `tt walk` / `tt watch start` resets the folder-local journal; `tt walk --resume` is the explicit "keep the previous session" escape hatch.
|
|
212
|
+
|
|
213
|
+
## File classification
|
|
214
|
+
|
|
215
|
+
Every path the watcher considers falls into one of three buckets:
|
|
216
|
+
|
|
217
|
+
| Class | Trigger | Storage | Feed display | Diff body |
|
|
218
|
+
|---|---|---|---|---|
|
|
219
|
+
| `source` (body stored) | extension in allowlist AND size ≤ `max_blob_bytes` AND content sniff is text | `file_versions` row + blob written; `file_changes.class='source'` | `+12 −3 src/service.ts` | unified diff rendered |
|
|
220
|
+
| `source` (truncated) | extension in allowlist AND size > `max_blob_bytes` | `file_versions` row, `blob_path = NULL`; `file_changes.class='source'` | `~ src/big.sql (12.4 MB → 12.6 MB)` | "file too large to render" |
|
|
221
|
+
| `opaque` | extension NOT in allowlist OR content sniff trips binary detection | `file_versions` row, `blob_path = NULL`; `file_changes.class='opaque'` | `~ assets/logo.png (hash a3f1… → b29c…)` | "binary or unknown file type — hash delta only" |
|
|
222
|
+
| `skipped` | size > `max_track_bytes` (any extension) | no `file_versions`; `path_heads.class='skipped'`, `version_id=NULL`; hidden `file_changes.visible=0` row only when needed to remove prior projected source | not shown as a diff item | — |
|
|
223
|
+
|
|
224
|
+
**Default source extension allowlist** (compiled in, extendable via `source_extensions` config):
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
ts, tsx, js, jsx, mjs, cjs, py, pyi, go, rs, java, kt, kts, scala,
|
|
228
|
+
c, h, cc, cpp, cxx, hh, hpp, hxx, cs, fs, fsx, swift, m, mm,
|
|
229
|
+
rb, erb, php, lua, pl, r, ex, exs, erl, hs, clj, cljs, cljc, ml, mli,
|
|
230
|
+
sh, bash, zsh, fish, ps1,
|
|
231
|
+
html, htm, css, scss, sass, less, vue, svelte, astro,
|
|
232
|
+
json, yaml, yml, toml, ini, conf, xml, env,
|
|
233
|
+
md, mdx, rst, adoc, txt, tex,
|
|
234
|
+
sql, proto, gql, graphql, prisma,
|
|
235
|
+
gitignore, gitattributes, editorconfig, dockerignore, npmrc
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Default source filename allowlist** (no extension, matched by basename):
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
Dockerfile, Containerfile, Makefile, CMakeLists.txt, Rakefile, Gemfile,
|
|
242
|
+
Procfile, Justfile, Vagrantfile, Brewfile, Pipfile, package.json,
|
|
243
|
+
tsconfig.json, jest.config.ts, vite.config.ts, .env, .gitignore
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The operator can extend either list (or replace defaults) in `watch.toml`:
|
|
247
|
+
|
|
248
|
+
```toml
|
|
249
|
+
source_extensions = ["ts", "tsx", "py", "...", "csv"] # add csv if you treat it as source
|
|
250
|
+
source_filenames = ["Dockerfile", "..."]
|
|
251
|
+
source_extensions_extra = ["mdc"] # additive without replacing defaults
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Binary detection.** Even if a path has a source extension, the watcher peeks the first 8 KiB. If the file has a UTF-8/UTF-16 BOM and decodes cleanly, it stays source. Otherwise, any NUL byte in the peek window reclassifies the path as `opaque` for that change. This protects against accidentally rendering a `.json` that turned out to be a binary blob without incorrectly demoting legitimate UTF-16 source files.
|
|
255
|
+
|
|
256
|
+
**Why three classes, not two:**
|
|
257
|
+
|
|
258
|
+
- `opaque` exists so the operator still sees that `assets/logo.png` or `data.parquet` *changed* — useful context when an agent regenerates a build artifact — without forcing the walker to read or render arbitrarily large or binary bodies.
|
|
259
|
+
- `skipped` exists so a 2 GB `node_modules` dump or a generated 500 MB JSON file doesn't make the watcher waste I/O hashing it on every save. We record the current path head so future shrink/delete events do not diff against stale older content; there is no content hash and no feed item. If the path used to be projected source, we also record a hidden projection tombstone so `projection.git` does not retain stale text.
|
|
260
|
+
- `source` is the human-readable middle. The walker's whole reason for existing.
|
|
261
|
+
|
|
262
|
+
## Watcher algorithm
|
|
263
|
+
|
|
264
|
+
The watcher is a long-lived process started by `tt watch start [--room <id>]`, scoped to one room and one canonical workspace. It is independent of the talking-stick MCP server — separate process, separate sqlite file, no shared schema.
|
|
265
|
+
|
|
266
|
+
Only one writer may own a canonical workspace at a time. `tt walk` and `tt watch start` first try to acquire the registry `watcher_leases` row. If a healthy writer exists for the same room, they become readers/subscribers. If a healthy writer exists for another room in the same folder, startup fails with an active-watcher conflict. If the lease is expired and the process is gone, the next starter takes over and follows the session reset contract: fresh baseline by default, or reconciliation against the prior journal only when `--resume` was explicit.
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
on_fs_event(path): # via chokidar/watchman
|
|
270
|
+
if ignored(path): return # .git, watch_dir, .gitignore (re-evaluated)
|
|
271
|
+
add path to dirty_set
|
|
272
|
+
if no batch open: open_batch(now)
|
|
273
|
+
bump batch_close_deadline = now + 150ms
|
|
274
|
+
bump batch_hard_deadline = batch_open + 1000ms
|
|
275
|
+
|
|
276
|
+
every tick (every 50ms):
|
|
277
|
+
if batch open and (now >= batch_close_deadline or now >= batch_hard_deadline):
|
|
278
|
+
close_batch()
|
|
279
|
+
if no batch open and now >= next_reconcile_deadline:
|
|
280
|
+
reconcile_workspace()
|
|
281
|
+
|
|
282
|
+
close_batch:
|
|
283
|
+
snapshot dirty_set plus git_status_delta(); clear dirty_set
|
|
284
|
+
for each path in snapshot:
|
|
285
|
+
scan_one(path) # see below
|
|
286
|
+
write change_batches row with attribution from room state
|
|
287
|
+
write file_changes rows
|
|
288
|
+
update path_heads rows for changed paths
|
|
289
|
+
fsync
|
|
290
|
+
notify subscribers (walker UIs) over local IPC or polling fallback
|
|
291
|
+
|
|
292
|
+
scan_one(path):
|
|
293
|
+
s1 = stat(path) # may not exist (deletion)
|
|
294
|
+
if not exists:
|
|
295
|
+
emit deletion change against last known version
|
|
296
|
+
return
|
|
297
|
+
if s1.size > MAX_TRACK_BYTES:
|
|
298
|
+
# huge file: outside tracking budget. Mark the path head so later
|
|
299
|
+
# deletion/shrink does not diff against stale older content.
|
|
300
|
+
if previous head was projected source:
|
|
301
|
+
record hidden file_change with class='skipped', visible=0
|
|
302
|
+
update path_heads(path, version_id=NULL, class='skipped', exists_now=1)
|
|
303
|
+
log diagnostic ("skipped huge file {path} {size}"); return
|
|
304
|
+
is_source = ext_in_allowlist(path) or basename_in_allowlist(path)
|
|
305
|
+
if is_source and s1.size <= MAX_BLOB_BYTES:
|
|
306
|
+
body = read(path)
|
|
307
|
+
s2 = stat(path)
|
|
308
|
+
if s1.size != s2.size or s1.mtime_ns != s2.mtime_ns:
|
|
309
|
+
retry_as_torn(); return
|
|
310
|
+
if first_8kib(body) contains NUL:
|
|
311
|
+
# source extension but actually binary — downgrade to opaque
|
|
312
|
+
sha = sha256(body)
|
|
313
|
+
record version with blob_path=NULL
|
|
314
|
+
record file_change with class='opaque'
|
|
315
|
+
return
|
|
316
|
+
sha = sha256(body)
|
|
317
|
+
if version (sha,size) not in store: write blob, insert file_versions
|
|
318
|
+
record file_change with class='source'
|
|
319
|
+
return
|
|
320
|
+
# source-but-truncated, or opaque: hash only, no body
|
|
321
|
+
sha = streaming_sha256(path)
|
|
322
|
+
s2 = stat(path)
|
|
323
|
+
if s1.size != s2.size or s1.mtime_ns != s2.mtime_ns:
|
|
324
|
+
retry_as_torn(); return
|
|
325
|
+
cls = 'source' if is_source else 'opaque'
|
|
326
|
+
record version with blob_path=NULL
|
|
327
|
+
record file_change with class=cls against previous version for this path
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Wake-vs-truth.** fs events only mark `dirty_set` and start the deadline. The actual statement of fact (this version replaced that version) comes from the post-quiet-window scan. Editor save patterns that emit weird sequences (atomic rename, `vim` swap dance, multi-write fsync) all collapse into one batch.
|
|
331
|
+
|
|
332
|
+
**Quiet window — adaptive and bounded.** First dirty event starts a batch. Close after 150ms of silence. Hard cap at 1000ms so an actively-generating script doesn't starve the UI. The 150ms default is a config knob; we expect to tune.
|
|
333
|
+
|
|
334
|
+
**Periodic reconciliation.** Filesystem events are a wakeup, not a correctness proof. Every 30s while the room is active, and on watcher takeover/restart, the watcher runs a bounded reconciliation pass using `git status --porcelain -z`, `git ls-files -z`, and `git ls-files -z --others --exclude-standard`. This catches dropped fs events, branch checkouts, generated files that appeared before the watcher was ready, and manual edits from outside the agent harness.
|
|
335
|
+
|
|
336
|
+
**Path discovery.** v1 uses chokidar with the workspace root as the watch root. Tracked files are always eligible even if they match an ignore rule; untracked files are eligible only when `git ls-files --others --exclude-standard` would show them. In non-git workspaces, the watcher falls back to a bounded filesystem walk with `.gitignore`-style excludes where available. The watcher's own `--store` path, the talking-stick data dir, nested watch stores, and `.git/` are unconditionally ignored.
|
|
337
|
+
|
|
338
|
+
**Bootstrapping.** At the beginning of every fresh session, the watcher seeds `file_versions` and `path_heads` from the current tracked + untracked-non-ignored set without emitting thousands of normal feed items. The UI shows a single baseline summary row ("baseline captured: 1,284 files") that can be expanded for debugging. Normal `file_changes` start only after the baseline is complete. A `--show-baseline-changes` debug flag may materialize baseline rows, but it is not the default operator UX.
|
|
339
|
+
|
|
340
|
+
The bootstrap also seeds `projection.git` with one baseline tree containing every current `source` path whose version has a stored blob. That baseline commit has no visible `file_changes` row. Without it, the first real batch cannot produce correct deletes, renames, or modifications against the session starting point.
|
|
341
|
+
|
|
342
|
+
## Attribution model
|
|
343
|
+
|
|
344
|
+
Attribution is observational, not authoritative. The watcher reads room state at batch open and brackets the batch with the room event cursor. It may maintain that cursor through `wait_for_events --target any` or by reading `getLatestEventSeq` / `getRoomEvents` directly; the important point is that attribution is tied to an event-seq window, not just wall-clock timestamps.
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
on batch open:
|
|
348
|
+
sample = get_room_state(room_id)
|
|
349
|
+
attributed_to = sample.owner # may be NULL if room idle
|
|
350
|
+
room_event_seq_lo = current_event_cursor
|
|
351
|
+
on batch close:
|
|
352
|
+
room_event_seq_hi = current_event_cursor
|
|
353
|
+
if owner changed in [lo, hi]:
|
|
354
|
+
attribution_kind = 'multi_owner'
|
|
355
|
+
attributed_to = NULL # ambiguous
|
|
356
|
+
elif attributed_to is None:
|
|
357
|
+
attribution_kind = 'none'
|
|
358
|
+
else:
|
|
359
|
+
attribution_kind = 'owner'
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The walker UI displays:
|
|
363
|
+
- `owner`: "by codex" — single attributed agent
|
|
364
|
+
- `multi_owner`: "during handoff" — visual indicator that ownership changed mid-batch
|
|
365
|
+
- `none`: "no owner" — change happened while room was idle (probably operator or unattributed automation)
|
|
366
|
+
|
|
367
|
+
**v2 schema affordance.** `change_batches.source` and `file_changes.tool_call_id` / `harness_event_id` exist now but are populated only by `fs_watch` in v1. A future hook integration may either enrich watcher-observed rows with hook metadata or write `source = 'hook'` batches that the UI coalesces with watcher rows by `(path, before_version_id, after_version_id)`. It cannot rely on two independent rows sharing the same `change_seq`, because `change_seq` is the primary key.
|
|
368
|
+
|
|
369
|
+
## Diff / projection layer
|
|
370
|
+
|
|
371
|
+
The shadow git cache is **disposable** and **rebuildable**. It exists only to give us free three-way diff and rename detection without reimplementing them.
|
|
372
|
+
|
|
373
|
+
```
|
|
374
|
+
${watch_dir}/projection.git/ # bare git repo, --object-format=sha256
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
On batch close, after the CAS write, the watcher:
|
|
378
|
+
|
|
379
|
+
1. Starts from the prior projected tree.
|
|
380
|
+
2. For source add/modify rows with a stored after-blob, writes that blob into the index.
|
|
381
|
+
3. For source delete rows, removes the path from the index.
|
|
382
|
+
4. For source paths that become opaque, skipped, or source-truncated, removes any prior projected path so stale text cannot survive in later diffs.
|
|
383
|
+
5. Commits with metadata: `author = attributed_to` when it can be converted into a valid git identity, otherwise `author = "watcher"`, message `batch:<batch_id>`.
|
|
384
|
+
|
|
385
|
+
Diff requests from the walker UI are served as `git diff -M --find-renames=85% <prev_tree> <next_tree> -- <path>`. The CAS already has the bodies; git just gives us the algorithm.
|
|
386
|
+
|
|
387
|
+
The projection's tree contains only paths whose current state is source with a stored blob. Source deletes, source-to-opaque transitions, source-to-truncated transitions, and hidden source-to-skipped tombstones remove prior projected paths even when the new state has no blob. Opaque changes and source-truncated changes appear in the feed (with size and hash deltas) but never render as text diffs. Skipped paths never appear as feed items. This means the projection's tree size is bounded by the stored source subset of the workspace, which is typically a small fraction of the total tree on disk — git's similarity heuristic stays fast and rename detection stays meaningful.
|
|
388
|
+
|
|
389
|
+
**If `projection.git` is corrupted or deleted**, the watcher detects on next start, recreates it by replaying `change_batches` in `batch_id` order. No history is lost; only the projection rebuild costs time.
|
|
390
|
+
|
|
391
|
+
**Renames.** Detected by git's similarity heuristic and recorded back into `file_changes.rename_from`. v1 displays the rename in the change feed as `path/old → path/new` with the diff against the most-similar prior version.
|
|
392
|
+
|
|
393
|
+
## Annotation delivery
|
|
394
|
+
|
|
395
|
+
Annotations are the only mutation the walker performs. The path is durable-first, deliver-second.
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
on operator confirms annotation modal:
|
|
399
|
+
insert annotations row, delivery_status = 'pending'
|
|
400
|
+
fsync
|
|
401
|
+
pick recipient:
|
|
402
|
+
if attributed_agent_id is active and owns-or-recently-owned: target = attributed_agent
|
|
403
|
+
else if room.owner is set: target = room.owner
|
|
404
|
+
else: target = None
|
|
405
|
+
if target is set:
|
|
406
|
+
send_message(room_id, to_agent_id=target, body=formatted_annotation)
|
|
407
|
+
update delivery_status = 'sent', message_event_seq, delivered_to
|
|
408
|
+
else:
|
|
409
|
+
add_note(room_id, body=formatted_annotation, turn_id=current_turn_or_null)
|
|
410
|
+
update delivery_status = 'noted', note_id
|
|
411
|
+
on any failure: delivery_status = 'failed'; surface in UI; offer retry
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**Why durable before delivery:** the operator's annotation is real work. If the talking-stick server is down or the recipient flaked, the annotation must still exist locally so we can retry without re-typing. The walker UI shows pending annotations in the feed with a small clock icon.
|
|
415
|
+
|
|
416
|
+
**Annotation format (sent body):**
|
|
417
|
+
|
|
418
|
+
```
|
|
419
|
+
[diff-walker] src/service.ts:412-418 (change #4731, batch #312)
|
|
420
|
+
> - throw new Error("not_found");
|
|
421
|
+
> + throw new TypedError({ code: "not_found", ... });
|
|
422
|
+
This breaks callers in tests/cli.test.ts that match on .message; consider a compat shim.
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
The change/batch IDs let the recipient correlate back to the walker if they want to inspect surrounding state.
|
|
426
|
+
|
|
427
|
+
**Routing fallbacks tightened from §Goals:**
|
|
428
|
+
|
|
429
|
+
1. Attributed agent if `currently active` AND (`is_owner` OR `last owner within 5 minutes`). The "recently owned" window catches the common case where the operator reviews a batch after the agent has already handed off, without paging an agent about stale work from hours ago.
|
|
430
|
+
2. Else current owner via `send_message`.
|
|
431
|
+
3. Else `add_note` (durable). No room broadcast — that loses agent-targeting and pollutes the message log.
|
|
432
|
+
|
|
433
|
+
The annotation modal must allow manual retargeting before send. The automatic chain is a default, not a hidden policy trap.
|
|
434
|
+
|
|
435
|
+
## Lifecycle and cleanup
|
|
436
|
+
|
|
437
|
+
- **Start:** `tt watch start` autospawns when `tt walk` is opened on a workspace/room pair with no live watcher. Operator can also start explicitly with `tt watch start --room <id> --background`.
|
|
438
|
+
- **Default reset:** when a starter becomes the writer for a canonical workspace, it resets that workspace's watcher store before bootstrapping. This is true even if old history exists from a previous room for the same folder.
|
|
439
|
+
- **Attach:** if a healthy watcher already owns the workspace lease for the same room, a new `tt walk` attaches as a reader/subscriber and does not reset anything. Co-walking an active session must not wipe the writer's state. If the live writer is tied to a different room for the same folder, startup fails with an active-watcher conflict instead of mixing attribution windows.
|
|
440
|
+
- **Resume:** `tt walk --resume` or `tt watch start --resume` keeps the prior watcher store and skips the default reset. This flag is explicit because the safe ergonomic default is "show me changes from now."
|
|
441
|
+
- **Stop:** the watcher exits when (a) operator runs `tt watch stop --room <id>`, (b) the room is closed/deleted, (c) all members of the room go inactive for > `idleRoomTtlMs / 4` (configurable). A later plain `tt walk` starts a fresh baseline; a later `tt walk --resume` reopens the stopped session read-only or restarts it.
|
|
442
|
+
- **GC:** `tt watch gc` removes blobs not referenced by any current-session `file_versions` row. It is mostly a repair/debug command because default reset deletes the whole store instead of relying on incremental pruning.
|
|
443
|
+
- **Archive:** `tt watch archive --room <id> --to <path>` or `tt walk --archive <path>` produces a self-contained tarball of `watch.sqlite + blobs/ + projection.git` before any reset. Archiving is opt-in so the default cleanup actually keeps disk use down.
|
|
444
|
+
|
|
445
|
+
## Session reset
|
|
446
|
+
|
|
447
|
+
The reset path is part of the normal startup contract, not a maintenance command the operator has to remember.
|
|
448
|
+
|
|
449
|
+
```
|
|
450
|
+
tt walk / tt watch start:
|
|
451
|
+
resolve canonical workspace path and room id
|
|
452
|
+
workspace_key = sha256(realpath(canonical_workspace_path))[:32]
|
|
453
|
+
acquire registry watcher lease for workspace_key
|
|
454
|
+
|
|
455
|
+
if a healthy writer already holds the lease for the same room:
|
|
456
|
+
attach as reader/subscriber
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
if a healthy writer already holds the lease for another room:
|
|
460
|
+
fail with active-watcher conflict; operator can stop that watcher first
|
|
461
|
+
|
|
462
|
+
if --resume:
|
|
463
|
+
open existing store if present; otherwise create fresh store
|
|
464
|
+
bootstrap if no baseline exists; otherwise reconcile prior journal
|
|
465
|
+
continue as writer
|
|
466
|
+
|
|
467
|
+
if pending annotations exist:
|
|
468
|
+
flush_pending_annotations()
|
|
469
|
+
if any remain pending and not --force:
|
|
470
|
+
fail before deleting local state
|
|
471
|
+
|
|
472
|
+
if --archive <path>:
|
|
473
|
+
tar watch.sqlite + blobs/ + projection.git before deletion
|
|
474
|
+
|
|
475
|
+
atomically rename current store to deleting-<timestamp>
|
|
476
|
+
create empty store at watch/workspaces/<workspace_key>
|
|
477
|
+
initialize schema and manifest
|
|
478
|
+
insert watch_session(reset_mode='fresh')
|
|
479
|
+
update registry current_session_id/current_room_id/last_reset_at
|
|
480
|
+
bootstrap baseline from current filesystem state
|
|
481
|
+
asynchronously remove deleting-<timestamp>
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
**What is scrubbed:** `watch.sqlite`, `blobs/`, `projection.git/`, local `annotations`, `file_versions`, `change_batches`, `file_changes`, `path_heads`, and any transient IPC/read-model state under the workspace watch store.
|
|
485
|
+
|
|
486
|
+
**What is not scrubbed:** Talking Stick room events, sent messages, and notes. Those belong to the coordination substrate. If an annotation was already delivered as a message or note, the walker reset removes only the local copy, not the room audit record.
|
|
487
|
+
|
|
488
|
+
**Pending annotations.** The design keeps the durable-before-delivery invariant for operator-authored annotations. Before a destructive reset, the watcher tries to deliver every `delivery_status='pending'` annotation through the normal routing chain. If the target agent is gone and no owner is present, it writes a Talking Stick note as the final durable sink. Only after the pending queue is empty does the reset delete local annotation rows. `--force` may hard-drop pending local annotations, but the UI must label that as data loss.
|
|
489
|
+
|
|
490
|
+
**Why atomic rename:** deleting a large blob tree can take time and can fail halfway on disk errors. Renaming the old store out of the active path first lets the new session start from a clean directory. If cleanup of `deleting-<timestamp>` fails, a later `tt watch gc --stores` can retry without exposing the old rows to the new walker.
|
|
491
|
+
|
|
492
|
+
## Failure modes
|
|
493
|
+
|
|
494
|
+
| Mode | Detection | Behavior |
|
|
495
|
+
|---|---|---|
|
|
496
|
+
| fs watcher dies | health check + heartbeat from watcher process | walker shows red banner; offers `tt watch restart` |
|
|
497
|
+
| fs event dropped | periodic reconciliation finds path hash mismatch | create normal batch with `source='reconcile'`; UI labels it as recovered |
|
|
498
|
+
| disk full mid-batch | sqlite write fails | watcher exits with diagnostic; partial batch rolled back; UI shows last good batch |
|
|
499
|
+
| sqlite corruption | startup PRAGMA quick_check | plain fresh startup moves old store aside and starts a new baseline; `--resume` refuses unless operator archives or forces a reset |
|
|
500
|
+
| projection.git corrupted | rebuild on detection | rebuild from CAS; no data loss |
|
|
501
|
+
| annotation delivery flake | MCP/CLI error | row stays `pending`; UI offers retry; periodic background retry |
|
|
502
|
+
| pending annotation before reset | reset preflight sees `delivery_status='pending'` | flush to message/note before wipe; fail unless `--force` if any cannot be durably handed off |
|
|
503
|
+
| reset interrupted mid-delete | active path missing or `deleting-<timestamp>` remains | recreate active store from scratch; retry old-store removal through `tt watch gc --stores` |
|
|
504
|
+
| second walker starts during live session | registry lease heartbeat fresh for same room | attach as reader; no reset |
|
|
505
|
+
| walker starts for different room in same folder | registry lease heartbeat fresh for another room | refuse with active-watcher conflict; attribution cannot safely mix rooms |
|
|
506
|
+
| operator annotates while watcher is offline | walker writes pending annotation against last known `change_seq` | watcher on restart processes pending queue and delivers |
|
|
507
|
+
| ambiguous rename | git heuristic uncertain | record as add+delete pair; UI hint "possibly renamed from X" |
|
|
508
|
+
| opaque file change (unknown extension or binary content) | scan_one classifies | record `file_changes.class='opaque'`, `blob_path=NULL`; UI shows hash delta only |
|
|
509
|
+
| source file over `max_blob_bytes` (default 8 MiB) | scan_one classifies | record `file_changes.class='source'`, `blob_path=NULL`; UI shows size delta with "too large to render" |
|
|
510
|
+
| huge file over `max_track_bytes` (default 64 MiB) | scan_one early-out | mark `path_heads.class='skipped'`; write only a hidden projection tombstone if prior source must be removed; logged as skip diagnostic; never appears in feed |
|
|
511
|
+
|
|
512
|
+
## Concrete surface (v1)
|
|
513
|
+
|
|
514
|
+
CLI:
|
|
515
|
+
- `tt watch start [--room <id>] [--store <path>] [--resume] [--archive <path>] [--force]`
|
|
516
|
+
- `tt watch [stop|status|gc|archive] [--room <id>] [--store <path>]`
|
|
517
|
+
- `tt watch gc --stores` — retry removal of abandoned `deleting-<timestamp>` stores and stale registry rows whose processes are gone
|
|
518
|
+
- `tt walk [--room <id>] [--store <path>] [--resume] [--archive <path>] [--force]` — interactive TUI
|
|
519
|
+
|
|
520
|
+
MCP:
|
|
521
|
+
- No new MCP tools in v1. The walker is operator-side; agents do not interact with it directly. v2 may add `mcp__talking-stick__list_recent_diffs` for agents that want to see what just happened.
|
|
522
|
+
|
|
523
|
+
Configuration (env / config file under `~/.config/talking-stick/watch.toml`):
|
|
524
|
+
- `max_blob_bytes` (default `8388608` — 8 MiB; source files larger than this are tracked as `class='source'` with `blob_path=NULL`)
|
|
525
|
+
- `max_render_bytes` (default `1048576` — 1 MiB; larger source blobs are stored but collapsed in the diff body by default)
|
|
526
|
+
- `max_track_bytes` (default `67108864` — 64 MiB; files larger than this are skipped entirely from the feed, with only path-head state and any needed hidden projection tombstone recorded)
|
|
527
|
+
- `source_extensions` (replaces default extension allowlist if present)
|
|
528
|
+
- `source_extensions_extra` (additive — appends to default allowlist)
|
|
529
|
+
- `source_filenames` (replaces default filename allowlist if present)
|
|
530
|
+
- `source_filenames_extra` (additive)
|
|
531
|
+
- `quiet_window_ms` (default `150`)
|
|
532
|
+
- `batch_hard_cap_ms` (default `1000`)
|
|
533
|
+
- `reconcile_interval_ms` (default `30000`)
|
|
534
|
+
- `recent_owner_window_ms` (default `300000`)
|
|
535
|
+
- `idle_watcher_grace_ms` (default `idleRoomTtlMs / 4`)
|
|
536
|
+
- `follow_settle_delay_ms` (default `1500`)
|
|
537
|
+
- `follow_idle_timeout_ms` (default `30000`)
|
|
538
|
+
|
|
539
|
+
## Implementation sequence
|
|
540
|
+
|
|
541
|
+
Build this in slices that can be reviewed and tested independently:
|
|
542
|
+
|
|
543
|
+
1. **Watch store and reset substrate.** Add registry/session sqlite modules, workspace key resolution, schema version checks, manifest validation, registry lease heartbeats, atomic reset, `--resume`, `--archive`, and `gc --stores`. No filesystem watcher yet.
|
|
544
|
+
2. **Scanner and classifier.** Implement git-aware path discovery, ignore handling, torn-read retries, source/opaque/skipped classification, CAS blob writes, and `path_heads` updates. Cover this with deterministic fixture directories before introducing live watcher events.
|
|
545
|
+
3. **Batch journal and reconciliation.** Add dirty-set batching, quiet/hard deadlines, periodic reconciliation, attribution windows, and subscriber notifications. At this point a polling/debug UI can list batches without rendering diffs.
|
|
546
|
+
4. **Projection and diff rendering.** Seed the baseline projection, apply source add/modify/delete rows, rebuild projection from CAS, detect renames, and serve unified diffs. Keep projection disposable; corruption must not poison the journal.
|
|
547
|
+
5. **Annotation delivery.** Add selection metadata, durable-first annotation writes, retry state, routing to attributed/recent owner/current owner/note, and reset preflight flushing of pending annotations.
|
|
548
|
+
6. **TUI walker.** Build the keyboard/mouse feed, diff pane, status bar, follow/review state machine, selection model, annotation modal, manual retargeting, and degraded states for offline watcher, opaque/truncated/skipped changes, and pending delivery.
|
|
549
|
+
|
|
550
|
+
## Test plan
|
|
551
|
+
|
|
552
|
+
- **Reset semantics:** default startup deletes old session rows/blobs/projection for the same canonical folder, preserves only registry metadata, and starts a new baseline. `--resume` keeps prior rows. A live same-room writer causes attach/no wipe; a live different-room writer fails.
|
|
553
|
+
- **Store safety:** workspace-key manifest mismatch hard-fails, reset uses atomic rename, interrupted deletion leaves a recoverable `deleting-<timestamp>` store, and `gc --stores` removes only stores not referenced by live leases.
|
|
554
|
+
- **Classification:** allowlisted text stores blobs, UTF-8/UTF-16 BOM source remains source, binary-looking source downgrades to opaque, unknown extensions hash-only, large source truncates, and huge files update `path_heads` as skipped without feed rows.
|
|
555
|
+
- **Watcher correctness:** rapid saves collapse into bounded batches, torn reads retry instead of recording false versions, atomic rename writes settle to one path, dropped fs events are recovered by reconciliation, and git ignored/untracked rules match `git ls-files` output.
|
|
556
|
+
- **Projection:** baseline projection exists before first visible batch; source adds/modifies/deletes produce correct diffs; source renames are detected; opaque/truncated/skipped changes never remain in the projected tree; hidden skipped tombstones remove prior projected source; projection rebuild from CAS matches the original tree sequence.
|
|
557
|
+
- **Annotation delivery:** annotations persist before delivery, route to attributed/recent/current owner before note fallback, survive delivery failures as retryable pending rows, and reset preflight flushes or blocks before destructive deletion unless `--force` is explicit.
|
|
558
|
+
- **TUI ergonomics:** default follow mode advances to new completed batches after the settle delay; navigation, mouse scroll, marking, pane toggle, search, help, and annotation entry enter review/compose mode; repeated `Esc` returns to live follow; idle review mode returns to live after the timeout; **any recognized key resets the idle timer**; keyboard navigation is stable under live batch inserts; mouse and key selection produce the same line ranges; long lines and tiny terminals remain readable; offline/lag/pending-delivery states are visible without obscuring the diff.
|
|
559
|
+
- **Return-to-review:** leaving review/compose for follow captures the current `change_seq` as `back_anchor`; `Backspace` and `Ctrl+O` in follow mode jump back to `back_anchor` and re-enter `review` mode; `Backspace` inside the annotation modal or search input is delete-char and does not back-step; consuming the anchor leaves a no-op state until the next return-to-follow re-arms it; the no-anchor case shows a quiet "no back step" status hint and does not beep.
|
|
560
|
+
|
|
561
|
+
## v1 / v2 cut
|
|
562
|
+
|
|
563
|
+
**v1:**
|
|
564
|
+
- Watcher process + CAS journal + projection.git
|
|
565
|
+
- TUI walker with live follow mode, feed/diff panes, keyboard nav, line/block annotation
|
|
566
|
+
- Owner-inferred attribution
|
|
567
|
+
- Durable annotation persistence + message/note delivery
|
|
568
|
+
- Folder-scoped default reset, single live writer per canonical workspace
|
|
569
|
+
- Single room per walker pane
|
|
570
|
+
|
|
571
|
+
**v2 candidates:**
|
|
572
|
+
- Hook integration for exact tool-call attribution (Claude Code PostToolUse, Codex equivalent)
|
|
573
|
+
- Multi-room aggregation in one walker pane
|
|
574
|
+
- Inline annotation reply in the walker (when an agent annotates back)
|
|
575
|
+
- Web-based walker for non-terminal contexts
|
|
576
|
+
- Diff replay scrubber ("rewind to batch 287")
|
|
577
|
+
- Cross-workspace project-board view
|
|
578
|
+
|
|
579
|
+
## Decisions and Remaining Questions
|
|
580
|
+
|
|
581
|
+
1. **Watcher implementation language.** Decision for v1: keep it in TypeScript with the existing `tt` package. A sibling Go binary is attractive for fs-watch performance, but it adds packaging, release, and cross-platform install surface before we know this is the bottleneck. Profile first; split only if the Node watcher proves inadequate.
|
|
582
|
+
2. **Walker TUI library.** Decision for v1: prefer a blessed/neo-blessed-style terminal renderer over Ink. The UX needs scroll panes, mouse support, stable diff layout, and low-level keyboard handling more than React component ergonomics. Keep the UI model separated enough that a future web or Ink renderer can reuse state.
|
|
583
|
+
3. **`get_room_state` polling cost.** The watcher needs a near-realtime read of room owner. Polling at batch open is one read per ~150ms-1s — fine. We could subscribe to `wait_for_events` instead, but that's more complex and the savings are tiny.
|
|
584
|
+
4. **Concurrent walkers.** Two operator panes on one room can both write annotations, but only one watcher process owns the registry lease and writes snapshots. Annotation writes still share the same sqlite WAL journal for the active workspace store. Should be fine, but worth a stress test.
|
|
585
|
+
5. **Ignore/reconciliation cost.** Running `git status` / `git ls-files` on every batch is cheap for normal dirty sets, but pathological generated trees can be large. Cache tracked/ignored sets between periodic reconciliations and fall back to path-prefix caches when a batch has thousands of files.
|