diff options
Diffstat (limited to 'docs/design')
41 files changed, 3096 insertions, 185 deletions
diff --git a/docs/design/2026-05-28-generic-agent-runtime-spec.org b/docs/design/2026-05-28-generic-agent-runtime-spec.org index 01be6d4..7d7a549 100644 --- a/docs/design/2026-05-28-generic-agent-runtime-spec.org +++ b/docs/design/2026-05-28-generic-agent-runtime-spec.org @@ -3,6 +3,10 @@ #+DATE: 2026-05-28 #+STARTUP: showall +* Status note (2026-06-16) + +The cross-agent-comms subsystem this spec references as an existing substrate (=cross-agent-send= / =-recv= / =-watch= / =-status= / =-discover= / =-halt= / =-resume=, the =inbox/from-agents/= file-IPC protocol) was *removed* on 2026-06-16 as unused — every real cross-project handoff goes through =inbox-send= instead. Sections below that propose extending the cross-agent protocol (e.g. "Cross-agent updates", the =machine.project.agent-id= targeting) are historical: if this arc is revived, that layer would be rebuilt on =inbox-send=, not the deleted scripts. + * Introductory note Craig asked for a design pass on making =rulesets= generic rather than diff --git a/docs/design/2026-05-28-pattern-catalog-prompt-labels-and-defaults.org b/docs/design/2026-05-28-pattern-catalog-prompt-labels-and-defaults.org index cc2cb77..9ee29d6 100644 --- a/docs/design/2026-05-28-pattern-catalog-prompt-labels-and-defaults.org +++ b/docs/design/2026-05-28-pattern-catalog-prompt-labels-and-defaults.org @@ -28,7 +28,7 @@ A generic =pearl--with-sentinel SENTINEL CANDIDATES= helper lets each call site ** Why for the catalog -This is the "no hidden affordances" pattern from the earlier note ([[file:2026-05-28-0003-from-pearl-rulesets-followup-no-empty-input.org]]) sharpened with a second rule: *if the affordance is visible, its label has to match what picking it does*. Visibility without accuracy is its own problem. A label that says "none" when the behavior is "any" is no better than an invisible empty-input idiom — both leave the user holding the wrong model. +This is the "no hidden affordances" pattern from the earlier note (a processed pearl-rulesets inbox item, since removed) sharpened with a second rule: *if the affordance is visible, its label has to match what picking it does*. Visibility without accuracy is its own problem. A label that says "none" when the behavior is "any" is no better than an invisible empty-input idiom — both leave the user holding the wrong model. Catalog shape suggestion: this is the same principle as Pattern 3, in a follow-up form. Either a single entry that captures both halves (visible + accurate) or two cross-linked entries. diff --git a/docs/design/2026-06-02-flush-promotion.org b/docs/design/2026-06-02-flush-promotion.org index 9d9d8a3..ff4e579 100644 --- a/docs/design/2026-06-02-flush-promotion.org +++ b/docs/design/2026-06-02-flush-promotion.org @@ -1,5 +1,5 @@ #+TITLE: Flush Promotion — Handoff Bundle (from work) -#+AUTHOR: Craig Jennings & Claude +#+AUTHOR: Craig Jennings #+DATE: 2026-06-02 * Provenance diff --git a/docs/design/2026-06-02-pattern-catalog-spec.org b/docs/design/2026-06-02-pattern-catalog-spec.org index e74b8ae..6bb105a 100644 --- a/docs/design/2026-06-02-pattern-catalog-spec.org +++ b/docs/design/2026-06-02-pattern-catalog-spec.org @@ -1,5 +1,5 @@ #+TITLE: Cross-Project Pattern Catalog — Spec -#+AUTHOR: Craig Jennings & Claude +#+AUTHOR: Craig Jennings #+DATE: 2026-06-02 One-page spec for capturing reusable design patterns so they travel from one project to the next instead of being re-derived. Drafted for Craig's spec-review; the five open questions from the [[file:../../todo.org][todo.org]] task carry a recommended call each, marked DECISION. diff --git a/docs/design/2026-06-15-auto-triage-intake-spec.org b/docs/design/2026-06-15-auto-triage-intake-spec.org new file mode 100644 index 0000000..b41c99c --- /dev/null +++ b/docs/design/2026-06-15-auto-triage-intake-spec.org @@ -0,0 +1,47 @@ +#+TITLE: Proposed engine addition — Auto mode (auto triage-intake) +#+DATE: 2026-06-15 + +* What this adds + +A new *mode* of the triage-intake engine: *auto mode* (auto triage-intake). It's a self-running monitor for when Craig is away from the desk but wants tight awareness — a loop that runs the standard triage on a short interval, accumulates rather than mutating, and hands Craig a controlled checkpoint to commit the batch. + +Origin: 2026-06-15, the morning Craig had to clear his day for a family emergency and wanted the desk watched while he was in and out. He asked for 20-minute sweeps that summarize what's come in for him, with an explicit command to process and commit the batch. + +* The mode + +** Cadence +A loop (CronCreate / =/loop=) fires the triage on an interval — default *20 minutes*. Craig sets the interval. + +** Accumulate, don't mutate (the core difference from a normal run) +A normal triage run writes the sentinel, creates =:quick:reactive:= todos, takes mail actions on confirmation, and is a one-shot. An auto-mode *sweep* does none of the mutations: + +- Does NOT advance the sentinel — the scan window grows from the last *close* until the next close, so nothing is dropped between sweeps. +- Does NOT create todo.org tasks — accumulates them (in the session log) for the close. +- Does NOT take mail actions (trash / mark-read / star). +- Does NOT commit. +- DOES run the full plugin scan, DOES update an active daily-prep (Update mode) and re-open it on change, DOES report. + +** End-of-sweep output +Each sweep ends with two short sections: +1. *Deltas* — what changed since the last sweep (one line if nothing). +2. *Responses awaiting your acknowledgment* — every Slack reply, email, or message directed at the user that he hasn't explicitly acknowledged or had the agent answer. This is a *running list carried forward* across sweeps until the user acks each item or closes the triage. It exists because an away user's main need is "who's waiting to hear back from me," which a delta-only sweep loses the moment it scrolls past. + +** Close / stop commands (the checkpoint) +The mutations are gated behind two user commands: + +- *"close the triage"* — finish the mail + sentinel processing, add all accumulated todos (asking the user questions along the way), commit and push, then *keep looping* (next sweep on the normal interval). This is the "flush the batch and carry on" checkpoint. +- *"stop the triage"* — the same close processing, then *stop the loop* and revert to manual triage (run only when asked). + +* Why it's worth adding to the engine + +The standard engine is one-shot and mutating, which is right for an at-the-desk "what's new?" glance. It's wrong for unattended monitoring: running it every 20 minutes would spray reactive todos, advance the sentinel past unprocessed items, and commit noise without review. Auto mode separates the cheap, frequent *watching* from the deliberate, gated *committing* — and adds the away-user's missing primitive, the running unacknowledged-responses list. + +* Companion notes + +- The interval loop is the delivery mechanism (CronCreate session-only, or a durable schedule); the mode is the behavior. They compose. +- "Responses awaiting acknowledgment" tracking needs a small piece of state. In a session it can live in the session-context log; if the engine wants it durable across sessions, a tiny =.ai/triage-unacked.org= (or similar) is the natural home — flagged as a design choice for the rulesets side to decide. +- Deltas-only reporting (the 2026-06-11 ruling) and loud scan-failure surfacing both still apply inside each sweep. + +* Addendum (work, 2026-06-15 17:16) + +Add a third end-of-sweep output line: the current date/time/timezone, on its own final line, via =date "+%A %Y-%m-%d %H:%M:%S %Z (%z)"=. Reason: on an away day with frequent unattended sweeps, the per-sweep stamp shows how fresh each summary is at a glance. Sweep output sections become: (a) Deltas, (b) Responses awaiting acknowledgment, (c) the timestamp. Implemented 2026-06-15; the stamp prints on quiet sweeps too, as proof the loop ran. diff --git a/docs/design/2026-06-15-fix-speedrun-workflow-proposal.org b/docs/design/2026-06-15-fix-speedrun-workflow-proposal.org new file mode 100644 index 0000000..c1c7077 --- /dev/null +++ b/docs/design/2026-06-15-fix-speedrun-workflow-proposal.org @@ -0,0 +1,21 @@ +#+TITLE: Proposed reusable workflow: "fix speedrun" mode, available t +#+SOURCE: from .emacs.d +#+DATE: 2026-06-15 19:22:56 -0500 + +Proposed reusable workflow: "fix speedrun" mode, available to all coding projects. + +Origin: a 2026-06-15 .emacs.d theme-studio session. Craig batched a list of quick wins / small fixes and asked me to run them autonomously. + +The shape that worked: +- Entry: Craig names an ordered set of tasks (or points at a tagged subset) and says "fix speedrun" / "no approvals until done". +- Execution: work the set in order, no per-step approval gates. Each task still runs the full quality bar (TDD red->green, /review-code, /voice on the commit), and each is committed + pushed as its own logical commit when green ("always push this session" pairs naturally). +- Ambiguity handling: if a task turns out underspecified or already-satisfied, do not guess-implement — file a VERIFY noting why and move on. (This session: "raise max spans to 5" — every cap was already 8.) +- Exit / handoff: when the set is done, PAGE the user (proactive push notification) with the project name, the completed task(s), and a numbered list of remaining tasks. The user confirms ready + names the next project in one reply. + +Open questions for the canonical: +- Where the page fires (every task vs end-of-set) and via what (push notification). +- How it composes with the existing no-approvals + always-push session modes (is "fix speedrun" just a named preset of those plus the end page?). +- Whether it should auto-pull the task set from a tag/priority query rather than an explicit list. +- Guardrails: it should refuse to speedrun tasks that need design decisions or carry data-loss risk without a checkpoint (e.g. the unused-tile flag here was biased-safe deliberately). + +This is a rulesets-owned concept (cross-project), so sending it here rather than building it locally. No local stopgap made. diff --git a/docs/design/2026-06-15-spec-storage-lifecycle-proposal.org b/docs/design/2026-06-15-spec-storage-lifecycle-proposal.org new file mode 100644 index 0000000..7dbba72 --- /dev/null +++ b/docs/design/2026-06-15-spec-storage-lifecycle-proposal.org @@ -0,0 +1,44 @@ +#+TITLE: Proposal — spec storage location + lifecycle-status convention + +* What I'm proposing + +Two coupled documentation conventions, surfaced while triaging ~28 design docs in .emacs.d. Both belong in rulesets — the spec-create workflow (=.ai/workflows/spec-create.org=) and likely a new docs-lifecycle rule under =claude-rules/=. + +** A. Separate specs from working notes by location +Today .emacs.d/docs/design/ holds everything in one bucket: formal specs (goals/decisions/phases/acceptance) jumbled with brainstorms, inventories, reviews, and idea-lists. You can't tell specs from notes without opening each file. + +Proposal: formal specs live in =docs/specs/=; =docs/design/= keeps working notes, brainstorms, inventories, and reviews. A "spec" is a doc proposing a buildable change with a Decisions section and phases; everything else is a note. + +** B. Make a spec's lifecycle status glanceable +Specs carry no lifecycle status today, so which shipped, which are open, and which are dead is invisible. I had to run a four-agent sweep reading every spec against the code to reconstruct it (6 implemented, 8 in-progress, 12 not-started, 1 superseded). A status convention makes that a one-line scan. + +Two parts: +- Filename suffix (Craig's idea): =<topic>-spec.org= (draft / not-started) → =-spec-doing.org= → =-spec-implemented.org= → =-spec-superseded.org= / =-spec-cancelled.org=. Visible in =ls=, greppable by glob, impossible to forget. +- Authoritative Status field in the spec's Metadata table, on every spec (retrofit old ones). The filename is the index; the field is the record — it also carries a dated history line a filename can't hold. + +* Why + +Triage. A collection of specs with no status field and no location split degrades into "open it to find out." The cost compounds with every spec added. The two conventions together make the answer visible from a directory listing. + +* Recommendations / refinements + +- Pair filename suffix + Status field; the field is authoritative, the filename is the at-a-glance index. Don't let the filename be the only record (it's volatile and loses the why/when). +- Link safety is the real cost. Both the move to =docs/specs/= and every status rename break =[[file:...]]= links (e.g. todo.org → a spec's Related link). Mitigate one of two ways, and pick one as the standard: + - Switch cross-doc links to =org-id= (=[[id:...]]=), which survive moves and renames; or + - A helper that moves/renames + relinks inbound references + stamps the Status field + appends a dated history line, run on each transition — so a status change is one command, not manual link surgery. +- Vocabulary: draft (no suffix) / doing / implemented / superseded / cancelled. An in-progress-but-partial spec is "doing." + +* The bigger design choice rulesets should weigh + +Filename-encoded status is one option; the other is the org TODO keyword on the spec's top heading. These specs already carry =#+TODO: TODO | DONE SUPERSEDED CANCELLED=, so the top-heading keyword could be the status — scannable via a docs/specs org-agenda view, greppable, and it never breaks a link because the filename stays stable. The tradeoff is ls-visibility (filename wins) versus link-stability and zero-rename transitions (org-keyword wins). Worth deciding deliberately; my lean is filename suffix for the listing-level visibility Craig wants, paired with the Status field, with org-id links to neutralize the rename cost. + +* Generalization — a reusable pattern + +This is not spec-specific. The shape is: lifecycle state visible in the artifact's name (or location), an authoritative status record inside the artifact, rename-safe linking, and formal artifacts separated from working notes by location. Reach for it whenever triaging a growing collection of processed documents or media — brainstorms, inbox items, working-files, a transcription/recording queue, anything with a draft → in-progress → done/dropped lifecycle. Worth capturing as a general docs-lifecycle convention in claude-rules/, with the spec-create workflow as the first concrete instance. + +* Follow-up + +- Decide the canonical mechanism (filename suffix vs org-TODO-keyword) and the link-safety standard (org-id vs relink-helper). +- Update spec-create to emit into =docs/specs/= with a Status field and the chosen status mechanism documented. +- If you want the relink-helper, it's a natural =.ai/scripts/= addition that downstream projects get via the template sync. +- Send a note back if you want .emacs.d to pilot the chosen approach before it's generalized. diff --git a/docs/design/2026-06-16-inbox-zero-phase-e-proposal.diff b/docs/design/2026-06-16-inbox-zero-phase-e-proposal.diff new file mode 100644 index 0000000..e3d8ee8 --- /dev/null +++ b/docs/design/2026-06-16-inbox-zero-phase-e-proposal.diff @@ -0,0 +1,65 @@ +--- claude-templates/.ai/workflows/inbox-zero.org 2026-06-13 13:18:35.988799778 -0500 ++++ /proc/self/fd/12 2026-06-16 00:18:07.592944696 -0500 +@@ -1,6 +1,3 @@ +-#+TITLE: Inbox Zero Workflow +-#+AUTHOR: Craig Jennings & Claude +-#+DATE: 2026-06-13 + + * Overview + +@@ -19,7 +16,7 @@ + Reused from three callers so the steps live in one place: + - *Startup* (read-only nudge) — count the items, identify which appear related to this project, surface both numbers, offer processing as one of the startup options. Never auto-files. + - *Wrap-up* (Step 3 sub-step) — sweep items that belong here before the cleanup scripts, so imported tasks lint and ride the wrap commit. +-- *On demand* — "inbox zero", "empty the inbox", "process the roam inbox", "triage my roam inbox". ++- *On demand* — "inbox zero", "empty the inbox", "process the roam inbox", "triage my roam inbox". The on-demand caller (and the recurring loop that invokes it) also runs *Phase E* below: after routing the inbox, it works the project's actionable backlog, implementing eligible =:next:= / =:quick:+:solo:= tasks. Startup and wrap-up skip Phase E. + + Each project touches the roam inbox at least twice a session this way: once at startup, once at wrap-up. + +@@ -69,6 +66,46 @@ + + Report: moved (with their new priorities and tags), folded, dropped-as-done. Then the residue: foreign items (left for their owners, count only) and unowned items (count plus the headings that appear related to this project, for manual claim or prefix). Same "summarize what we kept" shape. + ++* Phase E — Execute actionable tagged tasks (autonomous; on-demand / loop caller only) ++ ++After routing the inbox, the on-demand and loop callers work the project's actionable backlog. *Startup (read-only) and wrap-up (winding down) skip this phase entirely* — it runs only when a human or the recurring loop invokes the workflow to make progress, never as a side effect of session bookkeeping. ++ ++** Eligibility gate ++ ++Scan =todo.org= (both items freshly filed in Phase B and the existing backlog). A task is a candidate when ALL hold: ++ ++1. Status is =TODO= — not =VERIFY=, =DOING=, =DONE=, or =CANCELLED=. =VERIFY= means "awaiting Craig's manual confirmation"; never auto-implement a VERIFY. Those are the manual-testing verifications this phase deliberately leaves alone. ++2. Tagged =:next:=, OR tagged BOTH =:quick:= AND =:solo:=. ++3. Implementable solo — no input or undecided judgment call from Craig. ++4. Estimated at roughly 30 minutes or less of focused work. ++ ++** Act-vs-file decision ++ ++For each candidate, after a quick scope read of the relevant code: ++ ++- *Clear, bounded, solo, ≤ ~30 min* → implement it now (below). ++- *Needs Craig's input, a decision, or design discussion* → do NOT implement. Leave it filed, add a one-line note on the task naming the input it needs, and surface it. ++- *An hour or more* → do NOT implement. Leave it filed and surface it as a larger task for a dedicated =/start-work= session. ++ ++When unsure which side a task falls on, file rather than implement. A wrong auto-implement costs more than a deferred task. ++ ++** Implementing a candidate ++ ++Per task, follow the project's commit discipline — the per-project waiver: no approval gate, but TDD + =/review-code= + =/voice personal= on every commit, no AI attribution: ++ ++1. Trace to root cause; write the failing test first (Red → Green → Refactor). ++2. Live-reload into the running daemon and verify per the emacs reload-and-verify loop. ++3. Close the task per =todo-format.md= (top-level → =DONE= + =CLOSED:=; sub-task → dated log rewrite). When the only residue is Craig's manual check, file a =VERIFY= child under "Manual testing and validation" and close the originating task (the codified manual-verification-handoff pattern). ++4. =/review-code --staged= → fix all Critical/Important → =/voice personal= on the message → commit individually. Push per the project's flow. ++ ++** Bounding the run ++ ++Default to one task per run: implement the highest-priority eligible candidate (=[#A]= before =[#B]= before =[#C]=), commit, then stop and let the next tick or the next on-demand invocation continue. A caller may work more than one in a run when the eligible tasks are small and clearly independent — but each gets its own test and its own commit, and the run stays reviewable. Never batch unrelated changes into one commit. ++ ++** Surface ++ ++Report what was implemented (task + commit), what was deferred and why (needs-input / too-large), and what stays filed. ++ + * Skip conditions + + - No =~/org/roam/inbox.org= → silent no-op. diff --git a/docs/design/2026-06-16-inbox-zero-phase-e-proposal.org b/docs/design/2026-06-16-inbox-zero-phase-e-proposal.org new file mode 100644 index 0000000..5fa7e12 --- /dev/null +++ b/docs/design/2026-06-16-inbox-zero-phase-e-proposal.org @@ -0,0 +1,137 @@ +#+TITLE: Inbox Zero Workflow +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-13 + +* Overview + +The roam global inbox (=~/org/roam/inbox.org=) is Craig's cross-project GTD capture: one shared file every project can see. This workflow routes each inbox item to the project that owns it. The current session claims only the items belonging to THIS project, files them into the project's =todo.org=, and removes them from the shared inbox. Everything it doesn't own stays. The aspiration is inbox zero: over time, every item lands in its owning project. + +This is NOT =process-inbox.org=. That workflow handles the project's own =inbox/= directory (handoffs from other projects, scripts, Craig). This one handles the single global =roam/inbox.org= and the cross-project routing a shared file creates. + +This is also distinct from the wrap-up inbox/transcript routing feature (which moves session-filed keepers between projects). This routes the shared roam capture file by ownership prefix. + +** Scope: single-destination (v1) + +This version routes each item to its one owning project, identified by an explicit =<project>:= heading prefix. The multi-project domain-aware mode, which would guess the owner of every unprefixed item and empty the whole inbox in one run, is deferred (see "Deferred: domain-aware routing" at the end). v1 claims only what's prefixed for the current project, surfaces the rest, and never guesses. + +** Three callers + +Reused from three callers so the steps live in one place: +- *Startup* (read-only nudge) — count the items, identify which appear related to this project, surface both numbers, offer processing as one of the startup options. Never auto-files. +- *Wrap-up* (Step 3 sub-step) — sweep items that belong here before the cleanup scripts, so imported tasks lint and ride the wrap commit. +- *On demand* — "inbox zero", "empty the inbox", "process the roam inbox", "triage my roam inbox". The on-demand caller (and the recurring loop that invokes it) also runs *Phase E* below: after routing the inbox, it works the project's actionable backlog, implementing eligible =:next:= / =:quick:+:solo:= tasks. Startup and wrap-up skip Phase E. + +Each project touches the roam inbox at least twice a session this way: once at startup, once at wrap-up. + +* The ownership rule (the coordination primitive) + +The inbox is shared, so the workflow must never let two projects fight over an item or let one project grab another's. Ownership is by explicit prefix: + +- =<project>: ...= heading → owned by that project. The current project claims only items prefixed with its own identifier. +- Prefixed for *another* project → leave untouched (cross-project boundary, =protocols.org=). +- *No prefix* → unowned. Never auto-claim. Surface as candidates a human can claim or prefix. + +The prefix partition is what makes concurrent triage across projects safe: each project only ever removes its own items, so two sessions editing the inbox touch disjoint lines. + +** Resolving this project's identifier (v1) + +Use the project root basename plus its common aliases (=.emacs.d= ↔ =emacs=, and the obvious ones: =rulesets=, =work=, =home=). A project may override the inferred set with an =:INBOX_PREFIX:= line in =notes.org='s *Workflow State* section when the basename is fragile (a dot in the name, an alias the inference misses). The explicit override is optional in v1; the durable multi-project resolution is part of the deferred domain-aware mode. + +* Phase A — Identify, count, and match + +1. Resolve the current project's identifier and aliases (above). +2. Read =~/org/roam/inbox.org=. If absent, silent no-op (the file lives only on machines with the roam clone). +3. Bucket every item under the inbox heading: + - *claimed* — prefixed for this project + - *foreign* — prefixed for another project → leave + - *unowned* — no project prefix +4. *Summarize the scan* (Craig's requirement, every scan): report the total item count in the inbox, then the count that appears related to this project. "Appears related" is the union of claimed items (exact prefix) and any unowned item whose topic plainly concerns this project's domain (a content judgment, surfaced as a candidate, never auto-claimed). Foreign-prefixed items are not "related" — they belong to their owner. +5. If both claimed and related-unowned are empty, report the total and stop (the common case for most wraps). + +* Phase B — File each claimed item into todo.org + +Apply =process-inbox.org='s discipline against the project's =todo.org=; don't reinvent it: + +1. *Status check first.* Already done, or already a task in =todo.org=? → drop it, or fold into the existing task (dated sub-entry per =todo-format.md=). Don't duplicate. +2. *Rewrite* to terse-heading + body per =todo-format.md=. +3. *Priority + tags from THIS project's scheme* — the legend at the top of its =todo.org=, tags from that scheme's allowed set only. The project expresses someday-maybe with =[#D]=; there's no special someday-maybe routing. +4. *File* under the project's Open Work section. + +* Phase C — Reconcile the shared inbox + +The roam inbox lives in a git repo (=~/org/roam=, auto-synced by the =roam-sync= timer). Edit it carefully: + +1. *Pull first* (=git -C ~/org/roam pull --ff-only=). If it can't fast-forward (dirty tree, divergence), surface and stop. Don't auto-stash, auto-merge, or force. Resolve before removing items. +2. *Remove only the claimed items.* Never touch foreign or unowned items. +3. *Commit the roam repo as its own commit* (separate from any project wrap commit): =chore(inbox): route <project> tasks to <project>/todo.org=. Push, or leave for the =roam-sync= timer. Surface a blocked push; don't force. + +* Phase D — Surface + +Report: moved (with their new priorities and tags), folded, dropped-as-done. Then the residue: foreign items (left for their owners, count only) and unowned items (count plus the headings that appear related to this project, for manual claim or prefix). Same "summarize what we kept" shape. + +* Phase E — Execute actionable tagged tasks (autonomous; on-demand / loop caller only) + +After routing the inbox, the on-demand and loop callers work the project's actionable backlog. *Startup (read-only) and wrap-up (winding down) skip this phase entirely* — it runs only when a human or the recurring loop invokes the workflow to make progress, never as a side effect of session bookkeeping. + +** Eligibility gate + +Scan =todo.org= (both items freshly filed in Phase B and the existing backlog). A task is a candidate when ALL hold: + +1. Status is =TODO= — not =VERIFY=, =DOING=, =DONE=, or =CANCELLED=. =VERIFY= means "awaiting Craig's manual confirmation"; never auto-implement a VERIFY. Those are the manual-testing verifications this phase deliberately leaves alone. +2. Tagged =:next:=, OR tagged BOTH =:quick:= AND =:solo:=. +3. Implementable solo — no input or undecided judgment call from Craig. +4. Estimated at roughly 30 minutes or less of focused work. + +** Act-vs-file decision + +For each candidate, after a quick scope read of the relevant code: + +- *Clear, bounded, solo, ≤ ~30 min* → implement it now (below). +- *Needs Craig's input, a decision, or design discussion* → do NOT implement. Leave it filed, add a one-line note on the task naming the input it needs, and surface it. +- *An hour or more* → do NOT implement. Leave it filed and surface it as a larger task for a dedicated =/start-work= session. + +When unsure which side a task falls on, file rather than implement. A wrong auto-implement costs more than a deferred task. + +** Implementing a candidate + +Per task, follow the project's commit discipline — the per-project waiver: no approval gate, but TDD + =/review-code= + =/voice personal= on every commit, no AI attribution: + +1. Trace to root cause; write the failing test first (Red → Green → Refactor). +2. Live-reload into the running daemon and verify per the emacs reload-and-verify loop. +3. Close the task per =todo-format.md= (top-level → =DONE= + =CLOSED:=; sub-task → dated log rewrite). When the only residue is Craig's manual check, file a =VERIFY= child under "Manual testing and validation" and close the originating task (the codified manual-verification-handoff pattern). +4. =/review-code --staged= → fix all Critical/Important → =/voice personal= on the message → commit individually. Push per the project's flow. + +** Bounding the run + +Default to one task per run: implement the highest-priority eligible candidate (=[#A]= before =[#B]= before =[#C]=), commit, then stop and let the next tick or the next on-demand invocation continue. A caller may work more than one in a run when the eligible tasks are small and clearly independent — but each gets its own test and its own commit, and the run stays reviewable. Never batch unrelated changes into one commit. + +** Surface + +Report what was implemented (task + commit), what was deferred and why (needs-input / too-large), and what stays filed. + +* Skip conditions + +- No =~/org/roam/inbox.org= → silent no-op. +- No claimed and no related-unowned items → report the total, stop. +- Roam pull blocked → surface, stop before editing. + +* Caller integration + +** Startup (read-only nudge) + +Phase A of =startup.org= reads =~/org/roam/inbox.org= and produces the scan summary; Phase C surfaces one line: "Roam inbox: N items total, M appear related to this project — say 'inbox zero' to file them." Offered as one of the priority options. Startup never auto-files; it counts and offers. + +** Wrap-up (Step 3 sub-step) + +A sub-step at the start of wrap-up Step 3 (before the cleanup scripts, so imported tasks get linted and ride the wrap commit) delegates here for the claimed set. Skip-fast when nothing matches. + +* Deferred: domain-aware routing (future work, multi-project) + +v1 handles the single-destination case via the prefix rule. The multi-project parts are deferred until the need is real: + +1. *Domain-aware empty-it-all mode.* If rulesets held a description of each project's domain, one run could guess the owner of every item (prefixed or not) and empty the whole inbox at once, delivering each item to its owning project's =inbox/= via =inbox-send= (where that project's =process-inbox= gate still decides whether to file it). This turns "inbox zero" from a per-project aspiration into a single command. Open: where the domain map lives (central registry vs each project's =notes.org=), how confident a guess must be before auto-routing vs surfacing, and whether a low-confidence item stays put. +2. *Explicit per-project =:INBOX_PREFIX:= as the durable resolver*, replacing basename inference. +3. *Unowned-item lifecycle* once domain-aware routing exists (no item stays unrouted indefinitely). +4. *Concurrent push contention* on the shared roam repo: the pull-before-edit + ff-only + surface-on-conflict floor may want a retry-once-after-pull. + +Take these up when the single-destination version is in use and the multi-project pain is concrete. diff --git a/docs/design/2026-06-16-inbox-zero-phase-e-sender-note.org b/docs/design/2026-06-16-inbox-zero-phase-e-sender-note.org new file mode 100644 index 0000000..08e7650 --- /dev/null +++ b/docs/design/2026-06-16-inbox-zero-phase-e-sender-note.org @@ -0,0 +1,27 @@ +#+TITLE: inbox-zero Phase E — autonomous execution of :next: / :quick:+:solo: tasks + +* What changed + +Added a new "Phase E — Execute actionable tagged tasks" to inbox-zero.org (edited locally in .emacs.d as a stopgap; the edited file is attached alongside this note). After routing the roam inbox, the on-demand and loop callers now scan todo.org and autonomously implement eligible tasks. + +Eligibility gate (all must hold): status TODO (not VERIFY/DOING/DONE/CANCELLED); tagged :next: OR both :quick: and :solo:; solo-doable without Craig's input; ~30 min or less. VERIFY is explicitly excluded — in this project VERIFY means "awaiting Craig's manual confirmation," i.e. the manual-testing verifications that must NOT be auto-implemented. Anything needing input or an hour-plus is filed and surfaced, never implemented. Each implementation follows the project commit discipline (TDD + /review-code + /voice personal + individual commit, no AI attribution). Startup (read-only) and wrap-up (winding down) skip Phase E. Default is one task per run, highest priority first. + +* Why + +Craig wants the 30-minute inbox-zero loop to also burn down the small actionable backlog autonomously, not just route capture. The existing tag convention (:next:, :quick:+:solo:) already marks exactly the solo-doable work, so Phase E acts on it under a conservative act-vs-file gate. Companion change: .emacs.d is scheduling inbox-zero on a 30-minute loop with Phase E enabled. + +* Requesting considerations for a general (cross-project) improvement + +The local edit is a stopgap; the durable, canonical version is yours to shape. Open questions for the canonical: + +1. Commit autonomy. Phase E as written assumes .emacs.d's per-project waiver (no per-commit approval gate). Most projects do NOT have that waiver. The canonical Phase E should probably gate on whether autonomous commits are allowed, defaulting to file-only (no auto-commit, surface a diff) unless a project opts in. + +2. Tag resolution. The eligibility tags and the per-project priority/tag scheme vary. Phase E hardcodes :next: / :quick:+:solo:. Better: read the project's todo.org priority/tag scheme header to resolve which tags mean "autonomous-safe." + +3. Do-not-implement markers. "Manual testing verification" is encoded here as status VERIFY (faithful to todo-format.md). Other projects may use VERIFY differently or not at all. Worth a canonical definition of the "do-not-auto-implement" marker set. + +4. Bounding and kill switch. A loop firing every 30 min that implements + commits unattended is powerful. Consider a per-run cap (or a token budget) and an explicit kill switch beyond "default one task per run." + +5. Seam. Whether autonomous execution belongs inside inbox-zero at all, or in its own workflow (e.g. work-the-backlog.org) that the loop chains after inbox-zero. Coupling capture-routing with autonomous-implementation may be the wrong seam — separating them keeps inbox-zero's three existing callers (startup, wrap-up, on-demand) clean. + +Sent from .emacs.d (helper id n/a — primary session). diff --git a/docs/design/2026-06-17-flashcard-multitag-note.md b/docs/design/2026-06-17-flashcard-multitag-note.md new file mode 100644 index 0000000..cfe960d --- /dev/null +++ b/docs/design/2026-06-17-flashcard-multitag-note.md @@ -0,0 +1,28 @@ +# Flashcard tooling: multi-tag headings + --tag-filter (for curated subset decks) + +Source: work project, 2026-06-17. Two synced scripts edited locally as a stopgap; please reconcile into the rulesets canonical so the next sync doesn't revert them. + +## What changed and why + +Craig wanted a curated "DeepSat Fundamentals" Anki deck: the 100 most fundamental cards out of the 465-card deepsat.org org-drill deck, marked with a second org tag (`:fundamental:`) so they stay findable/grep-able in the source. + +The blocker: both `flashcard-to-anki.py` and `flashcard-stats.py` keyed cards on a heading ending in exactly ` :drill:` (`CARD_RE = ^\*\*\s+(.+?)\s+:drill:\s*$`). Adding any second org tag turns the heading into `... :fundamental:drill:`, which that regex does not match — so the 100 tagged cards would silently drop from the full-deck apkg and be undercounted by stats. The "passing gate skips your file" failure mode. + +## flashcard-to-anki.py + +- `CARD_RE` now matches a trailing org tag block (`^\*\*\s+(.*?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$`); a heading is a card when `drill` is among its tags. Other org tags ride along as Anki tags next to the section tag. +- Card body is now bounded by any L1/L2 heading (`HEADING_RE = ^\*{1,2}\s`) instead of only `* ` or a drill heading. +- New `--tag-filter <tag>`: emit only cards carrying that org tag (e.g. `--tag-filter fundamental` → the 100-card subset). +- New `--guid-salt <s>`: salt note GUIDs so a derived subset deck gets its own GUID space. Without it, the subset's notes share fronts with the full deck, Anki dedupes on GUID, and the subset deck imports empty. Default (no salt) is unchanged — `guid_for(front)` — so the existing deepsat deck's GUIDs and SRS state are untouched. + +Generation used: `flashcard-to-anki.py deepsat.org --tag-filter fundamental --deck "DeepSat Fundamentals" --guid-salt fundamentals`. + +## flashcard-stats.py + +- Same `CARD_RE` broadening + `HEADING_RE` body bound, and the card guard now checks `drill` membership in the tag block. Verified: full deck still counts 465 after 100 cards were multi-tagged. + +## Companion files to reconcile + +- Both rulesets copies: `~/code/rulesets/.ai/scripts/` and `~/code/rulesets/claude-templates/.ai/scripts/` (the synced source). +- `claude-templates/.ai/scripts/tests/flashcard-sync.bats` — worth adding a multi-tag case (a `:foo:drill:` heading still parses; `--tag-filter foo` returns only those) so this doesn't regress. +- Regression checked locally: full deck parses to 465 with and without the change; `--tag-filter fundamental` returns exactly 100. diff --git a/docs/design/2026-06-17-flashcard-multitag-stats.py b/docs/design/2026-06-17-flashcard-multitag-stats.py new file mode 100755 index 0000000..3c984e7 --- /dev/null +++ b/docs/design/2026-06-17-flashcard-multitag-stats.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Inventory + authoring-quality checks for an org-drill deck source file. + +Reports counts and flags two tiers of issue. + +Blocking WARNs (exit 1): +- PROPERTIES drawer count not matching card count +- Cards missing :ID: (risks SRS-state loss across rewrites) +- `*** Answer` sub-headers (should be 0 per flashcard-review.org) +- Non-prompt headings (topic-as-heading not yet rewritten) +- #+TITLE missing, or carrying source-tool jargon ("org-drill") +- Answer leakage: a card whose question echoes most of its own answer + (Source: citation lines and created-date lines are excluded from the + overlap, and range/category cards that recall numbers are exempted) +- Duplicate / near-duplicate fronts (interference between confusable cards) + +Non-blocking NOTEs (exit unaffected): +- Overloaded backs (long answer — candidate to split into atomic cards) +- List-shaped backs (enumeration — candidate to split or use overlapping cloze) +- Binary yes/no prompts (low retrieval effort — candidate to reformulate) + +Exits 0 when no blocking warnings are present, 1 otherwise, 2 on bad usage. +Use as a gate before regenerating the Anki deck or running flashcard-sync. + +The fuzzy checks (leakage, duplicate, overloaded) are tuned by the LEAKAGE_* +and BACK_WORD_LIMIT constants below; loosen them if a real deck trips false +positives. + +Usage: + flashcard-stats.py <file.org> +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# A level-2 card heading carries a trailing org tag block that includes +# `drill`. The block may hold more than one tag (e.g. ":fundamental:drill:"), +# so match the whole block and check membership rather than pinning :drill: +# as the literal last tag. +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$") +HEADING_RE = re.compile(r"^\*{1,2}\s") +ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b") +PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$") +PROP_END_RE = re.compile(r"^\s*:END:\s*$") +ID_RE = re.compile(r"^\s*:ID:\s+(\S+)\s*$") +TITLE_RE = re.compile(r"^#\+TITLE:\s*(.+?)\s*$", re.IGNORECASE) +SOURCE_TOOL_RE = re.compile(r"\borg[-\s]?drill\b", re.IGNORECASE) +PLANNING_RE = re.compile(r"^\s*(SCHEDULED|DEADLINE|CLOSED):\s") +SOURCE_LINE_RE = re.compile(r"^\s*source:\s", re.IGNORECASE) +CREATED_LINE_RE = re.compile(r"^\s*:?created:?\s", re.IGNORECASE) +RANGE_RE = re.compile(r"\d[^\n]*[-–—]\s*\d") +THRESHOLD_RE = re.compile(r"[<>≤≥]\s*\d") +BULLET_RE = re.compile(r"^\s*([-+*]|\d+[.)])\s+") +BINARY_LEAD_RE = re.compile( + r"^\s*(is|are|was|were|does|do|did|can|could|should|would|will|has|have|had)\b", + re.IGNORECASE, +) + +# A heading qualifies as "prompt form" if it contains `?` or starts with one of +# these imperative verbs (directive prompts like "Spell these out" and +# "Introduce yourself" are valid even without `?`). +IMPERATIVE_VERBS = frozenset({ + "spell", "describe", "explain", "name", "list", "give", + "show", "tell", "define", "compare", "identify", "outline", + "introduce", "walk", "state", "recite", "recall", "summarize", +}) + +# Function words ignored when comparing a question against its answer. +STOPWORDS = frozenset({ + "the", "a", "an", "is", "are", "was", "were", "of", "to", "in", "on", + "for", "and", "or", "with", "what", "who", "whom", "when", "where", "why", + "how", "which", "does", "do", "did", "tell", "me", "about", "their", "this", + "that", "it", "as", "at", "by", "be", "your", "you", "they", "them", +}) + +# Tuning knobs for the fuzzy checks. +LEAKAGE_RATIO = 0.8 # share of a question's content words echoed in its answer +LEAKAGE_MIN_WORDS = 3 # ignore very short questions, where overlap is noise +BACK_WORD_LIMIT = 60 # words on a card back before it's flagged as overloaded + + +def is_prompt_form(heading: str) -> bool: + """True if the heading reads as a question or imperative prompt.""" + if "?" in heading: + return True + first_word = heading.split(None, 1)[0].lower().rstrip(":,;") + return first_word in IMPERATIVE_VERBS + + +def content_words(text: str) -> set[str]: + """Lowercased alphanumeric tokens of length >= 3, minus stopwords.""" + return {w for w in re.findall(r"[a-z0-9]+", text.lower()) + if len(w) >= 3 and w not in STOPWORDS} + + +def leakage_ratio(heading: str, body: str) -> float: + """Fraction of the question's content words that reappear in the answer. + + A high ratio means the answer is largely restated in the question, so the + card can be answered by recognition rather than recall. Returns 0.0 for a + question with fewer than LEAKAGE_MIN_WORDS content words, where overlap is + just noise. + """ + hw = content_words(heading) + if len(hw) < LEAKAGE_MIN_WORDS: + return 0.0 + return len(hw & content_words(body)) / len(hw) + + +def prose_body(body: str) -> str: + """Body with Source: citation and created-date lines removed. + + Those lines are metadata, not the answer. A Source line's URL slug often + repeats the question's words, and a created date is bookkeeping — neither + should count toward answer-leakage overlap. + """ + return "\n".join( + ln for ln in body.splitlines() + if not SOURCE_LINE_RE.match(ln) and not CREATED_LINE_RE.match(ln) + ) + + +def has_distinct_numeric_recall(heading: str, body: str) -> bool: + """True if the answer carries numeric ranges/thresholds the question lacks. + + A range/category card ("What are the HbA1c ranges across normal, + prediabetes, and diabetes?") echoes its categories in the answer, but the + recalled content is the numbers, which the question doesn't give away — so + high word overlap isn't leakage. + """ + body_nums = bool(RANGE_RE.search(body) or THRESHOLD_RE.search(body)) + head_nums = bool(RANGE_RE.search(heading) or THRESHOLD_RE.search(heading)) + return body_nums and not head_nums + + +def is_leaky(heading: str, body: str) -> bool: + """True if a card leaks its answer, after excluding citation lines and + numeric-recall (range/category) cards.""" + prose = prose_body(body) + if leakage_ratio(heading, prose) < LEAKAGE_RATIO: + return False + return not has_distinct_numeric_recall(heading, prose) + + +def normalize_heading(heading: str) -> str: + """Collapse a heading to a comparison key (lowercase, alnum + single spaces).""" + return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", heading.lower())).strip() + + +def is_binary_prompt(heading: str) -> bool: + """True for yes/no or 'A or B' prompts, which need little retrieval effort.""" + if BINARY_LEAD_RE.match(heading): + return True + return bool(re.search(r"\bor\b", heading, re.IGNORECASE)) and heading.rstrip().endswith("?") + + +def back_word_count(body: str) -> int: + return len(body.split()) + + +def is_list_back(body: str) -> bool: + """True if the answer body is mostly an org list (an enumeration card).""" + lines = [ln for ln in body.splitlines() if ln.strip()] + if len(lines) < 2: + return False + bullets = sum(1 for ln in lines if BULLET_RE.match(ln)) + return bullets >= 2 and bullets * 2 >= len(lines) + + +def parse_cards(lines: list[str]) -> tuple[list[dict], int]: + """Parse :drill: cards from org lines. + + Returns (cards, prop_count). Each card is a dict with heading, has_id, + has_answer, and body (the answer text with PROPERTIES drawers, planning + lines, and `*** Answer` headers removed, approximating the rendered back). + """ + cards: list[dict] = [] + prop_count = 0 + i = 0 + n = len(lines) + while i < n: + m = CARD_RE.match(lines[i]) + if not m or "drill" not in [t for t in m.group(2).split(":") if t]: + i += 1 + continue + heading = m.group(1).strip() + i += 1 + has_id = False + has_answer = False + in_drawer = False + body_lines: list[str] = [] + while i < n: + line = lines[i] + if HEADING_RE.match(line): + break + if PROP_START_RE.match(line): + prop_count += 1 + in_drawer = True + elif in_drawer and PROP_END_RE.match(line): + in_drawer = False + elif in_drawer: + if ID_RE.match(line): + has_id = True + elif ANSWER_RE.match(line): + has_answer = True + elif PLANNING_RE.match(line): + pass + else: + body_lines.append(line) + i += 1 + cards.append({ + "heading": heading, + "has_id": has_id, + "has_answer": has_answer, + "body": "\n".join(body_lines).strip(), + }) + return cards, prop_count + + +def find_duplicate_fronts(cards: list[dict]) -> list[tuple[str, str]]: + """Return (first, dup) heading pairs that normalize to the same key.""" + seen: dict[str, str] = {} + dups: list[tuple[str, str]] = [] + for c in cards: + key = normalize_heading(c["heading"]) + if not key: + continue + if key in seen: + dups.append((seen[key], c["heading"])) + else: + seen[key] = c["heading"] + return dups + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} <file.org>", file=sys.stderr) + return 2 + + path = Path(sys.argv[1]).expanduser().resolve() + if not path.is_file(): + print(f"error: {path} not found", file=sys.stderr) + return 2 + + lines = path.read_text(encoding="utf-8").splitlines() + + title: str | None = None + for line in lines[:20]: + m = TITLE_RE.match(line) + if m: + title = m.group(1).strip() + break + + cards, prop_count = parse_cards(lines) + + no_id = [c["heading"] for c in cards if not c["has_id"]] + not_prompt = [c["heading"] for c in cards if not is_prompt_form(c["heading"])] + answer_count = sum(1 for c in cards if c["has_answer"]) + leaky = [c["heading"] for c in cards if is_leaky(c["heading"], c["body"])] + dups = find_duplicate_fronts(cards) + overloaded = [c["heading"] for c in cards if back_word_count(c["body"]) > BACK_WORD_LIMIT] + listy = [c["heading"] for c in cards if is_list_back(c["body"])] + binary = [c["heading"] for c in cards if is_binary_prompt(c["heading"])] + + print(f"{path.name} — drill deck stats") + print() + print(f"Deck title: {title if title else '(no #+TITLE)'}") + print(f"Cards: {len(cards)}") + drawer_status = "match" if prop_count == len(cards) else f"mismatch (expected {len(cards)})" + print(f"PROPERTIES drawers: {prop_count} ({drawer_status})") + print(f"*** Answer sub-headers: {answer_count} ({'clean' if answer_count == 0 else 'workflow violation'})") + print(f"Cards missing :ID:: {len(no_id)}") + print(f"Cards with non-prompt heading: {len(not_prompt)}") + print(f"Cards with possible answer leakage: {len(leaky)}") + print(f"Duplicate / near-duplicate fronts: {len(dups)}") + print() + + warnings = 0 + + def emit_list(items: list[str]) -> None: + for h in items[:5]: + print(f" - {h}") + if len(items) > 5: + print(f" - ... and {len(items) - 5} more") + + def warn(msg: str, items: list[str] | None = None) -> None: + nonlocal warnings + warnings += 1 + print(f"WARN: {msg}") + if items: + emit_list(items) + + def note(msg: str, items: list[str] | None = None) -> None: + print(f"NOTE: {msg}") + if items: + emit_list(items) + + if title is None: + warn("no #+TITLE: line found; deck name will fall back to the file basename") + elif SOURCE_TOOL_RE.search(title): + warn(f"#+TITLE contains source-tool jargon ('{title}'); the deck name shows in Anki — drop 'Org-Drill' for a name that reads well on the consumption side") + if answer_count: + warn(f"{answer_count} cards have *** Answer sub-headers (drop per flashcard-review.org)") + if prop_count != len(cards): + warn(f"PROPERTIES count {prop_count} does not match card count {len(cards)}") + if no_id: + warn(f"{len(no_id)} cards missing :ID:; losing identity risks SRS-state loss across rewrites", no_id) + if not_prompt: + warn(f"{len(not_prompt)} cards have non-prompt headings (no '?' and no imperative-verb start); likely topic-as-heading not yet rewritten", not_prompt) + if leaky: + warn(f"{len(leaky)} cards may leak their answer (question echoes >= {int(LEAKAGE_RATIO * 100)}% of its own answer's key words); reformulate so the answer is recalled, not recognized", leaky) + if dups: + warn(f"{len(dups)} duplicate / near-duplicate fronts (interference between confusable cards); disambiguate or merge", + [f"{a} == {b}" for a, b in dups]) + + if overloaded: + note(f"{len(overloaded)} cards have a long answer (> {BACK_WORD_LIMIT} words); candidates to split into atomic cards", overloaded) + if listy: + note(f"{len(listy)} cards have a list-shaped answer; enumeration cards recall poorly — candidates to split or use overlapping cloze", listy) + if binary: + note(f"{len(binary)} cards are binary (yes/no or 'A or B'); low retrieval effort — candidates to reformulate open-ended", binary) + + if warnings == 0: + print("clean (with non-blocking notes above)" if (overloaded or listy or binary) else "clean") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/design/2026-06-17-flashcard-multitag-to-anki.py b/docs/design/2026-06-17-flashcard-multitag-to-anki.py new file mode 100755 index 0000000..3764acf --- /dev/null +++ b/docs/design/2026-06-17-flashcard-multitag-to-anki.py @@ -0,0 +1,294 @@ +#!/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 the org tag block). Back = entry body with newlines + converted to <br>. + +A card heading may carry more than one org tag (e.g. +"** Question :fundamental:drill:"). Any heading whose trailing tag block +includes `drill` is a card; the other org tags ride along as Anki tags +next to the section tag. Pass --tag-filter <tag> to emit only the cards +carrying that org tag (e.g. a curated "fundamentals" subset). + +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. + +Note GUIDs default to a hash of the card front, so re-running against the +same source preserves SRS state. A derived subset deck (one built with +--tag-filter) should pass --guid-salt so its notes get a distinct GUID +space and Anki treats it as a separate deck rather than merging its cards +into a full deck that shares the same fronts. + +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 + flashcard-to-anki.py <input.org> --tag-filter fundamental \ + --deck "DeepSat Fundamentals" --guid-salt fundamentals + +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 + + +# A level-2 heading carrying a trailing org tag block. Group 1 is the +# front text, group 2 the colon-delimited tag block (e.g. ":fundamental:drill:"). +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$") +# Any level-1 or level-2 heading — used to bound a card's body. +HEADING_RE = re.compile(r"^\*{1,2}\s") +SECTION_RE = re.compile(r"^\*\s+(.+?)\s*$") + + +def parse( + org_text: str, tag_filter: str | None = None +) -> list[tuple[str, str, list[str]]]: + """Return [(front, back_html, anki_tags), ...] for every :drill: card. + + A card is any level-2 heading whose trailing org tag block includes + `drill`. Additional org tags become Anki tags alongside the section + tag. When `tag_filter` is set, only cards carrying that org tag are + returned. + """ + cards: list[tuple[str, str, list[str]]] = [] + current_section: str | None = None + + 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 + + m = CARD_RE.match(line) + tags = [t for t in m.group(2).split(":") if t] if m else [] + if m and "drill" in tags: + front = m.group(1).strip() + body_lines: list[str] = [] + i += 1 + while i < len(lines): + nxt = lines[i] + if HEADING_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) + + org_tags = [t for t in tags if t != "drill"] + if tag_filter and tag_filter not in org_tags: + continue + anki_tags: list[str] = [] + if current_section: + anki_tags.append(section_to_tag(current_section)) + anki_tags.extend(org_tags) + if not anki_tags: + anki_tags = ["drill"] + cards.append((front, back_html, anki_tags)) + continue + + i += 1 + + return cards + + +def build( + cards: list[tuple[str, str, list[str]]], + deck_name: str, + guid_salt: str | None = None, +) -> genanki.Deck: + deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name) + model = make_model(deck_name) + for front, back, tags in cards: + guid = ( + genanki.guid_for(guid_salt, front) + if guid_salt + else genanki.guid_for(front) + ) + note = genanki.Note( + model=model, + fields=[front, back], + tags=tags, + guid=guid, + ) + deck.add_note(note) + return deck + + +def default_deck_name(input_path: Path) -> str: + 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 input basename.", + ) + parser.add_argument( + "--output", + type=Path, + help="Output .apkg path. Defaults to " + "~/sync/phone/anki/<input-basename>.apkg.", + ) + parser.add_argument( + "--tag-filter", + help="Emit only cards carrying this org tag (e.g. 'fundamental').", + ) + parser.add_argument( + "--guid-salt", + help="Salt note GUIDs with this string so a derived subset deck " + "gets its own GUID space and Anki keeps it separate from a " + "full deck sharing the same card fronts.", + ) + 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) + 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, tag_filter=args.tag_filter) + if not cards: + if args.tag_filter: + print( + f"error: no :drill: cards tagged :{args.tag_filter}: in {input_path}", + file=sys.stderr, + ) + else: + print(f"error: no :drill: cards found in {input_path}", file=sys.stderr) + return 1 + + deck = build(cards, deck_name, guid_salt=args.guid_salt) + 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-17-ntfy-agent-comms-proposal.org b/docs/design/2026-06-17-ntfy-agent-comms-proposal.org new file mode 100644 index 0000000..0961b47 --- /dev/null +++ b/docs/design/2026-06-17-ntfy-agent-comms-proposal.org @@ -0,0 +1,89 @@ +#+TITLE: Proposal — Promote the ntfy phone channel into a general agent-comms tool +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-17 + +* Why this is in rulesets' inbox + +The home project built a working, private phone-notification channel for Craig on 2026-06-17 (self-hosted ntfy over Tailscale). Craig wants rulesets to consider promoting it from a one-way notification system into a *general two-way communication tool* between him and his agents — and, critically, to move it off pure polling toward event-driven delivery (an inbound message can trigger an action or notify an agent, not just sit in a queue waiting to be polled). + +This is a proposal, not a change to anything rulesets owns. It documents exactly what exists, what ntfy makes possible, and the open design decisions rulesets would own. It also relates directly to the cross-agent-comms scripts that were retired from the templates in this same session — ntfy may be the transport layer that effort was missing. + +* Part 1 — What exists now (as-built, verified) + +- *Server:* ntfy in Docker on =ratio= at =~/docker/ntfy/= (=compose.yml= + =server.yml=, =data/= volume, =restart=unless-stopped=, healthcheck on =/v1/health=). Listens container :80, published to =127.0.0.1:2586=. +- *Tailnet exposure:* =tailscale serve --bg --http=80 http://127.0.0.1:2586= → reachable at =http://ratio.tailf3bb8c.ts.net= (tailnet only, no public exposure). Disable with =tailscale serve --http=80 off=. +- *Transport security:* plain HTTP, but every byte rides inside the WireGuard mesh (Tailscale), so it is encrypted end to end. The Tailscale account does not support TLS certs, and TLS would be redundant on the tailnet anyway. If ever exposed publicly, TLS + stronger auth become mandatory. +- *Auth:* =auth-default-access: deny-all=. User =cj= has read-write on topics =claude= and =infra=. Anonymous is denied — verified 403 on both publish AND subscribe without a token. Token =tk_…= never expires. App login is username =cj= + a short password. +- *Phone:* Pixel 6, ntfy F-Droid build (no Firebase / Google Play Services), WebSocket instant delivery. Already on the tailnet. +- *Publisher wrapper:* =~/.local/bin/phone-notify= (on ratio only). Reads =~/.config/phone-notify/config= (chmod 600: URL, token, topic). Supports =-t/--title=, =-p/--priority=, =-T/--tags=, =--topic=, =--click=, =--url=. +- *Verified two-way:* agent → phone push lands instantly; phone → publish to the topic lands on the server and is readable by the agent (Craig sent "It did, in fact, land." from the app and the agent polled and saw it). + +* Part 2 — The ntfy building blocks rulesets can use + +** Publish (agent → phone), already wired +- =curl -H "Authorization: Bearer <token>" -d "msg" <url>/<topic>= or =phone-notify=. +- Rich features available, unused so far: =Priority= (1-5), =Tags= (emoji/keywords), =Click= (URL opened on tap), =Actions= (tappable buttons — =view= a URL, =http= fire a request, =broadcast= an Android intent), =Attach= (files/images), =Markdown=, scheduled/delayed delivery (=At:= / =Delay:= header), and email/call forwarding. + +** Read (agent ← phone) +- One-shot poll, all cached: =GET /<topic>/json?poll=1= (needs the token). +- Only-new since a point: =?since=<id|timestamp|duration>= (e.g. =?since=5m= or =?since=<last-seen-id>=). This is the basis of a =phone-recv= helper that prints only messages newer than the last one seen. +- Cache window is 12h (=cache-duration= in server.yml), so on-demand polling never misses a recent message. + +** Subscribe with side effects (the event-driven primitive) +- =ntfy subscribe <topic> '<command>'= holds a persistent connection (WebSocket / JSON stream) and runs =<command>= for *every* inbound message, with fields exposed as environment variables (=$message=, =$title=, =$topic=, =$tags=, =$priority=, etc.). +- This is the answer to "not all polling": a long-running subscriber reacts the instant a message arrives. + +* Part 3 — Making it event-driven (Craig's core ask) + +Three tiers, increasing capability and difficulty: + +** Tier A — Subscriber daemon routes inbound (clean, doable now) +A systemd *user* service on ratio (always-on): +#+begin_src +ntfy subscribe --since=<last> claude /usr/local/bin/ntfy-inbound-handler +#+end_src +=ntfy-inbound-handler= classifies the message and routes it: +- Append to a watched queue (a project =inbox/= or a dedicated comms file) → the next agent session picks it up at a task boundary (already in protocols: inbox check at task boundaries). +- Fire desktop =notify= so a human at a screen sees it immediately. +- Tag-based dispatch: =#task= → file as a TODO; =#infra= → infra queue; etc. + +This gets us instant reaction with zero polling, and it degrades gracefully — if nothing is listening, the message still sits in the queue. + +** Tier B — Inbound spawns a *new* agent session +The handler invokes the =ai= launcher (or a scheduled/cron Claude run) to process the message autonomously. An inbound phone text becomes an agent action — "remind me to X" from anywhere, "what's the status of Y", "approve the pending commit". This is where it stops being a notifier and becomes a remote control for the agent fleet. Ties into the harness cron/schedule features and the retired cross-agent-comms intent. + +** Tier C — Notify / interrupt a *live* agent session (hardest, harness-dependent) +A turn-based session has no native external interrupt. Honest options to explore: +- The session runs a background subscriber/poll loop; the harness re-invokes the agent when backgrounded work emits or completes (the background-Bash + Monitor + ScheduleWakeup / =/loop= dynamic-pacing mechanisms). +- A =/loop= that polls the topic every N seconds (still polling, but bounded and cheap). +- Whatever the harness exposes for inbound push into a live session (e.g. a RemoteTrigger / inbound-PushNotification path) — needs experimentation. + +Recommendation: ship Tier A first (high value, low risk), prototype Tier B, treat Tier C as research. + +* Part 4 — The general-comms vision (beyond notifications) + +- *Channels as topics:* =claude= (agent ↔ Craig), =infra= (server/health/backup alerts — the DEGRADED-pool class), per-project topics, a cross-agent topic. +- *Bidirectional chat:* Craig texts his agent from anywhere over Tailscale; the agent replies. Effectively private, self-hosted "SMS with your agent." +- *Approval buttons:* the publish =Actions= feature can render Approve / Reject buttons on the phone. For the commits.md approval gates (commit message, PR body, PR review) when Craig is away from the desk, a tapped button fires a webhook the handler turns into "proceed." This is a concrete, high-value use. +- *Attachments:* agent sends a generated screenshot/report to the phone; Craig sends a photo to the agent. + +* Part 5 — What rulesets would own / decide + +1. *Canonical tooling:* promote =phone-notify= (send) and add =phone-recv= (check-since) as rulesets bin scripts, synced to all machines via dotfiles/templates. Today =phone-notify= lives only on ratio. +2. *Config + secret convention:* where the server URL + token live per machine (=~/.config/phone-notify/config= chmod 600 today), and whether the token should be a rulesets-managed GPG-encrypted secret distributed via dotfiles. +3. *The subscriber daemon:* a reference =ntfy-inbound-handler= + a systemd user-unit template, plus the routing convention (tags → destinations). +4. *Protocol conventions:* topic naming, a message format/tag vocabulary for routing, and how inbound maps to the existing =inbox/= and (retired) cross-agent-comms protocols. +5. *Harness integration:* how — if at all — to wake or notify a live/new agent session on inbound. The Tier C research. +6. *Relationship to cross-agent-comms:* decide whether ntfy is the transport that replaces the just-retired scripts, and whether agent↔agent messaging rides the same server (a dedicated topic) or stays separate. + +* Part 6 — Open questions + +- Multi-machine token distribution (per-machine config vs encrypted-in-dotfiles). +- Daemon placement: one always-on subscriber on ratio vs per-machine subscribers. +- Inbound integration with the existing inbox + the retired cross-agent protocols. +- Live-session interrupt feasibility (entirely harness-dependent — needs a spike). +- Whether agent↔agent comms and agent↔Craig comms share a server or are isolated. + +* Companion artifact + +The full as-built runbook (concrete values, server.yml, the verification checklist, the security model) lives in the home project at =working/phone-notifications/spec.org=. This proposal is the forward-looking half; that file is the operational record of what was deployed. diff --git a/docs/design/2026-06-18-triage-intake-phone-push-note.org b/docs/design/2026-06-18-triage-intake-phone-push-note.org new file mode 100644 index 0000000..2f6502b --- /dev/null +++ b/docs/design/2026-06-18-triage-intake-phone-push-note.org @@ -0,0 +1,11 @@ +#+TITLE: WORKFLOW UPDATE — triage-intake.org auto mode gains a phone +#+SOURCE: from work +#+DATE: 2026-06-18 15:15:47 -0500 + +WORKFLOW UPDATE — triage-intake.org auto mode gains a phone (ntfy) delivery step. Supersedes/consolidates the earlier 2026-06-18-1512 send. + +WHAT we did: added a new subsection 'Push each sweep to Craig's phone (ntfy) — the primary delivery' under 'Trigger and delivery' in the auto-mode section. It makes phone-notify (the self-hosted ntfy channel over Tailscale) the primary delivery for every auto-mode sweep, pushing the fuller End-of-sweep output (per-source deltas + open-PR/Linear state + the awaiting-ack list + a one-line verdict + a timestamp; SCAN FAILED banner if any source failed), and polling phone-recv each sweep for Craig's replies. Falls back to inline if phone-notify is absent. + +WHY: auto mode exists precisely for when Craig is away from the desk (out of office for a while, on vacation — which is the case right now, traveling 6/17-24). An inline-only report is useless if he is not at the screen; the whole point is reaching his phone. We ran it live this way all day and it worked, so we codified the delivery rather than re-deriving it each time. Craig confirmed this is his standard pattern: he starts auto-triage before leaving the office / on vacation. + +Companion: the reference_phone_notify_ntfy_channel harness memory documents the channel + the high-bar caveat. A separate task is producing standalone ntfy setup instructions (install + start/stop the service) so the channel can be brought up on other machines. diff --git a/docs/design/2026-06-18-triage-intake-phone-push-workflow.org b/docs/design/2026-06-18-triage-intake-phone-push-workflow.org new file mode 100644 index 0000000..a0bb416 --- /dev/null +++ b/docs/design/2026-06-18-triage-intake-phone-push-workflow.org @@ -0,0 +1,427 @@ +#+TITLE: Triage Intake Workflow (Engine) +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-01 + +* Summary + + +Lightweight, between-meetings sweep across whatever sources are plugged in — email, calendar, chat, open PRs, ticketing. Classifies what came in since the last check (Action / FYI / Noise-keep / Noise-trash), produces a single synthesized summary, and offers to execute the routine actions (trash, mark-read, star, respond, merge, attachment fetch). + +Think of it as the ER intake queue: every new message, invite, and PR notification is a "patient" walking through the door. This workflow is the triage nurse looking at the queue and telling Craig what needs attention now, what's just FYI, and what can be cleared. + +*This file is the engine.* It carries no sources of its own. Every source it scans comes from a *source plugin* — a =triage-intake.<source>.org= file the engine loads at Phase 0. The engine is source-agnostic and project-agnostic; the project- and account-specific knowledge lives entirely in the plugins. To add a source, drop a plugin file. To change one, edit its plugin. Never wire a source into this file. + +Distinct from =daily-prep.org=: +- *daily-prep* — heavier, once daily, builds the day's plan + standup brief + meeting prep + time blocks. +- *triage-intake* — fast, repeatable, just answers "what's new since last check?" + + +Quick contract — what it does: fans out across source plugins, classifies every item into Action / FYI / Noise-keep / Noise-trash, synthesizes one deduped summary, writes each Action item to =todo.org= as a =:quick:reactive:= task, and executes star/mark-read/trash on confirmation. + +** When to Use This Workflow + +Trigger phrases: + +- "Run a triage-intake" +- "Triage intake" +- "What's new" / "What's new since I last checked" +- "Do a sweep" / "Do a triage sweep" +- "Check email, calendar, and PRs" + +Typical timing: + +- Between meetings (1-2 minute glance) +- After a long focused-work block +- Before context-switching to a new task +- When ambient anxiety about "did I miss something?" creeps in + +Do *not* use when running daily-prep — daily-prep already does this as Phase 3. + + +* Execution + +** Phase 0 — Load source plugins (MANDATORY — do not skip) + +The engine has no sources baked in. It discovers them by globbing *two* directories, and you MUST glob *both*: + +#+begin_src bash +ls .ai/workflows/triage-intake.*.org .ai/project-workflows/triage-intake.*.org 2>/dev/null +#+end_src + +- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Telegram, personal GitHub PRs). +- =.ai/project-workflows/triage-intake.*.org= — *PROJECT-SPECIFIC* source plugins, never synced, owned by this project (e.g. a work project's Linear, work Gmail, work Slack, enterprise-GitHub PRs). + +⚠ *THE #1 FAILURE MODE — read this twice.* Globbing only =.ai/workflows/= and silently missing every project plugin. If you skip =.ai/project-workflows/=, the sweep runs with *half its sources* and Craig never learns what it dropped — the omission is invisible, because a missing source looks identical to a quiet source in the output. There is no error, no empty block, no warning. The sweep just lies by omission. *Glob both directories. Always.* + +The glob exclude is automatic: =triage-intake.*.org= matches the plugins but not this engine file (=triage-intake.org= has no second dot-segment), so the engine never loads itself. + +After globbing, for each plugin file: +1. Read it. +2. Evaluate its =ENABLED= precondition. If false, *announce the skip with its reason* ("skipping linear — mcp__linear not present") and move on. +3. The surviving set is the source list for Phases A-D. + +*Announce the loaded set before scanning* so the omission can't hide: + +#+begin_example +Loaded 5 source plugins: + general: personal-gmail, personal-calendar, cmail, github-prs + project: deepsat-gmail + skipped: linear (mcp__linear not present) +#+end_example + +If the project directory glob returns nothing, say so explicitly ("no project plugins in .ai/project-workflows/") rather than staying silent — silence is indistinguishable from forgetting to look. + + +** Approach: Phases A → D + +*** Phase A: Fan-out (one parallel batch) + +Issue every enabled source's =Scan= command in a single message, with the anchor substituted in each source's declared format. They have no dependencies and benefit from running concurrently. + +Per-source subagent escalation: if a source's scan is expected to return more than its =SUBAGENT_OVER= count (e.g. personal Gmail after a multi-day gap), dispatch a subagent for that source. The subagent applies Phase B classification and returns the synthesized buckets, not the raw item list. + +*** Phase B: Classify per source (shared four-bucket model) + +Every item lands in one bucket. Plugins refine these with source-specific bias and noise patterns in their =Classify= section; they do not redefine the buckets. + +- *Action* — needs Craig to do something: an explicit ask, a decision needed, blocked-on-Craig, a mergeable PR, an invite needing a response, a deadline inside 48h. +- *FYI* — substantive context worth seeing, but no action owed. +- *Noise-keep* — low value but worth retaining (audit trail, receipts). +- *Noise-trash* — safe to discard: newsletters, marketing, social digests, bot pings, redundant aggregator digests, wrong-recipient mail, past-event artifacts. + +Per-source bias (a work email account leans keep for audit value; a personal account leans trash on high noise volume) lives in each plugin's =Classify= section. Read it from there; don't re-derive it here. + +*** Phase C: Synthesize a single summary + +One markdown summary surfaced inline to Craig. Order: + +0. *Scan failures — first, loud, always.* Any loaded source whose scan failed, hung, was killed, or was skipped for an operational reason renders at the very top of the summary, before Top signals: + + #+begin_example + ⚠ SCAN FAILED: <source> — <reason, one line> — <what's now unknown> + #+end_example + + A failed scan is never folded into "quiet." Quiet means the scan ran and found nothing; a failure means the sweep is blind on that channel, and the reader must know which. The same applies to a precondition skip the user hasn't standing-approved (e.g. a messaging client that needs a temporary server spin-up): run the lifecycle or report the failure — don't silently narrow the sweep. + +1. *Top signals to act on* — bullet list of 3-7 items, ordered by urgency, *Action only*. Each bullet links to the source (permalink, thread URL, PR number). +2. *Per-source breakdown* — one short section per loaded source *that has changes*, in =ORDER=, using that plugin's =Render= shape: Action items detailed, FYI items as a short list, Noise as a tally only ("Noise: 12 trash candidates, 4 keep, 0 starred"). +3. *Suggested actions* — explicit list of state changes Craig could take this run (trash these N messages, mark-read these M, star this Action item, respond to this invite, merge PRs #X and #Y, etc.). This line stays whenever there are queued actions, regardless of how quiet the sweep was. + +*Deltas only.* The summary reports what *changed* since the anchor: a new invite, a new/moved/cancelled calendar event, a new message needing attention. A source with no changes gets no block — no "Calendar — quiet", no "PRs — nothing new" roll-call. A sweep where nothing changed anywhere renders as a single line: + +#+begin_example +17:39 sweep: no changes +#+end_example + +(Craig, 2026-06-11: "we only need to report if anything's changed when we do triage intake. did someone send me a new invite? did christine throw something on my calendar that wasn't there earlier? did someone cancel a meeting?") + +Scan failures are the standing exception: a failed or skipped scan always renders loudly per point 0 above and is never folded into the no-change line — "no changes" is a claim about channels the sweep could actually see. + +Format target: scannable in 30 seconds, full read in 2 minutes. Don't pad. + +**** Sub-step: write each Action item into =todo.org= as its own =:quick:= task + +After surfacing the summary inline, append every Action item — regardless of source — to =todo.org= as its own top-level =** TODO= heading carrying the =:quick:= tag plus =:reactive:= and any relevant person/entity tag. + +Each Action item is one task. Don't group items by source under =** Email Response=, =** PR Review=, etc. sub-headings. Each response is its own filterable task so Craig can re-prioritize, =SCHEDULE:= / =DEADLINE:=, or tag individually. + +Format: + +#+begin_example +*** TODO [#B] Merge PR #42 on archsetup (approved, CI green) — [[https://github.com/<user>/archsetup/pull/42][PR #42]] :quick:reactive: +*** TODO [#B] Respond to the 2pm reschedule invite from Dana :quick:reactive: +*** TODO [#B] Reply to the contract-terms email thread :quick:reactive: +#+end_example + +Rules: + +- Heading is plain prose. Lead with the verb (Read / Re-review / Reply / Respond / Address / Merge / Schedule). +- Priority: default =[#B]= for fresh reactive items. Bump to =[#A]= only if blocking someone or a deadline lands inside 7 days. +- Tags: always =:quick:= + =:reactive:=. Add person/entity tags when the dependency is sharp. +- Link the source in the heading when it has a URL (GitHub PR, mail thread, chat permalink). Use org's =[[url][label]]= form so the heading stays clickable in Emacs. +- *Record the source locator in the task body* so a reply can be routed back to where the request came from — the channel + thread id for chat, the repo + PR number, the message id for mail. The general rule: a reply goes back to the *origin* of the request, not a fixed notification channel. (Project plugins may add stricter routing rules in their own files.) +- Placement: append at end of =* Work Open Work= (just before =* Work Incubate=) unless the project's =todo.org= has a designated triage section near the top (=* Triage= or =* Inbox=). + +This sub-step makes triage-intake's findings *persist* in =todo.org= instead of evaporating after the inline summary. + +*** Phase D: Execute actions on confirmation + +Wait for Craig's go-ahead before running any state changes. Default to single-confirmation for the whole batch ("yes" → run everything proposed). Craig may also pick a subset ("trash personal but hold the work account") or hand back a different plan ("trash all but star the expense thread and queue PR merges for after lunch"). + +Each action dispatches to the owning source plugin's =Actions= verb (trash, mark-read, star, respond, merge, comment, attachment-fetch). The engine doesn't hardcode action commands — it reads them from the loaded plugins. Read each plugin's =Actions= section for the exact command. + +After actions complete, write the Phase A capture into the sentinel's *content* (see "Capture the Phase A timestamp"): =echo "$PHASE_A_TS $(date -d "@$PHASE_A_TS" '+%Y-%m-%d %H:%M:%S %z')" > .ai/last-triage-intake=. Do not use plain =touch= (writes mtime to /now/ and strands items posted between Phase A and end of run) and do not use =touch -d "@$PHASE_A_TS"= (correct timestamp but mtime is per-machine — won't survive a fresh clone or cross-machine sync). + +*Do not close the workflow yet.* See Exit Criteria below. + +*** Exit Criteria + +The workflow stays open until Craig has *explicitly* either: + +1. *Confirmed* that the executed actions are sufficient and nothing more is needed this round, or +2. *Handed back a different plan* (e.g., "actually hold the PR merges, address #131 first"). + +A successful Phase D run is *not* an exit signal. After the action batch returns, surface what shipped and wait. Don't volunteer "done" or "all set" — those are exit-claim phrases that pre-empt Craig's call. Use a status report ("17 actions succeeded, sentinel written at 12:19") and stop. + +If Craig has been silent for a while after Phase D and the surface looks closed-out, *ask*: "Anything else on this triage, or are we good to close out?" Don't auto-terminate. + +This rule prevents the failure mode where the workflow self-declares done and the next exchange has to relitigate what state things are in. + + +* Auto mode (unattended monitoring) + +Auto mode is a self-running variant of the engine for when Craig is away from the desk but wants tight awareness — a loop that runs the standard sweep on a short interval, *accumulates* findings rather than mutating state, and hands Craig a gated checkpoint to commit the batch. It composes two things: the *delivery* (a =/loop= in the live session) and the *behavior* (accumulate-don't-mutate sweeps with a checkpoint). The one-shot run above is unchanged; auto mode is an additional way to run the same Phase 0 / A-D engine. + +** Trigger and delivery + +- "auto triage" / "auto triage-intake" / "watch the desk" / "monitor the triage" — start auto mode. +- Default interval *20 minutes*; Craig sets it. + +Auto mode runs as a =/loop= in the *live session*, not a detached cron job: + +#+begin_src +/loop 20m run an auto-mode triage-intake sweep per triage-intake.org +#+end_src + +Running in the live session means MCP auth (Slack, Gmail, Linear) is inherited from the session — the headless-auth wall that blocks a detached cron run does not apply. A durable cross-session schedule is out of scope here; that belongs to the morning-ops orchestrator, which can later invoke auto mode's accumulate behavior as its triage limb. The close/stop commands below require a live session by design. + +*** Push each sweep to Craig's phone (ntfy) — the primary delivery + +Auto mode exists for when Craig is away from the desk (out of office for a while, on vacation), so the report's primary delivery is a push to his phone, not an inline message he won't be looking at. After every sweep, send the End-of-sweep output to his phone via =phone-notify= (the self-hosted ntfy channel over Tailscale; see the phone-notify reference memory for usage and the high-bar caveat). Push on *every* sweep, including a quiet "no changes" one — the timestamp line is the proof the loop ran. + +The pushed summary is the *fuller* shape, not a terse one-liner: per-source deltas (Slack / work-email / Linear / PRs / calendar / Telegram, noise tallies included), the current open-PR + Linear state, the awaiting-acknowledgment list, a one-line verdict on whether anything needs Craig, and the timestamp. Lead with a ⚠ SCAN FAILED banner if any source failed. + +Poll =phone-recv= at the top of each sweep for anything Craig sends back (delivery is not pushed to the agent); act on his requests and reply via =phone-notify=. Note that =phone-recv= echoes the agent's own outgoing pushes back, so only treat a message as inbound from Craig when it is not one of the sweep summaries. + +If =phone-notify= isn't installed on the host (it lives on ratio for now), fall back to inline delivery and say so once. + +** Preconditions and Close-out + +Auto mode borrows the inbox-monitor gates (=monitor-inbox.org=): do not start on a dirty worktree or a red test suite — a close's batch commit would otherwise sweep up unrelated changes — and leave the tree clean and green when the loop stops. Surface a blocker with inline numbered options per =interaction.md= and wait. + +** A sweep: accumulate, don't mutate + +Each sweep runs Phase 0 (load *both* plugin dirs — the loud requirement still holds) and Phases A-D's scan / classify / synthesize, but performs *none* of the normal run's mutations: + +- Does NOT advance the sentinel. The scan window grows from the last *close* until the next close: every sweep scans from the existing sentinel up to now, so nothing between sweeps is dropped. +- Does NOT write =todo.org= Action tasks — accumulates them for the close. +- Does NOT take mail actions (trash / mark-read / star). +- Does NOT commit. +- DOES update an active daily-prep in Update mode and re-open it on change (per =daily-prep.org=). +- DOES report, deltas-only, with loud scan-failure banners (Phase C rules unchanged). + +** End-of-sweep output — three sections + +1. *Deltas* — what changed since the *previous sweep* (the standard Phase C summary scoped to the inter-sweep delta; one line if nothing: "HH:MM sweep: no changes"). +2. *Responses awaiting your acknowledgment* — every Slack reply, email, or message directed at Craig that he hasn't acknowledged or had the agent answer. A *running list carried forward across sweeps* until Craig acks each item or closes the triage. An away user's first need is "who's waiting to hear back from me," which a delta-only sweep loses the moment it scrolls past. +3. *Timestamp* — the current date, time, and timezone on the sweep's own final line, so an away reader sees how fresh the summary is without computing it. Print it on *every* sweep, including a quiet "no changes" one — on a quiet sweep the stamp is the proof the loop ran. Generate it with: + + #+begin_src bash + date "+%A %Y-%m-%d %H:%M:%S %Z (%z)" + #+end_src + +** The unacked list — durable state + +The awaiting-acknowledgment list lives in =.ai/triage-intake-unacked.org=, so it survives a session crash, a =/clear=, or a restart — the away-from-desk case auto mode exists for. It's project-local state, tracked the same way as the sentinel (=.ai/last-triage-intake=), created on first need. + +Shape — one =** = heading per awaiting item: + +#+begin_example +#+TITLE: Triage Intake — Responses Awaiting Acknowledgment +# Maintained by triage-intake auto mode. One heading per item; acked items are removed. + +** Dana — 2pm reschedule invite +:PROPERTIES: +:SOURCE: personal-calendar +:LOCATOR: <event id or thread url — the dedupe key> +:SINCE: 2026-06-15 10:42 +:END: +She's waiting on a yes/no to the move. +#+end_example + +- *Add* — a sweep appends any new directed-at-Craig response not already listed, deduped on =LOCATOR=. +- *Carry forward* — every sweep re-renders the full list in its second section, whether or not it changed this sweep. +- *Ack* — "ack <item>" (e.g. "ack the Dana thread") removes that heading; "ack all" clears the list. +- *Close* — a close empties the list as part of processing (each item is either actioned or filed). + +** Close and stop — the checkpoint + +The mutations are gated behind two commands: + +- *"close the triage"* — run the full close: take the accumulated mail actions, add the accumulated Action items to =todo.org= as =:quick:reactive:= tasks (asking Craig the questions a normal Phase C/D would), empty the unacked list, then *advance the sentinel* — capture the close run's Phase A timestamp, do the mutations, write that timestamp to =.ai/last-triage-intake= exactly as a normal run does (per "Capture the Phase A timestamp") — and commit + push the batch. Then *keep looping* (next sweep on the normal interval). This is the "flush the batch and carry on" checkpoint. +- *"stop the triage"* — the same close processing, then *stop the loop* and revert to manual (on-demand) triage. + +A close is the only point auto mode advances the sentinel or commits. Between closes the engine state is untouched — that is what makes a 20-minute sweep cheap and non-destructive, and it preserves the engine invariant: the sentinel still means "everything before this timestamp has been scanned," it just advances once per close instead of once per run. + +** Why a separate mode + +The standard engine is one-shot and mutating — right for an at-the-desk "what's new?" glance, wrong for unattended polling: run every 20 minutes it would advance the sentinel past unprocessed items, spray reactive todos, take mail actions, and commit noise without review. Auto mode separates the cheap, frequent *watching* from the deliberate, gated *committing*, and adds the away-user's missing primitive — the running unacked-responses list. + + +* Reference + +** Source Plugin Contract + +A source plugin is a file named =triage-intake.<source>.org=. The first dot after =triage-intake= is the engine/plugin boundary; the segment after it is the source id. Hyphens stay *inside* a segment (=triage-intake.personal-gmail.org= is engine =triage-intake=, source =personal-gmail=). Deeper dots (=triage-intake.<source>.<sub>.org=) are reserved for sub-adapters — YAGNI for now, but the namespace accommodates them at no cost. + +A plugin file declares exactly one source through a fixed shape: + +*Property drawer* on the top-level =* Source:= heading: +- =ORDER= — integer. Output ordering in the per-source breakdown (lower = earlier). +- =ENABLED= — the precondition the engine evaluates before loading the source. The source is skipped — *with an announced reason* — when it's false. Forms: =always=, a shell test (=command -v gh && gh auth status=), or =mcp <server> present=. +- =ANCHOR= — the cutoff format this source consumes: =epoch=, =iso8601=, =day=, or =none= (state-based source with no since-window — e.g. live IMAP unread, or open-PR state). The engine computes the anchor once and substitutes it in the requested format. +- =SUBAGENT_OVER= — integer. If the scan is expected to return more than this many items, dispatch a subagent for the source so its raw output stays out of main context. The subagent applies Phase B and returns buckets only, not the raw list. + +*Body sections:* +- =** Scan= — the command(s) that fetch new/unread items since =<anchor>=, emitting raw items. +- =** Classify= — the source's per-bucket bias and noise patterns. *Deltas* from the engine's shared four-bucket model below, not a re-derivation. +- =** Render= — the source's block in the Phase C summary. "Omit if empty." +- =** Actions= — the executable state-changes, one verb per line: =verb :: command template (parameterized by item id)=. + +Template: + +#+begin_example + +** Source: <id> +:PROPERTIES: +:ORDER: <n> +:ENABLED: <precondition> +:ANCHOR: epoch | iso8601 | day | none +:SUBAGENT_OVER: <n> +:END: + +*** Scan +<command(s) that fetch new/unread items since <anchor>> + +*** Classify +<bias + noise patterns; deltas from the shared four-bucket model> + +*** Render +"<Source label> — N <unit>" block; omit if empty. + +*** Actions +- <verb> :: <command, parameterized by <id>> +#+end_example + + +** Anchor: Since When? + +The workflow needs a "scan since" timestamp. Resolution order: + +1. *Sentinel file content:* first whitespace-delimited token in =.ai/last-triage-intake= is the Phase A scan-kickoff epoch from the most recent successful run (see "Capture the Phase A timestamp" below). Most accurate. +2. *Sentinel file mtime* (back-compat): if the file exists but is empty, read its mtime — that's the older mtime-based convention that pre-dates the content-based change. Still accurate on the machine that wrote it. +3. *Most recent prep doc:* if no sentinel content or readable mtime, anchor on the latest =daily-prep/YYYY-MM-DD-daily-prep.org= mtime. +4. *Most recent session file:* if none of the above, anchor on the most recent =.ai/sessions/= file's mtime. +5. *Session start:* fall back to the current session's start time. Last resort. + +The engine computes the anchor *once* and exposes it in every format a plugin might request (=epoch=, =iso8601=, =day=). Each plugin's =ANCHOR= field says which it consumes; the engine substitutes that form into the plugin's =<anchor>= placeholder. Sources with =ANCHOR: none= are state-based (live unread, open-PR state) and get no cutoff substituted — they report current state, and Phase B uses the anchor only to flag what's *new since* last check. + +*** Capture the Phase A timestamp + +Just before issuing the Phase A batch, capture the current epoch seconds: + +#+begin_src bash +PHASE_A_TS=$(date +%s) +#+end_src + +Hold this value through Phases B, C, and D. At end of run, *write* the captured timestamp into the sentinel's content (not its mtime): + +#+begin_src bash +echo "$PHASE_A_TS $(date -d "@$PHASE_A_TS" '+%Y-%m-%d %H:%M:%S %z')" > .ai/last-triage-intake +#+end_src + +The file ends up with a single line like =1778683109 2026-05-13 09:38:29 -0500= — epoch first (machine-readable, parsed by reading the first token), human-readable timestamp second. + +*Why content, not mtime:* the sentinel is checked into git. Git tracks content, not mtime, so an mtime-based sentinel is per-machine: one machine's anchor stays on that machine; a fresh clone gets the file but the mtime is whenever the clone happened, not the actual triage time. Writing the epoch as content means the anchor travels with the repo and stays accurate after a fetch + pull on any machine. + +*Why Phase A and not end-of-run:* Phase A runs at one moment, but Phases B-D may take 5-30 minutes. Items posted to any source /during/ Phases B-D land between the Phase A scan time and the eventual end-of-run time. If the sentinel were set to the end-of-run time, those items would silently fall through the cracks: the next triage's Phase A would skip the gap window and never see them. Anchoring the sentinel to Phase A's scan time guarantees the next run's window starts where this run's window ended, with zero gap. + +*** Reading the sentinel + +When the workflow needs the anchor at the start of a new run: + +#+begin_src bash +# Content-first, mtime-fallback. +ANCHOR_EPOCH=$(awk 'NR==1 {print $1; exit}' .ai/last-triage-intake 2>/dev/null) +if [ -z "$ANCHOR_EPOCH" ] && [ -f .ai/last-triage-intake ]; then + ANCHOR_EPOCH=$(stat -c %Y .ai/last-triage-intake) +fi +#+end_src + +If both fail, fall through to the resolution order above (prep doc → session file → session start). + + +** Output Template + +The summary follows this shape (deltas only: a source with no changes gets no block; when *nothing* changed anywhere, the whole summary collapses to the one-line form below — plus any scan-failure banners and the suggested-actions line if actions are queued): + +#+begin_example +17:39 sweep: no changes +#+end_example + +When there are changes, render one block per changed source in =ORDER=, using each plugin's =Render= shape: + +#+begin_example +**Anchor:** <previous run timestamp> → now (<elapsed> elapsed) +**Loaded:** <general plugins> + <project plugins> (skipped: <disabled, with reason>) + +**Top signals to act on:** +1. <terse Action description with link> +2. ... + +<one block per loaded source, in ORDER — see each plugin's Render> + +**Suggested actions:** +- Trash N noise items +- Mark-read M keep items +- Respond to <invite> +- Merge PRs #X and #Y +- ... +#+end_example + +Order matters: top-signals first because that's what Craig reads in 30 seconds between meetings. Per-source detail second. Suggested actions last because they require a decision. + + +** Common Mistakes + +1. *Globbing only =.ai/workflows/= and missing the project plugins.* The single most damaging failure mode — the sweep runs with half its sources and the omission is invisible (a missing source looks identical to a quiet one). Phase 0 globs *both* =.ai/workflows/triage-intake.*.org= and =.ai/project-workflows/triage-intake.*.org=, every run, and announces the loaded set. +2. *Running Phase A sequentially.* Send every enabled source's scan in one message — the whole point is parallelism. +3. *Wiring a source into the engine.* Sources live in plugin files, never here. If you find yourself editing this file to add an account, repo, or channel, stop — write or edit a =triage-intake.<source>.org= plugin instead. +4. *Executing actions without explicit confirmation.* Phase D runs only after Craig says "yes" or picks a subset. +5. *Forgetting to set the sentinel at the end.* Without it, the next run re-scans the same window. +6. *Using mtime instead of content for the sentinel.* Plain =touch= writes /now/ to mtime, stranding items posted between Phase A and end of run. =touch -d "@$PHASE_A_TS"= fixes the time but mtime is per-machine — git tracks content, not metadata, so the anchor doesn't survive a clone or cross-machine sync. Always write the epoch into the file's *content*. +7. *Running this alongside daily-prep.* Daily-prep already does this as Phase 3 — don't duplicate. +8. *Mixing Action and FYI in the top-signals list.* Top signals = Action only. FYI lives in the per-source detail. +9. *Reporting a failed or skipped scan as a quiet source.* A hung receive, a dead daemon, or a skipped spin-up looks identical to "no new messages" in the output unless it's flagged. The 2026-06-10 sweep shipped with Signal silently missing because the scan hung on an account lock. Failures lead the summary, in their own banner line. +10. *Rendering a per-source quiet roll-call.* "Calendar — quiet" / "PRs — nothing new" lines on every silent source bury the one change that matters and pad a no-change sweep into a report. Deltas only: changed sources get blocks, unchanged sources get nothing, and an all-quiet sweep is one line (Craig's 2026-06-11 ruling in Phase C). + + +* History / Design Notes + +** Living Document + +Update the engine as the orchestration pattern evolves; update a plugin as its source evolves. Source-specific learnings belong in the plugin's own file, not here. + +*** Updates and Learnings + +**** 2026-06-15: Auto mode (unattended monitoring) +Added a self-running mode for when Craig is away but wants tight awareness — a =/loop= in the live session running accumulate-don't-mutate sweeps with "close the triage" / "stop the triage" as the gated checkpoint. Born the morning Craig cleared his day for a family emergency and wanted the desk watched while in and out. Design decisions (work-project proposal, ratified by Craig 2026-06-15): the unacked-responses list is durable in =.ai/triage-intake-unacked.org= (survives a crash/clear, the away-from-desk case it exists for); the sentinel advances only at close, preserving the scanned-before invariant; delivery is an in-session loop so MCP auth is inherited (a detached cron schedule belongs to the morning-ops orchestrator, not here, because of the headless-auth wall); it stays a mode of this engine, distinct from but reusable by that orchestrator. Same-day addendum (work, 2026-06-15): each sweep ends with a date/time/timezone stamp on its own final line (printed on quiet sweeps too, as proof the loop ran) so an away reader gauges freshness at a glance. + +**** 2026-05-01: Initial creation +Extracted from daily-prep's Phase 3 pattern as a standalone, lightweight, between-meetings sweep. + +**** 2026-05-07: Anchor the sentinel to Phase A scan time, not run-end time +Gap-window bug: a run had Phase A fire at 13:35 and the sentinel set at 15:04, so an item posted at 14:20 would be skipped by the next run (the sentinel claimed everything before 15:04 was scanned when Phase A only reached 13:35). Fix: capture =PHASE_A_TS= just before Phase A, hold it through B-D, write it to the sentinel at end of run. The sentinel means "everything before this timestamp has been scanned," the only invariant that prevents items falling through the cracks. + +**** 2026-05-13: Move the sentinel from mtime to content (cross-machine survivability) +The sentinel is checked into git, but git tracks content, not mtime — so an mtime anchor is per-machine. Fix: write the captured epoch into the sentinel's content (=EPOCH ISO-8601=), read with =awk 'NR==1 {print $1}'=, mtime as back-compat fallback. + +**** 2026-06-11: Deltas-only reporting (Phase C + Output Template + Common Mistake 10) +Craig, via the work project's same-day handoff: "we only need to report if anything's changed when we do triage intake." Sweep summaries report deltas only — a new invite, a new/moved/cancelled event, a new message needing attention. Unchanged sources get no block (the "Calendar — quiet" roll-call is retired), and an all-quiet sweep renders as a single "HH:MM sweep: no changes" line. Failures keep their loud banner (never folded into the no-change line) and the suggested-actions line stays when actions are queued. Same ruling: the telegram plugin's dev-community group traffic is dropped from reports entirely unless Craig asks (see that plugin's 2026-06-11 note). + +**** 2026-06-10: Loud failure surfacing (Phase C item 0 + Common Mistake 9) +Craig: "highlight any failures in daily triage loudly. I get important communication from all these channels." Trigger: the 2026-06-10 sweep shipped with Signal silently missing — a standalone receive hung on the account lock while the signel daemon owned it, and the failure looked identical to a quiet source. Failures now lead the summary in a ⚠ SCAN FAILED banner; the telegram plugin's failure path points at this rule. + +**** 2026-05-26: Refactor into engine + source plugins +Split the monolithic workflow into a source-agnostic engine (this file) and per-source plugins named =triage-intake.<source>.org=. The engine carries the anchor/sentinel logic, the four-bucket model, the Phase A-D orchestration, the todo.org persistence convention, and the exit criteria. Each source's scan/classify/render/action knowledge moved to its own plugin. General plugins (personal-gmail, personal-calendar, cmail, github-prs) live in =.ai/workflows/= and are template-synced; project-specific plugins (a work project's Linear, work Gmail, work Slack, enterprise PRs) live in the project's =.ai/project-workflows/= and are never synced. Phase 0 globs *both* directories — the loud requirement, because missing the project dir silently halves the sweep. Naming convention: first dot is the engine/plugin boundary, deeper dots reserved for sub-adapters. This removed all DeepSat/Linear specifics from the engine; they become work-project plugins. + diff --git a/docs/design/2026-06-21-anki-titlefix-proposal.org b/docs/design/2026-06-21-anki-titlefix-proposal.org new file mode 100644 index 0000000..08b8c13 --- /dev/null +++ b/docs/design/2026-06-21-anki-titlefix-proposal.org @@ -0,0 +1,57 @@ +#+TITLE: Proposal — flashcard-to-anki.py deck name should come from #+TITLE + +From: home session, 2026-06-21. Two attached files are the edited +canonical scripts (flashcard-to-anki.py + its test). Applied locally in +home as a stopgap; this is the durable proposal for the rulesets +canonical. Please reconcile and re-sync. + +* The bug (longstanding) + +flashcard-to-anki.py's default_deck_name returned input_path.stem (the +filename), so every deck generated through flashcard-sync (which passes no +--deck) was named after the file, e.g. "personal-drill" / "health-drill" +/ "kit", not the curated #+TITLE. + +flashcard-review.org already documents the intended behavior: "The +#+TITLE line drives ... the Anki deck name on the phone" and "derives the +Anki deck ID from the deck name." The script never matched the doc. +deepsat only looked correct because its first run used an explicit +--deck "DeepSat Flashcards". + +* The fix + +default_deck_name(input_path, org_text) now scans for a #+TITLE: line +(case-insensitive, surrounding whitespace trimmed) and returns it; falls +back to input_path.stem when there's no non-empty #+TITLE. main() passes +the already-read org_text. Help text + module docstring updated. + +TDD: the two old deck-name tests asserted the buggy basename behavior — +rewrote them. New tests cover title-driven naming, trimming, +case-insensitive #+title, basename fallback (no title), and basename +fallback (blank title). Full file: 29 pass. + +No companion script changes needed: flashcard-sync passes no --deck so it +picks up the new default automatically, and flashcard-stats.py already +reads #+TITLE. flashcard-review.org needs no change (the script now +matches what it already says). + +* Migration caveat (worth a line in the doc if you want) + +Deck ID derives from the deck name, so this fix changes the ID for any +deck previously generated without --deck. On next import those land as +new decks; the old basename-named decks keep their review history and +must be deleted by hand. The workflow's existing "Stable-ID caveat" +already covers the mechanics. In home this affected personal-drill, +health-drill, kit (regenerated this session as Personal / Health / KIT, +with titles also stripped of "Flashcards"/"Drill" per Craig). deepsat is +unaffected (already title-named). + +* Related idea (separate, not in these files) — apkg → org-drill converter + +deepsat-fundamentals.apkg (100-card DeepSat subset, made once with +--deck "DeepSat Fundamentals") has no saved .org source anywhere. Craig +wants an apkg → org-drill converter — the inverse of flashcard-to-anki.py +— to recover orphaned decks and pull phone-authored cards back into the +org source-of-truth. Flagging as a candidate rulesets tool alongside the +flashcard-* family; deepsat-fundamentals is the concrete first use case. +Not built yet; raising for the backlog. diff --git a/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org b/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org new file mode 100644 index 0000000..37a866f --- /dev/null +++ b/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org @@ -0,0 +1,68 @@ +#+TITLE: Build request — apkg → org-drill converter (inverse of flashcard-to-anki.py) + +From: home session, 2026-06-21. Craig wants this built (backlogged, not +urgent). Standalone build request — the earlier anki-title-fix-proposal +only mentioned it in passing; this is the real ask. + +* Why + +The flashcard pipeline is one-directional (org-drill → apkg). Decks +authored or curated on the phone, and orphaned apkgs whose .org source +was never saved, can't get back into the org source-of-truth. Concrete +case: deepsat-fundamentals.apkg — a 100-card DeepSat subset generated +once with --deck "DeepSat Fundamentals" — has no .org source anywhere on +ratio, velox, or in work git history. The converter recovers it and makes +phone → org round-tripping possible. + +* What — contract (inverse of flashcard-to-anki.py) + +Input: an Anki =.apkg= (a zip containing collection.anki2 / .anki21 +sqlite, plus a media blob). +Output: an org-drill =.org= file in the house canonical shape that +flashcard-stats.py / flashcard-to-anki.py already agree on. + +Mapping (mirror flashcard-to-anki.py's parse/build): +- Deck name (from the apkg) → =#+TITLE:=. +- Each note → =** <Front> :drill:= with the Back as the body. +- Card tag → top-level =* Section= grouping (inverse of section_to_tag; + cards sharing a tag collect under one section; the slug won't round-trip + to the exact original section title, so this is best-effort — emit the + tag as the section heading and let a human retitle). +- Back HTML → org: convert =<br>= back to newlines; unescape + =&/</>=; strip the =<hr id="answer">= the card template adds + (the Back field itself shouldn't contain it, but guard anyway). +- Generate a fresh =:ID:= UUID per card in a =:PROPERTIES:= drawer so the + output is immediately org-drill-valid and round-trips back through + flashcard-to-anki.py. (Note: GUIDs in flashcard-to-anki.py are derived + from the front text, not the :ID:, so a regenerated apkg still matches + existing phone cards by front — call that out in the docstring.) + +Edge cases to cover in tests (Normal/Boundary/Error): +- Multiple decks in one apkg (emit one file per deck, or error asking for + a deck filter — pick one and document it). +- Notes with multiple fields / non-basic note types (the pipeline only + models Front/Back — skip or warn on others, don't silently drop). +- HTML entities, embedded =<br>=, and any =Source:= footer surviving + round-trip. +- Empty back; media references (flag, since org side has no media path). +- collection.anki2 vs .anki21 schema differences. + +* Where it lives + +Rulesets-owned, beside the flashcard-* family +(=claude-templates/.ai/scripts/=): suggest =anki-to-flashcard.py= (or +=apkg-to-orgdrill.py= — your naming call). Add tests under +=scripts/tests/=. A new file can't be built downstream — home/.ai/scripts/ +is wiped to match the template by the startup =--delete= rsync — so this +has to be built in the rulesets canonical. PEP 723 uv-run script like its +sibling; genanki isn't needed for reading (stdlib =zipfile= + =sqlite3= +suffice), so it has no runtime deps. + +* Acceptance + +Round-trip test: take a known org-drill source, run it through +flashcard-to-anki.py, run the result back through this converter, and +assert the cards (front/back/section) match the original (modulo +regenerated :ID:s and best-effort section titles). Plus: run it on the +real deepsat-fundamentals.apkg and hand the recovered .org back so its +source can be filed (work project). diff --git a/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org b/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org new file mode 100644 index 0000000..bbbe175 --- /dev/null +++ b/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org @@ -0,0 +1,57 @@ +#+TITLE: Proposal — flashcard-stats.py refutation / claim-prompt mode + +From: home session, 2026-06-21. Backlog, not urgent. Relates to the +refutation-drill deck being built in the home project. + +* Problem + +A new card family doesn't fit the linter: the *refutation / claim-prompt* +card. Its heading is a bare false claim ("The earth is flat.") and its +body is the rebuttal. This is a legit org-drill simple card (org-drill is +happy), but flashcard-stats.py — built for Q&A decks — trips two BLOCKING +checks on every such card, both false positives: + +- *non-prompt heading*: a declarative claim has no '?' and no + imperative verb, so it reads as "topic-as-heading not yet rewritten". + But for this family the declarative claim IS the intended prompt. +- *answer leakage*: the claim's words necessarily reappear in the + refutation, so front/back overlap is high. But the answer (the rebuttal) + is not given away by the claim — there's no actual leakage. + +Concrete: the home refutation-drill.org (6 cards) reports 6 non-prompt +headings + 1 leakage WARN, so flashcard-sync's gate blocks it entirely. +The deck currently has to be generated with the flashcard-to-anki.py +override, losing the safety net. + +* Proposed fix + +A per-deck opt-in marker that switches the two checks off for that file +only. Two options (your call): + +1. A file-level keyword: =#+DECK_KIND: refutation= near the top. When + present, flashcard-stats skips the non-prompt-heading check and the + answer-leakage check for the whole file (keeps the others: + missing-:ID:, *** Answer sub-headers, duplicate fronts, the + non-blocking NOTEs). +2. A per-card tag: cards tagged =:claim:= (alongside =:drill:=) are + exempted from those two checks individually. + +Option 1 is simpler and matches how this deck works (the whole file is +one family). Option 2 is finer-grained if a deck ever mixes families. + +Either way: document the new card family in flashcard-review.org (a +"Refutation / claim-prompt cards" subsection under Canonical Card Shape — +heading is the bare claim, body is snap-response + backups + named-fallacy ++ restate, Source footer), and note that flashcard-sync then works +normally on these decks. + +* Affected files +- =flashcard-stats.py= — the check skip + (option 1) keyword parse / (option 2) tag check. +- =flashcard-review.org= — document the family + the marker. +- =flashcard-to-anki.py= / =flashcard-sync= — no change needed (they don't gate on heading form). +- Tests: add cases for a refutation-marked file passing despite declarative headings + claim/answer overlap. + +* Companion context +The home deck's card format and the org-drill-fine / Anki-linter-fights +finding are written up in home:refutation-drill-sources.org (Tooling +note). The override command is documented there too. diff --git a/docs/design/2026-06-21-host-identity-guard-proposal.org b/docs/design/2026-06-21-host-identity-guard-proposal.org new file mode 100644 index 0000000..f389825 --- /dev/null +++ b/docs/design/2026-06-21-host-identity-guard-proposal.org @@ -0,0 +1,54 @@ +#+TITLE: From archsetup — hardcoded machine identity in CLAUDE.md (consider fleet-wide) +#+DATE: 2026-06-21 + +* What we did + +Built a Super+F Dirvish popup in the archsetup/dotfiles + .emacs.d projects, +modeled on the existing Super+Shift+N org-capture popup (launcher script names an +emacsclient frame, Hyprland window rules float it, an Emacs command runs in the +frame and q closes it). Cross-project: dotfiles half committed from archsetup, +Emacs half handed off to .emacs.d's inbox. + +* The bug it surfaced + +While stowing on this machine, =make stow hyprland= pulled the *velox* host tier, +and =uname -n= returned =velox=. But archsetup's CLAUDE.md asserted, as a fixed +fact, "This machine is **ratio**." It was simply wrong on velox — a stale +identity baked into a per-project doc that travels to every machine via git. + +I'd been reasoning from that line all session (e.g. "the touchpad-auto reminder +is velox-only, and we're on ratio, so skip it") — exactly backwards. A hardcoded +"this machine is X" in a synced/tracked project file is a latent trap on any +multi-machine setup: the file is identical on every host, so the claim is false +on every host but one. + +* The fix (this project) + +Replaced the fixed identity with a runtime instruction. The attached CLAUDE.md +now reads, in the Notes section: + + Never assume which machine this is — always run =uname -n= to find the hostname + (the =hostname= binary is absent, so =uname -n= is the source of truth; + =uname -r= is the kernel release, not the host). The fleet is ratio + (workstation) and velox (laptop), both Hyprland (Wayland)... + +(Craig initially said =uname -r=; that's the kernel release. =uname -n= is the +nodename/hostname, which is what the stow host-tier logic already keys on.) + +* Why this is a rulesets concern + +This isn't an archsetup-only quirk. Any project whose CLAUDE.md / notes get +synced or cloned across machines can hardcode environment identity — current +host, current OS, "the laptop", an IP, a display name — and be wrong everywhere +the doc lands but the origin. rulesets governs how every project's CLAUDE.md and +rules are shaped, so it's the right layer to consider a general guard: + +- A rule (claude-rules) along the lines of: don't assert mutable + environment/host identity as a fixed fact in a tracked/synced project file; + derive it at runtime (=uname -n= for host, etc.) and name the command. +- Possibly a startup or codify-time lint that flags "this machine is <name>" / + "the current host is" style claims in CLAUDE.md. + +Sending the edited CLAUDE.md (attached separately) plus this note so the rulesets +session can decide whether to codify the broader pattern. Proposal, not a +directive — your value gate applies. diff --git a/docs/design/2026-06-22-inbox-zero-capture-hardening.org b/docs/design/2026-06-22-inbox-zero-capture-hardening.org new file mode 100644 index 0000000..69acf94 --- /dev/null +++ b/docs/design/2026-06-22-inbox-zero-capture-hardening.org @@ -0,0 +1,39 @@ +#+TITLE: inbox-zero Phase D wedges live org-capture sessions on the roam inbox + +* The bug + +Phase D of =inbox-zero.org= removes claimed items from =~/org/roam/inbox.org= by editing the file on disk (Edit / sed / Write). That collides with any live org-capture session Craig has open against the same file. + +org-capture works through an *indirect buffer* cloned from the target file. When the inbox-zero disk write lands and Emacs reverts the main =inbox.org= buffer underneath, the indirect capture buffers are left pointing at stale state and wedge — they can no longer finalize cleanly with =C-c C-c=. The visible symptom is org-capture failing, and one or more orphaned =CAPTURE-*inbox.org= / =CAPTURE-N-inbox.org= buffers piling up as Craig retries. + +Hit live on 2026-06-22 during a home-session inbox-zero: I filed three home items, wrote =inbox.org= on disk, and Craig's open capture wedged, leaving two orphaned =CAPTURE-inbox.org= buffers. No data was lost that time (the orphaned buffers held only existing file content, not a freshly-typed item), but that was luck — had he typed an item into the capture before it wedged, finalizing the stale buffer afterward would have written it back against the post-edit file and could have clobbered the routing or a foreign item. + +* Why it's worth fixing in the canonical + +=inbox-zero.org= is a rulesets-owned synced workflow that runs in every project (startup nudge, wrap-up sub-step, on demand), and the roam inbox is the single shared file all of them edit. Craig edits that same file live in Emacs and captures into it constantly. So the collision window recurs in every project, every session — not a home-only quirk. A local fix in home gets reverted by the next template sync, so the durable fix has to land in rulesets. + +* Proposed fix (recommended: guard before the disk write) + +Add a pre-edit guard to Phase D, before removing any claimed items: + +1. If Emacs is reachable (=emacsclient -e t= succeeds), check for live capture buffers targeting the roam inbox: + + #+begin_src bash + emacsclient -e '(mapcar #(quote buffer-name) + (seq-filter (lambda (b) (string-match-p "CAPTURE.*inbox" (buffer-name b))) + (buffer-list)))' 2>/dev/null + #+end_src + +2. If any =CAPTURE-*inbox*= buffer exists, *stop before editing* and surface it: "You have a live org-capture session open against the roam inbox — finalize (=C-c C-c=) or abort (=C-c C-k=) it before I route items, otherwise the edit will wedge the capture." Resume Phase D only once it's clear. This mirrors the existing pull-before-edit / surface-and-stop discipline already in Phase D. + +3. Independently, when Emacs has =inbox.org= open and *unmodified* (the common case, no live capture), the disk edit is benign — Emacs reverts a clean buffer without complaint. Optionally trigger an explicit =revert-buffer= via emacsclient afterward so the buffer is immediately consistent rather than lazily on next focus. + +* Alternative (heavier): do the removal through Emacs when it's running + +Instead of editing on disk, when Emacs is reachable, perform the claimed-item removal inside the running daemon (find the buffer, delete the items, save), and fall back to the disk edit only when Emacs isn't running. This keeps Emacs's buffer authoritative and sidesteps the disk/buffer divergence entirely. It's more code and more failure surface for arbitrary item removal, so I'd lean on the guard above unless you want the stronger guarantee. + +* Note for whoever builds it + +The =emacs.md= rule already covers "don't make Craig restart Emacs; push changes into the running daemon." This is the same principle one layer out: don't edit a file *on disk* that the running daemon is actively editing/capturing into. Worth a line in =emacs.md= too, or at least a cross-reference from inbox-zero Phase D. + +Origin: home, 2026-06-22. diff --git a/docs/design/2026-06-23-install-lang-claude-md-gap.org b/docs/design/2026-06-23-install-lang-claude-md-gap.org new file mode 100644 index 0000000..cf16256 --- /dev/null +++ b/docs/design/2026-06-23-install-lang-claude-md-gap.org @@ -0,0 +1,31 @@ +#+TITLE: install-lang CLAUDE.md gap — non-elisp projects get a wrong or missing CLAUDE.md +#+DATE: 2026-06-23 + +Surfaced while running the archangel .ai/ conversion you sent (the 2026-06-20 handoff). archangel is a bash project — 437 =.sh= files; the only =.el=/=.py= in the tree are under =work/x86_64/airootfs/= (archiso staging, not source). Per your handoff I installed both elisp and python bundles. The result exposed two coupled issues that block CLAUDE.md consistency across projects. + +* The two findings + +1. *Only the elisp bundle ships a CLAUDE.md template.* =languages/elisp/CLAUDE.md= exists; =languages/python/=, =go/=, =typescript/= ship none. =install-lang.sh= guards on =[ -f "$SRC/CLAUDE.md" ]=, so a bundle without a template silently contributes nothing — no line printed, no file seeded. + +2. *No shell/bash bundle exists* (only elisp, go, python, typescript). archangel and archsetup are bash projects with no bundle that fits. + +* The consequence + +- Install python (or go/ts) alone → project gets *no* CLAUDE.md. +- Install elisp + anything → project gets the elisp stub, whose first line is "Elisp project." Because install-lang seeds CLAUDE.md only on first install and never overwrites without FORCE=1, install order doesn't matter — the elisp template is the only one available, so it always wins. +- Net: archangel, a bash project, ended up with a CLAUDE.md headed "Elisp project." An inaccurate CLAUDE.md is worse than none — it mislabels the project for every future session. + +* Proposals (rulesets' call) + +1. *Add a shell/bash language bundle.* This is the real gap for archangel/archsetup and any other shell-heavy project. +2. *Give every bundle its own CLAUDE.md template*, or ship a language-neutral default so install-lang always seeds an accurate (or at least non-misleading) header. A stub that says "<LANG> project — customize this" is only safe when the bundle actually matches the language. +3. *Consider the multi-bundle case* — when a project installs more than one bundle, the CLAUDE.md "Project" line shouldn't hardcode a single language picked by which template happened to exist. + +* Companion files to reconcile + +- =scripts/install-lang.sh= — the seed-on-first-install / no-overwrite logic (sections 3 and 3b) is correct; the gap is the missing templates and missing bash bundle, not this logic. +- =languages/elisp/CLAUDE.md= — the only template today; pattern to replicate per language. + +* What archangel did locally (stopgap) + +Installed both bundles as you asked; the generic =.claude/rules/= and gitignore hygiene are the real gain there. I flagged the elisp-stub mismatch to Craig and offered to hand-write archangel's CLAUDE.md as a bash ISO-build project. That local fix doesn't address the cross-project pattern — hence this note. diff --git a/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org b/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org new file mode 100644 index 0000000..a47aa2d --- /dev/null +++ b/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org @@ -0,0 +1,124 @@ +#+TITLE: Proposal — wrap-it-up teardown + "wrap it up and shutdown" variant + +* Source + +Raised by Craig in a home-project session, 2026-06-23, after talking the +design through. Two related additions to =wrap-it-up.org=. Both touch the +Claude-session lifecycle (workflow + hook + the =ai-term= buffer/tmux pair), +so they're rulesets — with one companion piece that has to live in +=.emacs.d/modules/ai-term.el= (flagged below). Originally floated as an +archsetup task; archsetup owns the Hyprland/waybar layer, not the +Claude-session lifecycle, so it was re-routed here. + +* Architecture this depends on (so the design is grounded) + +- =ai-term.el= (=.emacs.d=) is the in-Emacs launcher: a vertical-split vterm + buffer running a tmux session named =aiv-<project-basename>= (prefix + =aiv-=). Layering: =claude= process → tmux session =aiv-<proj>= → Emacs + vterm buffer. +- Killing the tmux session takes the =claude= process with it, so "quit + Claude Code" is a *consequence* of killing =aiv-<proj>=, not a separate + step. +- Hooks already exist under =~/.claude/hooks/= (e.g. =session-clear-resume.sh=, + =precompact-priorities.sh=) — the teardown trigger fits that pattern. +- =sudo= is =NOPASSWD: ALL= on Craig's machines, so =sudo shutdown now= runs + unattended. + +* Item 1 — wrap-up also removes the buffer, quits Claude, removes the tmux session + +Recommend: yes, with one structural rule — the wrap-up runs *inside* the +things it tears down, so teardown is self-terminating and must be the last, +decoupled action, or the valediction may not flush before the session dies. + +Design: +1. *Teardown lives in =ai-term.el=* (companion, see below): one function + =cj/ai-term-quit= that kills the =aiv-<proj>= tmux session (takes =claude= + with it), kills the vterm buffer, and restores the saved window geometry — + =ai-term.el= already owns the buffer↔session pair and the geometry logic. +2. *Trigger from a Stop / SessionEnd hook, not inline.* Wrap-up does all its + git/archive work, delivers the valediction, then drops a sentinel (flag + file, e.g. =/tmp/ai-wrap-teardown-<session>=). The hook fires when Claude + finishes, sees the sentinel, and runs =cj/ai-term-quit= via =emacsclient=. + Decoupling guarantees the valediction lands before the session dies. +3. *Gate on commit+push verified* — never tear down before the session record + is pushed (wrap-up's existing Step 4 / validation checklist already + enforces push; teardown is strictly after it). +4. *Phrase split — teardown IS the default* (Craig's decision 2026-06-23). + Bare "wrap it up" does the full wrap AND removes the buffer/session/quits — + that's his typical case. The non-destructive variant gets the explicit + qualifier: "wrap it up with summary" summarizes + commits + pushes + + archives but keeps the buffer (no teardown), so the summary stays readable. + So: "wrap it up" → teardown; "wrap it up with summary" → no teardown; + "wrap it up and shutdown" → wrap + poweroff (supersedes teardown, Item 2). + +* Item 2 — "wrap it up and shutdown": 10-count then =sudo shutdown now= + +Recommend: yes, but the safety gate is load-bearing and the countdown has a +rendering gotcha. + +Design: +1. *"Only ai-term left" = hard blocking precondition*, evaluated BEFORE the + countdown. Count live sessions (=tmux ls | grep '^aiv-'= or + =pgrep -fc claude=). If more than this one is alive, ABORT the shutdown, + list what's running, and fall back to a normal wrap. Never power the box + off out from under another active Claude session. This is the most + important part of the item. +2. *The live countdown can't run through Claude's tool output.* The Bash tool + buffers stdout until the command returns, so a =for i in $(seq 10 -1 1); + sleep 1= prints all ten at once at the end, not one per second. It has to + run detached or in Emacs: + - tty writer: =for i in $(seq 10 -1 1); do printf '\rShutting down in %2d…' + "$i" > /dev/tty; sleep 1; done; sudo shutdown now= (backgrounded), or + - an Emacs =run-at-time= timer printing 10→1 in the echo area, then + =(shell-command "sudo shutdown now")=. +3. *Make it abort-able* (Ctrl-C / keypress cancels). A 10-second countdown's + whole purpose is a last-chance window; a non-cancellable one is just a + delay. +4. *Sequencing.* "...and shutdown" supersedes Item 1's teardown — if the box + is powering off, killing the buffer/session first is moot. Wrap (commit + + push + archive) → session-count gate → countdown → =shutdown=. + +Packaging: a small rulesets bin script (e.g. =ai-wrap-shutdown=) doing the +gate → abort-able countdown → shutdown, invoked by the workflow after the wrap +commit/push. Countdown either in that script (tty) or handed to Emacs. + +* Companion — required change in =.emacs.d/modules/ai-term.el= + +Item 1's teardown function =cj/ai-term-quit= must live in =ai-term.el= (it +owns =aiv-<proj>= session naming, the vterm buffer, and geometry restore). +rulesets owns the workflow + hook + bin script that *call* it; =.emacs.d= owns +the function itself. Spec for the =.emacs.d= side: + +- =cj/ai-term-quit (&optional project)= — resolve the =aiv-<basename>= session + for the current/!named project, =tmux kill-session= it, =kill-buffer= the + associated vterm buffer, restore saved geometry. Idempotent / no-op if the + session or buffer is already gone. Callable from =emacsclient -e= so the + Stop hook can invoke it headlessly. +- (Optional) a count helper =cj/ai-term-live-count= so the Item-2 gate can ask + Emacs how many ai-term sessions are live, as an alternative to =tmux ls= / + =pgrep=. + +When rulesets builds the workflow/hook side, route this companion to +=.emacs.d= (inbox-send) so the two land together. + +* Open decisions for Craig + +- Phrase set: DECIDED (2026-06-23) — "wrap it up" tears down (default); + "wrap it up with summary" wraps without teardown; "wrap it up and shutdown" + is the poweroff variant. Remaining nuance: confirm the exact non-destructive + qualifier wording is "with summary" (vs e.g. "and summarize"). +- Countdown home: tty-writer bin script vs Emacs timer. (Emacs timer reads + cleaner inside the vterm and is trivially abort-able.) +- Session-count mechanism for the gate: =tmux ls=, =pgrep claude=, or + =cj/ai-term-live-count=. + +* Verify + +- Item 1: bare "wrap it up" → valediction renders fully, THEN buffer + + =aiv-<proj>= session + claude all gone, geometry restored; "wrap it up with + summary" → wrap completes but the buffer stays intact (no teardown). +- Item 2 gate: with a second =aiv-*= session alive, "wrap it up and shutdown" + refuses, lists the other session, and does a normal wrap (no poweroff). +- Item 2 happy path: sole session → 10→1 renders one-per-second, is + cancellable, then =shutdown= fires. +- Teardown never runs before commit+push is verified. diff --git a/docs/design/2026-06-27-bug-priority-matrix-cover-note.org b/docs/design/2026-06-27-bug-priority-matrix-cover-note.org new file mode 100644 index 0000000..d6398e0 --- /dev/null +++ b/docs/design/2026-06-27-bug-priority-matrix-cover-note.org @@ -0,0 +1,5 @@ +#+TITLE: Cover note for the bug-priority-matrix-proposal.md just sent +#+SOURCE: from emacs-wttrin +#+DATE: 2026-06-27 23:57:30 -0400 + +Cover note for the bug-priority-matrix-proposal.md just sent. WHAT: a Severity × Frequency bug-priority matrix (P1-P4) distilled from wttrin's priority scheme today. It derives a bug's priority from two facts (severity, frequency) instead of opinion, maps each cell to a release vehicle, and — for projects running the todo-format [#A]-[#D] scheme — has bugs derive their letter from the matrix while features keep their roadmap judgment. Includes a special-category rule (privacy/security/safety graded on severity alone) and a tie-in that makes a 'no open [#A]' release gate fact-based. WHY SEND: this is a generic defect-management pattern, not wttrin-specific, so it belongs upstream to distribute with any code-centric project (a claude-rule and/or a todo.org priority-scheme template snippet — your call on placement and whether it folds into todo-format.md or stands alone). TWO ASKS: (1) consider a companion NON-CODING matrix for work-product/ops/decision defects — the same frequency×severity shape likely generalizes beyond bugs; (2) treat this as a LIVING DOCUMENT — adjust bands and wording as projects use it until it's as mature as the coding matrix. SOURCING: the matrix is a standard industry pattern; I adapted a generic version and the wttrin worked-example. Nothing work-confidential is copied — please keep the upstream artifact generic and uncited to any private reference. wttrin keeps its local copy in todo.org as the stopgap until you distribute the canonical. diff --git a/docs/design/2026-06-27-bug-priority-matrix-proposal.md b/docs/design/2026-06-27-bug-priority-matrix-proposal.md new file mode 100644 index 0000000..4b49769 --- /dev/null +++ b/docs/design/2026-06-27-bug-priority-matrix-proposal.md @@ -0,0 +1,70 @@ +# Bug Priority — Severity × Frequency Matrix (proposed rule for code projects) + +Applies to: code-centric projects (a `:bug:`-tracking `todo.org`, an issue tracker, or any defect backlog). + +Status: proposed. Distilled from wttrin's priority scheme on 2026-06-27. Generic defect-management pattern — adapt the bands per project. Treat as a living document; refine the wording and bands as projects use it, the way wttrin's matrix will mature in use. + +## The problem + +Without a systematic scheme, bugs get prioritized by who's loudest, who has the most authority, or who's most frustrated. Most bugs drift to "medium," and no one can say what medium means. Whether a bug is "high priority" stays a matter of opinion, so the release decision becomes a judgment call every time. + +## The approach — derive priority from two facts + +Priority should fall out of two relatively objective factors, not an argument: + +1. Severity — how bad is it when the bug occurs? (Service down / data loss / security or privacy leak at one end; cosmetic nit at the other.) +2. Frequency — how often will a user hit it? (Every user every time at one end; rare edge case at the other.) + +Put severity on one axis and frequency on the other. Each cell maps to a priority level; each level maps to a release vehicle. + +``` +| Frequency / Severity | Critical | Major | Minor | Cosmetic | +|------------------------+----------+-------+-------+----------| +| Every user, every time | P1 | P1 | P2 | P3 | +| Most users, frequently | P1 | P2 | P3 | P4 | +| Some users, sometimes | P2 | P3 | P3 | P4 | +| Rare edge case | P2 | P3 | P4 | P4 | +``` + +Response per level: + +- P1 / Critical — fix in the current release or patch. Blocks a release. +- P2 / High — next patch release. +- P3 / Medium — next major release. +- P4 / Low — backlog, fix when convenient. + +(A P0 / showstopper tier — all hands, emergency release — sits above P1 for projects that need it.) + +## Adapt the bands per project + +The axis labels are generic; each project defines what Critical/Major/Minor/Cosmetic and the frequency rows mean in its own terms. wttrin's worked example: + +- Critical: no weather at all on the common path (crash, hard error), wrong weather shown as if correct (silent data error), or a privacy/security leak. +- Major: a feature broken but with a workaround. +- Minor: degraded but usable. +- Cosmetic: a pure visual nit. +- Frequency rows map to default-config-every-time → common-action → feature/config-specific → unusual-input/environment. + +## Special category — severity alone + +Some defects don't correlate with frequency: privacy/security leaks, compliance violations, safety issues. Grade these on severity alone — one occurrence with the right consequences is a showstopper no matter how rarely it'd be noticed. (wttrin's worked case: a home address leaked into public git history — Critical on severity alone, scrubbed 2026-06-26.) + +## Integrating with a project's `[#A]`–`[#D]` priority scheme + +Projects that already run a letter-priority scheme (the rulesets `todo-format.md` convention) keep it for features — deciding feature X is more urgent than feature Y is a legitimate roadmap judgment. Bugs are different: their letter should be derived, not argued. + +Rule: a `:bug:`'s letter comes from the matrix, not a choice. + +- P1, P2 → `[#A]` (release-gating) +- P3 → `[#C]` (scheduled fix, has a workaround) +- P4 → `[#D]` (backlog) + +(The exact letter mapping is a per-project knob; this is wttrin's.) Features keep their `[#A]`–`[#D]` judgment as a roadmap call; only bugs derive their letter from the matrix. + +## Tie-in to release criteria + +When a project gates a release on a soak or a "no open [#A]" rule, the matrix makes that gate fact-based: "no open [#A] bugs" becomes "no open P1/P2," which the matrix produces. A severity-graded soak follows naturally — a P1/P2/P3 found in the window resets the clock and must be fixed; a P4 logs to backlog without resetting. + +## The payoff + +Once each priority maps to a release vehicle, you've pre-decided most issues and only discuss the genuine edge cases. The release decision stops being a soul-search and becomes arithmetic. When there's disagreement on a bug's priority, the matrix wins. diff --git a/docs/design/2026-06-29-green-baseline-proposal.org b/docs/design/2026-06-29-green-baseline-proposal.org new file mode 100644 index 0000000..47de18d --- /dev/null +++ b/docs/design/2026-06-29-green-baseline-proposal.org @@ -0,0 +1,72 @@ +#+TITLE: Proposal: ensure a green test run before starting work +#+AUTHOR: Craig Jennings (via .emacs.d session) +#+DATE: 2026-06-29 + +* Why + +While running a multi-task refactor speedrun in =.emacs.d=, the very first full +=make test= surfaced a failing test (=test-system-cmd-restart-emacs-no-service-aborts=) +that turned out to be *pre-existing* -- it failed on clean HEAD, unrelated to +the work. It had nothing to do with the task; it just happened to fail on this +machine (a native-comp mock that bypasses =symbol-function= redefinition, real +check passing because the box has =emacs.service=). + +Two costs landed because the red was already there when work began: + +1. Every later "did I break this?" suite run carried a known failure, so the + green bar became "only that one fails" instead of a clean pass I could read + at a glance. Easy to let a *new* regression hide behind the familiar red. +2. The work assumed the tree was in a known-good state. It wasn't, and nothing + in the workflow forced that assumption to be checked first. + +The fix is cheap and general: run the suite *before* starting work, and clear +(or explicitly triage) any failure before the work begins. A green start +confirms we're in the known-good place we think we are, and any issue is fixed +before it can be confused with our own changes. + +* Proposed change 1 -- claude-rules/verification.md + +Add a section (suggested placement: right after =## The Rule=, before +=## What Fresh Means=). Proposed text, ready to paste: + +#+begin_example +## Green Baseline Before Starting Work + +Run the test suite before you start work, not only before you finish. A clean run at the start confirms the tree is in the known-good state you assume it is, so the baseline you build on and measure your changes against is actually green. + +If the suite is red before you touch anything, fix or explicitly triage the failure first. A pre-existing failure left in place poisons every later "did I break this?" check: you can't separate your own regressions from the noise, and the end-of-work run stops being readable as pass/fail at a glance. Work that assumes a known-good base may also be built on a broken assumption you never saw. + +When a pre-existing failure genuinely can't be fixed before the work begins (out of scope, or it needs a decision), record it as a tracked task with the diagnosis and carry its name forward. The green bar for the rest of the work is then explicitly "only this known failure remains," not a silent tolerance for red. + +This is the start-of-work counterpart to the Before Committing gate below: one confirms the ground is solid before you build, the other confirms you didn't crack it. +#+end_example + +* Proposed change 2 -- start-work skill, Pre-work phase + +The start-work skill already has a Pre-work phase (eligibility, fetch-and-reconcile +against base, source-code check that the problem still exists). Add a green-baseline +step to that phase: + +- Run the project's test suite before claiming the work. +- If it's fully green, proceed. +- If it's red, fix the failure first, or (when out of scope / needs a decision) + file a tracked task with the diagnosis and carry its name forward as the only + tolerated failure for this work. +- Surface the baseline result so "we started from green" is on the record. + +This makes the verification.md principle operational at the exact moment it +matters -- the start of a task -- the same way the Verify phase and the +Review-and-Publish flow operationalize the end-of-work gates. + +* Note on why this came as a proposal, not a direct edit + +=.emacs.d='s =.claude/rules/*.md= are symlinks into =~/code/rulesets/claude-rules/=, +so editing =verification.md= from the downstream session would modify the +rulesets canonical directly. Per the cross-project rule, downstream sessions +send rulesets the proposed change rather than editing its canonical in place. +Hence this note instead of a local stopgap edit. The start-work skill isn't +installed on this machine to edit anyway. + +The test that surfaced all this is already fixed in =.emacs.d= (commit on main: +mock =executable-find= at the boundary instead of the helper). The durable +process change is the part that belongs here. diff --git a/docs/design/2026-06-29-lint-org-structural-checkers-proposal.org b/docs/design/2026-06-29-lint-org-structural-checkers-proposal.org new file mode 100644 index 0000000..c464aca --- /dev/null +++ b/docs/design/2026-06-29-lint-org-structural-checkers-proposal.org @@ -0,0 +1,55 @@ +#+TITLE: lint-org.el — four structural heading checkers org-lint doesn't cover + +* What changed (from .emacs.d, 2026-06-29) + +Added four custom judgment checkers to =lint-org.el=, following the existing +=lo--check-tables= / =lo--check-level2-dated-headers= pattern (custom scans run +after the org-lint pass, emitting judgment items, never auto-fixed): + +- =indented-heading= — a line of whitespace + stars + space OUTSIDE any block. + org parses a heading only at column 0, so leading whitespace silently demotes + a would-be heading to body text: the task vanishes from the agenda and never + archives. The worst defect class (an invisible task) and entirely silent + today. Skips indented stars inside =#+begin_/#+end_= blocks (legit content). +- =empty-heading= — a line of bare stars with no title. +- =malformed-priority-cookie= — a =[#x]=-shaped token org rejected (lowercase, + multi-char, non-letter) left stranded where a cookie would be. Checks only the + first cookie token per heading; skips verbatim-wrapped =[#D]= in dated-log + titles. +- =level2-done-without-closed= — a level-2 DONE/CANCELLED with no CLOSED line. + Directly supports the todo-cleanup aging step (sent separately today): an + undated completed task gets force-archived immediately, so flagging it lets + the human add CLOSED first. + +Two attached files (edited canonical candidates): =lint-org.el=, +=tests/test-lint-org.el=. + +* Why + +org-lint validates links, drawers, blocks, and babel — but NOT heading +well-formedness. On Craig's .emacs.d todo.org a missing org-bullet in the live +buffer prompted the question "is the file structurally okay?", and org-lint +(even unfiltered, all checkers) reported nothing actionable. These four close +the gap. They are general (any org file), not project-specific. + +* Design notes for the canonical + +- All four are regex-based, NOT org-element/keyword-based, so they don't depend + on which TODO keywords the batch Emacs happens to recognize (lint-org.el does + not set =org-todo-keywords=). The =level2-done-without-closed= done set is a + defconst =lo-done-keywords= (DONE/CANCELLED) for easy extension. +- *Gotcha worth carrying in the canonical:* =case-fold-search= defaults to t, so + a naive =[A-Z]= cookie check accepts =[#a]= as valid and =\(DONE\|CANCELLED\)= + matches the title words "done"/"cancelled". Both letter-sensitive checkers + bind =case-fold-search nil=. (Caught by a failing test before it shipped.) +- Wired into =lo-process-file= after =lo--check-level2-dated-headers=. Judgment + output already flows through the existing report + followups-file machinery. +- 8 new ERT tests (good-input-silent + bad-input-flagged for each, plus + block-skip and verbatim-skip boundary cases). 44/44 green. Zero false + positives on a real 5600-line todo.org. + +* Note + +=make task-sorted= in .emacs.d now runs =lint-org.el todo.org= after the +archive, so these checkers also gate the task-hygiene target. Makefiles aren't +template-synced; that wiring is project-local (noted for context). diff --git a/docs/design/2026-06-29-todo-cleanup-aging-proposal.org b/docs/design/2026-06-29-todo-cleanup-aging-proposal.org new file mode 100644 index 0000000..5a18990 --- /dev/null +++ b/docs/design/2026-06-29-todo-cleanup-aging-proposal.org @@ -0,0 +1,64 @@ +#+TITLE: todo-cleanup.el — add Resolved-section file-aging to --archive-done + +* What changed (from .emacs.d, 2026-06-29) + +Extended =todo-cleanup.el='s =--archive-done= mode (the =make task-sorted= +target) with a SECOND step, run after the existing Open Work -> Resolved move: + +- *Age the Resolved section.* Level-2 DONE/CANCELLED subtrees whose CLOSED date + is older than =tc-archive-retain-days= (default 7) — AND any with no parseable + CLOSED date — move out of the in-file Resolved section to =tc-archive-file= + (default =archive/task-archive.org= beside the todo file). Only tasks closed + within the last week stay in todo.org itself. + +Two files are attached (the edited canonical candidates): +- =todo-cleanup.el= +- =tests/test-todo-cleanup.el= + +* Why + +Craig's .emacs.d todo.org had grown to 768KB / 9616 lines, ~44% of it a +243-task in-file "Resolved" section. The existing =--archive-done= only moved +closures Open Work -> Resolved (same file), so the file grew without bound. The +new step keeps only the last week of closed tasks in the file and sheds the rest +to a git-tracked archive sibling. After this run: 207 aged out, todo.org +9616 -> 5625 lines. + +* Design notes for the canonical + +- New defvars: =tc-archive-retain-days= (7; nil disables the step, preserving + legacy in-file-only behavior), =tc-archive-reference-date= ((YEAR MONTH DAY), + nil=real today — mockable for deterministic tests), =tc-archive-file= (nil => + =archive/task-archive.org= beside the todo file). +- Policy: KEEP iff CLOSED date present AND within the window (cutoff inclusive). + Older OR undated => archive. The undated->archive call is deliberate ("keep + the last week and that's it"); an earlier undated->keep version left 14 legacy + undated tasks behind and read as two weeks. +- The aging step honors =--check= (previews + reports, writes nothing). +- Report: an additive "N aged subtree(s) moved to task-archive.org" line, only + when N>0, so the existing real-mode-no-op silence tests are unaffected. +- Archive file scaffold: =#+TITLE: Task Archive= / =#+FILETAGS: :archive:= / + =* Resolved (archived)=; aged subtrees append as level-2 children; created on + first use, appended to thereafter (one scaffold, never duplicated). +- Tests: =tc-test--reset= now sets the aging knobs OFF (retain nil) so the + existing in-file-move + sync tests are untouched by the wall clock; a new + =tc-test--age= harness re-enables them with a fixed reference date and a temp + archive file. 6 new tests (old+undated move, cutoff-inclusive stay, disabled, + idempotent, check-no-write, straggler pipeline, append-preserves). 34/34 green. + +* Cross-project consideration for your value gate + +Default is ON (retain 7) for ALL consuming projects once this syncs. A project's +first =task-sorted= after the sync will shed everything in its Resolved section +older than a week to a new =archive/task-archive.org=. That's the intended +feature, but flag it — projects with a large historical Resolved section will see +a big first-run move (git-tracked, recoverable). Adjust the default or gate it if +you'd rather it be opt-in per project. + +* Companion (project-local, NOT synced) + +.emacs.d's Makefile =task-sorted= target now also runs =lint-org.el todo.org= +after the archive, as a structural-safety pass (org-lint catches links/drawers/ +blocks; we separately verified heading-level structure by hand). Makefiles aren't +template-synced, so this is per-project — noting it in case the pattern is worth +documenting alongside the tool. diff --git a/docs/design/2026-06-30-daily-drivers-tailscale-correction.org b/docs/design/2026-06-30-daily-drivers-tailscale-correction.org new file mode 100644 index 0000000..9e2bb52 --- /dev/null +++ b/docs/design/2026-06-30-daily-drivers-tailscale-correction.org @@ -0,0 +1,9 @@ +#+TITLE: Correction to claude-rules/daily-drivers.md: it states 'the +#+SOURCE: from .emacs.d +#+DATE: 2026-06-30 13:20:30 -0400 + +Correction to claude-rules/daily-drivers.md: it states 'the agent can't reach the other machine; the point is to surface...'. That assumption is now demonstrably false. On 2026-06-30, from velox, I drove ratio directly over tailscale ssh — git fetch + reset --hard to repair ratio's .emacs.d after a history rewrite, plus scp'd a file across. Both daily drivers are on the same tailnet and reachable from each other. + +Gotcha worth capturing in the rule: the BARE hostname does not resolve (ssh ratio -> 'Could not resolve hostname'), which makes it look unreachable. The tailscale IP (e.g. 100.71.182.1) and the MagicDNS name (ratio.tailf3bb8c.ts.net) DO resolve and connect. First connection from a given address fails host-key verification under BatchMode; -o StrictHostKeyChecking=accept-new clears it. 'tailscale status' lists every node's IP + online state. + +Suggested rule change: reframe daily-drivers.md from 'can't reach, so surface it' to 'CAN reach over tailscale ssh — so the agent can directly sync/verify/repair the other daily driver, not just flag it'. Keep the flag-it guidance as the fallback for when tailscale is actually down. Add the bare-hostname-doesn't-resolve / use-tailscale-IP-or-MagicDNS gotcha. uname -n still tells you which machine you're on. diff --git a/docs/design/2026-07-02-auto-flush-mechanism-note.org b/docs/design/2026-07-02-auto-flush-mechanism-note.org new file mode 100644 index 0000000..fbe06ae --- /dev/null +++ b/docs/design/2026-07-02-auto-flush-mechanism-note.org @@ -0,0 +1,20 @@ +#+TITLE: AUTO-FLUSH capability — proven live in the archsetup session +#+SOURCE: from archsetup +#+DATE: 2026-07-02 01:26:20 -0400 + +AUTO-FLUSH capability — proven live in the archsetup session 2026-07-02, Craig asks that it be promoted to all projects and recommended as part of the no-approvals speedrun to keep sessions sharp. + +Problem: /clear is a user-only keystroke, so long autonomous sessions either bloat or hit arbitrary auto-compaction. Craig can't always be around to type it. + +Mechanism (companion script: self-inject.sh, sent separately to this inbox): +1. At a clean task boundary, the agent refreshes .ai/session-context.org exactly as the flush skill does (checkpoint with Active Goal / Decisions / Next Steps). +2. It derives its own tmux pane: match pane_pid from 'tmux list-panes -a' against its process ancestry (the ai launcher runs every agent session inside tmux, so this holds everywhere). +3. It arms the injection VIA THE TMUX SERVER — tmux run-shell -b "sleep 25; tmux send-keys -t %N -l '/clear'; tmux send-keys -t %N Enter; sleep 15; tmux send-keys -t %N -l 'go — auto-flush resume: read .ai/session-context.org and continue per Next Steps'; tmux send-keys -t %N Enter" — and immediately ends its turn so the prompt is idle when the keys land. +4. /clear fires the SessionStart hook (which already points a fresh context at notes.org + session-context.org), and the injected resume line starts the next turn. Zero human keystrokes. + +Gotchas learned the hard way: +- A detached child (setsid/nohup/&) of a tool call DIES when the tool call ends; only tmux run-shell -b (server-owned) survives the turn boundary. +- Under run-shell the process is a child of the tmux server, so ancestry-based pane detection can't run there — derive the pane first from the agent's shell, pass it explicitly. +- Collision: if the user is typing when the keys fire, the injection merges into their input (a real /clear became '/clearto' mid-word). Fine for unattended sessions; warn the user to keep hands off the armed window if present. + +Suggested integration: an 'auto' mode on the flush skill (checkpoint, then self-inject instead of prompting the user), plus a line in the no-approvals speedrun workflow to auto-flush at clean boundaries when context grows heavy. The script could live in claude-templates' .ai/scripts/ so every project gets it on sync. diff --git a/docs/design/2026-07-13-runtime-portability-inventories.org b/docs/design/2026-07-13-runtime-portability-inventories.org new file mode 100644 index 0000000..b9e8244 --- /dev/null +++ b/docs/design/2026-07-13-runtime-portability-inventories.org @@ -0,0 +1,84 @@ +#+TITLE: Runtime Portability — Hook, MCP, and Memory Inventories +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-13 + +Decision-prep for the generic-agent-runtime arc (spec: [[file:2026-05-28-generic-agent-runtime-spec.org]]). Three of the eight gap child tasks are inventories; this doc is their deliverable. Each section ends with a verdict and any decision left for Craig. + +* Hook parity inventory + +What's wired today (global =.claude/settings.json=, symlinked to =~/.claude/settings.json=): + +- PreToolUse AskUserQuestion hard-deny — blocks the popup-choice tool, forcing inline numbered options per =interaction.md=. Exists because prose alone failed. +- PreCompact =precompact-priorities.sh= — saves priorities before context compaction. +- SessionStart =session-title.sh= (cosmetic title) and =session-clear-resume.sh= (resume-after-/clear plumbing). +- Stop =ai-wrap-teardown.sh= — sentinel-gated session teardown. + +Shipped but unwired (reference copies in =hooks/= + =settings-snippet.json=): =git-commit-confirm.py=, =gh-pr-create-confirm.py=, =destructive-bash-confirm.py=. Not active under Claude either, so they impose no parity requirement today. + +Language bundles wire PostToolUse validators per edit (=validate-bash.sh=, =validate-el.sh=, =validate-go.sh=, plus python/typescript equivalents) — and every bundle also ships the same validation as a =githooks/pre-commit=. + +Per-hook portability verdicts: + +- AskUserQuestion deny: moot off Claude. The popup tool is Claude Code's; Codex-style and local harnesses have no equivalent tool to deny. No port needed. +- PostToolUse validators: covered off Claude. The bundles' git pre-commit hooks run the same validators, so enforcement survives at commit time instead of edit time. Acceptable downgrade, no work needed. +- SessionStart clear-resume: not portable as-is; /clear semantics are Claude's. This is the same surface as the "session plumbing per runtime" child task — resolve it there, not as a hook port. +- session-title: cosmetic, skip on other runtimes. +- PreCompact priority-save: genuine gap. Codex-style harnesses compact without a hook point. Downgrade is prose (the session-context write triggers in protocols.org already say "save before compaction"), which is weaker but is also the pre-2026 Claude behavior. Accept prose, or shorten sessions on those runtimes. +- Stop wrap-teardown: partial gap. Codex has a turn-completion notify command that could run the same sentinel-gated script; local harnesses vary. Port the script trigger where a hook point exists; where none does, teardown becomes a manual step in the wrap phrase. + +Verdict: only PreCompact and Stop carry real porting work, and both have acceptable degraded modes. Nothing here blocks a pilot. + +* MCP portability check + +Locally-configured servers (user scope, =~/.claude.json=): linear, notion, figma, slack-deepsat, google-calendar, google-docs-personal, google-docs-work, drawio, google-keep. No project-scope servers anywhere. All are ordinary MCP server definitions, portable to any MCP-speaking harness (Codex CLI reads them from its config; most local harnesses that matter speak MCP now). Port = translate the server blocks + carry the auth material. Mechanical. + +claude.ai-managed connectors (session-injected, not in local config): Gmail, and the claude.ai variants of Calendar/Drive. These do not travel. Overlap analysis: calendar and docs are already covered by the locally-configured servers; Gmail's workflow-preferred path is already =cmail-action= (local CLI), so the managed Gmail connector is convenience, not dependency. + +Named gap: signal-mcp. protocols.org "Paging Craig" names it as the only supported away-from-desk page path, and it is not in any local config — it lives claude.ai-side. Off Claude there is currently no page channel at all. This lands squarely on the open Signal-pager task ([#C], todo.org): the signal-cli runbook that task produces would be the runtime-neutral paging path. Recommend noting runtime-portability as an added motivation on that task. + +Verdict: portable except paging; paging's fix is already a filed task. + +* Memory story for non-Claude agents + +Claude auto-memory (=~/.claude/projects/<enc>/memory/=, MEMORY.md index) is harness-owned: written by Claude's memory tooling, loaded by its session start. A non-Claude agent should neither read nor write it. + +The designed cross-agent store already exists: the org-roam KB (=knowledge-base.md= — reading is plain rg over files, writing is one node per fact, both runtime-neutral). Project state lives in file artifacts every agent reads anyway: todo.org, notes.org, session anchors, docs/. + +One wording gap: knowledge-base.md's "Capture, then promote" section names harness memory as the capture layer. For a non-Claude agent the capture layer is the session log itself. Recommended one-sentence addition there: an agent without harness memory captures into its session log and promotes from it at wrap-up. Shared-asset edit, needs approval. + +Verdict: no build. One approved sentence in knowledge-base.md closes it. + +* Skill and command parity + +What rulesets ships: 11 skills (SKILL.md, model-invokable — add-tests, debug, five-whys, flush, frontend-design, pairwise-tests, playwright-js, playwright-py, review-code, root-cause-trace, voice) and 18 commands (plain .md prompts under =.claude/commands/=, user-invoked). Every body is markdown instructions; nothing executable lives in the registration layer. + +The load-bearing observation: a =/name= reference is just a pointer to a file on disk. Any harness that reads files can execute all 29 artifacts through one resolution rule in its bootstrap entry file: "a skill or command reference (=/voice=, =/review-code=, =/brainstorm=) resolves to =~/.claude/skills/<name>/SKILL.md= or =~/.claude/commands/<name>.md= — read the file and follow it." That single sentence, emitted by the instruction-bootstrap install target, makes the whole library portable with zero per-skill work. commits.md's existing "/voice unavailable — walk the patterns inline" fallback generalizes the same way. + +What that rule does not carry: + +- Auto-invocation. Claude Code triggers skills from their descriptions; other harnesses won't. The high-value auto-triggers (voice and review-code inside the publish flow) don't actually need it — commits.md invokes them by name at fixed flow points, and the resolution rule covers a by-name invocation. The convenience triggers (debug, frontend-design firing on topic match) degrade to on-request. Acceptable. +- Native slash registration. Codex-style harnesses have their own custom-prompt mechanism; the 18 commands could also be registered there for ergonomics (an optional install nicety, not a requirement — the resolution rule already works). +- flush. Its mechanics are Claude Code's (/clear, the self-inject resume hook). Explicitly not ported; the session-plumbing child owns that surface. +- Harness built-ins (code-review ultra, plan mode, artifacts). Not rulesets' to port; flows that name them need per-runtime alternatives or graceful absence, same as today when they're unavailable. + +Verdict: no per-skill porting matrix needed. One resolution sentence in the bootstrap entry file (instruction-bootstrap child), optional native registration as an install nicety, flush explicitly excluded. + +* Session plumbing per runtime + +Four pieces, examined: + +- =session-context-path= is already runtime-aware by design — the =AI_AGENT_ID= shape carries a runtime segment (=host.project.runtime.epoch=) and the resolver is pure bash. Nothing to do. +- The anchor cycle (suspend entry, startup's interrupted-session recovery, wrap-up's archive rename) is plain files driven by workflow prose. Any agent that reads protocols.org executes it identically. Nothing to do. +- =self-inject.sh= is harness-agnostic: it types arbitrary strings into a tmux pane. The Claude-bound part is only the *payload* — "/clear" plus a resume line that leans on the SessionStart hook. A Codex auto-flush is a payload variant: the clear command differs (confirm the exact one when wiring) and the second injected line must itself carry the resume instruction ("read .ai/session-context.org and resume") since no hook fires. Arguably simpler than the Claude path. +- =session-clear-resume.sh= (the SessionStart hook) is Claude-only, and the payload variant above makes it unnecessary off Claude. + +Verdict: no build. The flush *skill* stays Claude-official; a codex auto-flush is a documented payload variant to wire the day a codex session actually wants it, not before. + +* Decisions for Craig + +All four approved by Craig, 2026-07-13: + +1. PreCompact downgrade off Claude: prose-only "save before compaction" accepted. +2. Stop-teardown off Claude: port via Codex notify where available, manual teardown elsewhere. +3. Runtime-portability motivation note added to the Signal-pager task. +4. knowledge-base.md capture-layer sentence added (non-Claude runtimes capture into the session log and promote from there). diff --git a/docs/design/2026-07-14-sentry-workflow-proposal.org b/docs/design/2026-07-14-sentry-workflow-proposal.org new file mode 100644 index 0000000..d29ceb8 --- /dev/null +++ b/docs/design/2026-07-14-sentry-workflow-proposal.org @@ -0,0 +1,150 @@ +#+TITLE: Sentry Workflow — Proposal for rulesets +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-13 + +* Status +Proposal from the work project to the rulesets session, for implementation as a +general (shared-layer) workflow: =.ai/workflows/sentry.org= plus a small +supporting script or two under =.ai/scripts/=. + +* Problem + +An agent session left running overnight can keep a project clean and healthy on +a cadence, but the current pieces don't coordinate. Two gaps: + +1. *No agent-vs-agent collision guard on shared public files.* =capture-guard= + only detects Emacs org-capture buffers (the human case) on one target file, + and it's advisory, not a lock. There is no flock anywhere in =.ai/scripts/=. + Multiple sessions (several projects, sometimes both daily drivers) can + read-modify-write =~/org/roam/inbox.org= and the =agents/= KB nodes at once. + The only coordination is the git pull/commit/push discipline in + knowledge-base.md, which is optimistic — a pull/push race forks a + =*sync-conflict*= file, and the KB search already globs those out, so we know + the forks happen and get dropped. + +2. *No single serialized runner.* Firing inbox-zero and triage-intake as + separate hourly crons means they can overlap each other and other agents. + +Sentry is one supervisor loop that runs a fixed sequence of hygiene passes +serially, holds locks so it never collides with itself or other agents, and +checkpoints aggressively for crash recovery. + +* What sentry is + +A single workflow, driven by one interactive =/loop= (one cron), that on each +fire walks an ordered list of passes inside a single turn. It replaces the +per-task crons. Design invariants: + +- *Serial.* Passes run one at a time, in order. They never overlap each other. +- *Single-runner lock.* Sentry takes a project-local flock + (=.ai/.sentry.lock=) at entry. If a prior fire is still running, this fire + skips instead of doubling up. +- *Shared-file lock.* Any pass that writes a public roam file first acquires a + roam-write lock (flock on =~/org/roam/.roam-write.lock=) held across the whole + read-modify-write-commit-push cycle, so it serializes against other agents on + the same host. =capture-guard= still runs underneath for the human case. (The + roam-write lock is new; it's the general guard the dropped KB-guard note would + have proposed, folded in here.) +- *Non-destructive by default.* Read-only and idempotent-filing passes run + fully. Anything destructive or judgment-heavy (triage trash/mark-read, task + regrades, spec flips) queues its proposal into the digest for morning + approval, never fires unattended. +- *Idempotent + isolated.* Every pass is safe to repeat. A pass that errors + doesn't abort the rest; its failure is a loud banner in the digest. + +* Crash-recovery spine (applies to every pass) + +Crash recovery has been a live consideration. Two enforced steps wrap each pass: + +1. *Session-context between passes.* Before the next pass starts, the current + pass appends a dated entry to =.ai/session-context.org= recording what it + did. A crash mid-sequence leaves a readable "got this far" trail, so the next + session resumes without re-deriving state. +2. *Local commit, no push, on any disk change.* Every pass that writes to disk + gets its own =chore(sentry): <pass> — <what changed>= commit on the current + branch, unpushed. A crash loses at most one pass. Craig reviews the stack in + the morning and pushes deliberately. Conventional messages, no AI + attribution. (Open decision: commit on the current branch vs a dedicated + =sentry/<date>= branch — see Open decisions.) + +The unit per pass is therefore: do the work → log to session-context → commit. +That triplet is the recovery boundary. + +* Passes (v1 — all ten, plus the first-run KB promotion) + +Ordered so read-only/pull passes precede writes, and the heaviest external +sweep sits mid-run: + +1. *Roam pull* — =git -C ~/org/roam pull --ff-only= so later reads are fresh. + Read-only. +2. *Inbox zero* — inbox.org roam mode. Route roam-inbox items this project + owns. Acquires the roam-write lock for the inbox edit. +3. *Triage intake* — triage-intake.org. External-accounts sweep, classify, + file Action tasks. Destructive actions (trash/mark-read) queue for morning; + =mbsync -a= and the sentinel advance run. +4. *Todo cleanup* — clean-todo.org + todo-cleanup.el + lint-org.el. Convert + level-3 done subtasks to dated logs, archive done subtrees, flag lint. +5. *Task audit* — task-audit.org. Re-check =:solo:=/=:quick:= tags, + priority-scheme conformance, bug severity×frequency grades, stale =DOING= + specs with closed parents, blocked/blocker reciprocity. Regrades queue for + morning. +6. *Working-files hygiene* — flag =working/<slug>/= dirs whose task is DONE but + not filed to =assets/=. Report only; filing is a morning decision. +7. *Spec status board* — the docs-lifecycle grep. Flag =DOING= specs with + closed parents, or specs stuck in =DRAFT=. +8. *Link integrity* — lint-org broken =file:= links across todo/notes/prep. +9. *Git health* — uncommitted drift, unpushed commits, stale local branches, + whether main is behind origin. +10. *Prep + symlink freshness* — verify tomorrow's prep and both symlinks + resolve. +11. *KB lesson promotion (first run of the session, and thereafter as new + lessons accrue)* — promote recent durable lessons out of the fast capture + layer. CLASSIFICATION-GATED: + - Personal project → write to the roam KB as =agents/= nodes (pull, lock, + write, commit, push), per knowledge-base.md. + - Work / denylisted project → NEVER touch roam. Promote to the project's own + durable store (e.g. =deepsat/knowledge.org=) instead, and emit the + one-line refusal note from the knowledge-base.md refusal contract so + nothing is lost silently. + +* Digest output + +One consolidated, timestamped summary per fire: run time first, then per-pass +one-liners (what it found / did), the morning-approval queue (proposed +trash/mark-read, regrades, files-to-move), and any failures as loud banners at +the top. Delta-only where a pass supports it (triage): a pass with nothing to +report is one line. + +* Cadence + +One hourly sentry to start (passes short-circuit fast when there's nothing to +do). A slower evening interval (every 2–3 hours) is a config knob if hourly +proves noisy. + +* Open decisions for rulesets + +1. *Commit target* — per-pass commits on the current branch (simplest, matches + "commit but don't push") vs a dedicated =sentry/<date>= branch (keeps the + working branch clean, costs a branch dance). Proposal leans current branch. +2. *Roam-write lock scope* — host-local flock only (solves same-host + multi-agent, the common case) vs also hardening roam-sync to surface + cross-host conflicts loudly. Proposal: ship the flock now, note the + cross-host limit, treat roam-sync conflict-surfacing as a separate follow-up. +3. *Interval default* — hourly vs evening-only. Proposal: hourly, config knob. +4. *Which passes gate on a green tree* — e.g. skip todo-cleanup commits if the + project suite is red, matching inbox monitor-mode's clean-tree gate. + +* Cover note from sender (work, 2026-07-14 00:02) + +Intro for the sentry-proposal.org file just sent. This is a NEW general workflow proposal (.ai/workflows/sentry.org + supporting scripts under .ai/scripts/), not an edit to an existing synced file. + +What it is: one serialized supervisor loop that runs a project's evening hygiene passes on a cadence, with locks so it never collides with itself or with other agents. It replaces firing inbox-zero + triage-intake as separate crons. + +Why now: an agent left running overnight can keep a project clean, but the current pieces don't coordinate. capture-guard only handles the Emacs-capture case on one file and holds no lock; there is no flock anywhere in .ai/scripts/; and separate crons let inbox-zero and triage overlap each other and other agents on shared roam files, which already fork *sync-conflict* files that the KB search globs out. + +Three design points worth your judgment: +1. Crash-recovery spine on every pass (crash recovery has been a live concern): enforce a session-context.org update BETWEEN passes, and a local commit (no push) after any disk change, so a crash loses at most one pass. +2. A new roam-write flock (~/org/roam/.roam-write.lock) as the shared-file guard. This absorbs a separate 'guard all public KB files' note we considered and dropped. The lock is the better home for that concern than a bespoke per-file guard. +3. The KB lesson-promotion pass is classification-gated: personal projects promote to roam agents/ nodes; a denylisted work project promotes to its own local store (deepsat/knowledge.org) and never touches roam, per knowledge-base.md's refusal contract. + +Companion files to reconcile: knowledge-base.md (the gated promotion + refusal contract), inbox.org (roam mode + the new lock), triage-intake.org (runs as a sentry pass; keep its own triggers), capture-guard (becomes one layer under the roam-write lock, not the whole guard), and clean-todo.org / task-audit.org / lint-org.el (invoked as passes). Open decisions for you are listed at the bottom of the proposal file: commit target, lock scope, interval default, and which passes gate on a green tree. diff --git a/docs/design/2026-07-15-subproject-pattern-proposal.org b/docs/design/2026-07-15-subproject-pattern-proposal.org new file mode 100644 index 0000000..61ade18 --- /dev/null +++ b/docs/design/2026-07-15-subproject-pattern-proposal.org @@ -0,0 +1,22 @@ +#+TITLE: Proposal: promote the "subproject" pattern into the claude-r +#+SOURCE: from home +#+DATE: 2026-07-15 23:24:07 -0500 + +Proposal: promote the "subproject" pattern into the claude-rules layer. + +WHAT WE DID (in home, 2026-07-15) +We named and built a pattern for what happens when a standalone project is consolidated into another project. home absorbed nine former projects on 2026-06-11 (danneel, finances, jr-estate, kit, health, documents, clipper, elibrary, philosophy). We now call each folded-in unit a "subproject": a former standalone project that lives as a subdirectory, shares the parent's .ai/ scope (one session, one toolchain), but keeps its own files, assets, and history self-contained under its subdir. + +The pattern has four parts: +1. Vocabulary — parent project, subproject, consolidation (the process), subproject archiving. Terminology so we can talk precisely about where a subproject's documents/assets live and archive them cleanly. +2. Subproject brief — a read-first orientation doc at <subproject>/<subproject>-brief.org (situation, current state, reading list, and for deep ones cast/timeline/key-facts-with-verify-flags). The parent's notes.org holds a thin index + a read-first rule + pointers to urgent items only, never a copy of subproject substance ("one fact, one home"). +3. Lifecycle criteria — when to CREATE a subproject (consolidate: domain fit, low independent cadence, shared-scope safety re: privacy/remote posture, no independent collaborators), and when/how to ARCHIVE one cleanly (terminal state -> move the self-contained subdir to <parent>/archive/, reconcile pointers/links/tags, manifest note). +4. Instrumentation — a use/miss log, metrics weighted toward Craig-caught errors over self-report, review folded into task-audit, and a falsifiable kill criterion. + +WHY IT'S GENERAL (not just a home thing) +Consolidating a project into another drops what the former project's own startup used to auto-load (its full depth), so sessions start cold and re-read files. That failure recurs whenever ANY project folds into another. The pattern also gives consolidation and archiving a named, repeatable shape, and the self-contained-subdir rule is what makes archiving a move-plus-pointer-reconcile instead of an excavation. + +THE PROPOSAL +Promote this to a rulesets claude-rule (e.g. claude-rules/subprojects.md): the vocabulary, the brief convention, the parent-vs-subproject content criterion, and the create/archive criteria. Any parent/hub project would then inherit it. home's .ai/subprojects.org (attached next) becomes the local instance/index under the general rule. It's a companion to working-files.md (in-progress artifact layout) and docs-lifecycle.md (formal-doc lifecycle) — same family, different scope: this one governs whole-subproject orientation and lifecycle. + +Attached: the home instance .ai/subprojects.org as the reference draft to adapt. Apply your value gate; this is a proposal, not a synced-file edit. diff --git a/docs/design/2026-07-15-subprojects-convention-home-instance.org b/docs/design/2026-07-15-subprojects-convention-home-instance.org new file mode 100644 index 0000000..b031a41 --- /dev/null +++ b/docs/design/2026-07-15-subprojects-convention-home-instance.org @@ -0,0 +1,282 @@ +#+TITLE: Subprojects — Convention +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-15 + +# The reference spec for the subproject pattern: what a subproject is, when to +# create one (consolidation), how to keep it legible (the brief + content +# split), and how to archive one cleanly. Not read every session — the +# always-on parts (the subproject index + the read-first rule) live in +# notes.org and point here. Read this when consolidating a project, creating +# or maintaining a subproject brief, or archiving a subproject. + +* Vocabulary (say it the same way every time) + +The terminology is the point — it's what lets us talk precisely about where a +subproject's documents and assets live, and archive them cleanly. + +- *Parent project* (a.k.a. hub) — a top-level project that owns an =.ai/= + scope: its own protocols, session cadence, memory, inbox, todo. home, work, + rulesets, .emacs.d are parent projects. Reached by launching an agent + session in its directory. +- *Subproject* — a formerly-standalone project that has been *consolidated* + into a parent and now lives as a subdirectory of it (=danneel/=, + =finances/=, =kit/= …). A subproject shares the parent's =.ai/= tooling and + session — it is not a separate agent scope — but keeps its own working + files, assets, notes, and history self-contained under its one subdirectory. +- *Consolidation* — the process of turning a standalone project into a + subproject of a parent. (home's nine subprojects were consolidated on + 2026-06-11.) The inverse — promoting a subproject back to a standalone + project — is rare but allowed; it's just consolidation run backward. +- *Subproject archiving* — retiring a completed or abandoned subproject + cleanly. Because a subproject is self-contained under one subdirectory, + archiving is a move plus a pointer reconcile, not an excavation. +- *Subproject brief* — the read-first orientation doc for one subproject, at + =<subproject>/<subproject>-brief.org=. + +The cross-project boundary rule (=claude-rules/cross-project.md=) governs +*parent* projects, which are separate =.ai/= scopes. It does *not* apply +between a parent and its subprojects — they share one scope. + +* Why the pattern exists + +As a standalone project, each of these units auto-loaded its own notes.org — +its full depth — at every startup, so recall felt instant. Consolidated into a +parent, a subproject is one compressed section competing with the others, and +the parent's startup surfaces hub-wide state, not any one subproject's depth. +So a session starts cold on a given subproject and re-reads several files on +demand. This convention restores fast, accurate orientation without +re-bloating what loads every session — and gives consolidation and archiving a +named, repeatable shape. + +* Document & asset location (what makes clean archiving possible) + +Everything for a subproject lives under =<subproject>/= — its =notes.org=, +=assets/=, working files, event log, briefs. Links *within* a subproject are +relative to the subproject dir, so the whole tree can move without breaking +them. The parent's own top-level =assets/= is for parent-level material only, +never a subproject's. This self-containment is exactly what lets archiving be +a single =mv= plus a pointer reconcile. Keep it strict: a subproject's asset +in the parent's shared =assets/= is a future broken link. + +* The two layers (parent vs subproject) + +- *Always-on layer* — the parent's notes.org. Read at every startup, so every + line is a tax paid whether or not it's relevant today. Holds only what must + be seen session-independently. +- *On-demand layer* — the subproject brief + the subproject's notes.org. + Loaded only when working that subproject. Holds the depth. + +The whole design is: sort content by *when it's needed*, keep the always-on +layer thin, put the depth one open away. + +* Creating a subproject (consolidation criteria) + +Consolidate a standalone project into a parent as a subproject when *all* hold: + +1. *Domain fit* — the project is a facet of a domain the parent already owns + (danneel is home life-admin; finances is home). +2. *Low independent cadence* — it no longer needs its own session rhythm. + You'd rather reach it from the parent than launch it standalone, and its + standalone startup/sync/session overhead now outweighs the isolation. +3. *Shared-scope safety* — it does not need a git/remote/visibility posture + distinct from the parent. A public code project, a team repo, or anything + with its own CI or gitignore-privacy posture *stays standalone* — folding + it would break the parent's privacy model (see + =claude-rules/git-hosting-privacy-model= and the gitignore-vs-track + decision). Personal/documentation units fold; code-with-a-public-remote + does not. +4. *No independent collaborators* depend on it as a standalone deliverable. + +Keep it standalone when any of those fail: active independent development, its +own team or remote, a distinct visibility/security posture, or activity high +enough that competing in the parent's hub would bury it. + +*The consolidation act itself* (mechanics, mirroring home's 2026-06-11 fold): +move the project tree under the parent as =<parent>/<subproject>/=; import its +tasks into the parent's todo with an area =:tag:= and a =:MIGRATED_FROM:= +property; fold its live reminders/decisions into the parent's notes as +pointers (see the content split); build the subproject brief; record the fold +in a consolidation manifest (=docs/consolidation-manifest-<subproject>.org=). +Retire the source to =~/projects/.retired/<name>= once parity is verified. + +* The subproject brief + +** Path (fixed — discovery is mechanical) + +=<subproject>/<subproject>-brief.org= (=danneel/danneel-brief.org=, +=finances/finances-brief.org=). The subproject name is the directory name, so +the brief name follows for free. One predictable name means the read-first +rule never has to hunt; put descriptive richness *inside*, not in the filename. + +** Spine (scales from light to deep) + +Required in every brief, even a fifteen-line one: +- *One-paragraph situation* — what this subproject is, in plain language. +- *Current state / open threads* — the live stuff. The only high-churn + section; everything else is stable. +- *Reading list* — the subproject's deeper files and what each holds. The + brief *points into* notes.org and the rest; it does not copy them. + +Add only where the subproject warrants it (danneel, jr-estate, finances earn +all of these; elibrary or documents may earn none): +- Cast / contacts. +- Timeline (for matters with history). +- Key facts / numbers, with explicit *verify* flags anywhere the underlying + record disagrees with itself. A brief that asserts a wrong number is worse + than no brief — flag, don't guess. + +** Read-first rule (the behavior) + +When a session's work targets a subproject — a file under =<subproject>/=, its +=:tag:=, or Craig naming the topic — open =<subproject>/<subproject>-brief.org= +*first*, before touching anything else in it. If the subproject has no brief +yet, creating it is the first step of the work (lazy backfill), and the same +pass reconciles that subproject's parent-vs-subproject content. + +* Parent-vs-subproject content criterion + +The test for any piece of content: *if next session I'm working a different +subproject, do I still need to see this?* Three bins: + +1. *Yes, and it's a fact or policy* → hub-level → the parent's notes.org. + Machine names, mail policy, calendar access, the hub map, the subproject + index. +2. *Yes, but only because it's urgent* — a deadline or blocking status that + would be missed if it only lived where I might not look this week → the + parent holds a *one-line pointer* (item + date + "see the subproject + brief"); the substance stays in the subproject. +3. *No* → the subproject. Working depth: contacts, contract facts, case + theory, evidence, document index, and any reminder that isn't time-critical. + +One hard rule over both axes: *one fact, one home.* The parent never copies +subproject substance — it points. The parent holds the trigger; the subproject +holds the truth. Every parent↔subproject duplicate is a second surface that +goes stale. + +* Archiving a subproject (archiving criteria + clean process) + +Archive a subproject when it reaches a *terminal* state: +- *Complete* — the work is done and closed (a settlement signed, a probate + closed, a trip finished): no live threads, no open tasks, nothing + time-sensitive in the parent still pointing at it; or +- *Abandoned / superseded* — dropped, or replaced by other work. + +Clean archive process (self-containment is what makes each step a one-liner): +1. Close or settle its tasks in the parent's todo (DONE / CANCELLED), per the + todo completion rules. +2. Move the whole subproject tree to the parent's archive: + =<parent>/archive/<subproject>/= (keep it intact — archiving is reversible, + not deletion). +3. Update inbound =file:= links that pointed into it (grep first, per the + keep-links-current rule). +4. Move or retire its brief with it, and drop its row from the active + subproject index (or mark it =archived= there). +5. Strip its pointers/reminders from the parent's always-on layer, and retire + its =:tag:= from active use. +6. Record the archive in a manifest note + (=docs/archive-manifest-<subproject>.org=): why, when, where it moved, + what links were repointed. Mirrors the consolidation manifest. + +The =:LAST_UPDATED:= on a brief plus "no open tasks under the tag" is the +signal a subproject is a candidate for archiving — surfaced at the review +cadence, never auto-archived. + +* Freshness + +- *Wrap-up hook*: at wrap-up, for each subproject touched this session, refresh + its brief's Current-state section and bump its =:LAST_UPDATED:= date. Stable + sections rarely change, so this is cheap. +- *Staleness nudge*: a brief whose =:LAST_UPDATED:= is old while its subproject + was recently active is worth surfacing at startup. + +* Self-improvement loop + +Every real use emits a signal, and the signal is *captured*, not silently +acted on: +- Brief answers the question → *hit*. +- Brief is missing something, stale, or wrong → *miss*, logged in + subprojects-log.org (a home-side artifact; no rulesets file). +- Each miss drives *two* updates: *local* (fix that brief) and, if the same + kind of miss recurs across subprojects, *structural* (fix this spine/spec, + not just the one file). The structural half is the actual self-improvement — + grow-and-refine from real use rather than rewriting from scratch (the ACE + idea behind the =codify= skill). + +* Metrics + +Split by who can trust them. + +*Process signals (I observe, cheaply):* +- *Orientation cost* — files/tool-calls before my first correct action in a + subproject. Target: ~1 (the brief), occasionally a second deep dive. +- *Brief hit rate* — fraction of subproject questions answered from the brief + alone. Should climb. +- *Freshness gap* — days between =:LAST_UPDATED:= and last subproject activity. + Target ~0. + +*Outcome signals (Craig is ground truth — weight these over the above):* +- *Craig-caught errors* — the real quality bar. Target: trend to zero. If + briefs work, Craig stops catching me. +- *Felt speed* — did dropping into the subproject feel fast and right. + +*Honesty caveat.* Most process signals are self-observed, and self-report is +biased toward "hit." So the external signals (Craig-caught errors, felt speed) +outrank mine, and the log is auditable so Craig can spot-check my self-grades. +The instrumentation must stay lighter than the briefs, or it rots like an +unmaintained brief. Small N, no clean A/B — this is directional evidence, not +proof. + +* Review cadence + +Fold a "subprojects health" check into the existing task-audit cadence: read +the log, look at the hit-rate / correction / freshness trends, flag any +subproject that's gone terminal (archive candidate), and check the +parent-vs-subproject criterion — anywhere the parent holds a copy where it +should hold a pointer. Decide continue / adjust / kill for the pattern itself. + +* Kill criteria (falsifiable) + +After ~6-8 subproject-sessions across ≥3 subprojects, drop or rework the +pattern if: +- the correction rate isn't falling, or Craig is still catching errors; or +- the freshness gap keeps reopening despite the wrap-up hook; or +- maintenance costs more time than the orientation it saves. + +Success is the mirror: orientation down to ~1 read, Craig catching ~nothing, +briefs current, and I'm right fast in any subproject cold. + +* Rollout + +- Define the convention (this file) + the index scaffold in notes.org. Done + 2026-07-15. +- *Lazy backfill*: the next session that touches a subproject with no brief + creates the brief as its first step — cost paid exactly when it's used. +- *Reconcile in the same pass*: building a subproject's brief includes + reconciling its parent-vs-subproject content — push substance down, leave + pointers up, drop dead reminders. +- *Promotion*: this pattern is home-instanced but general. Consolidation into + subprojects will recur whenever any project folds into another, so this is a + candidate to promote into the rulesets rules layer (proposed to rulesets + 2026-07-15). + +* Adoption status + +| Subproject | Brief exists | Reconciled | Notes | +|------------+--------------+------------+---------------------------------------| +| danneel | yes | yes | First worked instance, 2026-07-15. | +|------------+--------------+------------+---------------------------------------| +| clipper | no | no | Lazy backfill on next touch. | +|------------+--------------+------------+---------------------------------------| +| documents | no | no | Lazy backfill on next touch. | +|------------+--------------+------------+---------------------------------------| +| elibrary | no | no | No notes.org yet; lightest subproject.| +|------------+--------------+------------+---------------------------------------| +| finances | no | no | Deep; earns the full spine. | +|------------+--------------+------------+---------------------------------------| +| health | no | no | Has its own notes.org. | +|------------+--------------+------------+---------------------------------------| +| jr-estate | no | no | Deep; earns the full spine. | +|------------+--------------+------------+---------------------------------------| +| kit | no | no | Lazy backfill on next touch. | +|------------+--------------+------------+---------------------------------------| +| philosophy | no | no | Lazy backfill on next touch. | diff --git a/docs/design/2026-07-16-polyglot-bundle-collision.txt b/docs/design/2026-07-16-polyglot-bundle-collision.txt new file mode 100644 index 0000000..b7e1257 --- /dev/null +++ b/docs/design/2026-07-16-polyglot-bundle-collision.txt @@ -0,0 +1,30 @@ +Proposal: language bundles collide on coverage-makefile.txt in a polyglot project, and the second install loses silently. + +Context: scaffolded a new project (clock-panel) on 2026-07-16 with both the python and typescript bundles installed into the same project. + +What happened. The second install printed: + + [skip] coverage-makefile.txt already exists (use FORCE=1 to overwrite) + +Both bundles ship a file at that same path. Python installed first and won, so the project ended up with Python's coverage fragment only. The TypeScript one (c8, Istanbul json-summary) never landed. The line reads like routine idempotence, not a dropped deliverable, so it's easy to skim past. I only caught it by checking which targets were actually in the file. + +The workaround is bad. FORCE=1 gets the TypeScript fragment but overwrites the Python one, so you can't have both without renaming by hand between installs. FORCE=1 also re-seeds CLAUDE.md, which is fine on a fresh project and destructive on a customized one. What I did in clock-panel: install python, mv coverage-makefile.txt coverage-makefile-python.txt, install typescript FORCE=1, mv coverage-makefile.txt coverage-makefile-typescript.txt. Both fragments now coexist under language-suffixed names. + +The deeper problem, which the filename collision only hints at: both fragments define targets with the SAME names. + + coverage: + coverage-summary: + +So even with both files present, a polyglot project can't copy both into one Makefile. It gets duplicate targets. The fragments assume they're the only language in the project. Renaming the files doesn't fix that, it just makes both sets of instructions visible while leaving the conflict for whoever pastes them. + +Options, in the order I'd weigh them: + +Suffix the shipped filename per bundle (coverage-makefile-python.txt, coverage-makefile-typescript.txt) so installs never collide. Cheap, and it makes the multi-bundle case work by default. It leaves the target-name conflict. + +Namespace the targets too (coverage-python / coverage-typescript, with a coverage target that runs both). Fixes the real problem for polyglot projects. Bigger change, and it makes single-language projects type a longer target name unless there's an alias. + +At minimum, make the skip line loud when the skipped file came from a DIFFERENT bundle than the one that wrote it. A skip that means "already yours" and a skip that means "another language's file is here and yours is being dropped" currently look identical. + +Worth deciding whether polyglot projects are a supported case at all. If they are, the bundles need a collision story. If they aren't, install-lang could say so when it detects a second bundle going into a project that already has one, rather than half-installing. + +Also flagged separately in a note from home today: the same install-lang run is where I'd look for other per-bundle files that could collide on a shared filename. I only checked coverage-makefile.txt. diff --git a/docs/design/2026-07-17-dated-log-planning-line-strip-proposal.md b/docs/design/2026-07-17-dated-log-planning-line-strip-proposal.md new file mode 100644 index 0000000..2448600 --- /dev/null +++ b/docs/design/2026-07-17-dated-log-planning-line-strip-proposal.md @@ -0,0 +1,15 @@ +Proposal: two linked gaps that let a closed sub-task keep polluting the org agenda. + +WHAT HAPPENED (home, 2026-07-17) +Six completed sub-tasks under a DONE parent (a finished trip) had each been closed correctly to the dated event-log form per todo-format.org's depth-based completion rule — keyword, priority, and tags dropped, heading rewritten to "YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <past-tense>". But every one kept its old active SCHEDULED: <date> planning line. A dated-log heading has no TODO keyword, and org-agenda renders ANY headline carrying an active SCHEDULED, so all six showed on the daily agenda as weeks-overdue ("Sched.31x") long after the work and the parent were done. They're invisible to a keyword scan (no TODO) and survive archive-done (parent isn't level-2 CANCELLED), so nothing catches them. + +ROOT CAUSE +The completion rewrite strips keyword/priority/tags but nothing strips the planning line, and an interactive org close (org-log-done) only stamps CLOSED — it never removes a pre-existing SCHEDULED/DEADLINE. So the stale timestamp lingers and pins the entry to the agenda permanently. + +TWO FIXES, both rulesets-owned: + +1. todo-format.org — the sub-task completion rule (the "*** and deeper — rewrite to a dated event-log entry" section). Add an explicit step: remove any SCHEDULED:/DEADLINE: planning line when rewriting to the dated form. The completion time already lives in the heading; an active planning date on a historical log entry is always wrong. The VERIFY dated-completion path and todo-cleanup.el --convert-subtasks want the same treatment (the batch converter pulls the timestamp from CLOSED and keeps heading text — it should also drop the planning line). + +2. lint-org.el — add a checker (proposed name: dated-log-heading-active-timestamp). Flag any dated-log heading (a heading matching the YYYY-MM-DD ... @ ... form, no TODO keyword) that still carries an active <...> SCHEDULED or DEADLINE. This is the mechanical backstop for #1, the same way subtask-done-not-dated backstops the depth rule. It would have caught all six here. + +Local fix already applied in home (stripped the six lines by hand); this is about the durable convention + lint so the next instance is prevented and caught. No home-specific detail needs to travel — the gap is general to any project using the dated-log completion form. diff --git a/docs/design/2026-07-17-todo-cleanup-dated-seal-proposal.md b/docs/design/2026-07-17-todo-cleanup-dated-seal-proposal.md new file mode 100644 index 0000000..61d457c --- /dev/null +++ b/docs/design/2026-07-17-todo-cleanup-dated-seal-proposal.md @@ -0,0 +1,38 @@ +Proposal: change todo-cleanup.el's --archive-done aging to a dated-seal model + +Origin: work project, 2026-07-17. Reconciling a stale rotation convention with the tool's actual behavior. Craig ratified the design in-session. + +## The problem + +`--archive-done` today does two things: move level-2 DONE/CANCELLED out of Open Work into Resolved, then age Resolved by moving subtrees older than `tc-archive-retain-days` (default 7) into one rolling file `tc-archive-file` (default archive/task-archive.org). + +Two mismatches with the intended convention: + +1. Retention is 7 days; the convention wants the last month kept inline in Resolved. +2. One rolling file forever; the convention wants periodic dated files so todo.org's archive is browsable by seal date. + +An earlier attempt to name files by calendar quarter (resolved-YYYY-QN.org) hit a seam: with a one-month retention window, a task closed in the last month of a quarter isn't archived until after the quarter boundary, so a quarter-named file can't hold it without mislabeling. + +## The design (ratified) + +Redefine the sealed file by a predicate that's always true of its contents, and date it by the seal run rather than by calendar quarter: + +- A level-2 DONE/CANCELLED subtree is archived when its CLOSED date is older than one month, OR its CLOSED date can't be parsed (archive regardless — a keyword-complete task with no readable close date is cruft, not live work). +- The working (open) file keeps the existing name task-archive.org. A seal renames it to resolved-YYYY-MM-DD.org (the seal date) and starts a fresh working file. +- The dated file means "everything sealed as of that date," not a quarter — so a late-quarter close archived after a boundary is never mislabeled. Cadence (quarterly) becomes independent of correctness; slip is harmless. + +## Concrete changes to todo-cleanup.el + +1. `tc-archive-retain-days` default 7 → 31 (or expose it; the value should reflect "one month"). Reconcile the test at test-todo-cleanup.el:362 that asserts 7-day aging. +2. Aging predicate: archive when `closed < now - retain-days` OR `closed` is unparseable. Today an unparseable-CLOSED subtree is already moved out in aging per the docstring ("those with no parseable CLOSED date are moved out") — keep that, and make it explicit in the contract. +3. Add a seal step (new flag, e.g. `--seal`, or fold into a boundary check): when invoked, rename the current `tc-archive-file` (task-archive.org) → `resolved-<today>.org` beside it and let the next aging recreate a fresh working file. Whether the seal fires automatically at a quarter boundary or stays a manual flag is your call — the manual quarterly rotation task covers it until then. +4. Tests: update the file-aging tests (test-todo-cleanup.el:362, :378, :491, :513, :518) for the new retain default and the seal/rename behavior. The gitignore-inheritance logic (tc--ensure-archive-gitignored) applies unchanged to both the working and sealed names. + +## Companion state already applied in the work project (for reference, not to sync) + +- todo.org grew a `* Work Resolved` section; 30 level-2 DONE/CANCELLED moved there. +- archive/task-archive.org renamed to archive/resolved-2026-07-17.org (grandfathered H1 seal), inbound link updated. +- archive/README.org rewritten to the dated-seal contract above. +- The rotation task rewritten to the new procedure. + +The rulesets change is only the todo-cleanup.el behavior + its tests. The README lives per-project (this one is already updated); if rulesets ships a canonical archive README template, align it to the dated-seal contract too. diff --git a/docs/design/2026-07-18-colloquialisms-and-the-list-proposal.md b/docs/design/2026-07-18-colloquialisms-and-the-list-proposal.md new file mode 100644 index 0000000..7254447 --- /dev/null +++ b/docs/design/2026-07-18-colloquialisms-and-the-list-proposal.md @@ -0,0 +1,22 @@ +#+TITLE: PROPOSAL: a "Colloquialisms and Expansions" convention + the +#+SOURCE: from home +#+DATE: 2026-07-18 18:04:42 -0500 + +PROPOSAL: a "Colloquialisms and Expansions" convention + the "the list" before-close-queue norm. Craig recommends other projects adopt both. Home has implemented it locally as the reference; sending it up so rulesets can decide whether to make it a shared norm. + +THE NORM: "the list" = a before-close FIFO queue +When Craig says "put X on the list" / "add X to the list", X is appended to a Before-Close Queue: a FIFO queue of tasks and actions to finish before the session closes (wrap-up). Work oldest-first. Process the queue at wrap-up before teardown, surfacing anything unfinished rather than dropping it. Session-scoped: resets when the session anchor is archived at wrap. Anything that must outlive the session is a todo.org task instead. + +Home implementation (reference): +- The norm is documented in home .ai/notes.org under a new "* Colloquialisms and Expansions" section (notes.org is read every startup, so the agent honors it without a workflow change). +- The queue itself lives in the session anchor (session-context.org) under a "* Before-Close Queue" heading, seeded empty. + +THE BROADER IDEA: a "Colloquialisms and Expansions" shorthand dictionary +A per-project (or shared) reference mapping Craig shorthand phrases to their expansion, so the agent applies them without asking. Seed entries Craig named: +1. "the list" -> append to the Before-Close Queue (above). +2. "tell <project> <message>" -> drop the message in that project inbox via inbox-send (python3 .ai/scripts/inbox-send.py <project> --text "..."), the sanctioned cross-project handoff, never a direct write. + +WHY RULESETS SHOULD CARE +Both are cross-project by nature. "tell <project>" already rides inbox-send, which every project has. The before-close queue is a session-lifecycle concept that wrap-it-up owns, and wrap-it-up is a rulesets-owned synced workflow. If this becomes a shared norm, the durable wiring is: (a) a colloquialisms reference shipped in the template (or a protocols.org section), and (b) a wrap-it-up step that processes the Before-Close Queue before teardown. Home cannot wire (b) durably from downstream because wrap-it-up.org is synced, so it is stubbed via the notes.org norm for now and needs the canonical change to stick. + +RECOMMENDATION FROM CRAIG: roll this shorthand out to the other projects. diff --git a/docs/design/2026-07-20-signal-pager-runbook.org b/docs/design/2026-07-20-signal-pager-runbook.org new file mode 100644 index 0000000..f31ed18 --- /dev/null +++ b/docs/design/2026-07-20-signal-pager-runbook.org @@ -0,0 +1,198 @@ +#+TITLE: Signal Pager Runbook +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-20 + +The operational reference for the agent pager — how a page reaches Craig's +phone, how his replies come back, how the account stays healthy, and the +signal-cli setup behind it. This is the Signal successor to the retired ntfy +runbook. Canonical home is rulesets because the pager is cross-machine tooling. + +* What the pager is + +One Signal identity, =+15045173983=, registered in *velox's* signal-cli +(account file 465310, velox is the primary device). It is a dedicated pager +number, not Craig's personal Signal. Pages go *from* that identity *to* Craig's +own Signal account, which fires a normal mobile push on his phone. + +As of 2026-07-20 the identity spans two devices: velox (primary) and ratio +(linked device "ratio-pager"). Any machine holding the account sends directly; +a machine that doesn't relays to velox over the tailnet. + +Two constants the tooling depends on: + +- Pager account: =+15045173983= (primary on velox, linked on ratio). +- Recipient: Craig's Signal account UUID =b1b5601e-6126-47f8-afaa-0a59f5188fde=. + His phone *number* reads as unregistered in Signal's directory — always + target the UUID, never the number. + +velox is the laptop that travels with Craig, so the pager account rides with +him; ratio holds it too, so a page still lands when velox is down. + +* Choosing a channel + +Two trigger words, two channels, and both work from any agent runtime (nothing +here is Claude-specific). protocols.org "Reaching Craig" is the short version +pointed at every project; this runbook is the full one for the Signal side. + +- *"page me"* — desktop notification, stays up until dismissed: + + #+begin_src bash + notify info "Title" "Message" --persist + #+end_src + +- *"text me"* — the phone, over Signal: + + #+begin_src bash + agent-text "Message for Craig's phone" + #+end_src + +- *"text and page me"* — both. The default when a run can't tell whether he's + away: the desktop one is free and the phone one reaches him if he is. + +* Sending a text + +=agent-text= (shipped at =claude-templates/bin/agent-text=, installed to +=~/.local/bin= by =make -C ~/code/rulesets install=) is the interface. It hides +the machine topology by checking whether the account is registered in the +local signal-cli: + +- If the account is local (velox's primary or a linked device like ratio), it + sends directly — no velox dependency. +- Otherwise it ssh-relays the send to velox over the tailnet. +- On failure (velox down or unreachable from a non-linked machine) it prints the + desktop fallback line and exits non-zero, so a caller can tell the page did not + land. + +The raw command it runs, for reference or a manual send from velox: + +#+begin_src bash +signal-cli -a +15045173983 send -m "your message" b1b5601e-6126-47f8-afaa-0a59f5188fde +#+end_src + +From another machine, the same send relayed over the tailnet: + +#+begin_src bash +ssh velox.tailf3bb8c.ts.net \ + "signal-cli -a +15045173983 send -m 'your message' b1b5601e-6126-47f8-afaa-0a59f5188fde" +#+end_src + +Prefer =agent-text= over the raw command — it hardens the message for the remote +shell and handles the fallback. Reach for the raw form only when debugging. + +* Reading replies + +Craig replies to a page straight from Signal on his phone. The reply is a normal +data message *to* the pager account, so it is waiting in the pager's inbound +queue until something receives it. + +Drain the queue and read what is there: + +#+begin_src bash +# On velox: +signal-cli -a +15045173983 receive --timeout 10 +# From another machine: +ssh velox.tailf3bb8c.ts.net "signal-cli -a +15045173983 receive --timeout 10" +#+end_src + +=receive= prints every queued envelope and exits 0 once the queue drains or the +timeout elapses. A text reply from Craig arrives as an envelope from his UUID +carrying a =Body:= line — that line is the reply text. Most envelopes are +delivery/read receipts and typing indicators (no =Body:=); the reply you want is +the data message with body text. For a script that waits on a reply, add +=--send-read-receipts= so his phone shows the page was read, and parse stdout for +the =Body:= line on an envelope from =b1b5601e-…=. + +Note: =receive= is destructive — it consumes the queue. Whatever drains the +queue (an on-demand read, or the warm-keeping timer below) is what sees the +reply, and it is seen once. An agent that pages and then waits for an answer +should do its own =receive= rather than race the timer. + +* Keeping the account warm (receive timer) + +Signal expects a registered account to receive regularly. Left alone, the pager +account drifts stale — signal-cli warns "Messages have been last received N days +ago" (observed at 47 days on 2026-07-20 before a manual drain reset it). A stale +account is a reliability risk on the one channel that reaches Craig when he is +away. + +The fix mirrors roam-sync: a systemd user timer that drains the queue on a +cadence, keeping the account warm and, as a bonus, picking up async replies. With +the account linked on both machines, each device wants its own regular receive, +so the timer runs on *both* velox and ratio (the shared =common= dotfiles +package, same home as roam-sync). + +- Script: =scripts/signal-receive.sh= (rulesets, so both machines get it on + =git pull=). It no-ops cleanly on a machine that lacks the account. +- Units: =scripts/signal-receive.service= + =.timer= (reference copies under + =scripts/systemd/=; the stowed copies live in =common/.config/systemd/user/= + of the dotfiles repo, so both machines get them). +- Cadence: every 15 minutes (=OnUnitActiveSec=15min=), matching roam-sync. + +Enable on each machine (one-time, per daily-drivers.md's one-time-setup class): + +#+begin_src bash +# After the dotfiles + rulesets pull, on each daily driver: +systemctl --user daemon-reload +systemctl --user enable --now signal-receive.timer +systemctl --user status signal-receive.service # confirm a clean receive +#+end_src + +* signal-cli setup notes + +- *Version:* signal-cli 0.14.5 on velox (2026-07-20). +- *Accounts:* velox's signal-cli holds the pager identity =+15045173983= as the + registered primary (account file 465310). ratio's signal-cli holds two + accounts: Craig's personal number =+15103169357= (its own primary, + note-to-self only — no phone push) and the pager identity as a *linked device* + (Device 2, "ratio-pager", linked 2026-07-20). Both accounts coexist; target + the pager with =-a +15045173983=. A future daily driver joins the same way. +- *signal-mcp:* on velox, Claude sessions may also expose a =signal-mcp= tool + (=send_message_to_user=, same pager identity) configured in velox's global + =~/.claude.json=. It works there but is invisible from any other machine and + from non-Claude runtimes, so =agent-text= is the portable habit. The old + =page-signal= shell script was removed 2026-06-12 — do not resurrect it. +- *Linking a device:* to add a second signal-cli as a linked device of the pager + account (see the open decision below), provision it from the new machine and + approve the link from the account holder: + + #+begin_src bash + # On the new machine — prints a tsdevice:/ URI (render as QR to approve): + signal-cli link -n "ratio-pager" + # Approve from velox (the primary device): + signal-cli -a +15045173983 addDevice --uri "tsdevice:/?uuid=…" + #+end_src + +* Decision — linked device (2026-07-20) + +The topology question — ssh-relay only vs. registering daily drivers as linked +devices — was decided in favor of linked devices. ssh-relay only was simpler +(one identity, one receive point) but had a single point of failure: a page +failed when velox was down or off the tailnet. + +Registering ratio as a linked device removes that: ratio sends directly, so a +page lands even when velox is down. The costs, both paid: linked-device +provisioning per machine (the =link= / =addDevice= handshake above), and each +device wanting its own regular =receive= — so the warm-keeping timer moved from a +velox-only home to the shared =common= package, running on both. + +Adding another daily driver later is the same handshake plus a dotfiles stow; +the timer and =agent-text= already generalize to "any machine holding the +account." + +* History + +- 2026-07-04 — home retired ntfy (self-hosted on ratio) and tore it down, + switching agent paging to Signal. Handoff to rulesets to document and own. +- 2026-07-13 — reconciled to one pager identity on velox; =agent-page= shipped + (direct on velox, ssh-relay elsewhere, desktop fallback); protocols.org "Paging + Craig" rewritten around the two channels. +- 2026-07-20 — this runbook; receive-timer script + units added; a manual drain + cleared the 47-day staleness live; ratio linked as a device of the pager + account and its direct send verified; the tool (still named =agent-page= that + morning) generalized to send directly from any machine holding the account; + receive timer moved to the shared =common= package and enabled on both machines. +- 2026-07-20 (later) — notification vocabulary split: "page me" is the desktop + channel, "text me" is Signal, "text and page me" is both. The tool was renamed + =agent-page= → =agent-text= to match, with a deprecated =agent-page= shim + delegating to it. protocols.org section renamed "Paging Craig" → "Reaching + Craig". diff --git a/docs/design/task-review.org b/docs/design/task-review.org index 6c6dac7..c9ae023 100644 --- a/docs/design/task-review.org +++ b/docs/design/task-review.org @@ -1,5 +1,5 @@ #+TITLE: Design: Daily Task-Review Habit -#+AUTHOR: Craig Jennings & Claude +#+AUTHOR: Craig Jennings #+DATE: 2026-05-16 #+OPTIONS: toc:nil num:nil diff --git a/docs/design/wrapup-routing-spec.org b/docs/design/wrapup-routing-spec.org deleted file mode 100644 index 0091806..0000000 --- a/docs/design/wrapup-routing-spec.org +++ /dev/null @@ -1,181 +0,0 @@ -#+TITLE: Wrap-Up Inbox/Transcript Routing — Spec -#+AUTHOR: Craig Jennings -#+DATE: 2026-06-13 -#+TODO: TODO | DONE SUPERSEDED CANCELLED - -* Metadata -| Status | ready for review | -|----------+-----------------------------------------------------| -| Owner | Craig Jennings | -|----------+-----------------------------------------------------| -| Reviewer | Codex (spec-review) | -|----------+-----------------------------------------------------| -| Related | [[file:../../todo.org][todo.org: wrap-up routing task]] · [[file:2026-06-13-wrapup-inbox-transcript-routing-proposal.org][archsetup proposal]] | -|----------+-----------------------------------------------------| - -* Summary - -At wrap-up, an inbox handoff that belongs to another project has nowhere to go but the current project's =todo.org= or a deferral. This adds an optional routing step to =wrap-it-up.org=: surface the items that belong elsewhere, recommend a destination project for each, and move the whole batch there on one confirmation. A parallel step files meeting-transcript recordings into the right project's =assets/=. - -* Problem / Context - -=process-inbox.org= dispositions each handoff as act / fold / file / reject, and "file as TODO" lands the task in the *current* project's =todo.org=. When the real home is a different project, the choices today are: file it locally and let it rot in the wrong tracker, hand-edit two projects' =todo.org= files, or defer it and carry the debt to next session. - -The wrap-up's existing Step 3 "Inbox sanity check" only counts unprocessed items and blocks the wrap until they clear. It answers "is the inbox clean?" — it doesn't route anything. - -Meeting transcripts have the same homelessness: a recording dropped during a session belongs in some project's =assets/=, but nothing moves it there at wrap. - -The friction is small per-item but recurring, and the manual cross-project edit is error-prone (two files, two repos, easy to leave one half-done). - -* Goals and Non-Goals - -** Goals -- At wrap-up, surface inbox items (and transcripts) whose home is a different project, with a recommended destination each. -- Move the whole batch on one confirmation ("go with recommendations") or leave it entirely ("skip"). No per-item triage. -- Move a task/event item as a proper org task into the destination's "Open Work" section per =todo-format.md=; move a transcript as a flat-filed artifact per =working-files.md=. -- Keep the move atomic and visible (it shows in the destination's next git diff, with a provenance note). -- Discover any project with a =todo.org= as a candidate destination, not only =.ai/= projects. - -** Non-Goals -- Not a wrap gate. A skip is a clean, complete wrap. -- Not per-item triage. The interaction is batch-level: go or skip. -- Not a replacement for =process-inbox.org='s value gate. Routing assumes the item is already an accepted keeper. -- Not a confidence-free auto-mover. A low-confidence destination recommendation says so, and the batch "go" stays trustworthy because the surfaced list is reviewable before the keystroke. - -** Scope tiers -- v1: task/event routing to a destination project's =todo.org=. The interaction, the recommendation engine, the atomic move helper, the widened project discovery. -- Out of scope: per-item destination editing, an interactive correction loop, moving items that aren't accepted keepers. -- vNext: meeting-transcript filing (gated on the unresolved source-location decision and the file-vs-file+extract question — see Decisions). - -* Design - -** User-facing (the wrap interaction) - -The router is a new sub-step of =wrap-it-up.org='s Step 3, running after the existing inbox sanity check. Its input is filed keepers, not raw inbox files (decision: Reading B): tasks =process-inbox= accepted and filed into the local =todo.org= this session whose inferred home is a different project. When the router finds such a keeper, it surfaces it in a list, one line each: the task, the recommended destination project, and a confidence marker when the inference is weak. Then two options, batch-level: - -1. Go with the recommendations — apply every recommended move. -2. Skip — leave the whole batch in place. A skip is a clean wrap. - -That is the entire interaction. No per-item walk. The surfaced list is the review surface; the single keystroke is trustworthy because the list was reviewable and low-confidence recommendations flagged themselves. - -A move of a task/event relocates it into the destination project's "Open Work" section as a proper org task (terse heading, body for detail, tags on the heading line, per =todo-format.md=), and removes it from the source. A skipped or unroutable item stays where it is; the existing sanity check still governs whether the wrap is clean. - -** Implementer (the mechanics) - -*Candidate set (what the router considers).* Reading B means the router does not scan the whole local backlog — it would otherwise suggest moving legitimate local tasks every wrap. The candidate set is keepers =process-inbox= filed this session whose inferred home differs from the current project. How those are marked is an implementation detail for Phase 3/4: either =process-inbox= tags a cross-project-candidate keeper at file time, or the router infers from a =CREATED= stamp dated this session plus content. The reviewer should pin which; the design constraint is "session-filed inbox keepers only, never the standing backlog." - -*Destination discovery.* Widen the project-discovery filter from "directory with a =.ai/protocols.org= marker" (what =inbox-send.py= and the =ai= launcher use) to "directory with a =todo.org= containing a level-1 'Open Work' heading." A plain code repo Craig keeps a =todo.org= in is a valid destination; an =.ai/= directory is not required. - -*Destination anchor.* Reuse =todo-cleanup.el='s existing matcher: =tc--find-section= locates the unique level-1 heading containing "Open Work" (case-insensitive) and returns =nil= / ='multiple= when absent or ambiguous. A destination whose =todo.org= lacks a clean Open Work heading is surfaced and skipped, never guessed at. - -*The move helper.* A small tool inserts a task subtree under a named project's "Open Work" heading and removes the source atomically — extend =todo-cleanup.el= (it already owns the section matcher and the subtree-move logic for =--archive-done=) or add a sibling =.ai/scripts= tool. Hand-editing across two repos is the error-prone path this replaces. - -*Recommendation engine.* Infer the destination from the item's content — project names, file paths, topic words — matched against the discovered project list. Conservative by design: a weak match is labeled low-confidence so "go" stays a safe single keystroke. The engine is the interesting, uncertain part; it earns the spec. - -*Cross-project write discipline.* Moving an item into project X's =todo.org= writes into X's scope (=cross-project.md=). The batch "go" authorizes it, but the move stays visible (X's next git diff) and leaves a one-line provenance note on the moved task naming the source project. - -* Alternatives Considered - -** Per-item triage instead of batch go/skip -- Good, because it gives precise control over each destination. -- Bad, because it taxes the common case (a batch that's all-correct, or all-stay) with a walk. Craig explicitly asked for two options, not a triage loop. -- Neutral, because per-item correction could return as a vNext refinement if batch-only proves too blunt. - -** Fold the router into the existing Inbox sanity check step -- Good, because one inbox step is simpler than two. -- Bad, because the sanity check *gates* the wrap (blocks until clean) and the router is *optional* (skip is clean). Merging a blocking check with an optional action muddies both. -- Neutral, because the two share discovery code while staying separate steps. (Resolved: D1 keeps them separate, with the router acting on filed keepers rather than inbox files.) - -** Reuse process-inbox's "file as TODO" with a destination argument -- Good, because it avoids a second mechanism. -- Bad, because =process-inbox= runs per-item mid-session against the local project; the router runs at wrap, batch-level, cross-project. Different cadence, different scope. -- Neutral, because both ultimately call the same atomic move helper — the helper is the shared primitive, the two callers stay distinct. - -* Decisions [6/6] - -** DONE Reuse the Open Work matcher for destination anchoring -- Context: the move needs a reliable insertion point in the destination =todo.org=; guessing risks corrupting another project's file. -- Decision: We will reuse =todo-cleanup.el='s =tc--find-section "open work"= matcher, which already handles the unique / missing / ambiguous cases, and skip+surface any destination without a clean Open Work heading. -- Consequences: easier — no new parser, consistent with =--archive-done=. Harder — destinations must carry the "Open Work" heading convention, so a project with a differently-named section is silently unroutable until it conforms. - -** DONE Move atomically through a helper, never hand-edit two repos -- Context: a move touches two files in two repos; a half-done move loses or duplicates a task. -- Decision: We will route every move through one helper (extend =todo-cleanup.el= or a sibling =.ai/scripts= tool) that inserts under the destination's Open Work heading and removes the source as one operation. -- Consequences: easier — no partial-move corruption, one place to test. Harder — a new helper to build and cover with tests before the router can ship. - -** DONE Cross-project writes stay visible and carry provenance -- Context: writing into another project's =todo.org= crosses the =cross-project.md= scope boundary. -- Decision: We will treat the batch "go" as the authorization, leave the move visible in the destination's git diff, and stamp a one-line provenance note (source project + date) on each moved task. -- Consequences: easier — the boundary rule is honored without a per-move prompt. Harder — the destination's next session sees an externally-authored task it didn't file, so the provenance note is load-bearing, not decorative. - -** DONE Separate router step, operating on filed keepers (Reading B) -- Context: the sanity check gates the wrap on inbox/ contents; the router is optional. The deeper question was the router's input — raw inbox files (Reading A, which overlaps the sanity check) or already-filed keepers that belong elsewhere (Reading B, a todo-routing concern). -- Decision: We will keep the router a separate optional sub-step after the sanity check, and its input is Reading B: accepted keepers process-inbox filed into the local =todo.org= whose inferred home is another project. The sanity check stays a pure inbox gate; the router is a todo-routing action that shares only the destination-discovery code. -- Consequences: easier — each step has one job, the gate can't be muddied by an optional action, and the router never competes with the inbox gate over the same files. Harder — the candidate set (which local tasks the router considers) needs a marking mechanism (see the Implementer "candidate set" note); Reading A's "dispose raw inbox files at wrap" convenience is given up. - -** DONE Transcript routing deferred to vNext -- Context: transcripts file as artifacts, not tasks, and a meeting usually produces both a recording to keep and action items to track. Two unknowns block it: where recordings accumulate (a recordings inbox, a downloads dir, wherever the meeting tooling drops them), and whether filing should also extract action items into the destination's =todo.org=. -- Decision: We will defer transcript routing to vNext. Both the source-location dependency and the file-only-vs-extract-action-items question are deferred with it, to be settled when the vNext work is specced. v1 ships task routing only. -- Consequences: easier — v1 isn't blocked on the unresolved source location. Harder — until vNext, a meeting recording still has no automatic home; only its action items (if filed as tasks) route through v1. - -** DONE Keep defer-and-stage and the router as distinct policies -- Context: the 2026-06-12 Skeptical Review added a defer-and-stage path in =process-inbox.org= that files a =[#B]= VERIFY for shared-asset proposals parked for review. That also turns an inbox item into a =todo.org= task — overlapping surface with this router. -- Decision: We will keep them distinct. Defer-and-stage parks a proposal-under-review locally as a VERIFY; the router moves an accepted keeper to its home project as a TODO. They differ on review status (proposal vs accepted) and destination (local vs cross-project), and share only the atomic move helper, not the policy. Reading B makes the split clean: the router acts on accepted keepers, never on proposals under review. -- Consequences: easier — two clear, non-competing policies on one shared primitive. Harder — the workflow prose must name the boundary so a future reader doesn't collapse them and reintroduce the ambiguity. - -* Implementation phases - -** Phase 1 — Widened project discovery -A discovery function returning every project with a =todo.org= that has a clean Open Work heading, reusing =tc--find-section=. Unit-tested against fixtures: =.ai/= project, plain-code-repo-with-todo, todo-without-Open-Work (excluded), ambiguous-Open-Work (excluded). Leaves the tree working — nothing calls it yet. - -** Phase 2 — Atomic cross-project move helper -Extend =todo-cleanup.el= (or sibling tool) with a "move this subtree into project X's Open Work" operation that inserts at the destination and removes the source as one step, stamping the provenance line. ERT coverage: successful move, missing-destination-heading refusal, source-removal-on-success, no-partial-move-on-failure. - -** Phase 3 — Recommendation engine + candidate-set marking -Infer destination from item content against the discovered list, with a confidence label. Pure function over (item, project-list) → (destination, confidence). Unit-tested: strong match (project named in item), weak match (topic-only → low-confidence), no match (stays put). Also settle the candidate-set marking (tag at file time vs CREATED-this-session inference) so the router considers only session-filed inbox keepers, never the standing backlog. - -** Phase 4 — Wrap-up step wiring -Add the router sub-step to =wrap-it-up.org= Step 3: surface the batch, the two options, apply-on-go via the Phase 2 helper. Per the D1/D5 decisions once settled. Sync the =.ai/= mirror. - -** Phase 5 — Transcript routing (vNext, gated on the transcript decision) -Only after the transcript-scope decision resolves. File a recording into the destination =assets/= per =working-files.md=, batch go/skip mirroring the task router. - -* Acceptance criteria -- [ ] At wrap, an inbox item naming another project is surfaced with that project as the recommended destination. -- [ ] "Go" moves every recommended item into its destination's Open Work section as a valid org task with a provenance line, and removes it from the source. -- [ ] "Skip" leaves every item in place and the wrap completes cleanly. -- [ ] A destination =todo.org= without a clean Open Work heading is surfaced and skipped, never corrupted. -- [ ] A low-confidence recommendation is visibly labeled in the surfaced list. -- [ ] A plain code repo with a =todo.org= (no =.ai/=) is a valid destination. -- [ ] A failed move leaves both source and destination unchanged (no partial move). - -* Readiness dimensions -- Data model & ownership: items are org subtrees; the destination owns the moved task after the move (provenance note records origin). N/A for remote/cached state — all local files. -- Errors, empty states & failure: missing/ambiguous Open Work heading → skip+surface; failed move → atomic no-op; empty routable set → router stays silent (no prompt). -- Security & privacy: N/A — local org files, no credentials or external services. -- Observability: the move shows in the destination's git diff plus the provenance line; the surfaced batch list is the pre-move view. -- Performance & scale: bounded by inbox size (single digits) and project count (tens); no hot path. -- Reuse & lost opportunities: reuses =tc--find-section= and todo-cleanup's subtree-move; widens existing discovery rather than adding a parallel one. -- Architecture fit & weak points: the recommendation engine is the weak point (a wrong-confident destination is the worst failure) — mitigated by the confidence label and reviewable batch list. -- Config surface: possibly a discovery-root list (defaults to =~/projects/=, =~/code/=, matching =inbox-send.py=). Name it if it needs to be user-visible. -- Documentation plan: =wrap-it-up.org= step prose; a note in =cross-project.md= that the router is a sanctioned cross-project write path. -- Dev tooling: ERT for the elisp helper + discovery; the existing =make test= picks up new test files by glob. -- Rollout, compatibility & rollback: additive workflow step; rollback is removing the sub-step. No persisted-data migration. -- External APIs & deps: none. - -* Risks, Rabbit Holes, and Drawbacks -- *Recommendation accuracy is the rabbit hole.* A confidently-wrong destination silently files a task in the wrong project. Dodge: keep the engine conservative, label low confidence, and keep the batch list reviewable before the keystroke. Don't chase a clever inference model in v1. -- *Two inbox-touching steps* (sanity check + router) risk reading as redundant. Dodge: the D1 decision states the gate-vs-optional split in the workflow prose. -- *Scope creep into transcripts* before the source-location question is answered would stall v1. Dodge: transcripts are explicitly vNext behind decision D4. - -* Review and iteration history - -** 2026-06-13 Sat @ 01:23:13 -0500 — Claude Code (rulesets) — author -- What: initial draft. Problem, goals/scope tiers, two-altitude design, alternatives, six decisions (three DONE from grounding, three TODO for Craig), five implementation phases, acceptance criteria, readiness dimensions, risks. -- Why: the archsetup 2026-06-13 handoff cleared the spec bar in inbox triage and was filed spec-bound rather than applied. This draft turns the proposal into a reviewable design with the open questions isolated as decision tasks. -- Artifacts: proposal source at =docs/design/2026-06-13-wrapup-inbox-transcript-routing-proposal.org=; grounded against =wrap-it-up.org= Step 3, =todo-cleanup.el= =tc--find-section=, and =inbox-send.py= discovery. - -** 2026-06-13 Sat @ 01:36:28 -0500 — Craig Jennings + Claude Code (rulesets) — author -- What: resolved all three open decisions. The router's input is Reading B (filed keepers that belong elsewhere, not raw inbox files), so D1 keeps it a separate sub-step from the inbox gate and D5 keeps it distinct from the defer-and-stage router; D4 defers transcript routing to vNext. Reworked the design (input definition, a candidate-set note bounding the router to session-filed keepers) and Phase 3 to match. Cookie now [6/6]; Status moved to ready-for-review. -- Why: Craig chose Reading B after the A-vs-B input ambiguity surfaced as the root under D1 and D5. Reading B keeps the inbox gate, the router, and defer-and-stage each simple instead of entangling three mechanisms. -- Artifacts: this spec; the candidate-set marking mechanism is the one detail flagged for spec-review to pin. |
