Skip to content

Wayfinder map: make the scheduled pass real, then build the five issues on it #92

Closed
10 of 10 issues completed

Description

@kridaydave

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 source value.
  • 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.md so 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.jsonl over a time window on demand (pull model); one row per pass, manifests preserved; shipped as file_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+once line 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.json is for once --once writes history. The daemon writes it, --once does 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:154 deliberately 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 notify throttle state belongs in scheduler-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. RollbackService takes 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. searchHistory merges operations.jsonl plus 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 source value such as cron or systemd. Decided during charting: scheduled already 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.command can be curl if 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

  1. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Decisions so far (updated):

    • Daemon routes through the shared pass, and records the manifest id: one pass implementation; the daemon's private pass is deleted, its defaults are a 60 minute minimum age and 300 files per run, and scheduler-state stays daemon-only. Also found that the daemon already wrote a rollback manifest and that both paths discarded its id, so scheduled passes were never actually undoable.
  2. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Decisions so far (updated):

    • Daemon routes through the shared pass, and records the manifest id: one pass implementation; the daemon's defaults are a 60 minute minimum age and 300 files per run, and scheduler-state stays daemon-only. Found that both paths already wrote a rollback manifest and both discarded its id, so scheduled passes were never undoable.
    • How far back should selective undo reach: rollback to a point. undo_last_operation gains cascade, 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.
  3. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Decisions so far (updated):

    • Daemon routes through the shared pass, and records the manifest id: one pass implementation; the daemon's defaults are a 60 minute minimum age and 300 files per run, and scheduler-state stays daemon-only. Found that both paths already wrote a rollback manifest and both discarded its id, so scheduled passes were never undoable.
    • How far back should selective undo reach: rollback to a point. undo_last_operation gains cascade, 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.
    • Verify every listed manifest, and lock rollback: list unverified manifests with a verified: false flag rather than hiding them, and give RollbackService its own manifests.lock modelled on the existing operations.lock, holding it for the whole undo. Shared state comes first, so Prune rollback manifests by age, on read #102 waits on this.
  4. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Decisions so far (updated):

    • Daemon routes through the shared pass, and records the manifest id: one pass implementation; the daemon's defaults are a 60 minute minimum age and 300 files per run, and scheduler-state stays daemon-only. Found that both paths already wrote a rollback manifest and both discarded its id, so scheduled passes were never undoable.
    • How far back should selective undo reach: rollback to a point. undo_last_operation gains cascade, 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.
    • Verify every listed manifest, and lock rollback: list unverified manifests with a verified: false flag rather than hiding them, and give RollbackService its own manifests.lock modelled on the existing operations.lock, holding it for the whole undo. Shared state comes first, so Prune rollback manifests by age, on read #102 waits on this.
    • Notify on a moving pass, throttled: notify.command in the shared pass so it works on cron, throttle state in its own notify-state.json keyed per directory so a busy folder cannot mute a quiet one. exec not spawn, because a pasted command with a redirect must not fail silently.
  5. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Decisions so far (updated):

    • Daemon routes through the shared pass, and records the manifest id: one pass implementation; the daemon's defaults are a 60 minute minimum age and 300 files per run, and scheduler-state stays daemon-only. Found that both paths already wrote a rollback manifest and both discarded its id, so scheduled passes were never undoable.
    • How far back should selective undo reach: rollback to a point. undo_last_operation gains cascade, 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.
    • Verify every listed manifest, and lock rollback: list unverified manifests with a verified: false flag rather than hiding them, and give RollbackService its own manifests.lock modelled on the existing operations.lock, holding it for the whole undo. Shared state comes first, so Prune rollback manifests by age, on read #102 waits on this.
    • Notify on a moving pass, throttled: notify.command in the shared pass so it works on cron, throttle state in its own notify-state.json keyed per directory so a busy folder cannot mute a quiet one. exec not spawn, because a pasted command with a redirect must not fail silently.
    • organizer_digest: summary line plus breakdowns by directory and category, failures in their own section, manifest ids per directory row so it is actionable without a second call, relative and absolute windows both. No rotation disclosure, because rotation preserves history rather than truncating it. Surfaced a separate bug: #106, keepRotatedFiles is inert.
  6. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Decisions so far (complete)

    Every grilling ticket has a resolution comment. Nothing is left undecided in the map's scope.

    • Daemon routes through the shared pass, and records the manifest id: one pass implementation; the daemon's defaults are a 60 minute minimum age and 300 files per run, and scheduler-state stays daemon-only. Found that both paths already wrote a rollback manifest and both discarded its id, so scheduled passes were never undoable.
    • How far back should selective undo reach: rollback to a point. undo_last_operation gains cascade, 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.
    • Verify every listed manifest, and lock rollback: list unverified manifests with a verified: false flag rather than hiding them, and give RollbackService its own manifests.lock modelled on the existing operations.lock, holding it for the whole undo. Shared state comes first, so Prune rollback manifests by age, on read #102 waits on this.
    • Notify on a moving pass, throttled: notify.command in the shared pass so it works on cron, throttle state in its own notify-state.json keyed per directory so a busy folder cannot mute a quiet one. exec not spawn, because a pasted command with a redirect must not fail silently.
    • organizer_digest: summary line plus breakdowns by directory and category, failures in their own section, manifest ids per directory row so it is actionable without a second call, relative and absolute windows both. No rotation disclosure, because rotation preserves history rather than truncating it.
    • watch_status: fix the dead UTC cron helpers to local time with a test pinned to a non-UTC offset, report liveness as distinct states rather than a boolean, register all four watch handlers, derive liveness from history so it works under cron with no daemon.
    • Prune rollback manifests by age: 30 days, configurable, lazily on read under the #42: verify every listed manifest, and lock rollback #97 manifest lock, history rows left dangling because that is honest and append-only history should not gain a second writer.

    Two new findings during charting

    • #106: historyLogging.keepRotatedFiles is accepted by config and never read, so setting it silently does nothing. Surfaced while checking whether the digest needed to disclose rotations.
    • The claim that a daemon pass writes no rollback manifest was wrong. It does, at organizer.ts:648. What was true is that every path discarded the id, which is why scheduled passes were never undoable.

    Suggested implementation order

    1. #95 first. It is the substrate, it is small, and it makes scheduled passes undoable.
    2. #97 and #98 together, as one PR. The cascade must not land on an unlocked, unverified manifest set.
    3. #102 after #42: verify every listed manifest, and lock rollback #97.
    4. #101, #100, #99 are independent of each other and of everything above.

    Still fog

    • Whether a scheduled pass should ever be recorded from an MCP tool call rather than a cron line. Needs the source plumbing to exist first.
    • Why the three file_organizer_watch_* handlers were unregistered. Not recorded anywhere in the repo. #99 settles the outcome regardless.
  7. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Are these parallel?

    Short answer: no, not really. Three of them collide on files that merge conflicts are decided inside, and main has no merge queue and no auto-merge, so every merge makes every other open branch stale.

    Already shipped, remove from the queue

    Ticket State
    #97 verify and lock Done. PR #103, #105. The implementation is stricter than specified.
    #95 manifest id on the row Done. PR #103. The daemon routing half is still open.

    The real dependency chain

    #95 (daemon routes through shared pass)
          |
          v
    #101 (notify fires inside the shared pass)
    

    #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.

    #97 (done)  ->  #98 (cascade)  ->  #102 (prune)
    

    #98 and #102 both rewrite src/core/organize/rollback.ts, and #102 must run its prune inside #97's runExclusive. 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, one reg() line plus the import block
    • the generated tool count in README.md and ARCHITECTURE.md, which npm run docs:sync regenerates
    • config.schema.json if they add config fields, which neither currently does

    So they can be written in parallel and will still conflict on every merge. ARCHITECTURE.md:112 records 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:

    1. #52: should the daemon route through the shared pass, and does it log? #95 alone. It is the substrate, it touches hot files, and three tickets want the shape it settles.
    2. #42: how far back should selective undo reach? #98 + Prune rollback manifests by age, on read #102 as one PR. Same file, same subsystem, one review, and Prune rollback manifests by age, on read #102 needs #42: verify every listed manifest, and lock rollback #97's runExclusive which already exists.
    3. #48: notify on a moving pass, throttled #101 after #52: should the daemon route through the shared pass, and does it log? #95.
    4. #49: organizer_digest #100 and #31: file_organizer_watch_status #99 in parallel, last, once nothing else is competing for the registry.

    The cost to expect

    Every merge makes open branches stale, because main is strict: true with 9 required checks and no auto-merge. Expect to rebase and re-run docs:sync on 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:

    git fetch -q origin main && git merge-tree --write-tree --name-only origin/main HEAD | grep "Merge conflict in"

    Find out what it collides with while you still have the context.

  8. kridaydave commented on Oct 21, 2026

    @kridaydave
    OwnerAuthor

    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.

  9. kridaydave commented on Oct 21, 2026

    @kridaydave
    OwnerAuthor

    Fog items reviewed against the code. Status of each:

    Already resolved by shipped work (strike from fog):

    Decided:

    • Can a pass record source: "scheduled" from an MCP tool call? No. scheduled is honest only when an OS timer started the pass (once --source scheduled, the daemon). An MCP tool call is a person or agent acting, and server.ts already hardcodes source: "manual" for every tool audit row. No code change; manual stays the only value an MCP call can produce.
    • Purpose of scheduler-state.json once --once writes history? It stays daemon-owned bookkeeping, not folded into history, because it holds three things history does not: the per-directory lastRunTime behind since_last_run previews, the watch liveness fingerprint, and the notify throttle sibling key. --once deliberately does not write it, which is why watch_status falls back to history (recorded_by: "history").
    • Does the digest read rotated history? Yes, correctly: a time window should cover operations.jsonl plus operations.N.jsonl. This also exposed a stale note claiming keepRotatedFiles was ignored. It is honored (read by history-logger maxBackupFiles). Fixed organizer-digest.ts and API.md to say so.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    wayfinder:mapWayfinder map / decision ticket

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions