Repository navigation
Wayfinder map: make the scheduled pass real, then build the five issues on it #92
Description
Destination
The five open issues (#31, #42, #48, #49, #52) each carry a decision plus enough implementation detail that an agent can pick the issue up cold and build it without asking a question.
The route there runs through one substrate: a scheduled pass is currently invisible. Nothing in src/ can produce the string scheduled that both history filters already accept, and a daemon pass writes no history at all. Until a pass records itself honestly, three of the five issues cannot be built correctly.
Notes
Domain: File-Organizer-MCP, an MCP server that organizes files on a user's machine, plus a separate file-organizer-watch CLI that runs the same work on a timer.
Vocabulary settled during charting. pass = one organizing sweep over one directory, whoever started it (src/extensions/scheduler/organize-pass.ts). trigger = what started it, the existing source field. pass record = the row in operations.jsonl. run was considered and rejected in favour of pass because that is what the code already says.
Standing preferences from kriday, apply to every ticket here:
- One mechanism, not two. No second code path that duplicates an existing one.
- Desktop notifications only. No URL or webhook field, no phone hooks.
- Notify only when files actually moved. Never on a no-op pass.
- No heartbeat file, no pidfile. btrfs users feel metadata writes, and there is no clean signal anyway.
- New config fields must reuse the existing enum values. Do not add a third
sourcevalue. - MCP tools are the primary surface. CLI exists so an agent can drive and test it.
Skills every session on this map should consult: wayfinder, grilling, domain-modeling, verify-file-organizer. Read AGENTS.md first, it has the blocking rules that apply to every PR here, especially rule 3 on path portability and the docs contract.
Sandbox rule: none of this may touch a real home directory. Test data goes in tests/sandbox/ or an os.tmpdir() mkdtemp. Proof comes from node .opencode/skills/verify-file-organizer/scripts/control-file-organizer.mjs, not from a unit test alone.
When a ticket resolves, record the decision and the implementation detail as a resolution comment on the ticket, close it, and append a one line gist to Decisions so far.
Decisions so far
-
Lock the vocabulary: pass, trigger, pass record: use the word the code already uses, and pin it in
GLOSSARY.mdso the next agent stops guessing. -
Label a pass source: a process cannot detect that cron started it, so the caller asserts it with
once --source scheduled; the default stays manual and no third enum value exists. -
organizer_digest: aggregate
operations.jsonlover a time window on demand (pull model); one row per pass, manifests preserved; shipped asfile_organizer_organizer_digest. -
notify on a moving pass, throttled: desktop only, fires when files actually moved, default 60 minute throttle with the first always sent, command via
config.notify.command. -
Prune rollback manifests by age, on read: age-based 30 day retention, pruned lazily on read, no count cap.
-
Undo reaches back by cascading through newer passes: undoing an old pass walks newer manifests and reverses only conflicting actions; manifest id flows via structuredContent; lock renews across the batch.
-
watch_status: registered MCP tool reporting next-run times plus never_run/ok/stale/unknown liveness per watch, reading scheduler state fresh per call.
-
Daemon as thin loop over --once: the daemon keeps only the schedule; a tick runs the same pass a cron+
onceline runs, one attempt per tick, and the OS-timer path stays primary.
Not yet specified
Fog in scope, not yet sharp enough to ticket.
- Whether a pass should ever be able to record
source: "scheduled"from an MCP tool call. Today the source is a caller assertion with nothing behind it. Whether a scheduled pass triggered from inside the MCP server, rather than by cron, is a real case or a category error is unclear until the source plumbing exists. - What
scheduler-state.jsonis for once--oncewrites history. The daemon writes it,--oncedoes not, and it overlaps with what a history record already says. Whether it survives as daemon-only bookkeeping or gets folded into history is not decidable until both write consistently. - Whether the three existing
file_organizer_watch_*handlers should become registered MCP tools.tests/unit/tools/watch.test.ts:154deliberately asserts they are absent. #31 implies the user wants them. That is a deliberate decision someone made once, and reversing it needs the reason. - Whether
notifythrottle state belongs inscheduler-state.json, history, or a new small file. Depends on whether throttling is per-directory or global, which itself depends on how notify fires. - Interaction between manifest pruning and the missing rollback lock.
RollbackServicetakes no lock while history does, so pruning manifests while an undo is in flight is a new race the pruning ticket would introduce. - Whether the digest reads rotated history files.
searchHistorymergesoperations.jsonlplus five rotations into one result set, so a last-24h window silently spans rotations. Whether that is correct or a leak depends on pruning policy, which is not settled.
Out of scope
Work ruled beyond the destination.
- A third
sourcevalue such ascronorsystemd. Decided during charting:scheduledalready means this, and a new value costs the enum in two Zod schemas, two hand-written JSON schemas, the docs, and every test that asserts on source, in exchange for distinguishing schedulers nobody will ask to distinguish. - A notification URL or webhook field. Decided during charting:
notify.commandcan becurlif a URL is ever wanted, so a URL field is reachable from the command field with no second code path. - Phone or remote push notifications. Ruled out by kriday during charting. Desktop is enough for a folder swept every few minutes.
Activity
kridaydave commentedon Oct 11, 2026
Decisions so far (updated):
kridaydave commentedon Oct 11, 2026
Decisions so far (updated):
undo_last_operationgainscascade, which undoes a manifest and everything newer, newest-first. Ordering is what makes it safe, the conflict check is never disabled. A file that is already gone is skipped and reported, never a failure. No dry run, no confirm, no depth limit, because undo is always a person at a keyboard.kridaydave commentedon Oct 11, 2026
Decisions so far (updated):
undo_last_operationgainscascade, which undoes a manifest and everything newer, newest-first. Ordering is what makes it safe, the conflict check is never disabled. A file that is already gone is skipped and reported, never a failure. No dry run, no confirm, no depth limit, because undo is always a person at a keyboard.verified: falseflag rather than hiding them, and giveRollbackServiceits ownmanifests.lockmodelled on the existingoperations.lock, holding it for the whole undo. Shared state comes first, so Prune rollback manifests by age, on read #102 waits on this.kridaydave commentedon Oct 11, 2026
Decisions so far (updated):
undo_last_operationgainscascade, which undoes a manifest and everything newer, newest-first. Ordering is what makes it safe, the conflict check is never disabled. A file that is already gone is skipped and reported, never a failure. No dry run, no confirm, no depth limit, because undo is always a person at a keyboard.verified: falseflag rather than hiding them, and giveRollbackServiceits ownmanifests.lockmodelled on the existingoperations.lock, holding it for the whole undo. Shared state comes first, so Prune rollback manifests by age, on read #102 waits on this.notify.commandin the shared pass so it works on cron, throttle state in its ownnotify-state.jsonkeyed per directory so a busy folder cannot mute a quiet one.execnotspawn, because a pasted command with a redirect must not fail silently.kridaydave commentedon Oct 11, 2026
Decisions so far (updated):
undo_last_operationgainscascade, which undoes a manifest and everything newer, newest-first. Ordering is what makes it safe, the conflict check is never disabled. A file that is already gone is skipped and reported, never a failure. No dry run, no confirm, no depth limit, because undo is always a person at a keyboard.verified: falseflag rather than hiding them, and giveRollbackServiceits ownmanifests.lockmodelled on the existingoperations.lock, holding it for the whole undo. Shared state comes first, so Prune rollback manifests by age, on read #102 waits on this.notify.commandin the shared pass so it works on cron, throttle state in its ownnotify-state.jsonkeyed per directory so a busy folder cannot mute a quiet one.execnotspawn, because a pasted command with a redirect must not fail silently.keepRotatedFilesis inert.kridaydave commentedon Oct 11, 2026
Decisions so far (complete)
Every grilling ticket has a resolution comment. Nothing is left undecided in the map's scope.
undo_last_operationgainscascade, which undoes a manifest and everything newer, newest-first. Ordering is what makes it safe, the conflict check is never disabled. A file that is already gone is skipped and reported, never a failure. No dry run, no confirm, no depth limit, because undo is always a person at a keyboard.verified: falseflag rather than hiding them, and giveRollbackServiceits ownmanifests.lockmodelled on the existingoperations.lock, holding it for the whole undo. Shared state comes first, so Prune rollback manifests by age, on read #102 waits on this.notify.commandin the shared pass so it works on cron, throttle state in its ownnotify-state.jsonkeyed per directory so a busy folder cannot mute a quiet one.execnotspawn, because a pasted command with a redirect must not fail silently.Two new findings during charting
historyLogging.keepRotatedFilesis accepted by config and never read, so setting it silently does nothing. Surfaced while checking whether the digest needed to disclose rotations.organizer.ts:648. What was true is that every path discarded the id, which is why scheduled passes were never undoable.Suggested implementation order
Still fog
file_organizer_watch_*handlers were unregistered. Not recorded anywhere in the repo. #99 settles the outcome regardless.kridaydave commentedon Oct 11, 2026
Are these parallel?
Short answer: no, not really. Three of them collide on files that merge conflicts are decided inside, and
mainhas no merge queue and no auto-merge, so every merge makes every other open branch stale.Already shipped, remove from the queue
The real dependency chain
#101 is downstream of #95 in fact, not in decision. Both edit
organize-pass.ts, and #101 cannot be built correctly until #95 has decided where the pass boundary is.#98 and #102 both rewrite
src/core/organize/rollback.ts, and #102 must run its prune inside #97'srunExclusive. Sequenced by content, not preference.The genuinely parallel pair
#100 (digest) and #99 (watch_status) are disjoint from everything above and from each other.
They still collide on the three hot coordinates every tool PR touches:
src/mcp/registry.ts, onereg()line plus the import blocknpm run docs:syncregeneratesconfig.schema.jsonif they add config fields, which neither currently doesSo they can be written in parallel and will still conflict on every merge.
ARCHITECTURE.md:112records that auto-discovery was deliberately rejected in favour of a visible one-line edit, so this is a design decision, not an oversight.What that means in practice
Safe to run two worktrees at once: #100 and #99. Everything else wants a queue.
The realistic order:
runExclusivewhich already exists.The cost to expect
Every merge makes open branches stale, because
mainisstrict: truewith 9 required checks and no auto-merge. Expect to rebase and re-rundocs:syncon anything parked behind another merge. That is the normal cost of a generated tool list, not a bug in a PR.Before pushing a branch that adds a tool:
Find out what it collides with while you still have the context.
kridaydave commentedon Oct 21, 2026
Map complete. All 10 sub-issues (93-102) resolved, and the five destination issues (31, 42, 48, 49, 52) are closed. PR #123 merged (daemon one pass attempt per tick; macos flake fixed by polling for lock acquisition). Build, typecheck:tests, and docs:check all green on main.
Remaining fog items (scheduled pass from MCP, scheduler-state purpose, watch handlers as MCP tools, notify throttle storage, manifest pruning vs rollback lock, digest rotation) stay as open questions for future tickets.
kridaydave commentedon Oct 21, 2026
Fog items reviewed against the code. Status of each:
Already resolved by shipped work (strike from fog):
registry.ts(PR feat(watch): register the watch tools and add watch_status #121);tests/unit/tools/watch.test.tsnow asserts all fourfile_organizer_watch_*tools are present, not absent.scheduler-state.json(src/core/notify/notify-service.ts), per-directory. Decided in #48: notify on a moving pass, throttled #101.manifests.lock. Undo takesrunExclusive, prune takestryRunExclusiveand skips when locked. Settled in #42: how far back should selective undo reach? #98/Prune rollback manifests by age, on read #102/Added organizer digest, run-notify, and rollback retention #117/test(rollback): make the prune lock tests prove the lock is gone #119.Decided:
source: "scheduled"from an MCP tool call? No.scheduledis honest only when an OS timer started the pass (once --source scheduled, the daemon). An MCP tool call is a person or agent acting, andserver.tsalready hardcodessource: "manual"for every tool audit row. No code change;manualstays the only value an MCP call can produce.scheduler-state.jsononce--oncewrites history? It stays daemon-owned bookkeeping, not folded into history, because it holds three things history does not: the per-directorylastRunTimebehindsince_last_runpreviews, the watch liveness fingerprint, and the notify throttle sibling key.--oncedeliberately does not write it, which is whywatch_statusfalls back to history (recorded_by: "history").operations.jsonlplusoperations.N.jsonl. This also exposed a stale note claimingkeepRotatedFileswas ignored. It is honored (read byhistory-loggermaxBackupFiles). Fixedorganizer-digest.tsandAPI.mdto say so.