diff options
| -rwxr-xr-x | .ai/scripts/flashcard-to-anki.py | 26 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_flashcard_to_anki.py | 31 | ||||
| -rw-r--r-- | .ai/sessions/2026-06-24-09-27-task-audit-blocked-deps-anki-wrap-teardown.org | 75 | ||||
| -rw-r--r-- | .ai/workflows/inbox.org | 2 | ||||
| -rw-r--r-- | .ai/workflows/open-tasks.org | 25 | ||||
| -rw-r--r-- | .ai/workflows/task-audit.org | 15 | ||||
| -rw-r--r-- | claude-rules/todo-format.md | 51 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/flashcard-to-anki.py | 26 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py | 31 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/inbox.org | 2 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/open-tasks.org | 25 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/task-audit.org | 15 | ||||
| -rwxr-xr-x | docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py | 246 | ||||
| -rw-r--r-- | docs/design/2026-06-21-anki-titlefix-test.py | 190 | ||||
| -rw-r--r-- | todo.org | 243 |
15 files changed, 437 insertions, 566 deletions
diff --git a/.ai/scripts/flashcard-to-anki.py b/.ai/scripts/flashcard-to-anki.py index 7227683..ca4c70b 100755 --- a/.ai/scripts/flashcard-to-anki.py +++ b/.ai/scripts/flashcard-to-anki.py @@ -13,9 +13,11 @@ Parses org-drill structure: text (sans :drill: tag). Back = entry body with newlines converted to <br>. -Deck name defaults to the input basename, case preserved. Deck and model -IDs are derived from the deck name via stable hash so re-importing the -same deck updates existing cards instead of duplicating them. +Deck name defaults to the org #+TITLE: (so the phone deck reads as the +curated title), falling back to the input basename when the source has +no #+TITLE. Deck and model IDs are derived from the deck name via stable +hash so re-importing the same deck updates existing cards instead of +duplicating them. Output defaults to ~/sync/phone/anki/<input-basename>.apkg. The .apkg is a mobile-Anki artifact the phone picks up from its sync dir, so it lands @@ -177,7 +179,19 @@ def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck: return deck -def default_deck_name(input_path: Path) -> str: +def default_deck_name(input_path: Path, org_text: str) -> str: + """Deck name defaults to the org #+TITLE:, falling back to the basename. + + The #+TITLE drives both the org-drill display in Emacs and the Anki + deck name on the phone, so the consumed deck reads as the curated + title ("Refutations") rather than the filename slug + ("refutation-drill"). Falls back to the input basename (case + preserved) when the source has no non-empty #+TITLE line. + """ + for line in org_text.splitlines(): + m = re.match(r"^#\+TITLE:\s*(.*\S)\s*$", line, re.IGNORECASE) + if m: + return m.group(1).strip() return input_path.stem @@ -197,7 +211,7 @@ def main() -> int: ) parser.add_argument( "--deck", - help="Deck name. Defaults to the input basename.", + help="Deck name. Defaults to the org #+TITLE, or the input basename.", ) parser.add_argument( "--output", @@ -213,7 +227,7 @@ def main() -> int: return 1 org_text = input_path.read_text(encoding="utf-8") - deck_name = args.deck or default_deck_name(input_path) + deck_name = args.deck or default_deck_name(input_path, org_text) output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve() output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py index 058b0cd..87008a8 100644 --- a/.ai/scripts/tests/test_flashcard_to_anki.py +++ b/.ai/scripts/tests/test_flashcard_to_anki.py @@ -34,14 +34,33 @@ def test_default_output_path_targets_phone_anki_dir(drill): assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg" -def test_default_deck_name_is_raw_basename(drill): - """Deck name is the input basename with case preserved; #+TITLE is ignored.""" - assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat" +def test_default_deck_name_uses_org_title(drill): + """The #+TITLE drives the Anki deck name, not the filename slug.""" + org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n" + assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations" -def test_default_deck_name_keeps_hyphens(drill): - """A hyphenated basename is kept verbatim rather than title-cased.""" - assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill" +def test_default_deck_name_title_is_trimmed(drill): + """Surrounding whitespace on the #+TITLE value is stripped.""" + org = "#+TITLE: DeepSat Flashcards \n" + assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards" + + +def test_default_deck_name_title_match_is_case_insensitive(drill): + """A lowercase #+title: keyword is still recognized.""" + org = "#+title: Health Flashcards\n" + assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards" + + +def test_default_deck_name_falls_back_to_basename_without_title(drill): + """No #+TITLE line falls back to the input basename, case preserved.""" + org = "* Section\n** Q? :drill:\na\n" + assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat" + + +def test_default_deck_name_blank_title_falls_back_to_basename(drill): + """An empty #+TITLE value is ignored in favour of the basename.""" + assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE: \n") == "health-drill" # --- section_to_tag (pure) --- diff --git a/.ai/sessions/2026-06-24-09-27-task-audit-blocked-deps-anki-wrap-teardown.org b/.ai/sessions/2026-06-24-09-27-task-audit-blocked-deps-anki-wrap-teardown.org new file mode 100644 index 0000000..b0a4994 --- /dev/null +++ b/.ai/sessions/2026-06-24-09-27-task-audit-blocked-deps-anki-wrap-teardown.org @@ -0,0 +1,75 @@ +#+TITLE: Session — task audit, blocked/blocker deps, Anki fix, wrap-teardown unblocked + +* Summary + +** Active Goal + +Long post-wrap continuation (past the 00:14 wrap). Ran a task audit, built two +task-workflow features (cross-project dependency tags + audit consolidation), +shipped the Anki #+TITLE fix, and unblocked the wrap-teardown feature when the +.emacs.d companion landed. Ended on a wrap + a live test of the wrap-teardown +workflow. 15 rulesets commits pushed (5cdbf13..9709638) plus a roam sync. + +** What shipped (all pushed to origin/main) + +- *Roam sync* — committed + pushed the dirty roam tree via roam-sync (65514c2). +- *Task audit, Phases A-F* (5cdbf13): reconciled 23 open tasks (19 current, 2 + stale fixed, 2 VERIFY flags). Resolved the helper-instance dependency question + to a buildable TODO (cc93fa8). Memory-sync VERIFY parked. Added the + =daily-drivers.md= rule (ratio/velox machine-sync, 03ad150). Chained a + task-review pass: stamped 12 never-reviewed tasks, tagged two :quick:solo: + (558624e). +- *"session wrapped." signoff* (d5cc37c): wrap-it-up valediction now ends with + =session wrapped.= on its own line. (Your roam-inbox request.) +- *capture-guard --wait* (1eaec82): poll mode so a transient org-capture clears + itself instead of bouncing a roam edit; roam mode + auto-loop fall back only + after the wait. 3 new bats cases. +- *Cross-project dependency tags* (4d2f83d, 0d87c80, then 06b6cbc + 9709638 for + the bidirectional + tag-form revision): =:blocked:= on the waiting task, + =:blocker:= on the task that owes the work, detail in the body (no property). + Setting :blocked: requires a reciprocal inbox-send so the blocker learns; + open-tasks surfaces :blocker: first and pulls :blocked: out of the cascade. + Global (todo-format.md + open-tasks.org + inbox.org). The two filed + task-mgmt ideas (6de1712) that spawned this are DONE. +- *Task-audit consolidation* (bcfce0e): Phase C.5 proposes merge-or-parent for + related-task clusters. +- *Anki #+TITLE fix* (060a938, closed 3b48416): default_deck_name reads the + org #+TITLE, not the filename. TDD red->green, 29 pass. Coordination note left + on the flashcard multi-tag task (its preserved file now predates this fix). +- *wrap-teardown unblocked* (0127889): .emacs.d landed the three ai-term + companion functions (double-checked the bodies — they match + exceed the + contract: TOCTOU re-check, configurable shutdown command). Dropped :blocked:. + +** State of the wrap-teardown feature + +Code-complete on both sides (rulesets Stop hook + wrap-it-up Step 6; .emacs.d +companion + 13 ERT tests). The feature is ARMED: a bare "wrap it up" now tears +the session down, "wrap it up and shutdown" powers off after the gate. The only +remaining item is the manual end-to-end validation (the checklist under the +task). IMPORTANT: the Stop hook was wired mid-session, and the harness loads +hooks at session start — so the teardown fires reliably from the NEXT session, +or this session only after =/hooks= is opened once. Don't drop a teardown +sentinel blind, or it misfires on the next session's first stop. + +** Open / carryover + +- wrap-teardown: DOING, manual validation pending (your env). Feature armed. +- Wrap-up inbox/transcript routing: DOING, spec Ready, 5 sub-tasks; the + recommendation-engine sub-task (:solo:) is the clean entry point. Craig may + pick this or another up next session. +- fix-speedrun proposal: still the deferred dirty file (docs/design/2026-06-15), + untouched. +- flashcard multi-tag task: re-derive against the post-Anki-fix canonical. + +KB: promoted 0 / consulted no. Durable lessons (the blocked/blocker convention, +the roam-no-pull rule, capture-guard --wait) all landed in the synced rules + +workflows themselves, so a KB node would duplicate the repo. + +* Session Log + +** 2026-06-24 Wed @ 09:27 — wrap + wrap-teardown live test +Closed out a long continuation: task audit, the blocked/blocker dependency +feature (built property-based, then refactored to plain tags on Craig's call), +the Anki #+TITLE fix, and the wrap-teardown unblock. todo-cleanup archived 2 +done subtrees (Anki, Morning-ops cancel); lint reflowed a table; roam sweep + +inbox both clean. Wrapping to test the now-armed wrap-teardown workflow live. diff --git a/.ai/workflows/inbox.org b/.ai/workflows/inbox.org index c442d17..5fc855f 100644 --- a/.ai/workflows/inbox.org +++ b/.ai/workflows/inbox.org @@ -114,6 +114,8 @@ The item extends a task already filed. Update the parent TODO's body with a date ** File as TODO Substantive but waits, or needs design/triage before implementation. Add the TODO under =* <Project> Open Work= with priority + tags per the priority-scheme check (core §6). Body summarizes the proposal and links the inbox content if it's been moved to =docs/design/=. Delete the inbox file (or move it to =docs/design/= first if the content survives). +*Blocking-dependency handoff.* A special shape: another project sends a note that *this* project's work is blocking one of theirs ("your task X is blocked on us — we need Y"). File or link the owning task, tag it =:blocker:=, and name the requesting project in the body (see the cross-project dependency convention in =todo-format.md=). The =:blocker:= tag makes =open-tasks.org= surface that task *first*, since clearing it unblocks the other project. Dedup against an existing task rather than filing a duplicate. When the work later lands, drop =:blocker:= and notify the waiting project (=inbox-send <their-project> --text "Delivered: <what> — you're unblocked."=) so it can lift its own =:blocked:=. + ** Defer Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment line at the top: =# Deferred YYYY-MM-DD: <condition>=. Don't accumulate deferred items indefinitely — sweep them on a future process pass when the condition is met or the deferral has aged out. diff --git a/.ai/workflows/open-tasks.org b/.ai/workflows/open-tasks.org index fe782d6..4ba29dd 100644 --- a/.ai/workflows/open-tasks.org +++ b/.ai/workflows/open-tasks.org @@ -176,6 +176,10 @@ Next Mode answers two questions in one output: "what matters most right now?" (t Apply the prioritization cascade in order. Stop at the first matching step. This is the importance/urgency answer. +*Exclude blocked tasks.* A task tagged =:blocked:= has an unmet cross-project dependency (its body names the project and the work owed, per =todo-format.md=). It can't be worked until that other project delivers, so it is *never* the cascade recommendation — skip it at every cascade step below. Blocked tasks are surfaced on their own in Step 3 so the stalled dependency stays visible instead of silently dropping out of view. + +*Surface blocking tasks first.* The mirror of the above: a task tagged =:blocker:= is holding up work in *another* project (its body names which project and what's owed, per =todo-format.md=). Clearing it unblocks that project, so it carries borrowed urgency — surface it at the *top* of the cascade recommendation regardless of its own priority cookie, ahead of the normal In-Progress / deadline / priority order. When several =:blocker:= tasks exist, lead with the one blocking the most, or the longest. This is the "do the thing that unblocks someone else first" rule; a =:blocker:= task left at its own low priority is exactly how a cross-project dependency stalls. + **** 1. In-Progress Tasks - Look for tasks marked =DOING= or partially complete. - *If found:* Recommend that task (always finish what's started). @@ -228,11 +232,22 @@ Within each row, pick a single task per the same-level tie-breakers above (block The friction filter is the override path. When the cascade winner is partially blocked, hardware-dependent, or simply too large for the user's current state, one of the friction rows is what they pick instead. +*** Step 3 — Blocked-on-other-projects surface + +Independently of the cascade and the friction filter, collect every open task tagged =:blocked:=. These are tasks this project can't advance until another project delivers; surfacing them keeps a cross-project dependency from rotting at low priority on the other side — the exact failure the tag exists to prevent (a blocked task whose blocker is a =[#D]= in another project sits forever otherwise). + +For each blocked task, read its body for the blocking project and what's owed, and present one line: the task, the blocking project, and what that project owes. Then offer — per blocked task — to nudge the blocker: an =inbox-send <project> --text= note naming what's needed and why it's blocking, so the dependency gets attention in the project that owns it. Don't send without the user's go. + +If no =:blocked:= tasks exist, omit this surface entirely (the common case). + *** Output Format -Pair the cascade recommendation with the friction block beneath it. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick. +Pair the cascade recommendation with the friction block beneath it, and the blocked-on-other-projects surface (Step 3) beneath that when any blocked task exists. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick. #+begin_example +Unblocks other projects (do these first): +- ai-term wrap-teardown companion — :blocker:, unblocks rulesets (the three ai-term functions) + Cascade recommendation (importance/urgency): - Fix org-noter reliability — [#A], Method 1, 8/18 complete, blocks daily reading/annotation @@ -240,17 +255,25 @@ If you want lower friction instead: 1. Quick + solo: Bump linter config — [#C] :quick:solo:, ~15 min 2. Quick: Confirm new dirvish setup — [#B] :quick:, needs your eye 3. Solo: Refactor config-utilities — [#B] :solo:, bounded but multi-hour + +Blocked on other projects (can't advance until the blocker delivers): +- Wrap-teardown feature — blocked by emacsd: ai-term companion functions — nudge? #+end_example +The =:blocker:= surface sits at the very top — clearing one of those is the highest-leverage thing on the list, since it frees work in another project. Omit it when no =:blocker:= task exists (the common case). + Include for each row: - Task name / description. - Priority + tag cluster. - One-line reasoning. For the cascade row, name which cascade step matched. For friction rows, an effort hint when one is obvious. - Progress indicator (for V2MOM-structured todos) on the cascade row only. +- For a =:blocker:= row: the project it unblocks and what's owed (from the task body). +- For a blocked row: the blocking project and what it owes (from the task body), plus the nudge offer. **** Edge cases - *Empty friction block.* If no =:quick:= or =:solo:= tagged tasks exist in the open set, omit the friction block entirely. Present only the cascade recommendation. +- *No =:blocker:= tasks.* Omit the "Unblocks other projects" surface entirely (the common case) — show it only when a task carries the =:blocker:= tag. - *Dedupe.* If the cascade recommendation IS the same task as one of the friction rows (e.g. it's =:quick:solo:= and also won the cascade), show it once at the top with both labels. Don't list it twice. - *Decline behavior.* If the user declines the cascade recommendation, drop straight to the friction block as the natural next prompt. Do not fall through to lower-cascade-tier tasks; the friction filter IS the override. diff --git a/.ai/workflows/task-audit.org b/.ai/workflows/task-audit.org index 67ce496..94b99da 100644 --- a/.ai/workflows/task-audit.org +++ b/.ai/workflows/task-audit.org @@ -84,6 +84,21 @@ For every STALE task, edit it in the main thread: Follow =todo-format.md= for completion mechanics (depth-based DONE vs dated-rewrite) and the working-files / link-hygiene rules when moving artifacts. +** Phase C.5 — Consolidate related tasks (interactive) + +Phase C's *Consolidate duplicates* bullet folds tasks that track the *same* thing. This step is the broader case: tasks that aren't duplicates but are really *one effort* fragmented across the list. A spread-out effort — several tasks all circling "make the tooling agent-agnostic," say — is harder to see, plan, and finish as a whole than one task, or one parent with the pieces as children. + +After the Phase C edits, read the open-task set as a whole and look for *clusters*: tasks that share a goal, a subsystem, or an obvious sequence. Use judgment over the task bodies, not a keyword heuristic — adjacency is a semantic call, and a brittle title-match both misses real clusters and invents false ones. + +For each cluster, surface it to Craig (inline numbered options per =interaction.md=, no popup) with a recommendation, offering the two shapes: + +- *Merge* — fold the cluster into one task when the members are genuinely the same work split up (near-duplicates, or steps with no independent value). The merged task keeps the strongest priority, unions the type tags, and absorbs each member's body as a dated note or a short list; the absorbed tasks close per =todo-format.md= (a =**= task → =CANCELLED= + =CLOSED:= with a one-line "merged into <task>", or deletion if it carried nothing unique). +- *Parent with children* — when the members are related but distinct (each ships independently or has its own value), promote a parent task and re-home the members beneath it as sub-tasks, so the list shows the effort as a unit without losing the individual pieces. + +Never merge or re-parent autonomously — which tasks belong together, and whether they're one-work or related-distinct, is a judgment only Craig ratifies. Propose, don't apply, until he picks. A cluster he declines stays as separate tasks; don't re-surface it every audit (note the decline in the session log). + +When no clear cluster exists, say so in one line and move on — most audits won't find one, and forcing a merge fragments worse than it consolidates. + ** Phase D — Flag the judgment calls (interactive) Present the NEEDS-USER bucket as a short, scannable list — one line per task, naming the decision or the fact required. Adjudicate with the user one item at a time (inline numbered options per =interaction.md=, no popup). Apply the user's calls as they come (which may itself produce more autonomous updates, or new tasks). diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md index 895526f..5c34966 100644 --- a/claude-rules/todo-format.md +++ b/claude-rules/todo-format.md @@ -263,3 +263,54 @@ are noise that pollute his `cj:` greps. ** DOING [#A] Kostya's contract :admin:kostya: *** 2026-05-15 Fri @ 14:00:00 -0500 Kostya basis — part-time, 20 hr/week Nerses confirmed 5/15 13:30 CDT: Kostya runs at 20 hr/week part-time, mirroring Vrezh's structure. Plugged into Exhibit A § 2 of the contract draft. + +## Cross-Project Dependency Tags + +A task can be blocked by work that has to happen in a *different project* — a rulesets task that can't finish until `.emacs.d` ships a companion function, say. Left unmarked, two things go wrong: the what's-next workflow keeps recommending the blocked task even though it can't move, and the blocker sits at low priority in the other project, so the dependency stalls silently. + +Two plain org tags track it, one on each side, so neither the waiter nor the blocker loses sight of the dependency: `:blocked:` on the task that's waiting, `:blocker:` on the task that owes the work. The cross-project detail — which project, what work — goes in the task *body*, not a property. This applies to *any* project pair; the convention here and the surfacing in `open-tasks.org` live in the shared rule + workflow layer, not in one project. + +### `:blocked:` — the waiting side + +The task that can't proceed carries `:blocked:`. Its body names the project it's waiting on and what that project owes: + +``` +** DOING [#B] Wrap-teardown feature :feature:blocked: +Blocked on emacsd: needs the ai-term companion functions +(cj/ai-term-quit, -live-count) before the manual validation can run. +``` + +`open-tasks.org` reads the `:blocked:` tag to pull the task out of the "do this next" cascade (it can't be worked) and surface it in a dedicated "Blocked on other projects" section, reading the body for which project to name and nudge. + +### Registering with the blocker — the reciprocal handoff (required) + +Setting `:blocked:` is not complete until the blocking project knows it's blocking. The moment you mark a task `:blocked:` on another project's work, send that project a dependency handoff: + +``` +inbox-send <project> --text "Blocking dependency: <this-project>'s task \"<task>\" is blocked on you — it needs <what>. It stays blocked until this lands. Tag the owning task :blocker: on your side so it surfaces as priority work." +``` + +This is what closes the gap: without it, the blocker only learns it's blocking by accident. The handoff lands in `<project>`'s `inbox/` and its normal inbox processing tags the work (below). A `:blocked:` task with no matching reciprocal handoff is half-done — the dependency is invisible to the one project that can clear it. Skip the send only when the blocker demonstrably already tracks the work (e.g. it's the same handoff that spawned the dependency); it dedups against an existing task either way. + +### `:blocker:` — the blocking side + +When a project processes a blocking-dependency handoff (inbox process mode), it tags the owning task `:blocker:` and names the requesting project in the body: + +``` +** TODO [#B] ai-term wrap-teardown companion :feature:blocker: +Rulesets' wrap-teardown feature is blocked on this — it needs the three +ai-term functions. Surface first so rulesets unblocks. +``` + +The blocking task does *not* carry `:blocked:` — it isn't blocked, it's the blocker. `:blocker:` is a priority signal: `open-tasks.org` surfaces a `:blocker:` task *first*, since clearing it unblocks work in another project, so a dependency that would otherwise stall at low priority gets pulled forward. This is the "surface dependencies first" half of the design. + +### Resolving the dependency + +When the blocker delivers: + +1. The blocking project completes its `:blocker:` task, drops the `:blocker:` tag, and notifies the waiter (`inbox-send <waiter> --text "Delivered: <what> — you're unblocked."`). +2. The waiting project drops the `:blocked:` tag; the task is workable again. Either side noticing the delivery can lift its own tag — the notification just makes it prompt. + +### Not the same as VERIFY + +`:blocked:` marks "waiting on another *project's* work"; `VERIFY` marks "waiting on Craig's input." If Craig's input is what's needed, it's a VERIFY, not `:blocked:`. And `:blocker:` only ever sits on the project that *owes* the work, never the one waiting. diff --git a/claude-templates/.ai/scripts/flashcard-to-anki.py b/claude-templates/.ai/scripts/flashcard-to-anki.py index 7227683..ca4c70b 100755 --- a/claude-templates/.ai/scripts/flashcard-to-anki.py +++ b/claude-templates/.ai/scripts/flashcard-to-anki.py @@ -13,9 +13,11 @@ Parses org-drill structure: text (sans :drill: tag). Back = entry body with newlines converted to <br>. -Deck name defaults to the input basename, case preserved. Deck and model -IDs are derived from the deck name via stable hash so re-importing the -same deck updates existing cards instead of duplicating them. +Deck name defaults to the org #+TITLE: (so the phone deck reads as the +curated title), falling back to the input basename when the source has +no #+TITLE. Deck and model IDs are derived from the deck name via stable +hash so re-importing the same deck updates existing cards instead of +duplicating them. Output defaults to ~/sync/phone/anki/<input-basename>.apkg. The .apkg is a mobile-Anki artifact the phone picks up from its sync dir, so it lands @@ -177,7 +179,19 @@ def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck: return deck -def default_deck_name(input_path: Path) -> str: +def default_deck_name(input_path: Path, org_text: str) -> str: + """Deck name defaults to the org #+TITLE:, falling back to the basename. + + The #+TITLE drives both the org-drill display in Emacs and the Anki + deck name on the phone, so the consumed deck reads as the curated + title ("Refutations") rather than the filename slug + ("refutation-drill"). Falls back to the input basename (case + preserved) when the source has no non-empty #+TITLE line. + """ + for line in org_text.splitlines(): + m = re.match(r"^#\+TITLE:\s*(.*\S)\s*$", line, re.IGNORECASE) + if m: + return m.group(1).strip() return input_path.stem @@ -197,7 +211,7 @@ def main() -> int: ) parser.add_argument( "--deck", - help="Deck name. Defaults to the input basename.", + help="Deck name. Defaults to the org #+TITLE, or the input basename.", ) parser.add_argument( "--output", @@ -213,7 +227,7 @@ def main() -> int: return 1 org_text = input_path.read_text(encoding="utf-8") - deck_name = args.deck or default_deck_name(input_path) + deck_name = args.deck or default_deck_name(input_path, org_text) output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve() output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py index 058b0cd..87008a8 100644 --- a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py +++ b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py @@ -34,14 +34,33 @@ def test_default_output_path_targets_phone_anki_dir(drill): assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg" -def test_default_deck_name_is_raw_basename(drill): - """Deck name is the input basename with case preserved; #+TITLE is ignored.""" - assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat" +def test_default_deck_name_uses_org_title(drill): + """The #+TITLE drives the Anki deck name, not the filename slug.""" + org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n" + assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations" -def test_default_deck_name_keeps_hyphens(drill): - """A hyphenated basename is kept verbatim rather than title-cased.""" - assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill" +def test_default_deck_name_title_is_trimmed(drill): + """Surrounding whitespace on the #+TITLE value is stripped.""" + org = "#+TITLE: DeepSat Flashcards \n" + assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards" + + +def test_default_deck_name_title_match_is_case_insensitive(drill): + """A lowercase #+title: keyword is still recognized.""" + org = "#+title: Health Flashcards\n" + assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards" + + +def test_default_deck_name_falls_back_to_basename_without_title(drill): + """No #+TITLE line falls back to the input basename, case preserved.""" + org = "* Section\n** Q? :drill:\na\n" + assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat" + + +def test_default_deck_name_blank_title_falls_back_to_basename(drill): + """An empty #+TITLE value is ignored in favour of the basename.""" + assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE: \n") == "health-drill" # --- section_to_tag (pure) --- diff --git a/claude-templates/.ai/workflows/inbox.org b/claude-templates/.ai/workflows/inbox.org index c442d17..5fc855f 100644 --- a/claude-templates/.ai/workflows/inbox.org +++ b/claude-templates/.ai/workflows/inbox.org @@ -114,6 +114,8 @@ The item extends a task already filed. Update the parent TODO's body with a date ** File as TODO Substantive but waits, or needs design/triage before implementation. Add the TODO under =* <Project> Open Work= with priority + tags per the priority-scheme check (core §6). Body summarizes the proposal and links the inbox content if it's been moved to =docs/design/=. Delete the inbox file (or move it to =docs/design/= first if the content survives). +*Blocking-dependency handoff.* A special shape: another project sends a note that *this* project's work is blocking one of theirs ("your task X is blocked on us — we need Y"). File or link the owning task, tag it =:blocker:=, and name the requesting project in the body (see the cross-project dependency convention in =todo-format.md=). The =:blocker:= tag makes =open-tasks.org= surface that task *first*, since clearing it unblocks the other project. Dedup against an existing task rather than filing a duplicate. When the work later lands, drop =:blocker:= and notify the waiting project (=inbox-send <their-project> --text "Delivered: <what> — you're unblocked."=) so it can lift its own =:blocked:=. + ** Defer Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment line at the top: =# Deferred YYYY-MM-DD: <condition>=. Don't accumulate deferred items indefinitely — sweep them on a future process pass when the condition is met or the deferral has aged out. diff --git a/claude-templates/.ai/workflows/open-tasks.org b/claude-templates/.ai/workflows/open-tasks.org index fe782d6..4ba29dd 100644 --- a/claude-templates/.ai/workflows/open-tasks.org +++ b/claude-templates/.ai/workflows/open-tasks.org @@ -176,6 +176,10 @@ Next Mode answers two questions in one output: "what matters most right now?" (t Apply the prioritization cascade in order. Stop at the first matching step. This is the importance/urgency answer. +*Exclude blocked tasks.* A task tagged =:blocked:= has an unmet cross-project dependency (its body names the project and the work owed, per =todo-format.md=). It can't be worked until that other project delivers, so it is *never* the cascade recommendation — skip it at every cascade step below. Blocked tasks are surfaced on their own in Step 3 so the stalled dependency stays visible instead of silently dropping out of view. + +*Surface blocking tasks first.* The mirror of the above: a task tagged =:blocker:= is holding up work in *another* project (its body names which project and what's owed, per =todo-format.md=). Clearing it unblocks that project, so it carries borrowed urgency — surface it at the *top* of the cascade recommendation regardless of its own priority cookie, ahead of the normal In-Progress / deadline / priority order. When several =:blocker:= tasks exist, lead with the one blocking the most, or the longest. This is the "do the thing that unblocks someone else first" rule; a =:blocker:= task left at its own low priority is exactly how a cross-project dependency stalls. + **** 1. In-Progress Tasks - Look for tasks marked =DOING= or partially complete. - *If found:* Recommend that task (always finish what's started). @@ -228,11 +232,22 @@ Within each row, pick a single task per the same-level tie-breakers above (block The friction filter is the override path. When the cascade winner is partially blocked, hardware-dependent, or simply too large for the user's current state, one of the friction rows is what they pick instead. +*** Step 3 — Blocked-on-other-projects surface + +Independently of the cascade and the friction filter, collect every open task tagged =:blocked:=. These are tasks this project can't advance until another project delivers; surfacing them keeps a cross-project dependency from rotting at low priority on the other side — the exact failure the tag exists to prevent (a blocked task whose blocker is a =[#D]= in another project sits forever otherwise). + +For each blocked task, read its body for the blocking project and what's owed, and present one line: the task, the blocking project, and what that project owes. Then offer — per blocked task — to nudge the blocker: an =inbox-send <project> --text= note naming what's needed and why it's blocking, so the dependency gets attention in the project that owns it. Don't send without the user's go. + +If no =:blocked:= tasks exist, omit this surface entirely (the common case). + *** Output Format -Pair the cascade recommendation with the friction block beneath it. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick. +Pair the cascade recommendation with the friction block beneath it, and the blocked-on-other-projects surface (Step 3) beneath that when any blocked task exists. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick. #+begin_example +Unblocks other projects (do these first): +- ai-term wrap-teardown companion — :blocker:, unblocks rulesets (the three ai-term functions) + Cascade recommendation (importance/urgency): - Fix org-noter reliability — [#A], Method 1, 8/18 complete, blocks daily reading/annotation @@ -240,17 +255,25 @@ If you want lower friction instead: 1. Quick + solo: Bump linter config — [#C] :quick:solo:, ~15 min 2. Quick: Confirm new dirvish setup — [#B] :quick:, needs your eye 3. Solo: Refactor config-utilities — [#B] :solo:, bounded but multi-hour + +Blocked on other projects (can't advance until the blocker delivers): +- Wrap-teardown feature — blocked by emacsd: ai-term companion functions — nudge? #+end_example +The =:blocker:= surface sits at the very top — clearing one of those is the highest-leverage thing on the list, since it frees work in another project. Omit it when no =:blocker:= task exists (the common case). + Include for each row: - Task name / description. - Priority + tag cluster. - One-line reasoning. For the cascade row, name which cascade step matched. For friction rows, an effort hint when one is obvious. - Progress indicator (for V2MOM-structured todos) on the cascade row only. +- For a =:blocker:= row: the project it unblocks and what's owed (from the task body). +- For a blocked row: the blocking project and what it owes (from the task body), plus the nudge offer. **** Edge cases - *Empty friction block.* If no =:quick:= or =:solo:= tagged tasks exist in the open set, omit the friction block entirely. Present only the cascade recommendation. +- *No =:blocker:= tasks.* Omit the "Unblocks other projects" surface entirely (the common case) — show it only when a task carries the =:blocker:= tag. - *Dedupe.* If the cascade recommendation IS the same task as one of the friction rows (e.g. it's =:quick:solo:= and also won the cascade), show it once at the top with both labels. Don't list it twice. - *Decline behavior.* If the user declines the cascade recommendation, drop straight to the friction block as the natural next prompt. Do not fall through to lower-cascade-tier tasks; the friction filter IS the override. diff --git a/claude-templates/.ai/workflows/task-audit.org b/claude-templates/.ai/workflows/task-audit.org index 67ce496..94b99da 100644 --- a/claude-templates/.ai/workflows/task-audit.org +++ b/claude-templates/.ai/workflows/task-audit.org @@ -84,6 +84,21 @@ For every STALE task, edit it in the main thread: Follow =todo-format.md= for completion mechanics (depth-based DONE vs dated-rewrite) and the working-files / link-hygiene rules when moving artifacts. +** Phase C.5 — Consolidate related tasks (interactive) + +Phase C's *Consolidate duplicates* bullet folds tasks that track the *same* thing. This step is the broader case: tasks that aren't duplicates but are really *one effort* fragmented across the list. A spread-out effort — several tasks all circling "make the tooling agent-agnostic," say — is harder to see, plan, and finish as a whole than one task, or one parent with the pieces as children. + +After the Phase C edits, read the open-task set as a whole and look for *clusters*: tasks that share a goal, a subsystem, or an obvious sequence. Use judgment over the task bodies, not a keyword heuristic — adjacency is a semantic call, and a brittle title-match both misses real clusters and invents false ones. + +For each cluster, surface it to Craig (inline numbered options per =interaction.md=, no popup) with a recommendation, offering the two shapes: + +- *Merge* — fold the cluster into one task when the members are genuinely the same work split up (near-duplicates, or steps with no independent value). The merged task keeps the strongest priority, unions the type tags, and absorbs each member's body as a dated note or a short list; the absorbed tasks close per =todo-format.md= (a =**= task → =CANCELLED= + =CLOSED:= with a one-line "merged into <task>", or deletion if it carried nothing unique). +- *Parent with children* — when the members are related but distinct (each ships independently or has its own value), promote a parent task and re-home the members beneath it as sub-tasks, so the list shows the effort as a unit without losing the individual pieces. + +Never merge or re-parent autonomously — which tasks belong together, and whether they're one-work or related-distinct, is a judgment only Craig ratifies. Propose, don't apply, until he picks. A cluster he declines stays as separate tasks; don't re-surface it every audit (note the decline in the session log). + +When no clear cluster exists, say so in one line and move on — most audits won't find one, and forcing a merge fragments worse than it consolidates. + ** Phase D — Flag the judgment calls (interactive) Present the NEEDS-USER bucket as a short, scannable list — one line per task, naming the decision or the fact required. Adjudicate with the user one item at a time (inline numbered options per =interaction.md=, no popup). Apply the user's calls as they come (which may itself produce more autonomous updates, or new tasks). diff --git a/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py b/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py deleted file mode 100755 index ca4c70b..0000000 --- a/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "genanki>=0.13", -# ] -# /// -"""Convert an org-drill file into an Anki .apkg deck. - -Parses org-drill structure: - - Top-level "* Section" headings become tags on every card under them. - - Each "** Card name :drill:" entry becomes a card. Front = heading - text (sans :drill: tag). Back = entry body with newlines converted - to <br>. - -Deck name defaults to the org #+TITLE: (so the phone deck reads as the -curated title), falling back to the input basename when the source has -no #+TITLE. Deck and model IDs are derived from the deck name via stable -hash so re-importing the same deck updates existing cards instead of -duplicating them. - -Output defaults to ~/sync/phone/anki/<input-basename>.apkg. The .apkg is -a mobile-Anki artifact the phone picks up from its sync dir, so it lands -there rather than next to the org source. - -Usage: - flashcard-to-anki.py <input.org> - flashcard-to-anki.py <input.org> --deck "My Deck Name" - flashcard-to-anki.py <input.org> --output /path/to/deck.apkg - -Requires genanki, which uv resolves automatically via the PEP 723 -script metadata above. No venv or system install needed. -""" -from __future__ import annotations - -import argparse -import hashlib -import re -import sys -from pathlib import Path - -import genanki - -# 32-bit integer space genanki accepts. Start above the conventional -# "user model" floor so collisions with hand-written decks stay -# unlikely. -ID_BASE = 1_500_000_000 -ID_RANGE = 500_000_000 - - -def stable_id(name: str, salt: str) -> int: - """Derive a deterministic 32-bit id from `name` and a `salt`. - - Same (name, salt) pair always returns the same id, so re-running - against the same source produces a stable deck/model id pair and - Anki imports update existing cards in place rather than duplicating. - """ - h = hashlib.sha256(f"{salt}:{name}".encode()).hexdigest() - return ID_BASE + (int(h[:8], 16) % ID_RANGE) - - -def make_model(deck_name: str) -> genanki.Model: - return genanki.Model( - stable_id(deck_name, "model"), - f"{deck_name} (Craig)", - fields=[{"name": "Front"}, {"name": "Back"}], - templates=[ - { - "name": "Card 1", - "qfmt": "{{Front}}", - "afmt": '{{FrontSide}}<hr id="answer">{{Back}}', - } - ], - css=( - ".card { font-family: sans-serif; font-size: 18px; " - "color: #222; background: #fafafa; line-height: 1.45; }\n" - "hr#answer { margin: 14px 0; }\n" - ), - ) - - -def section_to_tag(title: str) -> str: - return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") - - -def escape_html(s: str) -> str: - return ( - s.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - ) - - -def strip_org_metadata(body_lines: list[str]) -> list[str]: - """Drop :PROPERTIES: drawers, planning lines, and created-date lines. - - Org-drill needs these in the source file (SRS state lives in the - PROPERTIES drawer; SCHEDULED carries the next-review date), but they - are noise on the back of an Anki card. A created/added date never - belongs on a card, so a stray "Created:" or ":CREATED:" body line is - dropped too. - """ - cleaned: list[str] = [] - in_drawer = False - planning_re = re.compile(r"^\s*(SCHEDULED|DEADLINE|CLOSED):\s") - created_re = re.compile(r"^\s*:?created:?\s", re.IGNORECASE) - drawer_start_re = re.compile(r"^\s*:PROPERTIES:\s*$") - drawer_end_re = re.compile(r"^\s*:END:\s*$") - for line in body_lines: - if in_drawer: - if drawer_end_re.match(line): - in_drawer = False - continue - if drawer_start_re.match(line): - in_drawer = True - continue - if planning_re.match(line) or created_re.match(line): - continue - cleaned.append(line) - return cleaned - - -def parse(org_text: str) -> list[tuple[str, str, str]]: - """Return [(front, back_html, tag), ...] for every :drill: card.""" - cards: list[tuple[str, str, str]] = [] - current_section: str | None = None - - section_re = re.compile(r"^\*\s+(.+?)\s*$") - card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") - - lines = org_text.splitlines() - i = 0 - while i < len(lines): - line = lines[i] - - sec = section_re.match(line) - if sec: - current_section = sec.group(1).strip() - i += 1 - continue - - card = card_re.match(line) - if card: - front = card.group(1).strip() - body_lines: list[str] = [] - i += 1 - while i < len(lines): - nxt = lines[i] - if nxt.startswith("* ") or card_re.match(nxt): - break - body_lines.append(nxt) - i += 1 - body_lines = strip_org_metadata(body_lines) - while body_lines and not body_lines[0].strip(): - body_lines.pop(0) - while body_lines and not body_lines[-1].strip(): - body_lines.pop() - back_html = "<br>".join(escape_html(ln) for ln in body_lines) - tag = section_to_tag(current_section) if current_section else "drill" - cards.append((front, back_html, tag)) - continue - - i += 1 - - return cards - - -def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck: - deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name) - model = make_model(deck_name) - for front, back, tag in cards: - note = genanki.Note( - model=model, - fields=[front, back], - tags=[tag], - guid=genanki.guid_for(front), - ) - deck.add_note(note) - return deck - - -def default_deck_name(input_path: Path, org_text: str) -> str: - """Deck name defaults to the org #+TITLE:, falling back to the basename. - - The #+TITLE drives both the org-drill display in Emacs and the Anki - deck name on the phone, so the consumed deck reads as the curated - title ("Refutations") rather than the filename slug - ("refutation-drill"). Falls back to the input basename (case - preserved) when the source has no non-empty #+TITLE line. - """ - for line in org_text.splitlines(): - m = re.match(r"^#\+TITLE:\s*(.*\S)\s*$", line, re.IGNORECASE) - if m: - return m.group(1).strip() - return input_path.stem - - -def default_output_path(input_path: Path) -> Path: - anki_dir = Path.home() / "sync" / "phone" / "anki" - return anki_dir / f"{input_path.stem}.apkg" - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Convert an org-drill file into an Anki .apkg deck.", - ) - parser.add_argument( - "input", - type=Path, - help="Path to the org-drill source file.", - ) - parser.add_argument( - "--deck", - help="Deck name. Defaults to the org #+TITLE, or the input basename.", - ) - parser.add_argument( - "--output", - type=Path, - help="Output .apkg path. Defaults to " - "~/sync/phone/anki/<input-basename>.apkg.", - ) - args = parser.parse_args() - - input_path: Path = args.input.expanduser().resolve() - if not input_path.is_file(): - print(f"error: {input_path} not found", file=sys.stderr) - return 1 - - org_text = input_path.read_text(encoding="utf-8") - deck_name = args.deck or default_deck_name(input_path, org_text) - output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve() - output_path.parent.mkdir(parents=True, exist_ok=True) - - cards = parse(org_text) - if not cards: - print(f"error: no :drill: cards found in {input_path}", file=sys.stderr) - return 1 - - deck = build(cards, deck_name) - genanki.Package(deck).write_to_file(str(output_path)) - print(f"wrote {output_path} ({len(cards)} cards, deck '{deck_name}')") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/docs/design/2026-06-21-anki-titlefix-test.py b/docs/design/2026-06-21-anki-titlefix-test.py deleted file mode 100644 index 87008a8..0000000 --- a/docs/design/2026-06-21-anki-titlefix-test.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for flashcard-to-anki.py default-path and deck-name helpers. - -The script is a PEP 723 uv-run script that imports genanki, which uv resolves -at runtime but isn't installed in the test environment. The fixture stubs -genanki in sys.modules so the module loads; the pure helpers under test never -call into it. -""" -from __future__ import annotations - -import importlib.util -import sys -import types -from pathlib import Path - -import pytest - -SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-to-anki.py" - - -@pytest.fixture(scope="module") -def drill(): - # Only stub when genanki is genuinely absent, so a real install isn't shadowed. - sys.modules.setdefault("genanki", types.ModuleType("genanki")) - spec = importlib.util.spec_from_file_location("flashcard_to_anki", SCRIPT) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_default_output_path_targets_phone_anki_dir(drill): - """The .apkg is a phone artifact, so it defaults under sync/phone/anki/.""" - result = drill.default_output_path(Path("/home/x/projects/health/health-drill.org")) - assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg" - - -def test_default_deck_name_uses_org_title(drill): - """The #+TITLE drives the Anki deck name, not the filename slug.""" - org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n" - assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations" - - -def test_default_deck_name_title_is_trimmed(drill): - """Surrounding whitespace on the #+TITLE value is stripped.""" - org = "#+TITLE: DeepSat Flashcards \n" - assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards" - - -def test_default_deck_name_title_match_is_case_insensitive(drill): - """A lowercase #+title: keyword is still recognized.""" - org = "#+title: Health Flashcards\n" - assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards" - - -def test_default_deck_name_falls_back_to_basename_without_title(drill): - """No #+TITLE line falls back to the input basename, case preserved.""" - org = "* Section\n** Q? :drill:\na\n" - assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat" - - -def test_default_deck_name_blank_title_falls_back_to_basename(drill): - """An empty #+TITLE value is ignored in favour of the basename.""" - assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE: \n") == "health-drill" - - -# --- section_to_tag (pure) --- - -def test_section_to_tag_slugifies_words(drill): - assert drill.section_to_tag("Orbital Regimes") == "orbital-regimes" - - -def test_section_to_tag_strips_leading_and_trailing_nonalnum(drill): - assert drill.section_to_tag(" People & Roles! ") == "people-roles" - - -def test_section_to_tag_empty_string(drill): - assert drill.section_to_tag("") == "" - - -# --- escape_html (pure) --- - -def test_escape_html_escapes_amp_lt_gt(drill): - assert drill.escape_html("a & b < c > d") == "a & b < c > d" - - -def test_escape_html_plain_text_unchanged(drill): - assert drill.escape_html("plain text") == "plain text" - - -def test_escape_html_escapes_amp_first_so_existing_entity_is_literal(drill): - # & is replaced before < / >, so a literal "<" becomes "&lt;", - # not silently treated as an already-escaped entity. - assert drill.escape_html("<") == "&lt;" - - -def test_escape_html_empty_string(drill): - assert drill.escape_html("") == "" - - -# --- stable_id (pure) --- - -def test_stable_id_is_deterministic(drill): - assert drill.stable_id("DeepSat", "deck") == drill.stable_id("DeepSat", "deck") - - -def test_stable_id_salt_changes_the_result(drill): - assert drill.stable_id("DeepSat", "deck") != drill.stable_id("DeepSat", "model") - - -def test_stable_id_stays_within_the_reserved_range(drill): - value = drill.stable_id("anything", "deck") - assert drill.ID_BASE <= value < drill.ID_BASE + drill.ID_RANGE - - -# --- strip_org_metadata (pure) --- - -def test_strip_org_metadata_drops_properties_drawer(drill): - body = [":PROPERTIES:", ":ID: x", ":END:", "real content"] - assert drill.strip_org_metadata(body) == ["real content"] - - -def test_strip_org_metadata_drops_planning_lines(drill): - body = ["SCHEDULED: <2026-05-30>", "DEADLINE: <2026-06-01>", - "CLOSED: [2026-05-29]", "body"] - assert drill.strip_org_metadata(body) == ["body"] - - -def test_strip_org_metadata_leaves_plain_body_unchanged(drill): - body = ["line one", "line two"] - assert drill.strip_org_metadata(body) == ["line one", "line two"] - - -def test_strip_org_metadata_empty_list(drill): - assert drill.strip_org_metadata([]) == [] - - -def test_strip_org_metadata_unclosed_drawer_swallows_the_rest(drill): - # An unterminated :PROPERTIES: drawer consumes everything after it. - body = [":PROPERTIES:", ":ID: x", "still in drawer"] - assert drill.strip_org_metadata(body) == [] - - -def test_strip_org_metadata_drops_created_date_line(drill): - # A created/added date never belongs on a card back. - assert drill.strip_org_metadata(["Created: 2026-05-30", "real answer"]) == ["real answer"] - - -# --- parse (pure, core parser) --- - -SECTIONED = """* Orbital Regimes -** What is LEO? :drill: -Low Earth Orbit. -** What is GEO? :drill: -Geostationary Earth Orbit. -""" - - -def test_parse_returns_front_back_tag_per_card(drill): - cards = drill.parse(SECTIONED) - assert len(cards) == 2 - assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes") - assert cards[1][0] == "What is GEO?" - - -def test_parse_card_without_a_section_gets_the_drill_tag(drill): - assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")] - - -def test_parse_strips_properties_drawer_from_back(drill): - text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n" - assert drill.parse(text) == [("Q?", "The answer.", "drill")] - - -def test_parse_trims_leading_and_trailing_blank_body_lines(drill): - cards = drill.parse("** Q? :drill:\n\n\nanswer\n\n\n") - assert cards[0][1] == "answer" - - -def test_parse_card_with_only_a_drawer_has_empty_back(drill): - text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n" - assert drill.parse(text) == [("Q?", "", "drill")] - - -def test_parse_joins_multiline_body_with_br(drill): - cards = drill.parse("** Q? :drill:\nline one\nline two\n") - assert cards[0][1] == "line one<br>line two" - - -def test_parse_no_drill_cards_returns_empty(drill): - assert drill.parse("* Section\nno drill cards here\n") == [] @@ -30,16 +30,15 @@ Optional *effort and autonomy tags* — orthogonal to type, both can apply on th - =:quick:= — likely to take ≤30 minutes from start through verification. - =:solo:= — Claude can complete the work end to end, including verification, without input from Craig. +Optional *dependency tags* — cross-project, both plain tags with the which-project detail in the task body (per =todo-format.md=): + +- =:blocked:= — the task can't advance until another project delivers the work named in its body. =open-tasks.org= pulls =:blocked:= tasks out of the cascade and surfaces them on their own. Distinct from =VERIFY= (which waits on Craig). +- =:blocker:= — this task owes work that's blocking another project (named in its body). =open-tasks.org= surfaces =:blocker:= tasks first, since clearing one unblocks the other project. + Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest in passing. * Rulesets Open Work -** TODO [#D] Fully-unattended scheduled inbox check (/schedule cron pass) :feature: -:PROPERTIES: -:CREATED: [2026-06-23 Tue] -:END: -vNext from the inbox-consolidation spec. =auto inbox zero= (v1) is the interactive =/loop= recurring check that waits for Craig's yes before executing. A fully-unattended =/schedule= cron pass that fires while Craig is away needs its own contract before it can ship: read-only vs may-mutate =todo.org= / =~/org/roam/inbox.org=, how a find surfaces asynchronously when Craig isn't at the session, how dedup state persists across runs that don't share a session, and what session/auth context a cron run carries. Design it after v1 consolidation lands. From the inbox-consolidation spec-review (Codex finding 1). See [[file:docs/inbox-workflow-consolidation-spec.org][spec]]. - ** DOING [#B] wrap-it-up teardown + "wrap it up and shutdown" :feature: :PROPERTIES: :CREATED: [2026-06-23 Tue] @@ -53,6 +52,9 @@ Craig's three decisions (2026-06-23): non-destructive qualifier is *both* "with *** 2026-06-23 Tue @ 23:40 .emacs.d received + filed the companion spec Per a roam-inbox FYI from .emacs.d (2026-06-23 23:38): both ai-term handoffs (multi-LLM support + this wrap-teardown companion spec) landed and are filed as .emacs.d tasks. The teardown one is flagged for its own focused session to land alongside the rulesets half. Part (c) is now in progress on the .emacs.d side. +*** 2026-06-24 Wed @ 06:51:13 -0400 Unblocked — .emacs.d companion landed; feature now live +The three companion functions are in =.emacs.d/modules/ai-term.el= (=cj/ai-term-quit= 1068, =cj/ai-term-live-count= 1087, =cj/ai-term-shutdown-countdown= 1109), matching the contract — double-checked the bodies: quit kills session+buffer+restores layout idempotently, live-count returns the gate integer, shutdown-countdown re-checks the gate (TOCTOU guard), runs an abort-able =run-at-time= countdown (C-g cancels), then a configurable =cj/ai-term-shutdown-command=. 13 ERT tests, headless-verified live (.emacs.d FYI 2026-06-24 06:44). Dropped =:blocked:= / =:BLOCKED_BY:= — the build dependency is resolved; only the manual end-to-end validation below remains. NOTE: with the Stop hook wired and the companion present, the feature is now functional — the next bare "wrap it up" will actually tear the session down. Run the validation below before relying on it. + *** TODO Manual testing and validation :test: What we're verifying: the wrap-teardown + shutdown feature end to end, once =.emacs.d/modules/ai-term.el= has the three companion functions and the =Stop= hook is installed (=make install-hooks= + the =settings-snippet.json= Stop block in =~/.claude/settings.json=). These need a live Emacs daemon + tmux + an =aiv-<proj>= ai-term session; they can't be driven from a script. @@ -84,58 +86,7 @@ What we're verifying: no sentinel is dropped before commit + push succeeds. - Trigger a teardown wrap in a state where the push would fail (e.g. temporarily point the remote somewhere unreachable). Expected: the wrap surfaces the push failure and stops; no =/tmp/ai-wrap-*-<proj>= sentinel is created, so no teardown fires. -** TODO [#C] Multiple agent-source improvements :spec: -:PROPERTIES: -:CREATED: [2026-06-23 Tue] -:LAST_REVIEWED: 2026-06-24 -:END: -Make the tooling agent-agnostic instead of Claude-specific. Three threads from Craig (roam 2026-06-23): (1) give the agent a name so workflows don't say "Claude" everywhere — a non-Claude agent (Codex) reading "you are Claude" gets confused; evaluate whether naming resolves the confusion or whether other spots also leak Claude-specificity. (2) Pull agent-neutral content out of Anthropic-specific files (=CLAUDE.md=) into a shared source that each agent's own entry file points to, so Codex (which runs more literal) reads the same rules; or link =CLAUDE.md= and the Codex equivalent to one source. Have Codex review the workflows for literal-reading wording gaps. (3) Send =.emacs.d= a note (inbox-send) to let =ai-term= launch Claude / Codex / a local ollama LLM, switchable seamlessly at session start. Spec-shaped — needs design before build. From the roam inbox 2026-06-23 (deferred from the 2026-06-21 session). - -*** 2026-06-24 Wed @ 00:21:20 -0400 Partial — agent-neutral wording sweep + thread-3 note landed -Thread 2's wording half shipped in 6ad0442 (=refactor(rules): use agent-neutral language in shared rules=): agent-as-actor phrasing replaced with "the agent" across interaction.md, cross-project.md, triggers.md, working-files.md. Thread 3's note reached =.emacs.d=, whose 2026-06-23 inbox FYI confirms it received and filed the "multi-LLM support" ai-term handoff. Remaining and still TODO: thread 1 (give the agent a name), and thread 2's structural half (extract agent-neutral content into a shared source with a Codex entry-file pointer, then have Codex review the workflows for literal-reading gaps). - -** TODO [#B] Anki deck name from #+TITLE :bug:quick:solo: -:PROPERTIES: -:CREATED: [2026-06-22 Mon] -:LAST_REVIEWED: 2026-06-24 -:END: -flashcard-to-anki.py's =default_deck_name= returns =input_path.stem= (the filename), so every deck built through =flashcard-sync= (which passes no =--deck=) is named after the file, not the curated =#+TITLE=. =flashcard-review.org= already documents the intended behavior ("the #+TITLE line drives the Anki deck name"); the script never matched it. Fix: =default_deck_name(input_path, org_text)= scans for a =#+TITLE:= line (case-insensitive, trimmed) and returns it, basename fallback when absent; =main()= passes the already-read =org_text=. Edited script + test ready (validated, 29 pass): [[file:docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py][script]], [[file:docs/design/2026-06-21-anki-titlefix-test.py][test]], rationale [[file:docs/design/2026-06-21-anki-titlefix-proposal.org][proposal]]. Apply to both =.ai/scripts/= and =claude-templates/.ai/scripts/=, sync-check + make test. Migration caveat: deck ID derives from the name, so decks previously built without =--deck= land as new decks on next import (old basename-named decks keep history, delete by hand). Coordinate with "Reconcile flashcard multi-tag tooling into canonical" below — both edit =flashcard-to-anki.py=, build together to avoid conflicting edits. Shared-asset, review-gated. From home 2026-06-21. - -** TODO [#C] apkg → org-drill converter :feature: -:PROPERTIES: -:CREATED: [2026-06-22 Mon] -:LAST_REVIEWED: 2026-06-24 -:END: -Inverse of =flashcard-to-anki.py=: read an Anki =.apkg= (zip → =collection.anki2=/=.anki21= sqlite) and emit an org-drill =.org= in the house canonical shape. Recovers orphaned decks (=deepsat-fundamentals.apkg= has no saved =.org= source) and enables phone→org round-trip. Mapping: deck name → =#+TITLE=; each note → =** <Front> :drill:= with Back as body; card tag → =* Section= grouping (best-effort); Back HTML → org (=<br>= → newlines, unescape entities, strip =<hr id="answer">=); fresh =:ID:= UUID per card. Edge cases for tests: multiple decks per apkg, non-basic note types (skip/warn), HTML entities, empty back, media refs, =.anki2= vs =.anki21= schema. Lives beside the flashcard-* family in =claude-templates/.ai/scripts/= (a new file must be built in canonical — downstream =.ai/scripts/= is wiped by startup =--delete=). PEP 723 uv-run, stdlib =zipfile= + =sqlite3= (no genanki for reading). Acceptance: round-trip a known org-drill source through =flashcard-to-anki.py= then back, assert cards match. Build request: [[file:docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org][buildreq]]. Backlog, not urgent. From home 2026-06-21. - -** TODO [#C] flashcard-stats refutation / claim-prompt mode :feature: -:PROPERTIES: -:CREATED: [2026-06-22 Mon] -:LAST_REVIEWED: 2026-06-24 -:END: -A refutation card (heading is a bare false claim, body is the rebuttal) is valid org-drill but trips two BLOCKING =flashcard-stats.py= checks as false positives: non-prompt-heading (a declarative claim has no =?= or imperative verb) and answer-leakage (claim words reappear in the rebuttal). =flashcard-sync='s gate then blocks the whole deck. Fix (pick one): a file-level =#+DECK_KIND: refutation= keyword that skips those two checks for the file, or a per-card =:claim:= tag exempting individual cards. Option 1 is simpler and matches how the deck works (the whole file is one family). Also document the family in =flashcard-review.org= and add tests (refutation-marked file passes despite declarative headings + claim/answer overlap). Edits =flashcard-stats.py= — coordinate with the multi-tag reconcile, same file. Proposal: [[file:docs/design/2026-06-21-flashcard-stats-refutation-proposal.org][proposal]]. Backlog. From home 2026-06-21. - -** TODO [#C] Guard against hardcoded host identity in synced files :feature: -:PROPERTIES: -:CREATED: [2026-06-22 Mon] -:LAST_REVIEWED: 2026-06-24 -:END: -A =CLAUDE.md= / notes file that asserts mutable environment identity as a fixed fact ("This machine is ratio", a current OS, an IP, "the laptop") is false on every machine the synced/tracked file lands on but one. It bit a real archsetup session: a stale "this machine is ratio" line made the agent reason backwards all session while on velox. Proposal: a claude-rule — don't assert mutable host/env identity as a fixed fact in a tracked/synced project file; derive it at runtime and name the command (=uname -n= for host; the =hostname= binary is often absent). Optionally a codify- or startup-time lint flagging "this machine is <name>" / "the current host is" style claims. Decide rule-only vs rule+lint. Proposal: [[file:docs/design/2026-06-21-host-identity-guard-proposal.org][proposal]]. From archsetup 2026-06-21. - -** TODO [#C] coverage-summary.el install location vs CI reachability :bug: -:PROPERTIES: -:CREATED: [2026-06-22 Mon] -:LAST_REVIEWED: 2026-06-24 -:END: -The elisp bundle installs =coverage-summary.el= into =.claude/scripts/=, which is gitignored in code projects, so CI can't run =make coverage-summary= against it. emacs-wttrin flagged this (its copy's header was rewritten to claim a tracked =scripts/= home). Decide: ship =coverage-summary.el= to a tracked =scripts/= dir so CI reaches it, or keep =.claude/scripts/= and document it as a local-only helper. If moved, reconcile the bundle install path + the =make coverage-summary= fragment + the script's header comment. Surfaced 2026-06-21 during the coverage-summary autoloads bugfix (commit fb86736). - -** TODO [#D] Warn-only pre-commit hook for tooling-path enumeration :feature: -:PROPERTIES: -:CREATED: [2026-06-22 Mon] -:END: -Optional enforcement teeth for the no-attribution / no-tooling-artifacts tightening landed 2026-06-22 (commit 91217d9), which is documentation-only. A warn-only (not blocking) pre-commit hook could scan the commit subject + body for tooling-path enumeration (=CLAUDE.md=, =.claude/=, =.ai/=, =todo.org=, =notes.org=, =session-context=) and AI-attribution language, with the two exemptions baked in: a commit whose change IS one of those files, and private single-user repos. Must warn, not block — a rigid grep false-positives on legit subject mentions. Deferred: Craig chose docs-only for now. - -** TODO [#B] Helper-instance support — concurrent same-project Claude :feature:spec: +** TODO [#B] Helper-agent instance support — concurrent same-project Claude :feature:spec: :PROPERTIES: :CREATED: [2026-06-11 Thu] :LAST_REVIEWED: 2026-06-24 @@ -179,6 +130,87 @@ DEPENDENCY QUESTION (Craig, 2026-06-15, resolved 2026-06-24 — see below): does *** 2026-06-24 Wed @ 00:30:32 -0400 RESOLVED — independent, unblocked (keyword VERIFY → TODO) Craig's call (2026-06-24): helper-instance is independent of the generic-runtime refactor and builds on its own. The shipped pieces and the remaining wiring are all shared-file concurrency-safety (two Edit writers, one file), orthogonal to which LLM runtime launches — none of it assumes the runtime-manifest / multi-runtime machinery of phases 2-6. One caveat: =ai --helper= overlaps the launcher refactor the generic-runtime arc plans, so keep that launcher change small and contained so the later refactor doesn't fight it. Now a buildable [#B] task behind its own three-ring gate (bats → sandbox drills → live pilot). +** DOING [#B] Wrap-up inbox/transcript routing to destination projects :feature:spec: +:PROPERTIES: +:CREATED: [2026-06-13 Sat] +:LAST_REVIEWED: 2026-06-24 +:END: +Optional wrap-up step that surfaces filed keepers belonging to another project, recommends a destination, and routes each to that project's =inbox/= via =inbox-send= (the destination's own =process-inbox= files it; transcript filing deferred to vNext). Spec: [[file:docs/design/wrapup-routing-spec.org]] — Ready, [9/9] decisions. Source proposal: [[file:docs/design/2026-06-13-wrapup-inbox-transcript-routing-proposal.org]]. + +*** 2026-06-21 Sun @ 02:06:37 -0400 Spec-review + spec-response complete — Ready +Craig's review challenge reshaped the design from a direct cross-repo =todo.org= move to =inbox-send= delivery into the destination's inbox (safer: reuses the sanctioned cross-project path, gets provenance + per-project filing for free, degrades gracefully where a destination has an =inbox/= but no =todo.org=). D2/D3 superseded; D7 (inbox-send delivery), D8 (=:ROUTE_CANDIDATE:= marker at file time), D9 (local source removal + reject-flow recovery) added. Spec-review file consumed and deleted. Implementation-task breakdown filed below (spec-response Phase 6). + +*** 2026-06-24 Wed @ 00:21:20 -0400 Reconcile — marker sub-task repointed at inbox.org +The 2026-06-23 inbox consolidation (24ca58d) merged =process-inbox= + =monitor-inbox= + =inbox-zero= into one =inbox.org= engine (process/monitor/roam modes) and deleted the three old files. The =:ROUTE_CANDIDATE:= marker sub-task targeted =process-inbox.org='s Phase D — repointed it to =inbox.org= process mode (core §3 "File as TODO"). No build has started, so this is a target-rename only; the spec design is unaffected. + +*** TODO [#B] Recommendation engine + destination discovery :feature:solo: +Pure function =(item, project-list) → (destination, confidence)= reusing =inbox-send.py='s =discover_projects= for the project list. Confidence tiers: strong (destination name/path literal in the item), weak (topic-word overlap only — still routed, visibly labeled), none (stays put, never surfaced). Unit-tested directly: strong/weak/none, two-project tie, empty project list. Covers spec Phases 1 + 3. Spec: [[file:docs/design/wrapup-routing-spec.org]]. + +*** TODO [#B] =:ROUTE_CANDIDATE:= marker in inbox process mode :feature:solo: +Extend =inbox.org= process mode's "File as TODO" disposition (core §3 / Phase D) to stamp =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home differs from the current project (uses the engine above). Edit the canonical, sync the =.ai/= mirror, verify sync-check clean. Spec Phase 2 / D8. Spec: [[file:docs/design/wrapup-routing-spec.org]]. (Originally targeted =process-inbox.org=, merged into =inbox.org= by the 2026-06-23 consolidation.) + +*** TODO [#B] Wrap-up router sub-step in wrap-it-up.org :feature:solo: +Add the optional router to =wrap-it-up.org= Step 3 after the inbox sanity check: surface the =:ROUTE_CANDIDATE:= batch (task / destination / delivery mode / confidence), go/skip; on go, per candidate =inbox-send= a one-task handoff to the destination's =inbox/= and remove the keeper from the local =todo.org=; empty set = silent. Name the gate-vs-optional split in the prose. Edit canonical + sync mirror. Spec Phase 4 / D7 / D9. Spec: [[file:docs/design/wrapup-routing-spec.org]]. + +*** TODO [#B] Wrap-up routing — test surface :test:solo: +Unit: recommendation engine (strong/weak/none, two-project tie, empty list); marker stamping (cross-project keeper tagged, local keeper not, standing backlog never). Integration (bats, fixture projects + temp =todo.org=): go issues N =inbox-send= calls to the right inboxes with sources removed; skip leaves all in place; empty set = zero interaction; a candidate whose destination has =inbox/= but no =todo.org= still delivers. Spec: [[file:docs/design/wrapup-routing-spec.org]] (Acceptance criteria). + +*** TODO [#B] Wrap-up routing — manual end-to-end validation :test: +What we're verifying: a real keeper routes through a live wrap and the destination actually files it. +- In a project session, let process-inbox file a handoff whose home is a different project; confirm the local task carries =:ROUTE_CANDIDATE: <dest>=. +- Run wrap-it-up; at the router sub-step, confirm the candidate is surfaced with the right destination + confidence, then choose "go". +- Confirm a =from-<thisproject>= handoff landed in the destination's =inbox/= and the keeper was removed from the local =todo.org=. +- Open the destination project; confirm its startup/process-inbox files the handoff into its =todo.org= per its own conventions. +Expected: the task ends up in the destination's =todo.org=, gone from the source, with no foreign =todo.org= written directly. Not =:solo:= — needs a real cross-project wrap and the destination's next session. + +*** TODO [#D] Wrap-up routing — transcript filing (vNext) :feature:no-sync: +File a meeting recording into the destination =assets/= per =working-files.md=, batch go/skip mirroring the task router. Gated on the source-location decision (spec D4). Spec: [[file:docs/design/wrapup-routing-spec.org]] (Phase 5). + +** TODO [#C] Multiple agent-source improvements :spec: +:PROPERTIES: +:CREATED: [2026-06-23 Tue] +:LAST_REVIEWED: 2026-06-24 +:END: +Make the tooling agent-agnostic instead of Claude-specific. Three threads from Craig (roam 2026-06-23): (1) give the agent a name so workflows don't say "Claude" everywhere — a non-Claude agent (Codex) reading "you are Claude" gets confused; evaluate whether naming resolves the confusion or whether other spots also leak Claude-specificity. (2) Pull agent-neutral content out of Anthropic-specific files (=CLAUDE.md=) into a shared source that each agent's own entry file points to, so Codex (which runs more literal) reads the same rules; or link =CLAUDE.md= and the Codex equivalent to one source. Have Codex review the workflows for literal-reading wording gaps. (3) Send =.emacs.d= a note (inbox-send) to let =ai-term= launch Claude / Codex / a local ollama LLM, switchable seamlessly at session start. Spec-shaped — needs design before build. From the roam inbox 2026-06-23 (deferred from the 2026-06-21 session). + +*** 2026-06-24 Wed @ 00:21:20 -0400 Partial — agent-neutral wording sweep + thread-3 note landed +Thread 2's wording half shipped in 6ad0442 (=refactor(rules): use agent-neutral language in shared rules=): agent-as-actor phrasing replaced with "the agent" across interaction.md, cross-project.md, triggers.md, working-files.md. Thread 3's note reached =.emacs.d=, whose 2026-06-23 inbox FYI confirms it received and filed the "multi-LLM support" ai-term handoff. Remaining and still TODO: thread 1 (give the agent a name), and thread 2's structural half (extract agent-neutral content into a shared source with a Codex entry-file pointer, then have Codex review the workflows for literal-reading gaps). + +** TODO [#C] apkg → org-drill converter :feature:solo: +:PROPERTIES: +:CREATED: [2026-06-22 Mon] +:LAST_REVIEWED: 2026-06-24 +:END: +Inverse of =flashcard-to-anki.py=: read an Anki =.apkg= (zip → =collection.anki2=/=.anki21= sqlite) and emit an org-drill =.org= in the house canonical shape. Recovers orphaned decks (=deepsat-fundamentals.apkg= has no saved =.org= source) and enables phone→org round-trip. Mapping: deck name → =#+TITLE=; each note → =** <Front> :drill:= with Back as body; card tag → =* Section= grouping (best-effort); Back HTML → org (=<br>= → newlines, unescape entities, strip =<hr id="answer">=); fresh =:ID:= UUID per card. Edge cases for tests: multiple decks per apkg, non-basic note types (skip/warn), HTML entities, empty back, media refs, =.anki2= vs =.anki21= schema. Lives beside the flashcard-* family in =claude-templates/.ai/scripts/= (a new file must be built in canonical — downstream =.ai/scripts/= is wiped by startup =--delete=). PEP 723 uv-run, stdlib =zipfile= + =sqlite3= (no genanki for reading). Acceptance: round-trip a known org-drill source through =flashcard-to-anki.py= then back, assert cards match. Build request: [[file:docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org][buildreq]]. Backlog, not urgent. From home 2026-06-21. + +** TODO [#C] flashcard-stats refutation / claim-prompt mode :feature: +:PROPERTIES: +:CREATED: [2026-06-22 Mon] +:LAST_REVIEWED: 2026-06-24 +:END: +A refutation card (heading is a bare false claim, body is the rebuttal) is valid org-drill but trips two BLOCKING =flashcard-stats.py= checks as false positives: non-prompt-heading (a declarative claim has no =?= or imperative verb) and answer-leakage (claim words reappear in the rebuttal). =flashcard-sync='s gate then blocks the whole deck. Fix (pick one): a file-level =#+DECK_KIND: refutation= keyword that skips those two checks for the file, or a per-card =:claim:= tag exempting individual cards. Option 1 is simpler and matches how the deck works (the whole file is one family). Also document the family in =flashcard-review.org= and add tests (refutation-marked file passes despite declarative headings + claim/answer overlap). Edits =flashcard-stats.py= — coordinate with the multi-tag reconcile, same file. Proposal: [[file:docs/design/2026-06-21-flashcard-stats-refutation-proposal.org][proposal]]. Backlog. From home 2026-06-21. +#+begin_src cj: comment + we need to make it more generic than this. there will be other cards like this in the future. let's not block against the information when it exists in the org header. +#+end_src + +** TODO [#C] Guard against hardcoded host identity in synced files :feature:solo: +:PROPERTIES: +:CREATED: [2026-06-22 Mon] +:LAST_REVIEWED: 2026-06-24 +:END: +A =CLAUDE.md= / notes file that asserts mutable environment identity as a fixed fact ("This machine is ratio", a current OS, an IP, "the laptop") is false on every machine the synced/tracked file lands on but one. It bit a real archsetup session: a stale "this machine is ratio" line made the agent reason backwards all session while on velox. Proposal: a claude-rule — don't assert mutable host/env identity as a fixed fact in a tracked/synced project file; derive it at runtime and name the command (=uname -n= for host; the =hostname= binary is often absent). Optionally a codify- or startup-time lint flagging "this machine is <name>" / "the current host is" style claims. Decide rule-only vs rule+lint. Proposal: [[file:docs/design/2026-06-21-host-identity-guard-proposal.org][proposal]]. From archsetup 2026-06-21. + +** TODO [#C] coverage-summary.el install location vs CI reachability :bug: +:PROPERTIES: +:CREATED: [2026-06-22 Mon] +:LAST_REVIEWED: 2026-06-24 +:END: +The elisp bundle installs =coverage-summary.el= into =.claude/scripts/=, which is gitignored in code projects, so CI can't run =make coverage-summary= against it. emacs-wttrin flagged this (its copy's header was rewritten to claim a tracked =scripts/= home). Decide: ship =coverage-summary.el= to a tracked =scripts/= dir so CI reaches it, or keep =.claude/scripts/= and document it as a local-only helper. If moved, reconcile the bundle install path + the =make coverage-summary= fragment + the script's header comment. Surfaced 2026-06-21 during the coverage-summary autoloads bugfix (commit fb86736). + +#+begin_src cj: comment +we can document it as a local-only helper. +#+end_src + ** VERIFY [#C] Check that memories are sync'd across machines via git :spec: :PROPERTIES: :LAST_REVIEWED: 2026-06-15 @@ -274,51 +306,6 @@ Expected: all four behave per the spec; any miss promotes to a bug task. (Agent- *** 2026-06-10 Wed @ 18:21:33 -0500 Phase 4 done — monthly hygiene automation live =scripts/kb-hygiene.sh= (6 bats green, shellcheck clean, read-only by design) inventories =:agent:= nodes, flags orphans / duplicate titles / conflict files, and writes an org report into the rulesets inbox; =roam-hygiene.timer= (monthly, Persistent) installed + enabled. Live run against the real KB verified (4 agent nodes, 428 files, 0 conflicts). Conditional vNext stays in the spec's scope tiers: a =/promote= command if the wrap-up prompt proves insufficient, an =:agent:inbox:= staging tag if free writes prove too noisy. Commit b014095. -** DOING [#B] Wrap-up inbox/transcript routing to destination projects :feature:spec: -:PROPERTIES: -:CREATED: [2026-06-13 Sat] -:LAST_REVIEWED: 2026-06-24 -:END: -Optional wrap-up step that surfaces filed keepers belonging to another project, recommends a destination, and routes each to that project's =inbox/= via =inbox-send= (the destination's own =process-inbox= files it; transcript filing deferred to vNext). Spec: [[file:docs/design/wrapup-routing-spec.org]] — Ready, [9/9] decisions. Source proposal: [[file:docs/design/2026-06-13-wrapup-inbox-transcript-routing-proposal.org]]. - -*** 2026-06-21 Sun @ 02:06:37 -0400 Spec-review + spec-response complete — Ready -Craig's review challenge reshaped the design from a direct cross-repo =todo.org= move to =inbox-send= delivery into the destination's inbox (safer: reuses the sanctioned cross-project path, gets provenance + per-project filing for free, degrades gracefully where a destination has an =inbox/= but no =todo.org=). D2/D3 superseded; D7 (inbox-send delivery), D8 (=:ROUTE_CANDIDATE:= marker at file time), D9 (local source removal + reject-flow recovery) added. Spec-review file consumed and deleted. Implementation-task breakdown filed below (spec-response Phase 6). - -*** 2026-06-24 Wed @ 00:21:20 -0400 Reconcile — marker sub-task repointed at inbox.org -The 2026-06-23 inbox consolidation (24ca58d) merged =process-inbox= + =monitor-inbox= + =inbox-zero= into one =inbox.org= engine (process/monitor/roam modes) and deleted the three old files. The =:ROUTE_CANDIDATE:= marker sub-task targeted =process-inbox.org='s Phase D — repointed it to =inbox.org= process mode (core §3 "File as TODO"). No build has started, so this is a target-rename only; the spec design is unaffected. - -*** TODO [#B] Recommendation engine + destination discovery :feature:solo: -Pure function =(item, project-list) → (destination, confidence)= reusing =inbox-send.py='s =discover_projects= for the project list. Confidence tiers: strong (destination name/path literal in the item), weak (topic-word overlap only — still routed, visibly labeled), none (stays put, never surfaced). Unit-tested directly: strong/weak/none, two-project tie, empty project list. Covers spec Phases 1 + 3. Spec: [[file:docs/design/wrapup-routing-spec.org]]. - -*** TODO [#B] =:ROUTE_CANDIDATE:= marker in inbox process mode :feature:solo: -Extend =inbox.org= process mode's "File as TODO" disposition (core §3 / Phase D) to stamp =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home differs from the current project (uses the engine above). Edit the canonical, sync the =.ai/= mirror, verify sync-check clean. Spec Phase 2 / D8. Spec: [[file:docs/design/wrapup-routing-spec.org]]. (Originally targeted =process-inbox.org=, merged into =inbox.org= by the 2026-06-23 consolidation.) - -*** TODO [#B] Wrap-up router sub-step in wrap-it-up.org :feature:solo: -Add the optional router to =wrap-it-up.org= Step 3 after the inbox sanity check: surface the =:ROUTE_CANDIDATE:= batch (task / destination / delivery mode / confidence), go/skip; on go, per candidate =inbox-send= a one-task handoff to the destination's =inbox/= and remove the keeper from the local =todo.org=; empty set = silent. Name the gate-vs-optional split in the prose. Edit canonical + sync mirror. Spec Phase 4 / D7 / D9. Spec: [[file:docs/design/wrapup-routing-spec.org]]. - -*** TODO [#B] Wrap-up routing — test surface :test:solo: -Unit: recommendation engine (strong/weak/none, two-project tie, empty list); marker stamping (cross-project keeper tagged, local keeper not, standing backlog never). Integration (bats, fixture projects + temp =todo.org=): go issues N =inbox-send= calls to the right inboxes with sources removed; skip leaves all in place; empty set = zero interaction; a candidate whose destination has =inbox/= but no =todo.org= still delivers. Spec: [[file:docs/design/wrapup-routing-spec.org]] (Acceptance criteria). - -*** TODO [#B] Wrap-up routing — manual end-to-end validation :test: -What we're verifying: a real keeper routes through a live wrap and the destination actually files it. -- In a project session, let process-inbox file a handoff whose home is a different project; confirm the local task carries =:ROUTE_CANDIDATE: <dest>=. -- Run wrap-it-up; at the router sub-step, confirm the candidate is surfaced with the right destination + confidence, then choose "go". -- Confirm a =from-<thisproject>= handoff landed in the destination's =inbox/= and the keeper was removed from the local =todo.org=. -- Open the destination project; confirm its startup/process-inbox files the handoff into its =todo.org= per its own conventions. -Expected: the task ends up in the destination's =todo.org=, gone from the source, with no foreign =todo.org= written directly. Not =:solo:= — needs a real cross-project wrap and the destination's next session. - -*** TODO [#D] Wrap-up routing — transcript filing (vNext) :feature:no-sync: -File a meeting recording into the destination =assets/= per =working-files.md=, batch go/skip mirroring the task router. Gated on the source-location decision (spec D4). Spec: [[file:docs/design/wrapup-routing-spec.org]] (Phase 5). - -** TODO [#C] Morning ops orchestrator pilot — read-only :feature: -:PROPERTIES: -:CREATED: [2026-06-11 Thu] -:LAST_REVIEWED: 2026-06-15 -:END: -A scheduled headless morning run chaining the existing pieces: startup checks, the triage-intake scan, a system health check — producing the prep doc plus a report and a notify ping, with all remediation propose-only. Staged adoption from the 2026-06-11 insights report's "Self-Healing Daily Ops Orchestrator": read-only first; promote individual routine remediations to auto only after each has a track record. Known blockers to design around: headless MCP auth (interactively-authenticated servers are absent in cron runs) and the consent boundary (triage Phase D, anything destructive). - -The triage limb can reuse triage-intake's *auto mode* (added 2026-06-15, see [[file:.ai/workflows/triage-intake.org]]) — its accumulate-don't-mutate sweep is the propose-only behavior this orchestrator wants. Auto mode itself runs in-session (inherited MCP auth); the orchestrator is the durable headless schedule, so the headless-auth blocker above is the part still on this task to solve. - ** TODO [#C] Token-rotation helper for =@a-bonus/google-docs-mcp= OAuth refresh :feature:quick: :PROPERTIES: :LAST_REVIEWED: 2026-06-15 @@ -429,13 +416,27 @@ Proposal from the home project (2026-06-17): promote the self-hosted ntfy-over-T :END: The work project edited two synced scripts locally as a stopgap (2026-06-17) and asked rulesets to fold them into the canonical so the next sync doesn't revert them. Preserved bundle: [[file:docs/design/2026-06-17-flashcard-multitag-note.md][note]], [[file:docs/design/2026-06-17-flashcard-multitag-to-anki.py][to-anki.py]], [[file:docs/design/2026-06-17-flashcard-multitag-stats.py][stats.py]]. Change: support a second org tag on drill headings (=:fundamental:drill:=) for curated subset decks. =flashcard-to-anki.py= — broaden =CARD_RE= to match a trailing tag block (a heading is a card when =drill= is among its tags), bound the card body by any L1/L2 heading, add =--tag-filter <tag>= (emit only cards carrying that tag) and =--guid-salt <s>= (separate GUID space so a subset deck imports non-empty without disturbing the full deck's SRS state). =flashcard-stats.py= — same =CARD_RE=/=HEADING_RE= broadening plus a drill-membership guard. Use the preserved to-anki.py (the 0953 version: dropped an unused =heading_tags()= helper, tightened =CARD_RE= =(.*?)=→=(.+?)= for parity with stats). Apply to both =.ai/scripts/= and =claude-templates/.ai/scripts/=, add a multi-tag bats case to =flashcard-sync.bats= (a =:foo:drill:= heading parses; =--tag-filter foo= returns only those), verify the full deck still parses to 465 and =--tag-filter fundamental= returns 100, then sync-check + make test. Shared-asset change, so review-gated. -** TODO [#C] triage-intake.org auto mode — push each sweep to phone (ntfy) :feature: +Note (2026-06-24): the Anki =#+TITLE= deck-name fix landed (commit 060a938) — =default_deck_name= is now =default_deck_name(input_path, org_text)= with a new docstring. The preserved 2026-06-17 =to-anki.py= predates that, so *don't* copy it wholesale (it would revert the title-fix). Re-derive the multi-tag changes against the current canonical =flashcard-to-anki.py= and keep the =#+TITLE= behavior. + +** TODO [#C] triage-intake.org auto mode — push each sweep to phone (ntfy) :feature:solo: :PROPERTIES: :CREATED: [2026-06-20 Sat] :LAST_REVIEWED: 2026-06-24 :END: The work project (2026-06-18) added a "Push each sweep to Craig's phone (ntfy) — the primary delivery" subsection under "Trigger and delivery" in triage-intake.org auto mode, and asks to fold it into the canonical engine plus re-sync. Preserved bundle: [[file:docs/design/2026-06-18-triage-intake-phone-push-note.org][note]] + [[file:docs/design/2026-06-18-triage-intake-phone-push-workflow.org][edited workflow]]. Auto mode is the away-from-desk / vacation mode, so phone-notify becomes the primary delivery each sweep (fuller end-of-sweep output: per-source deltas, open-PR/Linear state, awaiting-ack list, one-line verdict, timestamp; SCAN FAILED banner on any source failure), plus phone-recv polling each sweep for Craig's replies. Falls back to inline when phone-notify is absent. Depends on the ntfy agent-comms task above (phone-notify/phone-recv must be canonicalized first). Shared template-workflow change, so review-gated. +** TODO [#D] Fully-unattended scheduled inbox check (/schedule cron pass) :feature: +:PROPERTIES: +:CREATED: [2026-06-23 Tue] +:END: +vNext from the inbox-consolidation spec. =auto inbox zero= (v1) is the interactive =/loop= recurring check that waits for Craig's yes before executing. A fully-unattended =/schedule= cron pass that fires while Craig is away needs its own contract before it can ship: read-only vs may-mutate =todo.org= / =~/org/roam/inbox.org=, how a find surfaces asynchronously when Craig isn't at the session, how dedup state persists across runs that don't share a session, and what session/auth context a cron run carries. Design it after v1 consolidation lands. From the inbox-consolidation spec-review (Codex finding 1). See [[file:docs/inbox-workflow-consolidation-spec.org][spec]]. + +** TODO [#D] Warn-only pre-commit hook for tooling-path enumeration :feature: +:PROPERTIES: +:CREATED: [2026-06-22 Mon] +:END: +Optional enforcement teeth for the no-attribution / no-tooling-artifacts tightening landed 2026-06-22 (commit 91217d9), which is documentation-only. A warn-only (not blocking) pre-commit hook could scan the commit subject + body for tooling-path enumeration (=CLAUDE.md=, =.claude/=, =.ai/=, =todo.org=, =notes.org=, =session-context=) and AI-attribution language, with the two exemptions baked in: a commit whose change IS one of those files, and private single-user repos. Must warn, not block — a rigid grep false-positives on legit subject mentions. Deferred: Craig chose docs-only for now. + ** TODO [#D] Build =create-documentation= skill for high-quality project/product docs :feature: :PROPERTIES: :LAST_REVIEWED: 2026-06-15 @@ -2967,3 +2968,37 @@ CLOSED: [2026-06-23 Tue] :CREATED: [2026-06-23 Tue] :END: Done in commit 3da2725 (empty-entry sweep folded into Phase D's reconcile, after capture-guard + pull, with the claimed-item removal) and carried into the consolidated =inbox.org= roam mode (Phase B =empty= bucket + Phase D sweep). From the roam inbox 2026-06-23. +** DONE [#C] Surface cross-project dependencies first in what's-next :feature:spec: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:CREATED: [2026-06-24 Wed] +:END: +Tasks that depend on another project can sit for ages when the dependency is low-priority or needs its own spec process — e.g. wrap-teardown depends on =.emacs.d= for the =ai-term= companion. Craig's proposal (roam 2026-06-24): (1) an org-tag marking a task as blocked-by / depends-on another project (pick a short tag name); (2) several ways to bind dependencies into the what's-next (=open-tasks.org=) decision tree so blocked-by-dependency tasks surface first; (3) review the what's-next workflow as a whole, since many projects use it. + +Built 2026-06-24 (tag name =:blocked:=, Craig's pick): the =:blocked:= tag + =:BLOCKED_BY: <project>: <what>= property convention in =todo-format.md=, and =open-tasks.org= Next Mode now excludes =:blocked:= tasks from the cascade and surfaces them in a dedicated "Blocked on other projects" section with an =inbox-send= nudge offer. Applied live to the wrap-teardown task above. Commits feat(tasks) cross-project-dependency. +** DONE [#C] Task-audit: consolidate adjacent / related tasks :feature: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:CREATED: [2026-06-24 Wed] +:END: +The task-audit workflow should also consider combining related tasks when they're adjacent, so a spread-out effort reads as one whole. Craig's example (roam 2026-06-24): the agent-agnostic / agent-source work could collapse into one item, or at least a parent task with the related ones as children. + +Built 2026-06-24: =task-audit.org= Phase C.5 reads the open-task set, spots semantic clusters by judgment, and proposes per cluster either a merge (same-work members fold into one) or a parent-with-children grouping (related-but-distinct), applied only on Craig's confirm — broader than Phase C's exact-duplicate fold. Commit feat(task-audit) consolidate. +** DONE [#B] Anki deck name from #+TITLE :bug:quick:solo: +CLOSED: [2026-06-24 Wed] +:PROPERTIES: +:CREATED: [2026-06-22 Mon] +:LAST_REVIEWED: 2026-06-24 +:END: +flashcard-to-anki.py's =default_deck_name= returns =input_path.stem= (the filename), so every deck built through =flashcard-sync= (which passes no =--deck=) is named after the file, not the curated =#+TITLE=. =flashcard-review.org= already documents the intended behavior ("the #+TITLE line drives the Anki deck name"); the script never matched it. Fix: =default_deck_name(input_path, org_text)= scans for a =#+TITLE:= line (case-insensitive, trimmed) and returns it, basename fallback when absent; =main()= passes the already-read =org_text=. Edited script + test ready (validated, 29 pass): [[file:docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py][script]], [[file:docs/design/2026-06-21-anki-titlefix-test.py][test]], rationale [[file:docs/design/2026-06-21-anki-titlefix-proposal.org][proposal]]. Apply to both =.ai/scripts/= and =claude-templates/.ai/scripts/=, sync-check + make test. Migration caveat: deck ID derives from the name, so decks previously built without =--deck= land as new decks on next import (old basename-named decks keep history, delete by hand). Coordinate with "Reconcile flashcard multi-tag tooling into canonical" below — both edit =flashcard-to-anki.py=, build together to avoid conflicting edits. Shared-asset, review-gated. From home 2026-06-21. + +Done 2026-06-24 (commit 060a938): applied the pre-staged script + test red-to-green (5 new =#+TITLE= tests, 29 pass total), synced both script dirs, full suite green. The two redundant staging =.py= files removed, the rationale proposal kept. +** CANCELLED [#C] Morning ops orchestrator pilot — read-only :feature: +CLOSED: [2026-06-24 Wed 05:46] +:PROPERTIES: +:CREATED: [2026-06-11 Thu] +:LAST_REVIEWED: 2026-06-15 +:END: +A scheduled headless morning run chaining the existing pieces: startup checks, the triage-intake scan, a system health check — producing the prep doc plus a report and a notify ping, with all remediation propose-only. Staged adoption from the 2026-06-11 insights report's "Self-Healing Daily Ops Orchestrator": read-only first; promote individual routine remediations to auto only after each has a track record. Known blockers to design around: headless MCP auth (interactively-authenticated servers are absent in cron runs) and the consent boundary (triage Phase D, anything destructive). + +The triage limb can reuse triage-intake's *auto mode* (added 2026-06-15, see [[file:.ai/workflows/triage-intake.org]]) — its accumulate-don't-mutate sweep is the propose-only behavior this orchestrator wants. Auto mode itself runs in-session (inherited MCP auth); the orchestrator is the durable headless schedule, so the headless-auth blocker above is the part still on this task to solve. |
