diff options
29 files changed, 1530 insertions, 142 deletions
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el index 8f55cc6..3633dba 100644 --- a/.ai/scripts/lint-org.el +++ b/.ai/scripts/lint-org.el @@ -368,6 +368,31 @@ Emits one judgment item per violating table." (string-join violations "; "))))))))) ;;; --------------------------------------------------------------------------- +;;; level-2 dated-header check (claude-rules/todo-format.md) +;; +;; A completed task or resolved VERIFY at level 2 must carry a terminal +;; keyword (DONE/CANCELLED + CLOSED:), never a dated heading. A `** <date>' +;; header has no keyword, so todo-cleanup's --archive-done can never archive +;; it (it accumulates in Open Work forever) and task-review drops it from +;; selection. Judgment-only, never auto-fixed: the repair needs a +;; DONE-vs-CANCELLED call and the original heading text, which is a judgment +;; the sweep can't make. Targets todo/task files; a dated-log-format org +;; file using `** <date>' headings intentionally will false-positive here, in +;; which case the human dismisses the judgment item. + +(defun lo--check-level2-dated-headers () + "Flag level-2 headings whose text begins with a YYYY-MM-DD date. +Emits one judgment item per offending heading (checker +`level-2-dated-header')." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward + "^\\*\\* \\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" nil t) + (lo--emit-judgment + 'level-2-dated-header (line-number-at-pos) + "level-2 dated header is a completion defect (todo-format.md): a ** task or VERIFY closes with DONE/CANCELLED + CLOSED:, not a dated heading — convert it so --archive-done can archive it")))) + +;;; --------------------------------------------------------------------------- ;;; File processing (defun lo--backup (file) @@ -401,6 +426,8 @@ left unmodified and mechanical entries are recorded with :preview t." ;; After org-lint items: the custom table-standard scan. Runs on the ;; post-fix buffer; judgment-only, so order doesn't perturb fixes. (lo--check-tables) + ;; Same shape: flag level-2 dated headers (completion defects). + (lo--check-level2-dated-headers) (when (and (not lo-check-only) (buffer-modified-p)) (save-buffer))) (with-current-buffer buf (set-buffer-modified-p nil)) diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el index 3a83602..242c35c 100644 --- a/.ai/scripts/tests/test-lint-org.el +++ b/.ai/scripts/tests/test-lint-org.el @@ -659,5 +659,31 @@ missing-rules violation." (judgments (lo-test--judgments (plist-get run :issues)))) (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) +;;; --------------------------------------------------------------------------- +;;; level-2 dated-header check (claude-rules/todo-format.md) + +(ert-deftest lo-level2-dated-header-is-judgment () + "A level-2 heading beginning with a YYYY-MM-DD date is flagged." + (let* ((out (lo-test--run + "* Open Work\n\n** 2026-06-20 Sat @ 10:00:00 -0500 Something resolved\nBody.\n")) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed + (should (member 'level-2-dated-header (lo-test--checkers judgments))))) + +(ert-deftest lo-level2-done-task-not-flagged () + "A level-2 task closed with a terminal keyword + CLOSED: is fine." + (let* ((out (lo-test--run + "* Open Work\n\n** DONE [#B] Something resolved\nCLOSED: [2026-06-20 Sat]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) + +(ert-deftest lo-level3-dated-entry-not-flagged () + "A dated event-log entry at level 3 is the correct sub-task shape, not a defect." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent task\n*** 2026-06-20 Sat @ 10:00:00 -0500 sub-entry landed\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) + (provide 'test-lint-org) ;;; test-lint-org.el ends here diff --git a/.ai/workflows/inbox-zero.org b/.ai/workflows/inbox-zero.org index aa7c273..8c4719b 100644 --- a/.ai/workflows/inbox-zero.org +++ b/.ai/workflows/inbox-zero.org @@ -69,6 +69,8 @@ The roam inbox lives in a git repo (=~/org/roam=, auto-synced by the =roam-sync= 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. +If triaging this batch surfaced a durable, cross-project fact (a reference pointer worth keeping, a pattern worth recording), consider writing it to the agent KB as one =:agent:= node (see =knowledge-base.md=; personal projects only). Skip silently when nothing durable came up — never pad an empty run with a KB line. + * Skip conditions - No =~/org/roam/inbox.org= → silent no-op. diff --git a/.ai/workflows/process-inbox.org b/.ai/workflows/process-inbox.org index af406ee..687767e 100644 --- a/.ai/workflows/process-inbox.org +++ b/.ai/workflows/process-inbox.org @@ -191,7 +191,7 @@ Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment ** Park (Skeptical Review in a no-approvals session) -Move the proposal file into =working/<task-slug>/= alongside the prepared diff, file the =[#B]= VERIFY per the Skeptical Review section, reply to the sender that it's parked for Craig's review, and delete the inbox file. On Craig's approval the apply is mechanical: apply the prepared edits, run the normal verify-and-publish flow, rewrite the VERIFY to a dated log entry per =todo-format.md=, and send the sender the acceptance reply. On rejection, the reject-from-another-project flow above runs unchanged. +Move the proposal file into =working/<task-slug>/= alongside the prepared diff, file the =[#B]= VERIFY per the Skeptical Review section, reply to the sender that it's parked for Craig's review, and delete the inbox file. On Craig's approval the apply is mechanical: apply the prepared edits, run the normal verify-and-publish flow, close the parked =**= VERIFY per =todo-format.md= (a top-level VERIFY resolves to =DONE= + =CLOSED:=, not a dated header), and send the sender the acceptance reply. On rejection, the reject-from-another-project flow above runs unchanged. * Phase E — Close out diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org index fe7778f..7540787 100644 --- a/.ai/workflows/startup.org +++ b/.ai/workflows/startup.org @@ -151,6 +151,20 @@ These calls have no dependencies on each other. Issue them all together in one m 8. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm. 9. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe. 10. =[ -f "$HOME/org/roam/inbox.org" ] && grep -cE '^\*\* ' "$HOME/org/roam/inbox.org" || true= — count items in the roam global inbox (=~/org/roam/inbox.org=), the inbox-zero startup nudge. Silent if the roam clone isn't on this machine. Phase C reads the file when the count is non-zero, splits total vs items related to this project, and surfaces the offer (see =inbox-zero.org=). Read-only; never files at startup. +11. KB surface prep (the read + contribute startup nudges; see =docs/design/2026-06-16-encourage-kb-contribution-spec.org=). Gated on the agent KB clone. Counts =:agent:= nodes, lists up to 5 whose content matches the current project basename (titles only; a few most-recent nodes as a fallback when nothing matches), and resolves the best-practices node path. Read-only; silent when the clone is absent. Phase C surfaces the relevant titles (consult) and the best-practices link (contribute). + + #+begin_src bash + ra="$HOME/org/roam/agents" + if [ -d "$ra" ]; then + proj=$(basename "$PWD") + echo "kb-total: $(rg -l '#\+filetags:.*:agent:' "$ra" 2>/dev/null | wc -l)" + echo "kb-bestpractices: $(rg -l 'agent-kb-best-practices' "$ra" 2>/dev/null | head -1)" + matches=$(rg -il "$proj" "$ra" 2>/dev/null | head -5) + [ -z "$matches" ] && matches=$(\ls -t "$ra"/*.org 2>/dev/null | head -3) + echo "kb-relevant-titles:" + for f in $matches; do rg -m1 '^#\+title:' "$f" 2>/dev/null | sed 's/^#+title:/ -/'; done + fi + #+end_src Notes on the rsync commands: - Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside. @@ -183,6 +197,8 @@ This phase touches the user and runs sequentially: - Briefly note significant template updates noticed during sync (new workflows, protocol changes). - *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "=<N>= top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing. - *Roam inbox nudge.* If the Phase A roam-inbox count is greater than zero, read =~/org/roam/inbox.org=, split total vs items related to this project (claimed by the =<project>:= prefix, plus any unprefixed item whose topic plainly concerns this project), and surface one line: "Roam inbox: =<N>= total, =<M>= appear related to this project — say 'inbox zero' to file them." Offer it as a priority option; never auto-file. If the count is zero or the file is absent, say nothing. See =inbox-zero.org=. + - *KB consult nudge (read side).* If the Phase A KB-surface prep returned any =kb-relevant-titles=, surface one line listing them (capped 5): "KB lessons that may be relevant: =<title>=; =<title>=… — open the node before related work." The titles are declarative, so the list alone tells you whether to open one. Gated on the roam clone; silent when the clone is absent or nothing relevant surfaced. See the best-practices node and =knowledge-base.md=. + - *KB contribute nudge (write side).* Once per session, surface one line pointing at the best-practices node (the =kb-bestpractices= path from Phase A): "Learned something durable? See =<path>= for how to write a KB node — contributing cross-project facts is welcome (personal projects only; work/unknown projects never write per =knowledge-base.md=)." Light encouragement, never a gate. Gated on the roam clone; silent when absent. - *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install-<lang> PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing. - *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, hook, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). For a newly-linked *hook*, note that the harness reads hooks at session start — it fires from the next session (or after Craig opens =/hooks= once); its settings.json wiring travels with the tracked file, so the link is usually the only missing piece. A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing. - *Template-sync churn (safety net).* Check whether Phase A's rsync left uncommitted churn in the synced =.ai/= paths — accumulated from a prior session that crashed before wrap-up, or freshly added this session when rulesets advanced. Without surfacing, it builds up silently until it blocks Phase A.0's auto-ff (git won't ff a dirty tree). Skip in the rulesets repo itself (there =.ai/= is a committed mirror, kept honest by the pre-commit hook). The check is sequential here, after the rsync has finished — not a Phase A step, to keep that batch race-free. diff --git a/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org index 9e9e3dd..7241017 100644 --- a/.ai/workflows/triage-intake.org +++ b/.ai/workflows/triage-intake.org @@ -167,6 +167,10 @@ If Craig has been silent for a while after Phase D and the surface looks closed- This rule prevents the failure mode where the workflow self-declares done and the next exchange has to relitigate what state things are in. +*** KB capture (only if the sweep surfaced something durable) + +If this sweep surfaced a durable, cross-project fact — a recurring pattern across sources, a reference pointer worth keeping, an environment gotcha — consider writing it to the agent KB as one =:agent:= node (see the best-practices node and =knowledge-base.md=; personal projects only, work never writes). One line of judgment, not a step: an all-quiet sweep surfaces nothing and writes nothing. Never blocking, never padded onto a no-signal run. + * Auto mode (unattended monitoring) diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org index 139d612..b1560eb 100644 --- a/.ai/workflows/wrap-it-up.org +++ b/.ai/workflows/wrap-it-up.org @@ -33,6 +33,10 @@ The absence of =.ai/session-context.org= is the signal that the last session wra ** Step 1: Finalize the Summary +*** Early KB reflection (capture while fresh, before the Summary) + +Before distilling the Summary, while the session is still fresh, ask: what did this session learn worth remembering, for yourself or a future agent? Reflect and stage any candidate durable facts — a decision and its why, an environment gotcha, a reference pointer, a transferable lesson. Self-answer silently; this adds no interactive turn (Craig already authorized the wrap). The candidates flow straight into the KB promotion check below, which does the actual writing and the receipt — this is the capture half, that is the commit half, one pipeline, one receipt. Reflecting here rather than reconstructing learnings after the Summary is the point: the early ask is what keeps the receipt from defaulting to "promoted 0" out of fatigue. + Read through the =* Session Log= in =.ai/session-context.org=. Populate (or refine) the =* Summary= section: - *Active Goal* — one or two sentences describing the session's focus diff --git a/.claude/commands/lint-org.md b/.claude/commands/lint-org.md index 953629c..64ec967 100644 --- a/.claude/commands/lint-org.md +++ b/.claude/commands/lint-org.md @@ -50,6 +50,7 @@ Out of scope (refuse, don't try to lint): | `invalid-fuzzy-link` | (1) Repair to a `[[*Heading]]` ref if a similar heading exists. (2) Drop to `=verbatim label=` text. (3) Skip. | | `misplaced-heading` *(verbatim-asterisk case)* | (1) Strip asterisks and rephrase to preserve semantics. (2) Convert surrounding markup to `~code~` style. (3) Skip. | | `suspicious-language-in-src-block` | (1) Emit an Emacs init one-liner that registers the language. (2) Change the block label to `text` or `example`. (3) Skip. | +| `level-2-dated-header` *(custom check, not org-lint)* | A `** <YYYY-MM-DD> …` heading is a completion defect per `todo-format.md` (no keyword, so `--archive-done` can't archive it). (1) Convert to `DONE`/`CANCELLED` + `CLOSED:`, keeping the heading text — the usual fix. (2) Demote to `***` if it's really a mis-leveled sub-entry. (3) Skip (a dated-log-format org file where `**` dates are intentional). | | anything else | Surface the raw `org-lint` message and ask the user how to proceed. | ## Phase A — Run the script diff --git a/.claude/commands/respond-to-cj-comments.md b/.claude/commands/respond-to-cj-comments.md index 7ee3909..2f16099 100644 --- a/.claude/commands/respond-to-cj-comments.md +++ b/.claude/commands/respond-to-cj-comments.md @@ -1,5 +1,5 @@ --- -description: Scan an org file for cj comments — Craig's annotations wrapped in `#+begin_src cj: ... #+end_src` source blocks — and process each via subagent-delegated accuracy. Each item is classified instruction / question / both, then dispatched to an instruction subagent (proposes a file:line patch) or a question subagent (researches with explicit scope, reports answer + evidence + confidence). Main thread reviews proposals before editing; subagents don't write to the source file. Org-mode TODO parents flip to DOING; new content lands under timestamped subheadings one level deeper; on completion, top- and second-level tasks advance to `DONE` while deeper tasks get their heading rewritten to a dated action description (no DONE keyword), becoming an in-place event log. VERIFY tasks at any depth flip to dated log entries with body replaced by the answer or action taken. Public-facing writing (commits, PRs, Slack, email, public docs) gets `/voice personal`; private writing skips it. Summary lists handled instructions, answered questions with evidence + confidence, follow-ups, unresolved items, and an explicit clean / N-remain verdict. Anything needing Craig's input becomes a `VERIFY` task in `todo.org` (top-level or first-level child of a parent task — never deeper) rather than a separate summary file. File/URL references render as clickable org-mode links. Use when an org file accumulates cj comments. Do NOT use for general code review (`/review-code`), new work without cj comments, or trivial items. +description: Scan an org file for cj comments — Craig's annotations wrapped in `#+begin_src cj: ... #+end_src` source blocks — and process each via subagent-delegated accuracy. Each item is classified instruction / question / both, then dispatched to an instruction subagent (proposes a file:line patch) or a question subagent (researches with explicit scope, reports answer + evidence + confidence). Main thread reviews proposals before editing; subagents don't write to the source file. Org-mode TODO parents flip to DOING; new content lands under timestamped subheadings one level deeper; on completion, top- and second-level tasks advance to `DONE` while deeper tasks get their heading rewritten to a dated action description (no DONE keyword), becoming an in-place event log. VERIFY tasks at `***` and deeper flip to dated log entries with body replaced by the answer or action taken; a top-level (`**`) VERIFY instead closes as `DONE`/`CANCELLED` + `CLOSED:` like any top-level task, the answer in its body. Public-facing writing (commits, PRs, Slack, email, public docs) gets `/voice personal`; private writing skips it. Summary lists handled instructions, answered questions with evidence + confidence, follow-ups, unresolved items, and an explicit clean / N-remain verdict. Anything needing Craig's input becomes a `VERIFY` task in `todo.org` (top-level or first-level child of a parent task — never deeper) rather than a separate summary file. File/URL references render as clickable org-mode links. Use when an org file accumulates cj comments. Do NOT use for general code review (`/review-code`), new work without cj comments, or trivial items. --- # /respond-to-cj-comments — Process cj Comments in an Org File @@ -143,9 +143,9 @@ For **instructions**: - **Regular `TODO`/`DOING` at `*` or `**`** — advance to `DONE` + `CLOSED:` line; the keyword and original heading stay visible in the agenda. - **Regular `TODO`/`DOING` at `***` and deeper** — rewrite the heading to `<depth> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <past-tense description>`; drop the keyword/priority/tags. - - **`VERIFY` at any depth** — dated-heading rewrite *and* a body replacement: replace the body with either the information Craig provided (when the VERIFY was a question) or a description of the action taken (when it was an instruction / pending-decision marker). VERIFYs at `**` follow this rule even though regular `**` DONE tasks stay task-shaped — a resolved VERIFY is an answered question, not a finished task. + - **`VERIFY` — depth decides the heading.** At `***` and deeper, a dated-heading rewrite. At `**`, a terminal keyword (`DONE`/`CANCELLED` + `CLOSED:`) like any top-level task — never a dated `**` header. Either way, replace the body with the information Craig provided (when the VERIFY was a question) or a description of the action taken (when it was an instruction / pending-decision marker). - **VERIFY-answer pattern.** When a cj annotation's `parent_heading_chain` ends with a `VERIFY ...` heading (i.e., the cj sits directly inside a VERIFY task), the cj is Craig's answer to the question that VERIFY held open. The cj content is the source for the dated-rewrite body. Two shapes: + **VERIFY-answer pattern.** When a cj annotation's `parent_heading_chain` ends with a `VERIFY ...` heading (i.e., the cj sits directly inside a VERIFY task), the cj is Craig's answer to the question that VERIFY held open. The cj content is the source for the resolved body. Two shapes: - *Direct answer.* The cj body IS the answer (a value, decision, link, paste from elsewhere). Lift the cj body verbatim into the new dated body; trim filler ("okay," "approved," "yes,") that isn't load-bearing. - *Indirect answer.* The cj points at where the answer lives ("Kostya gave this in Slack — pull it from DM channel X," "see the attached doc"). Execute the instruction first (per step 3 — subagent if research is needed), then the resolved info becomes the dated body. @@ -153,7 +153,7 @@ For **instructions**: Both shapes land at the same end state: 1. Generate the timestamp with `date "+%Y-%m-%d %a @ %H:%M:%S %z"`. - 2. Rewrite the VERIFY heading to its dated form (depth-preserving) with a short summary of what got answered. + 2. Rewrite the VERIFY heading by depth: at `***` and deeper, its dated form with a short summary of what got answered; at `**`, a `DONE`/`CANCELLED` keyword + `CLOSED:` line with the heading text kept. 3. Replace the body with the resolved info (the cj body for direct, the executed result for indirect). 4. Delete the cj annotation — it's now folded into the body. Don't keep both. diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md index b1fb57b..b9e93bb 100644 --- a/claude-rules/todo-format.md +++ b/claude-rules/todo-format.md @@ -130,7 +130,7 @@ becomes The agenda view (`org-agenda`) shows entries at the section + top-task level. Letting `**` tasks stay task-shaped preserves their visibility as "things that recently shipped." Letting `***+` sub-tasks flip to dated entries keeps the agenda from being clogged with a long list of completed sub-tasks at every depth — those become history within their parent instead. -`VERIFY` is the documented exception: it follows the dated-rewrite rule at **all** depths (including `**`), because a resolved VERIFY is an answered question rather than a finished task. See the VERIFY section below. +`VERIFY` follows the dated-rewrite rule at `***` and deeper, the same as any sub-task. At `**` it does *not*: a top-level VERIFY completes task-shaped — a `DONE`/`CANCELLED` keyword plus a `CLOSED:` line, exactly like a top-level `TODO`. Dated headers never appear at `**`. Level 2 always carries a terminal keyword; dated headers are a `***`-and-deeper shape only. See the VERIFY section below. ## VERIFY tasks @@ -191,19 +191,31 @@ The sibling rule is the active force that keeps `todo.org` flat. Without it, VERIFYs accumulate one level deeper than their trigger every time — turning a clean parent tree into a long pole of nested sub-headings. -### Completion — dated rewrite + content replacement +### Completion — depth decides the heading shape -When a VERIFY resolves, **rewrite the heading and body together** at the -same depth — regardless of whether the VERIFY is at `**` or `***`: +When a VERIFY resolves, **rewrite the heading and body together**. The body +replacement is the same at every depth (step 2 below); the heading shape +depends on the VERIFY's level, mirroring the depth-based rule for ordinary +tasks — dated entries at `***` and deeper, terminal keyword at `**`. -1. **Replace the heading.** Drop the `VERIFY` keyword (and any priority - cookie / tags) and replace with a timestamp + short description: +1. **Replace the heading — by depth.** - *** 2026-05-15 Fri @ 14:00:00 -0500 <what was answered or done> + - **At `***` and deeper — dated event-log entry.** Drop the `VERIFY` + keyword (and any priority cookie / tags) and replace with a timestamp + + short description: - Generate the timestamp with `date "+%Y-%m-%d %a @ %H:%M:%S %z"`. - Match the original depth (a `**` VERIFY becomes `** YYYY-MM-DD ...`; - a `***` VERIFY becomes `*** YYYY-MM-DD ...`). + *** 2026-05-15 Fri @ 14:00:00 -0500 <what was answered or done> + + Generate the timestamp with `date "+%Y-%m-%d %a @ %H:%M:%S %z"`. + + - **At `**` — terminal keyword, like any top-level task.** Change + `VERIFY` to `DONE` (answered / check passed) or `CANCELLED` (abandoned), + keep the heading text, priority cookie, and tags, and add a + `CLOSED: [YYYY-MM-DD Day]` line. Never a dated heading — a `**` dated + header is a defect; repair it to `DONE`/`CANCELLED` + `CLOSED:`. + + ** DONE [#B] <original VERIFY topic> :tags: + CLOSED: [2026-05-15 Fri] 2. **Replace the body.** Drop the original question/instruction prose and replace with either: @@ -213,16 +225,18 @@ same depth — regardless of whether the VERIFY is at `**` or `***`: instruction or pending-decision marker — what was done, when, where the artifact lives). -The completed VERIFY becomes an in-place event log entry. The original -question is preserved by the dated heading + body shape; anyone scanning -the agenda or `git log` can see what was asked and what landed. - -**Note on the top-level case.** Regular `**` DONE tasks stay task-shaped -with a `DONE` keyword + `CLOSED:` line per *Completion — depth-based* -above. VERIFYs at `**` are the exception — they convert to dated log -entries on completion because a resolved VERIFY isn't a "done task," it's -an answered question. The dated-rewrite rule wins for VERIFYs at all -depths. +Either way the completed VERIFY records what was asked and what landed: at +`***` and deeper as a dated event-log entry, at `**` as a `DONE`/`CANCELLED` +task whose body holds the answer. Anyone scanning the agenda or `git log` +can see both. + +**Note on the top-level case.** A `**` VERIFY completes exactly like a `**` +`TODO`: a `DONE`/`CANCELLED` keyword + `CLOSED:` line, with the answer or +action in the body. The earlier habit of dating a resolved top-level VERIFY +— treating "answered question, not a finished task" as license for a `**` +dated header — is retired. It put dated headers at level 2, where the agenda +truncates them out of a clean keyword scan. Dated rewrite is for `***` and +deeper only; `**` always carries a terminal keyword. ### Don't leave stale placeholders diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el index 8f55cc6..3633dba 100644 --- a/claude-templates/.ai/scripts/lint-org.el +++ b/claude-templates/.ai/scripts/lint-org.el @@ -368,6 +368,31 @@ Emits one judgment item per violating table." (string-join violations "; "))))))))) ;;; --------------------------------------------------------------------------- +;;; level-2 dated-header check (claude-rules/todo-format.md) +;; +;; A completed task or resolved VERIFY at level 2 must carry a terminal +;; keyword (DONE/CANCELLED + CLOSED:), never a dated heading. A `** <date>' +;; header has no keyword, so todo-cleanup's --archive-done can never archive +;; it (it accumulates in Open Work forever) and task-review drops it from +;; selection. Judgment-only, never auto-fixed: the repair needs a +;; DONE-vs-CANCELLED call and the original heading text, which is a judgment +;; the sweep can't make. Targets todo/task files; a dated-log-format org +;; file using `** <date>' headings intentionally will false-positive here, in +;; which case the human dismisses the judgment item. + +(defun lo--check-level2-dated-headers () + "Flag level-2 headings whose text begins with a YYYY-MM-DD date. +Emits one judgment item per offending heading (checker +`level-2-dated-header')." + (save-excursion + (goto-char (point-min)) + (while (re-search-forward + "^\\*\\* \\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" nil t) + (lo--emit-judgment + 'level-2-dated-header (line-number-at-pos) + "level-2 dated header is a completion defect (todo-format.md): a ** task or VERIFY closes with DONE/CANCELLED + CLOSED:, not a dated heading — convert it so --archive-done can archive it")))) + +;;; --------------------------------------------------------------------------- ;;; File processing (defun lo--backup (file) @@ -401,6 +426,8 @@ left unmodified and mechanical entries are recorded with :preview t." ;; After org-lint items: the custom table-standard scan. Runs on the ;; post-fix buffer; judgment-only, so order doesn't perturb fixes. (lo--check-tables) + ;; Same shape: flag level-2 dated headers (completion defects). + (lo--check-level2-dated-headers) (when (and (not lo-check-only) (buffer-modified-p)) (save-buffer))) (with-current-buffer buf (set-buffer-modified-p nil)) diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el index 3a83602..242c35c 100644 --- a/claude-templates/.ai/scripts/tests/test-lint-org.el +++ b/claude-templates/.ai/scripts/tests/test-lint-org.el @@ -659,5 +659,31 @@ missing-rules violation." (judgments (lo-test--judgments (plist-get run :issues)))) (should-not (memq 'org-table-standard (lo-test--checkers judgments))))) +;;; --------------------------------------------------------------------------- +;;; level-2 dated-header check (claude-rules/todo-format.md) + +(ert-deftest lo-level2-dated-header-is-judgment () + "A level-2 heading beginning with a YYYY-MM-DD date is flagged." + (let* ((out (lo-test--run + "* Open Work\n\n** 2026-06-20 Sat @ 10:00:00 -0500 Something resolved\nBody.\n")) + (res (plist-get out :result)) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed + (should (member 'level-2-dated-header (lo-test--checkers judgments))))) + +(ert-deftest lo-level2-done-task-not-flagged () + "A level-2 task closed with a terminal keyword + CLOSED: is fine." + (let* ((out (lo-test--run + "* Open Work\n\n** DONE [#B] Something resolved\nCLOSED: [2026-06-20 Sat]\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) + +(ert-deftest lo-level3-dated-entry-not-flagged () + "A dated event-log entry at level 3 is the correct sub-task shape, not a defect." + (let* ((out (lo-test--run + "* Open Work\n\n** TODO [#B] Parent task\n*** 2026-06-20 Sat @ 10:00:00 -0500 sub-entry landed\nBody.\n")) + (judgments (lo-test--judgments (plist-get out :issues)))) + (should-not (member 'level-2-dated-header (lo-test--checkers judgments))))) + (provide 'test-lint-org) ;;; test-lint-org.el ends here diff --git a/claude-templates/.ai/workflows/inbox-zero.org b/claude-templates/.ai/workflows/inbox-zero.org index aa7c273..8c4719b 100644 --- a/claude-templates/.ai/workflows/inbox-zero.org +++ b/claude-templates/.ai/workflows/inbox-zero.org @@ -69,6 +69,8 @@ The roam inbox lives in a git repo (=~/org/roam=, auto-synced by the =roam-sync= 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. +If triaging this batch surfaced a durable, cross-project fact (a reference pointer worth keeping, a pattern worth recording), consider writing it to the agent KB as one =:agent:= node (see =knowledge-base.md=; personal projects only). Skip silently when nothing durable came up — never pad an empty run with a KB line. + * Skip conditions - No =~/org/roam/inbox.org= → silent no-op. diff --git a/claude-templates/.ai/workflows/process-inbox.org b/claude-templates/.ai/workflows/process-inbox.org index af406ee..687767e 100644 --- a/claude-templates/.ai/workflows/process-inbox.org +++ b/claude-templates/.ai/workflows/process-inbox.org @@ -191,7 +191,7 @@ Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment ** Park (Skeptical Review in a no-approvals session) -Move the proposal file into =working/<task-slug>/= alongside the prepared diff, file the =[#B]= VERIFY per the Skeptical Review section, reply to the sender that it's parked for Craig's review, and delete the inbox file. On Craig's approval the apply is mechanical: apply the prepared edits, run the normal verify-and-publish flow, rewrite the VERIFY to a dated log entry per =todo-format.md=, and send the sender the acceptance reply. On rejection, the reject-from-another-project flow above runs unchanged. +Move the proposal file into =working/<task-slug>/= alongside the prepared diff, file the =[#B]= VERIFY per the Skeptical Review section, reply to the sender that it's parked for Craig's review, and delete the inbox file. On Craig's approval the apply is mechanical: apply the prepared edits, run the normal verify-and-publish flow, close the parked =**= VERIFY per =todo-format.md= (a top-level VERIFY resolves to =DONE= + =CLOSED:=, not a dated header), and send the sender the acceptance reply. On rejection, the reject-from-another-project flow above runs unchanged. * Phase E — Close out diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org index fe7778f..7540787 100644 --- a/claude-templates/.ai/workflows/startup.org +++ b/claude-templates/.ai/workflows/startup.org @@ -151,6 +151,20 @@ These calls have no dependencies on each other. Issue them all together in one m 8. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm. 9. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe. 10. =[ -f "$HOME/org/roam/inbox.org" ] && grep -cE '^\*\* ' "$HOME/org/roam/inbox.org" || true= — count items in the roam global inbox (=~/org/roam/inbox.org=), the inbox-zero startup nudge. Silent if the roam clone isn't on this machine. Phase C reads the file when the count is non-zero, splits total vs items related to this project, and surfaces the offer (see =inbox-zero.org=). Read-only; never files at startup. +11. KB surface prep (the read + contribute startup nudges; see =docs/design/2026-06-16-encourage-kb-contribution-spec.org=). Gated on the agent KB clone. Counts =:agent:= nodes, lists up to 5 whose content matches the current project basename (titles only; a few most-recent nodes as a fallback when nothing matches), and resolves the best-practices node path. Read-only; silent when the clone is absent. Phase C surfaces the relevant titles (consult) and the best-practices link (contribute). + + #+begin_src bash + ra="$HOME/org/roam/agents" + if [ -d "$ra" ]; then + proj=$(basename "$PWD") + echo "kb-total: $(rg -l '#\+filetags:.*:agent:' "$ra" 2>/dev/null | wc -l)" + echo "kb-bestpractices: $(rg -l 'agent-kb-best-practices' "$ra" 2>/dev/null | head -1)" + matches=$(rg -il "$proj" "$ra" 2>/dev/null | head -5) + [ -z "$matches" ] && matches=$(\ls -t "$ra"/*.org 2>/dev/null | head -3) + echo "kb-relevant-titles:" + for f in $matches; do rg -m1 '^#\+title:' "$f" 2>/dev/null | sed 's/^#+title:/ -/'; done + fi + #+end_src Notes on the rsync commands: - Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside. @@ -183,6 +197,8 @@ This phase touches the user and runs sequentially: - Briefly note significant template updates noticed during sync (new workflows, protocol changes). - *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "=<N>= top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing. - *Roam inbox nudge.* If the Phase A roam-inbox count is greater than zero, read =~/org/roam/inbox.org=, split total vs items related to this project (claimed by the =<project>:= prefix, plus any unprefixed item whose topic plainly concerns this project), and surface one line: "Roam inbox: =<N>= total, =<M>= appear related to this project — say 'inbox zero' to file them." Offer it as a priority option; never auto-file. If the count is zero or the file is absent, say nothing. See =inbox-zero.org=. + - *KB consult nudge (read side).* If the Phase A KB-surface prep returned any =kb-relevant-titles=, surface one line listing them (capped 5): "KB lessons that may be relevant: =<title>=; =<title>=… — open the node before related work." The titles are declarative, so the list alone tells you whether to open one. Gated on the roam clone; silent when the clone is absent or nothing relevant surfaced. See the best-practices node and =knowledge-base.md=. + - *KB contribute nudge (write side).* Once per session, surface one line pointing at the best-practices node (the =kb-bestpractices= path from Phase A): "Learned something durable? See =<path>= for how to write a KB node — contributing cross-project facts is welcome (personal projects only; work/unknown projects never write per =knowledge-base.md=)." Light encouragement, never a gate. Gated on the roam clone; silent when absent. - *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install-<lang> PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing. - *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, hook, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). For a newly-linked *hook*, note that the harness reads hooks at session start — it fires from the next session (or after Craig opens =/hooks= once); its settings.json wiring travels with the tracked file, so the link is usually the only missing piece. A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing. - *Template-sync churn (safety net).* Check whether Phase A's rsync left uncommitted churn in the synced =.ai/= paths — accumulated from a prior session that crashed before wrap-up, or freshly added this session when rulesets advanced. Without surfacing, it builds up silently until it blocks Phase A.0's auto-ff (git won't ff a dirty tree). Skip in the rulesets repo itself (there =.ai/= is a committed mirror, kept honest by the pre-commit hook). The check is sequential here, after the rsync has finished — not a Phase A step, to keep that batch race-free. diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org index 9e9e3dd..7241017 100644 --- a/claude-templates/.ai/workflows/triage-intake.org +++ b/claude-templates/.ai/workflows/triage-intake.org @@ -167,6 +167,10 @@ If Craig has been silent for a while after Phase D and the surface looks closed- This rule prevents the failure mode where the workflow self-declares done and the next exchange has to relitigate what state things are in. +*** KB capture (only if the sweep surfaced something durable) + +If this sweep surfaced a durable, cross-project fact — a recurring pattern across sources, a reference pointer worth keeping, an environment gotcha — consider writing it to the agent KB as one =:agent:= node (see the best-practices node and =knowledge-base.md=; personal projects only, work never writes). One line of judgment, not a step: an all-quiet sweep surfaces nothing and writes nothing. Never blocking, never padded onto a no-signal run. + * Auto mode (unattended monitoring) diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org index 139d612..b1560eb 100644 --- a/claude-templates/.ai/workflows/wrap-it-up.org +++ b/claude-templates/.ai/workflows/wrap-it-up.org @@ -33,6 +33,10 @@ The absence of =.ai/session-context.org= is the signal that the last session wra ** Step 1: Finalize the Summary +*** Early KB reflection (capture while fresh, before the Summary) + +Before distilling the Summary, while the session is still fresh, ask: what did this session learn worth remembering, for yourself or a future agent? Reflect and stage any candidate durable facts — a decision and its why, an environment gotcha, a reference pointer, a transferable lesson. Self-answer silently; this adds no interactive turn (Craig already authorized the wrap). The candidates flow straight into the KB promotion check below, which does the actual writing and the receipt — this is the capture half, that is the commit half, one pipeline, one receipt. Reflecting here rather than reconstructing learnings after the Summary is the point: the early ask is what keeps the receipt from defaulting to "promoted 0" out of fatigue. + Read through the =* Session Log= in =.ai/session-context.org=. Populate (or refine) the =* Summary= section: - *Active Goal* — one or two sentences describing the session's focus diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index 5a806ec..994dc1f 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -87,6 +87,7 @@ maybe_add_candidate() { build_candidates() { candidates=() maybe_add_candidate "$HOME/.emacs.d" + maybe_add_candidate "$HOME/.dotfiles" if [ -d "$HOME/code" ]; then while IFS= read -r d; do maybe_add_candidate "$d" diff --git a/docs/design/2026-06-16-encourage-kb-contribution-spec.org b/docs/design/2026-06-16-encourage-kb-contribution-spec.org index 059326c..cf8111b 100644 --- a/docs/design/2026-06-16-encourage-kb-contribution-spec.org +++ b/docs/design/2026-06-16-encourage-kb-contribution-spec.org @@ -4,7 +4,7 @@ #+TODO: TODO | DONE SUPERSEDED CANCELLED * Metadata -| Status | draft | +| Status | approved (decisions ratified 2026-06-20) | |----------+------------------------------------------------| | Owner | Craig Jennings | |----------+------------------------------------------------| @@ -101,45 +101,51 @@ The existing wrap-up Step 1 already has a "KB promotion check" sub-section that - Bad, because that's the status quo that produced the problem: a rule read once at load and then forgotten. Habits in this system come from workflow prompts, not background rules. - Neutral, because the rule still carries the authoritative boundary; the workflow prompts are the habit layer on top. -* Decisions [/] +* Decisions [6/6] -** TODO Where exactly does the startup link land — Phase A read, Phase C nudge, or notes.org? +** DONE Where exactly does the startup link land — Phase A read, Phase C nudge, or notes.org? - Owner / by-when: Craig / before implementation - Context: Startup has three candidate homes for the KB encouragement: a Phase A parallel read of the best-practices node (costs context every session), a Phase C surfaced nudge (one line, conditional, consistent with the existing roam-inbox and task-review nudges), or a static line in each project's =notes.org= Active Reminders (per-project, not synced, drifts). The Phase C nudge matches the established nudge pattern and costs nothing when there's nothing to say. - Decision: We will add the encouragement as a one-line Phase C nudge in startup.org, pointing at the best-practices node by its KB path, surfaced once near the other Phase C nudges. - Consequences: easier — consistent with existing nudge mechanics, synced to every project, no per-session read cost; harder — one more line competing for attention in the Phase C surface, so the wording has to earn its place and stay terse. -** TODO Is the startup nudge unconditional, or gated on the KB clone being present? +** DONE Is the startup nudge unconditional, or gated on the KB clone being present? - Owner / by-when: Craig / before implementation - Context: =~/org/roam/= isn't on every machine. The existing roam-inbox nudge already guards on the clone's presence ([ -f ~/org/roam/inbox.org ]). An unconditional KB nudge would fire on machines where the agent can't act on it. - Decision: We will gate the startup nudge on the roam clone being present, reusing the existing presence check, so the encouragement only appears where the agent can act on it. - Consequences: easier — no dead nudge on KB-less machines, mirrors the roam-inbox guard; harder — one more conditional in Phase C, and a machine without the clone gets no encouragement at all (acceptable — it can't contribute there anyway). -** TODO Does the early wrap-up prompt stop and ask Craig, or self-answer silently? +** DONE Does the early wrap-up prompt stop and ask Craig, or self-answer silently? - Owner / by-when: Craig / before implementation - Context: Wrap-up is meant to be quick — Craig already authorized the wrap, and the existing KB-promotion check self-answers (the agent decides what's durable; work projects skip the write). An early prompt that /stops and asks Craig/ "what did you learn?" would add an interactive turn to a flow designed not to have them. But a purely silent self-answer risks the agent skipping the reflection. - Decision: We will have the agent self-answer the early prompt — reflect on session learnings and stage candidate facts — without stopping to ask Craig, matching the wrap-up's no-extra-turns design; the candidates flow into the existing promotion check which writes the nodes and receipt. - Consequences: easier — preserves wrap-up cadence, no new interactive gate, one pipeline from reflect to receipt; harder — relies on the agent actually reflecting rather than rubber-stamping "nothing learned," which the receipt makes visible over time but doesn't enforce. -** TODO Do triage-intake and inbox-zero reminders fire every run, or only when the run surfaced something durable? +** DONE Do triage-intake and inbox-zero reminders fire every run, or only when the run surfaced something durable? - Owner / by-when: Craig / before implementation - Context: Both workflows run frequently (triage-intake between meetings, inbox-zero twice a session). A reminder on /every/ run is the textbook nag-fatigue failure — a line the agent learns to skip. A reminder gated on "this run surfaced a pattern / reference pointer worth keeping" fires rarely and stays meaningful, but requires the agent to make that judgment, which is softer than a mechanical condition. - Decision: We will make both reminders conditional in spirit — a single line phrased as "if anything here was durable, write it to the KB" that the agent acts on only when the run actually surfaced something, rather than an unconditional step; an all-quiet triage sweep or an empty inbox-zero run emits no KB line. - Consequences: easier — the reminder stays rare and credible, never pads a no-change sweep, fits triage-intake's deltas-only discipline; harder — "durable-looking" is an agent judgment with no mechanical check, so the reminder's effectiveness rides on the best-practices node teaching that judgment well. -** TODO Best-practices node: agent-authored once, or hand-authored by Craig? +** DONE Best-practices node: agent-authored once, or hand-authored by Craig? - Owner / by-when: Craig / before implementation - Context: =knowledge-base.md= says agents never edit Craig's hand-authored nodes. The best-practices node is /about/ how agents write nodes — if an agent authors it, future agents may treat it as fair game to edit; if Craig hand-authors it, it's protected and stable but he writes it. Given it's a foundational reference the whole feature points at, stability matters. - Decision: We will have Craig hand-author the best-practices node from the outline in this spec, so it's a protected, stable reference; the spec supplies the full drafted content for him to review and commit. - Consequences: easier — the node is stable and protected from agent edits, one authoritative reference; harder — Craig writes (or reviews-and-commits) it rather than delegating, and updates to it are his call, not an agent's. +** DONE Read side: how does startup surface lessons to consult, not just encourage contribution? +- Owner / by-when: Craig / ratified 2026-06-20 +- Context: The original spec only strengthened the /write/ side — startup encourages contributing (D1) but never surfaces existing KB lessons to /read/. The wrap-up receipt data shows "consulted no" across recent sessions: agents don't reach for the KB because nothing brings it to their attention at the moment work starts. =knowledge-base.md='s "search the KB first" is reactive and read-once-at-rule-load. A proactive surfacing at startup is the missing counterpart to D1. The cost constraint is the same one D1 dodged: a full Phase A read of matching nodes would spend context every session. +- Decision: We will add a second startup Phase C nudge (alongside D1's contribute-link, gated on the same roam-clone presence check) that surfaces KB lessons relevant to the current project — a count plus the nodes' declarative /titles only/ (no full-node read), capped at ~5. Relevance is matched cheaply on the project basename and obvious topic words against node titles/filetags/paths, with a most-recent fallback when nothing matches. The agent opens a node on demand. Titles are declarative by the best-practices node's own rule, so a title alone tells the agent whether to open it. +- Consequences: easier — closes the "consulted no" half with near-zero context cost (titles only), reuses the Phase C nudge pattern and the roam guard, and the consult and contribute nudges sit together as one KB surface; harder — relevance matching is a heuristic that can miss or mis-surface, and it adds a second KB line to Phase C, so both must stay terse to avoid nudge fatigue. If the receipt shows consults rising but the surfaced titles are noise, tighten the match. + * Implementation phases ** Phase 1 — Author the best-practices node Write =~/org/roam/agents/<timestamp>-agent-kb-best-practices.org= from the outline in Design, with a generated =:ID:=, =#+title:=, =:filetags: :agent:reference:=, the eight content sections, =[[id:...]]= links to any existing related =:agent:= nodes, and the sources footer. Commit + push the roam repo per =knowledge-base.md='s session discipline. Leaves the KB with one new reference node and nothing else touched. -** Phase 2 — Wire the startup encouragement -Add the one-line Phase C nudge to =claude-templates/.ai/workflows/startup.org= (canonical side), gated on the roam-clone presence check, pointing at the node by path. Run =scripts/sync-check.sh --fix=, commit both canonical + mirror. Propagates to every project on next startup. +** Phase 2 — Wire the startup encouragement (contribute + consult) +Add two one-line Phase C nudges to =claude-templates/.ai/workflows/startup.org= (canonical side), both gated on the roam-clone presence check: (1) D1's contribute-link pointing at the best-practices node by path, and (2) D6's consult-surface listing project-relevant KB node titles (count + titles only, capped ~5, project-basename match with recent fallback). A Phase A read counts =:agent:= nodes cheaply so Phase C only does the title surfacing when there's something to show. Run =scripts/sync-check.sh --fix=, commit both canonical + mirror. Propagates to every project on next startup. ** Phase 3 — Wire the three remaining prompts Add the end-of-flow KB reminder to =triage-intake.org= (end of Phase D / Exit Criteria) and =inbox-zero.org= (Phase D Surface), and the early KB prompt to =wrap-it-up.org= (top of Step 1, feeding the existing promotion check). All on the canonical side, then sync-check + commit. Each edit is one short block; the tree stays working after each. @@ -150,6 +156,7 @@ Confirm the four edits survive a startup sync into a test project, the wrap-up e * Acceptance criteria - [ ] Best-practices node exists at =~/org/roam/agents/= with =:agent:reference:= tags, is found by =rg '#\+filetags:.*:agent:' ~/org/roam/=, and cites its sources. - [ ] Startup surfaces a single KB-contribution line in Phase C, gated on the roam clone, pointing at the node — and stays silent when the clone is absent. +- [ ] Startup also surfaces a KB-consult line in Phase C (D6): project-relevant node titles (count + titles only, capped ~5), gated on the clone, silent when nothing matches and the clone is absent. - [ ] Triage-intake and inbox-zero each emit one KB reminder line only when the run surfaced something durable; an all-quiet run emits none. - [ ] Wrap-up asks the "what did you learn?" reflection early in Step 1, and its candidates feed the existing promotion check — producing exactly one =KB: promoted N / consulted yes-no= receipt, not two. - [ ] No workflow blocks, stalls, or fails because a node wasn't written. @@ -186,3 +193,7 @@ Sources for the best-practices node's curated content: - What: initial draft. - Why: Craig wants the org-roam KB to compound into a cross-project asset; needs the workflow wiring + curated best-practices node speced before building. - Artifacts: this spec; four target workflows (startup, triage-intake, inbox-zero, wrap-it-up); =knowledge-base.md=. +** 2026-06-20 Sat — ratified + read-side added +- What: ratified all five original decisions; added decision D6 (read-side startup consult-nudge) and threaded it through Design, Phase 2, and acceptance. Status draft → approved. +- Why: receipt data showed the write-only design left "consulted no" across recent sessions. Craig asked for the reverse of contribution — surfacing relevant lessons to read at startup. D6 is that counterpart. +- Artifacts: this spec; startup.org (now two Phase C nudges); the lint level-2-dated-header checker tracked separately. 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..ce17138 --- /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 & Claude (home project) +#+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..cd830fb --- /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 & Claude +#+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/wrapup-routing-spec.org b/docs/design/wrapup-routing-spec.org index 5749491..434f8d9 100644 --- a/docs/design/wrapup-routing-spec.org +++ b/docs/design/wrapup-routing-spec.org @@ -3,12 +3,8 @@ #+DATE: 2026-06-13 #+TODO: TODO | DONE SUPERSEDED CANCELLED -#+begin_src cj: comment - this spec is now approved. take it through the next steps and begin implementation per the spec response process. -#+end_src - * Metadata -| Status | ready for review | +| Status | Ready — review incorporated (spec-review, 2026-06-21) | |----------+-----------------------------------------------------| | Owner | Craig Jennings | |----------+-----------------------------------------------------| @@ -19,7 +15,7 @@ * 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/=. +At wrap-up, an inbox handoff that belongs to another project, once accepted and filed locally, has no clean home in the current project's =todo.org=. This adds an optional routing step to =wrap-it-up.org=: surface the filed keepers whose home is elsewhere, recommend a destination for each, and on one confirmation deliver each to that project's =inbox/= via =inbox-send= (one handoff per task), removing it from the local =todo.org=. The destination's own next session files it through =process-inbox=, applying that project's value gate, priority scheme, and =todo-format.md=. A parallel step (vNext) files meeting-transcript recordings into the right project's =assets/=. * Problem / Context @@ -34,11 +30,11 @@ The friction is small per-item but recurring, and the manual cross-project edit * 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. +- At wrap-up, surface filed keepers whose home is a different project, with a recommended destination each. +- Route the whole batch on one confirmation ("go with recommendations") or leave it entirely ("skip"). No per-item triage. +- Deliver each routable keeper to the destination's =inbox/= via =inbox-send=, one handoff per task, and remove the keeper from the local =todo.org= on send. The destination files it through its own =process-inbox=. +- Provenance is automatic: =inbox-send= stamps the source project and date on every handoff (the =from-<source>= filename and =#+SOURCE:= line). The delivery shows in the destination inbox; the removal shows in the source's git diff. +- The destination set is any project with an =inbox/= — reuse =inbox-send='s existing discovery. ** Non-Goals - Not a wrap gate. A skip is a clean, complete wrap. @@ -47,8 +43,8 @@ The friction is small per-item but recurring, and the manual cross-project edit - 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. +- v1: task/event routing by =inbox-send= delivery to the destination's =inbox/=. The interaction, the recommendation engine, the candidate-set marker stamped at file time, reusing =inbox-send='s discovery and delivery. +- Out of scope: per-item destination editing, an interactive correction loop, moving items that aren't accepted keepers, a new cross-repo =todo.org= move primitive (the superseded direct-move design). - vNext: meeting-transcript filing (gated on the unresolved source-location decision and the file-vs-file+extract question — see Decisions). * Design @@ -57,26 +53,26 @@ The friction is small per-item but recurring, and the manual cross-project edit 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. +1. Go with the recommendations — route every recommended item (inbox-send to the destination + local removal). 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. +On "go", each routable keeper is delivered to its recommended destination's =inbox/= via =inbox-send= (one handoff per task) and removed from the local =todo.org=; the destination's own next session files it through =process-inbox=. A skipped or no-match 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." +*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, identified by a marker stamped at file time (decision D8): =process-inbox='s "file as TODO" step stamps =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home is not the current project. At wrap, the router's candidate set is exactly the local tasks carrying that property — 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 discovery.* Reuse =inbox-send.py='s existing =discover_projects= (a project is a directory with =.ai/= AND =inbox/=). The destination must have an =inbox/= to receive a handoff, so that is the natural destination set — no new discovery code. A project with a =todo.org= but no =inbox/= cannot receive an inbox handoff and must be bootstrapped first; in practice every active project has an =inbox/=. -*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. +*Delivery.* For each candidate, on "go": (1) =inbox-send <destination> --file= a one-task handoff into the destination's =inbox/= (one file per task, so the destination's =process-inbox= dispositions it as a single item), then (2) remove the keeper from the local =todo.org=. Step 1 is a cross-project write, but it uses the =cross-project.md=-sanctioned path (dropping a file in another project's inbox needs no confirmation); step 2 is a single-file edit in the current project's own =todo.org=, which the wrap is already committing. No new cross-repo move primitive, no foreign =todo.org= edit. -*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. +*Provenance and filing.* =inbox-send= stamps the source and date automatically (=from-<source>= filename + =#+SOURCE:= line), so the destination's session knows where the item came from. That session files it through its own =process-inbox= — value gate, priority scheme, =todo-format.md= — so the task lands per the destination's conventions rather than as an externally-authored insertion. -*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. +*Recovery (mis-route).* If the recommendation engine picks a wrong destination, the receiving session rejects it via =process-inbox='s reject-from-another-project flow (write a response, =inbox-send= it back to the source named in the provenance, delete the local copy). The task returns to the source project's inbox; nothing is lost or corrupted. This is why removing the source on send is safe — the reject path is the undo. -*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. +*Recommendation engine.* Infer the destination from the item's content — project names, file paths, topic words — matched against the discovered project list, with a confidence tier: *strong* = a destination project's name or path appears literally in the item; *weak* = topic-word overlap only; *none* = no match, the item stays put and is never surfaced as a route. "Go" routes strong and weak items (weak visibly labeled); a no-match item is left in place. Pure function =(item, project-list) → (destination, confidence)=, unit-tested directly. The engine is the interesting, uncertain part; it earns the spec. * Alternatives Considered @@ -95,22 +91,22 @@ A move of a task/event relocates it into the destination project's "Open Work" s - 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] +* Decisions [9/9] ** 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 +** SUPERSEDED Move atomically through a helper, never hand-edit two repos +Superseded 2026-06-21 by "Deliver via inbox-send" below. The original plan built a new atomic helper to insert a subtree into a foreign =todo.org= and remove the source. The inbox-route delivers the keeper to the destination's inbox instead, so no cross-repo move primitive is built. - 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. +- Decision (superseded): route every move through one helper that inserts under the destination's Open Work heading and removes the source as one operation. -** DONE Cross-project writes stay visible and carry provenance +** SUPERSEDED Cross-project writes stay visible and carry provenance +Superseded 2026-06-21 by "Deliver via inbox-send" below. =inbox-send= already stamps provenance (=from-<source>= filename + =#+SOURCE:= line), so the hand-stamped note is unnecessary; the destination files the item through its own gate rather than receiving an externally-authored insertion. - 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. +- Decision (superseded): treat the batch "go" as authorization, leave the move visible in the destination's git diff, and stamp a one-line provenance note on each moved task. ** 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). @@ -127,31 +123,50 @@ A move of a task/event relocates it into the destination project's "Open Work" s - 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. +** DONE Deliver via inbox-send to the destination's inbox, not a direct todo.org move (supersedes D2/D3) +- Owner / by-when: Craig / ratified 2026-06-21 (spec-response) +- Context: D2/D3 built a new atomic helper that edits a foreign =todo.org= and removes the source, with a hand-stamped provenance note. =inbox-send= + =process-inbox= already do cross-project delivery: inbox-send writes the handoff with =from-<source>= provenance, and the destination's process-inbox files it through that project's own gate. =cross-project.md= names the inbox as the sanctioned cross-scope write path. A verified precondition reversed the old assumption — some projects have =inbox/= but no =todo.org=, so direct-move's discovery silently drops keepers headed there while inbox-route delivers. +- Decision: We will route each keeper by =inbox-send= into the destination's =inbox/= (one handoff per task) and let the destination's own =process-inbox= file it; we will not edit the destination's =todo.org= directly. D2 (atomic move helper) and D3 (hand-stamped provenance) are superseded — the helper isn't built, and provenance is inbox-send's by construction. +- Consequences: easier — no new cross-repo write primitive, no foreign-tracker corruption risk, provenance and per-project filing for free, graceful when the destination lacks a =todo.org=. Harder — filing is deferred to the destination's next session (self-resolving, since startup auto-runs =process-inbox= on a non-empty inbox), and a project never opened accumulates a visible inbox backlog rather than a silent foreign insertion. + +** DONE Candidate-set marking: tag :ROUTE_CANDIDATE: at process-inbox file time (Option A) +- Owner / by-when: Craig / ratified 2026-06-21 (spec-response) +- Context: the router must consider only this-session-filed inbox keepers whose home is elsewhere, never the standing backlog. Two options: tag at file time (process-inbox stamps a marker) or infer from a =CREATED=-this-session stamp + content. =process-inbox= does not stamp =:CREATED:= today, so the inference option would need that paired edit anyway, removing its only advantage. +- Decision: We will tag at file time. =process-inbox='s "file as TODO" step stamps =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home differs from the current project; the router's candidate set is the local tasks carrying it. +- Consequences: easier — precise (zero standing-backlog false positives), the inference happens once where context is richest, and the marker doubles as the router's "go" trigger. Harder — a paired edit to =process-inbox.org= Phase D ships coupled with the router. + +** DONE Source removal is a local todo.org edit on send; recovery via the reject flow +- Owner / by-when: Craig / ratified 2026-06-21 (spec-response) +- Context: the review left source-handling vague ("leave the source until the destination confirms by filing"), but there is no confirmation callback, so leaving it duplicates the task once the destination files. The keeper was filed into the *current* project this session and doesn't belong there. +- Decision: On "go" we will remove the routed keeper from the *current* project's =todo.org= (a local single-file edit, not a cross-repo write) right after the =inbox-send=. If the destination rejects the handoff, =process-inbox='s reject-from-another-project flow returns it to the source's inbox, so the removal is reversible. +- Consequences: easier — no duplication, the only deletion is from a file we own and are already committing, the reject path is the undo. Harder — a brief window exists where the task lives only as an in-flight inbox handoff (between send and the destination's filing); acceptable because the handoff file is durable and the reject path recovers a mis-route. + * 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 1 — Destination discovery (reuse inbox-send) +Reuse =inbox-send.py='s =discover_projects= (a directory with =.ai/= AND =inbox/=) as the destination set — no new discovery code. Confirm the destination universe: if a real destination has a =todo.org= but no =.ai/+inbox/=, name it and bootstrap its inbox; otherwise the existing filter already covers it. Leaves the tree working. -** 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 2 — Candidate-set marking in process-inbox +Extend =process-inbox.org='s "file as TODO" step (Phase D) to stamp =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home differs from the current project (decision D8). Sync the =.ai/= mirror. This is the paired workflow edit that lets the wrap-up router find candidates without scanning the standing backlog. (Replaces the superseded atomic-move helper.) -** 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 3 — Recommendation engine +Infer destination from item content against the discovered list, with a confidence tier. Pure function =(item, project-list) → (destination, confidence)=. Unit-tested: strong match (destination project named or path present literally → high) , weak match (topic-word overlap only → low, still routed but labeled), no match (stays put, never surfaced), two-project tie (lowest-confidence / tie-break), empty project list (all stay put). The engine is shared by process-inbox's file-time marker (Phase 2) and the wrap-up router (Phase 4), so it lives where both can call it. ** 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. +Add the optional router sub-step to =wrap-it-up.org= Step 3, after the inbox sanity check: surface the candidate batch (one line each: task, destination, delivery mode, confidence), the two options (go / skip). On "go", for each candidate, =inbox-send= a one-task handoff to the destination's =inbox/= and remove the keeper from the local =todo.org=. Empty candidate set = zero interaction (silent). Name the gate-vs-optional split in the prose (the sanity check gates; the router is optional). 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. +- [ ] At wrap, a filed keeper naming another project is surfaced with that project as the recommended destination. +- [ ] "Go" delivers every recommended item as a one-task =from-<source>= handoff into its destination's =inbox/= and removes it from the local =todo.org=. - [ ] "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). +- [ ] An empty candidate set produces zero interaction (no prompt, no "0 items" line). +- [ ] A weak (low-confidence) recommendation is visibly labeled in the surfaced list; a no-match item is never surfaced as a route. +- [ ] A candidate whose destination has an =inbox/= but no =todo.org= still delivers (degrades gracefully). +- [ ] A mis-routed handoff is recoverable via =process-inbox='s reject-from-another-project flow, returning it to the source's inbox. +- [ ] The router considers only =:ROUTE_CANDIDATE:=-tagged keepers, never the standing backlog. * 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. @@ -172,6 +187,14 @@ Only after the transcript-scope decision resolves. File a recording into the des - *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 dispositions + +Everything in the 2026-06-21 review was accepted, with one modify: + +- *Modified — H1 source-handling.* The review proposed leaving the source keeper in place "until the destination confirms by filing." There is no confirmation callback, so leaving it would duplicate the task once the destination files. Resolved instead (decision D9) to remove the keeper from the *local* =todo.org= on send — a single-file edit in the project we already own and are committing, with =process-inbox='s reject flow as the undo for a mis-route. Keeps the no-foreign-write safety win without the duplication. + +Everything else accepted as written: H1 (inbox-route supersedes direct-move; D2/D3 superseded), H1a (one handoff per task), H1b (reuse =inbox-send= discovery; Phase 1), H2 (tag at file time; D8), M1 (confidence tiers defined in Phase 3 + acceptance), M2 (empty-set silence; acceptance), M3 (paired =process-inbox= edit; Phase 2), M4 (=cross-project.md= note adjusted to "the router uses the sanctioned inbox path"). + * Review and iteration history ** 2026-06-13 Sat @ 01:23:13 -0500 — Claude Code (rulesets) — author @@ -183,3 +206,13 @@ Only after the transcript-scope decision resolves. File a recording into the des - 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. + +** 2026-06-21 Sun @ 01:58:41 -0400 — Claude Code (rulesets) — reviewer +- What: spec-review pass. Rubric *Not ready*, two blocking findings. H1: the inbox-route alternative (inbox-send each routable keeper to the destination's inbox/, let its own process-inbox file it) supersedes the direct-move design — reshape D2, drop Phase 2 and D3's provenance burden. H2: pin the candidate-set marking to Option A (tag =:ROUTE_CANDIDATE:= at process-inbox file time). Four medium findings (M1 confidence tiers, M2 empty-set silence, M3 paired process-inbox edit phase, M4 cross-project.md note). Full review + drop-in implementation tasks in the review file. +- Why: Craig challenged D2 directly (why edit a foreign todo.org rather than use the sanctioned inbox-send path). The review confirmed it: inbox-send already emits the exact provenance D3 reinvents, process-inbox already files per-item with the destination's own gate, cross-project.md sanctions the inbox path, and a verified precondition reverses the spec's assumption — chime and yt-sync have inbox/ but no todo.org, so direct-move silently drops keepers headed there while inbox-route degrades gracefully. +- Artifacts: [[file:wrapup-routing-spec-review.org][review file]]. Next: spec-response to disposition H1/H2 (recommend accept both), which moves the rubric to Ready. + +** 2026-06-21 Sun @ 02:06:37 -0400 — Craig Jennings + Claude Code (rulesets) — responder +- What: folded the spec-review in. Accepted H1 (inbox-route) and H2 (tag at file time); superseded D2 and D3; added D7 (deliver via =inbox-send=), D8 (=:ROUTE_CANDIDATE:= marker at file time), D9 (local source removal + reject-flow recovery). Rewrote Summary, Goals, Design mechanics, Implementation phases (dropped the atomic-move helper — Phase 2 is now the =process-inbox= marker edit), and Acceptance criteria for the inbox-route. One modify (D9) refines H1's vague source-handling. Cookie [9/9]; Status → Ready. +- Why: Craig's inbox-route challenge held up under review — it reuses the sanctioned cross-project path, gets provenance and per-project filing for free, and degrades gracefully where direct-move drops the task. D9 closes the duplication gap the review left open. +- Artifacts: review file deleted on this pass. Next: Phase 6 implementation-task breakdown into =todo.org= on the author's go. @@ -34,15 +34,11 @@ Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest * Rulesets Open Work -** VERIFY [#B] Parked: add =~/.dotfiles= to the ai launcher's project discovery (from archsetup) -:PROPERTIES: -:CREATED: [2026-06-16 Tue] -:END: -What arrived: archsetup asks to add =maybe_add_candidate "$HOME/.dotfiles"= to =build_candidates()= in =claude-templates/bin/ai= (right after the =~/.emacs.d= line), so the fzf picker and by-name launch see =~/.dotfiles= now that it's a bootstrapped AI project. Completes the =~/.dotfiles= install done earlier this session. Synced asset, so it parks per the no-approvals Skeptical Review gate rather than self-applying. +** DONE [#B] ~/.dotfiles discovery added to ai launcher; bootstrapped on velox +CLOSED: [2026-06-20 Sat] +Craig reported =~/.dotfiles= missing from the launcher picker. Two root causes, both fixed: (1) applied the parked one-liner =maybe_add_candidate "$HOME/.dotfiles"= in =build_candidates()= (=claude-templates/bin/ai=, after the =~/.emacs.d= line); (2) =~/.dotfiles/.ai/= was absent on velox — the 2026-06-16 bootstrap was on another machine and =.ai/= is gitignored, so it never traveled — re-bootstrapped via =install-ai.sh --gitignore ~/.dotfiles=. -Skeptical review: change is correct and minimal — verified =build_candidates()= scans only =~/.emacs.d= / =~/code/*= / =~/projects/*=, and =maybe_add_candidate= guards on =.ai/protocols.org= so the line is inert where =~/.dotfiles= isn't bootstrapped (same safety as the =~/.emacs.d= line). The sender's "check for a discovery test" concern resolves to nothing: no bin/ai test enumerates candidate roots. The =ai-roots.txt= generalization the sender raises is correctly deferred (not needed for two single-$HOME roots). - -Prepared change: [[file:working/ai-dotfiles-discovery/proposed.diff]] + [[file:working/ai-dotfiles-discovery/request.org]]. Apply is one line; sync-check mirrors it. Sender notified it's parked. Say "approve the parked dotfiles discovery" to apply. +Verified end-to-end: =build_candidates()= now lists =~/.dotfiles= (protocols.org guard passes). sync-check clean (bin/ai is single-canonical, no mirror). By-name launch =ai ~/.dotfiles= already worked via single_mode's marker-only check. working/ai-dotfiles-discovery/ staging dir removed. ** VERIFY [#B] Helper-instance support — concurrent same-project Claude :feature:spec: :PROPERTIES: @@ -180,7 +176,8 @@ Expected: all four behave per the spec; any miss promotes to a bug task. (Agent- *** 2026-06-10 Wed @ 18:21:33 -0500 Phase 4 done — monthly hygiene automation live =scripts/kb-hygiene.sh= (6 bats green, shellcheck clean, read-only by design) inventories =:agent:= nodes, flags orphans / duplicate titles / conflict files, and writes an org report into the rulesets inbox; =roam-hygiene.timer= (monthly, Persistent) installed + enabled. Live run against the real KB verified (4 agent nodes, 428 files, 0 conflicts). Conditional vNext stays in the spec's scope tiers: a =/promote= command if the wrap-up prompt proves insufficient, an =:agent:inbox:= staging tag if free writes prove too noisy. Commit b014095. -** 2026-06-16 Tue @ 00:53:36 -0500 Phase E spec'd — folded into the autonomous-batch spec +** DONE Phase E spec'd — folded into the autonomous-batch spec +CLOSED: [2026-06-16 Tue] :PROPERTIES: :CREATED: [2026-06-16 Tue] :END: @@ -192,15 +189,36 @@ Craig's answer (2026-06-16): spec it. Phase E reconciles with the "fix speedrun" :CREATED: [2026-06-13 Sat] :LAST_REVIEWED: 2026-06-15 :END: -Optional wrap-up step that surfaces filed keepers belonging to another project, recommends a destination, and batch-moves them into that project's =todo.org= Open Work section (transcript filing deferred to vNext). All six decisions resolved (Reading B: the router acts on session-filed keepers, separate from the inbox gate and from defer-and-stage). Spec ready for review. +Optional wrap-up step that surfaces filed keepers belonging to another project, recommends a destination, and routes each to that project's =inbox/= via =inbox-send= (the destination's own =process-inbox= files it; transcript filing deferred to vNext). Spec: [[file:docs/design/wrapup-routing-spec.org]] — Ready, [9/9] decisions. Source proposal: [[file:docs/design/2026-06-13-wrapup-inbox-transcript-routing-proposal.org]]. -Spec: [[file:docs/design/wrapup-routing-spec.org]]. Source proposal: [[file:docs/design/2026-06-13-wrapup-inbox-transcript-routing-proposal.org]] (archsetup handoff 2026-06-13). Next: =spec-review=. +*** 2026-06-21 Sun @ 02:06:37 -0400 Spec-review + spec-response complete — Ready +Craig's review challenge reshaped the design from a direct cross-repo =todo.org= move to =inbox-send= delivery into the destination's inbox (safer: reuses the sanctioned cross-project path, gets provenance + per-project filing for free, degrades gracefully where a destination has an =inbox/= but no =todo.org=). D2/D3 superseded; D7 (inbox-send delivery), D8 (=:ROUTE_CANDIDATE:= marker at file time), D9 (local source removal + reject-flow recovery) added. Spec-review file consumed and deleted. Implementation-task breakdown filed below (spec-response Phase 6). -#+begin_src cj: comment - I approved the spec in the spec document. please take it through the rest of the spec response process to implementation. bp -#+end_src +*** TODO [#B] Recommendation engine + destination discovery :feature:solo: +Pure function =(item, project-list) → (destination, confidence)= reusing =inbox-send.py='s =discover_projects= for the project list. Confidence tiers: strong (destination name/path literal in the item), weak (topic-word overlap only — still routed, visibly labeled), none (stays put, never surfaced). Unit-tested directly: strong/weak/none, two-project tie, empty project list. Covers spec Phases 1 + 3. Spec: [[file:docs/design/wrapup-routing-spec.org]]. + +*** TODO [#B] =:ROUTE_CANDIDATE:= marker in process-inbox :feature:solo: +Extend =process-inbox.org='s "file as TODO" step (Phase D) to stamp =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home differs from the current project (uses the engine above). Edit the canonical, sync the =.ai/= mirror, verify sync-check clean. Spec Phase 2 / D8. Spec: [[file:docs/design/wrapup-routing-spec.org]]. + +*** TODO [#B] Wrap-up router sub-step in wrap-it-up.org :feature:solo: +Add the optional router to =wrap-it-up.org= Step 3 after the inbox sanity check: surface the =:ROUTE_CANDIDATE:= batch (task / destination / delivery mode / confidence), go/skip; on go, per candidate =inbox-send= a one-task handoff to the destination's =inbox/= and remove the keeper from the local =todo.org=; empty set = silent. Name the gate-vs-optional split in the prose. Edit canonical + sync mirror. Spec Phase 4 / D7 / D9. Spec: [[file:docs/design/wrapup-routing-spec.org]]. + +*** TODO [#B] Wrap-up routing — test surface :test:solo: +Unit: recommendation engine (strong/weak/none, two-project tie, empty list); marker stamping (cross-project keeper tagged, local keeper not, standing backlog never). Integration (bats, fixture projects + temp =todo.org=): go issues N =inbox-send= calls to the right inboxes with sources removed; skip leaves all in place; empty set = zero interaction; a candidate whose destination has =inbox/= but no =todo.org= still delivers. Spec: [[file:docs/design/wrapup-routing-spec.org]] (Acceptance criteria). + +*** TODO [#B] Wrap-up routing — manual end-to-end validation :test: +What we're verifying: a real keeper routes through a live wrap and the destination actually files it. +- In a project session, let process-inbox file a handoff whose home is a different project; confirm the local task carries =:ROUTE_CANDIDATE: <dest>=. +- Run wrap-it-up; at the router sub-step, confirm the candidate is surfaced with the right destination + confidence, then choose "go". +- Confirm a =from-<thisproject>= handoff landed in the destination's =inbox/= and the keeper was removed from the local =todo.org=. +- Open the destination project; confirm its startup/process-inbox files the handoff into its =todo.org= per its own conventions. +Expected: the task ends up in the destination's =todo.org=, gone from the source, with no foreign =todo.org= written directly. Not =:solo:= — needs a real cross-project wrap and the destination's next session. + +*** TODO [#D] Wrap-up routing — transcript filing (vNext) :feature: +File a meeting recording into the destination =assets/= per =working-files.md=, batch go/skip mirroring the task router. Gated on the source-location decision (spec D4). Spec: [[file:docs/design/wrapup-routing-spec.org]] (Phase 5). -** DOING [#C] Encourage org-roam KB contribution across workflows :feature: +** DONE [#C] Encourage org-roam KB contribution across workflows :feature: +CLOSED: [2026-06-20 Sat] :PROPERTIES: :CREATED: [2026-06-16 Tue] :END: @@ -212,8 +230,8 @@ Touches four synced template workflows and needs a curation pass on the best-pra *** 2026-06-16 Tue @ 00:53:36 -0500 Spec written for review Drafted [[file:docs/design/2026-06-16-encourage-kb-contribution-spec.org][the KB-contribution spec]]: four light workflow prompts (startup nudge, triage-intake + inbox-zero end-of-flow reminders, an early wrap-up reflection feeding the existing KB receipt) plus one Craig-authored best-practices node curated from Ahrens / Matuschak / org-roam guidance. Five open sub-decisions filed as decisions-as-TODO in the spec. -*** VERIFY Review the KB-contribution spec -Review [[file:docs/design/2026-06-16-encourage-kb-contribution-spec.org]] and ratify (or adjust) its five open decisions. Implementation-ready once no decision is still TODO. +*** 2026-06-20 Sat @ 23:29:10 -0400 Spec ratified + built +Craig ratified all five decisions (2026-06-20) and added D6 — a read-side startup consult-nudge surfacing project-relevant KB node titles, the counterpart the original write-only design lacked. Built all of it: the best-practices node (=~/org/roam/agents/20260620232112-agent-kb-best-practices.org=), startup's two Phase C nudges (consult + contribute, gated on the roam clone), the conditional capture reminders in triage-intake + inbox-zero, and the early wrap-up reflection feeding the existing receipt. Commits 76e5559 (workflows + spec) and the related lint checker f6dde4e. Trigger for the build: receipt data showed "promoted 0 / consulted no" across recent sessions. ** TODO [#C] Morning ops orchestrator pilot — read-only :feature: @@ -319,6 +337,24 @@ Craig's "your call" (2026-06-16) answered in [[file:docs/design/2026-06-16-auton *** VERIFY Review the autonomous-batch execution spec Review [[file:docs/design/2026-06-16-autonomous-batch-execution-spec.org]] (covers both this and Phase E) and ratify (or adjust) its six open decisions. Implementation-ready once no decision is still TODO. +** TODO [#C] ntfy phone channel as general two-way agent-comms :feature:spec: +:PROPERTIES: +:CREATED: [2026-06-20 Sat] +:END: +Proposal from the home project (2026-06-17): promote the self-hosted ntfy-over-Tailscale phone channel it built and verified on ratio into a general two-way agent-comms tool rulesets owns. Full proposal: [[file:docs/design/2026-06-17-ntfy-agent-comms-proposal.org]] (as-built runbook stays in the home project at =working/phone-notifications/spec.org=). What rulesets would decide: canonicalize =phone-notify= (send) plus a new =phone-recv= (check-since) as synced bin scripts; the per-machine config/secret convention (token in =~/.config/phone-notify/config= chmod 600 today, vs GPG-encrypted in dotfiles); a reference =ntfy-inbound-handler= plus systemd user-unit for event-driven delivery (Tier A subscriber routes inbound to inbox/notify, Tier B inbound spawns an agent session, Tier C notify a live session — harness research); approval-button workflows for the commits.md gates when Craig is away from the desk (tap-to-approve, the high-value concrete use); and the relationship to the retired cross-agent-comms scripts (ntfy may be the transport they lacked). Worked via =spec-create=. Blocks the triage-intake phone-push task below. + +** TODO [#C] Reconcile flashcard multi-tag tooling into canonical :chore: +:PROPERTIES: +:CREATED: [2026-06-20 Sat] +:END: +The work project edited two synced scripts locally as a stopgap (2026-06-17) and asked rulesets to fold them into the canonical so the next sync doesn't revert them. Preserved bundle: [[file:docs/design/2026-06-17-flashcard-multitag-note.md][note]], [[file:docs/design/2026-06-17-flashcard-multitag-to-anki.py][to-anki.py]], [[file:docs/design/2026-06-17-flashcard-multitag-stats.py][stats.py]]. Change: support a second org tag on drill headings (=:fundamental:drill:=) for curated subset decks. =flashcard-to-anki.py= — broaden =CARD_RE= to match a trailing tag block (a heading is a card when =drill= is among its tags), bound the card body by any L1/L2 heading, add =--tag-filter <tag>= (emit only cards carrying that tag) and =--guid-salt <s>= (separate GUID space so a subset deck imports non-empty without disturbing the full deck's SRS state). =flashcard-stats.py= — same =CARD_RE=/=HEADING_RE= broadening plus a drill-membership guard. Use the preserved to-anki.py (the 0953 version: dropped an unused =heading_tags()= helper, tightened =CARD_RE= =(.*?)=→=(.+?)= for parity with stats). Apply to both =.ai/scripts/= and =claude-templates/.ai/scripts/=, add a multi-tag bats case to =flashcard-sync.bats= (a =:foo:drill:= heading parses; =--tag-filter foo= returns only those), verify the full deck still parses to 465 and =--tag-filter fundamental= returns 100, then sync-check + make test. Shared-asset change, so review-gated. + +** TODO [#C] triage-intake.org auto mode — push each sweep to phone (ntfy) :feature: +:PROPERTIES: +:CREATED: [2026-06-20 Sat] +:END: +The work project (2026-06-18) added a "Push each sweep to Craig's phone (ntfy) — the primary delivery" subsection under "Trigger and delivery" in triage-intake.org auto mode, and asks to fold it into the canonical engine plus re-sync. Preserved bundle: [[file:docs/design/2026-06-18-triage-intake-phone-push-note.org][note]] + [[file:docs/design/2026-06-18-triage-intake-phone-push-workflow.org][edited workflow]]. Auto mode is the away-from-desk / vacation mode, so phone-notify becomes the primary delivery each sweep (fuller end-of-sweep output: per-source deltas, open-PR/Linear state, awaiting-ack list, one-line verdict, timestamp; SCAN FAILED banner on any source failure), plus phone-recv polling each sweep for Craig's replies. Falls back to inline when phone-notify is absent. Depends on the ntfy agent-comms task above (phone-notify/phone-recv must be canonicalized first). Shared template-workflow change, so review-gated. + ** TODO [#D] Build =create-documentation= skill for high-quality project/product docs :feature: :PROPERTIES: :LAST_REVIEWED: 2026-06-15 @@ -2680,7 +2716,8 @@ What we're verifying: the SessionStart(clear) hook fires and the fresh context r - In any project session with a live .ai/session-context.org (this rulesets session qualifies), type /clear - Send any short message (the injected context loads but the model waits for your next keystroke) Expected: the reply starts with "flushed." on its own line, restates the Active Goal and immediate Next Step, and does NOT run the startup workflow. -** 2026-06-12 Fri @ 02:56:58 -0500 New personal projects are home regroupings — no mechanism needed +** DONE New personal projects are home regroupings — no mechanism needed +CLOSED: [2026-06-12 Fri] Craig's call (2026-06-12): new personal projects will live in home, and there's no project-creation mechanism to build — he'll be working in home and simply decide to group some things differently. Nothing to do. Concurrence, verified: no template doc directs new personal work into ~/projects (first-session.org, install-ai.sh, and the README carry no such guidance; the only ~/projects references are discovery-root scans, which home and work still need). The situation as it stands: a new personal "project" is an area dir plus tasks inside home's existing =.ai/= machinery, no bootstrap step; =first-session.org= remains the bootstrap for standalone code projects in ~/code, unchanged and correct; "launch finances"-style trigger phrases for folded names degrade politely to the no-match candidate list, worth work only if real friction shows up. diff --git a/working/ai-dotfiles-discovery/proposed.diff b/working/ai-dotfiles-discovery/proposed.diff deleted file mode 100644 index f22c7a8..0000000 --- a/working/ai-dotfiles-discovery/proposed.diff +++ /dev/null @@ -1,7 +0,0 @@ ---- a/claude-templates/bin/ai -+++ b/claude-templates/bin/ai -@@ build_candidates() { - candidates=() - maybe_add_candidate "$HOME/.emacs.d" -+ maybe_add_candidate "$HOME/.dotfiles" - if [ -d "$HOME/code" ]; then diff --git a/working/ai-dotfiles-discovery/request.org b/working/ai-dotfiles-discovery/request.org deleted file mode 100644 index 3535daf..0000000 --- a/working/ai-dotfiles-discovery/request.org +++ /dev/null @@ -1,41 +0,0 @@ -#+TITLE: Add ~/.dotfiles to the ai launcher's project discovery -#+DATE: 2026-06-16 - -* Request - -Add =~/.dotfiles= as an explicit discovery candidate in the =ai= launcher (=claude-templates/bin/ai=), the same way =~/.emacs.d= is handled. - -* Why - -=~/.dotfiles= was migrated out of archsetup into a standalone stow repo and just got bootstrapped as its own AI project (=.ai/protocols.org= now present). But =build_candidates()= only scans three roots — =~/.emacs.d=, =~/code/*=, and =~/projects/*=. =~/.dotfiles= sits directly in =$HOME=, under none of them, so the fzf picker (bare =ai=) and the "launch dotfiles" by-name trigger never see it. Explicit =ai ~/.dotfiles= already works (=single_mode= only checks for the marker, not a discovery root), but the picker and by-name launch don't. - -This is the launcher half of making =~/.dotfiles= a first-class project. The inbox-send half is already handled downstream: =~/.dotfiles= was added to =~/.claude/inbox-roots.txt= (its config-file extension point), so =inbox-send= reaches it now. - -* The change - -In =claude-templates/bin/ai=, inside =build_candidates()= (currently ~line 89, right after the =~/.emacs.d= line), add: - -#+begin_src bash - maybe_add_candidate "$HOME/.dotfiles" -#+end_src - -So the head of =build_candidates= reads: - -#+begin_src bash -build_candidates() { - candidates=() - maybe_add_candidate "$HOME/.emacs.d" - maybe_add_candidate "$HOME/.dotfiles" - if [ -d "$HOME/code" ]; then - ... -#+end_src - -=maybe_add_candidate= already guards on =.ai/protocols.org=, so this is inert on any machine where =~/.dotfiles= isn't bootstrapped — same safety as the existing =~/.emacs.d= line. - -* Notes - -- I did NOT edit the rulesets canonical from archsetup's session — rulesets had a dirty tree and an interrupted session-context when I checked, so this goes through your own flow instead of interleaving. -- Worth a glance: if =bin/ai= has a discovery test that enumerates the expected candidate roots, it may need the new line added there too. -- Generalization to consider (your call, not requested): =~/.emacs.d= and =~/.dotfiles= are both single-project roots in =$HOME=. If more of these accrue, a small explicit list (or a =~/.claude/ai-roots.txt= mirroring =inbox-roots.txt=) would scale better than one =maybe_add_candidate= line each. Not needed for two. - -Sender: archsetup (2026-06-16 task-audit follow-through). |
