diff options
Diffstat (limited to '.ai')
| -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 |
6 files changed, 161 insertions, 13 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). |
