aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-24 07:00:48 -0400
committerCraig Jennings <c@cjennings.net>2026-06-24 07:00:48 -0400
commit06b6cbcf086729414ff9a533b1f031fb41c4088b (patch)
treefd9ce04b5b749567ebcb8bf65ce54e2bee5af7fb
parent0127889a41fc4f870def2982d822023e0dcb49dd (diff)
downloadrulesets-06b6cbcf086729414ff9a533b1f031fb41c4088b.tar.gz
rulesets-06b6cbcf086729414ff9a533b1f031fb41c4088b.zip
feat(tasks): make cross-project dependencies bidirectional
The :blocked: tag only marked the waiting side, so a blocker could stay unaware it was holding up another project: the dependency was visible to the one project that couldn't act on it and invisible to the one that could. This closes that gap. Setting :blocked: now requires a reciprocal inbox-send to the blocker, which files the work with a :BLOCKS: <project>: <what> property on its side. open-tasks.org surfaces :BLOCKS: tasks first, since clearing one unblocks another project (the highest-leverage pick), the mirror of pulling :blocked: tasks out of the cascade. Inbox process mode recognizes the blocking-dependency handoff shape, and the convention documents the resolution flow (drop :BLOCKS:, notify the waiter, who lifts :blocked:). This works for any project pair, since the convention (todo-format.md) and the surfacing (open-tasks.org) live in the shared rule and workflow layer, not in one project. Claude-Session: https://claude.ai/code/session_017PtX1nt1rtYVATuzmzBS4f
-rw-r--r--.ai/workflows/inbox.org2
-rw-r--r--.ai/workflows/open-tasks.org9
-rw-r--r--claude-rules/todo-format.md44
-rw-r--r--claude-templates/.ai/workflows/inbox.org2
-rw-r--r--claude-templates/.ai/workflows/open-tasks.org9
5 files changed, 60 insertions, 6 deletions
diff --git a/.ai/workflows/inbox.org b/.ai/workflows/inbox.org
index c442d17..f3d400a 100644
--- a/.ai/workflows/inbox.org
+++ b/.ai/workflows/inbox.org
@@ -114,6 +114,8 @@ The item extends a task already filed. Update the parent TODO's body with a date
** File as TODO
Substantive but waits, or needs design/triage before implementation. Add the TODO under =* <Project> Open Work= with priority + tags per the priority-scheme check (core §6). Body summarizes the proposal and links the inbox content if it's been moved to =docs/design/=. Delete the inbox file (or move it to =docs/design/= first if the content survives).
+*Blocking-dependency handoff.* A special shape: another project sends a note that *this* project's work is blocking one of theirs ("your task X is blocked on us — we need Y"). File or link the owning task and add a =:BLOCKS: <their-project>: <what>= property to it (see the cross-project dependency convention in =todo-format.md=). The =:BLOCKS:= marker makes =open-tasks.org= surface that task *first*, since clearing it unblocks the other project. Dedup against an existing task rather than filing a duplicate. When the work later lands, drop =:BLOCKS:= and notify the waiting project (=inbox-send <their-project> --text "Delivered: <what> — you're unblocked."=) so it can lift its own =:blocked:=.
+
** Defer
Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment line at the top: =# Deferred YYYY-MM-DD: <condition>=. Don't accumulate deferred items indefinitely — sweep them on a future process pass when the condition is met or the deferral has aged out.
diff --git a/.ai/workflows/open-tasks.org b/.ai/workflows/open-tasks.org
index 7f1bf1a..873fc0e 100644
--- a/.ai/workflows/open-tasks.org
+++ b/.ai/workflows/open-tasks.org
@@ -178,6 +178,8 @@ Apply the prioritization cascade in order. Stop at the first matching step. This
*Exclude blocked tasks.* A task tagged =:blocked:= has an unmet cross-project dependency (its =:BLOCKED_BY:= property names the project and the work owed, per =todo-format.md=). It can't be worked until that other project delivers, so it is *never* the cascade recommendation — skip it at every cascade step below. Blocked tasks are surfaced on their own in Step 3 so the stalled dependency stays visible instead of silently dropping out of view.
+*Surface blocking tasks first.* The mirror of the above: a task carrying a =:BLOCKS:= property is holding up work in *another* project (the property names which project and what's owed, per =todo-format.md=). Clearing it unblocks that project, so it carries borrowed urgency — surface it at the *top* of the cascade recommendation regardless of its own priority cookie, ahead of the normal In-Progress / deadline / priority order. When several =:BLOCKS:= tasks exist, lead with the one blocking the most, or the longest. This is the "do the thing that unblocks someone else first" rule; a =:BLOCKS:= task left at its own low priority is exactly how a cross-project dependency stalls.
+
**** 1. In-Progress Tasks
- Look for tasks marked =DOING= or partially complete.
- *If found:* Recommend that task (always finish what's started).
@@ -243,6 +245,9 @@ If no =:blocked:= tasks exist, omit this surface entirely (the common case).
Pair the cascade recommendation with the friction block beneath it, and the blocked-on-other-projects surface (Step 3) beneath that when any blocked task exists. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick.
#+begin_example
+Unblocks other projects (do these first):
+- ai-term wrap-teardown companion — :BLOCKS: rulesets (the three ai-term functions)
+
Cascade recommendation (importance/urgency):
- Fix org-noter reliability — [#A], Method 1, 8/18 complete, blocks daily reading/annotation
@@ -255,16 +260,20 @@ Blocked on other projects (can't advance until the blocker delivers):
- Wrap-teardown feature — blocked by emacsd: ai-term companion functions — nudge?
#+end_example
+The =:BLOCKS:= surface sits at the very top — clearing one of those is the highest-leverage thing on the list, since it frees work in another project. Omit it when no =:BLOCKS:= task exists (the common case).
+
Include for each row:
- Task name / description.
- Priority + tag cluster.
- One-line reasoning. For the cascade row, name which cascade step matched. For friction rows, an effort hint when one is obvious.
- Progress indicator (for V2MOM-structured todos) on the cascade row only.
+- For a =:BLOCKS:= row: the project it unblocks and what's owed (from the =:BLOCKS:= property).
- For a blocked row: the blocking project and what it owes (from =:BLOCKED_BY:=), plus the nudge offer.
**** Edge cases
- *Empty friction block.* If no =:quick:= or =:solo:= tagged tasks exist in the open set, omit the friction block entirely. Present only the cascade recommendation.
+- *No =:BLOCKS:= tasks.* Omit the "Unblocks other projects" surface entirely (the common case) — show it only when a task carries a =:BLOCKS:= property.
- *Dedupe.* If the cascade recommendation IS the same task as one of the friction rows (e.g. it's =:quick:solo:= and also won the cascade), show it once at the top with both labels. Don't list it twice.
- *Decline behavior.* If the user declines the cascade recommendation, drop straight to the friction block as the natural next prompt. Do not fall through to lower-cascade-tier tasks; the friction filter IS the override.
diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md
index 0bfd3d3..7bc7299 100644
--- a/claude-rules/todo-format.md
+++ b/claude-rules/todo-format.md
@@ -268,7 +268,11 @@ are noise that pollute his `cj:` greps.
A task can be blocked by work that has to happen in a *different project* — a rulesets task that can't finish until `.emacs.d` ships a companion function, say. Left unmarked, two things go wrong: the what's-next workflow keeps recommending the blocked task even though it can't move, and the blocker sits at low priority in the other project, so the dependency stalls silently.
-Mark the dependency with the `:blocked:` tag plus a `:BLOCKED_BY:` property:
+The dependency is tracked on *both* sides so neither the waiter nor the blocker loses sight of it: the waiting task carries `:blocked:` + `:BLOCKED_BY:`, and the blocking project gets a reciprocal handoff that becomes a `:BLOCKS:`-marked task on its side. This applies to *any* project pair — the convention here and the surfacing in `open-tasks.org` live in the shared rule + workflow layer, not in one project.
+
+### The waiting side — `:blocked:` + `:BLOCKED_BY:`
+
+Mark the dependent task with the `:blocked:` tag plus a `:BLOCKED_BY:` property:
```
** DOING [#B] Wrap-teardown feature :feature:blocked:
@@ -278,11 +282,39 @@ Mark the dependency with the `:blocked:` tag plus a `:BLOCKED_BY:` property:
Body...
```
-- The `:blocked:` tag on the heading is the filterable marker — it's what `open-tasks.org` reads to pull the task out of the "do this next" recommendation.
-- The `:BLOCKED_BY:` property names *which* project blocks the task and *what* that project owes, as `<project>: <what>`. The project token is the short basename (`emacsd`, `home`, `work`), matching the inbox/handoff naming.
+- The `:blocked:` tag on the heading is the filterable marker — `open-tasks.org` reads it to pull the task out of the "do this next" recommendation.
+- The `:BLOCKED_BY:` property names *which* project blocks the task and *what* it owes, as `<project>: <what>`. The project token is the short basename (`emacsd`, `home`, `work`), matching the inbox/handoff naming.
+
+### Registering with the blocker — the reciprocal handoff (required)
+
+Setting `:blocked:` is not complete until the blocking project knows it's blocking. The moment you add `:blocked:` + `:BLOCKED_BY: <project>: ...`, send `<project>` a dependency handoff:
+
+```
+inbox-send <project> --text "Blocking dependency: <this-project>'s task \"<task>\" is blocked on you — it needs <what>. It stays :blocked: until this lands. File it on your side as a task with :BLOCKS: <this-project>: <what> so it surfaces as priority work."
+```
+
+This is what closes the gap: without it, the blocker only learns it's blocking by accident. The handoff lands in `<project>`'s `inbox/` and its normal inbox processing files it (below). A `:blocked:` task with no matching reciprocal handoff is half-done — the dependency is invisible to the one project that can clear it. (Skip the send only when the blocker demonstrably already tracks the work, e.g. it's the same handoff that spawned the dependency; the blocker dedups against an existing task either way.)
+
+### The blocking side — `:BLOCKS:`
+
+When a project processes a blocking-dependency handoff (inbox process mode), it files or links the relevant task and marks it with the inverse property — a `:BLOCKS:` property naming the project + work it's holding up:
+
+```
+** TODO [#B] ai-term wrap-teardown companion :feature:
+:PROPERTIES:
+:BLOCKS: rulesets: wrap-teardown feature (the three ai-term functions)
+:END:
+```
+
+The blocking task does *not* carry `:blocked:` — it isn't blocked, it's the blocker. `:BLOCKS:` is a priority signal: `open-tasks.org` surfaces a `:BLOCKS:` task *first*, since clearing it unblocks work in another project, so a dependency that would otherwise stall at low priority gets pulled forward. This is the "surface dependencies first" half of the design.
+
+### Resolving the dependency
+
+When the blocker delivers:
-A task stays `:blocked:` only while the external dependency is genuinely outstanding. When the other project delivers, or the dependency dissolves, drop the `:blocked:` tag and the `:BLOCKED_BY:` property in the same edit — the task is workable again.
+1. The blocking project completes its `:BLOCKS:` task, drops the `:BLOCKS:` property, and notifies the waiter (`inbox-send <waiter> --text "Delivered: <what> — you're unblocked."`).
+2. The waiting project drops the `:blocked:` tag and `:BLOCKED_BY:` property in the same edit; the task is workable again. Either side noticing the delivery can lift its own marker — the notification just makes it prompt.
-`open-tasks.org` Next Mode surfaces `:blocked:` tasks in a dedicated "Blocked on other projects" section instead of the cascade, naming each blocker so a stalled cross-project dependency stays visible (and can be nudged via `inbox-send`) rather than rotting. See that workflow for the surfacing behavior.
+### Not the same as VERIFY
-This is distinct from `VERIFY`, which marks "waiting on Craig's input." `:blocked:` marks "waiting on another *project's* work." If Craig's input is what's needed, it's a VERIFY, not `:blocked:`.
+`:blocked:` marks "waiting on another *project's* work"; `VERIFY` marks "waiting on Craig's input." If Craig's input is what's needed, it's a VERIFY, not `:blocked:`. And `:BLOCKS:` only ever sits on the project that *owes* the work, never the one waiting.
diff --git a/claude-templates/.ai/workflows/inbox.org b/claude-templates/.ai/workflows/inbox.org
index c442d17..f3d400a 100644
--- a/claude-templates/.ai/workflows/inbox.org
+++ b/claude-templates/.ai/workflows/inbox.org
@@ -114,6 +114,8 @@ The item extends a task already filed. Update the parent TODO's body with a date
** File as TODO
Substantive but waits, or needs design/triage before implementation. Add the TODO under =* <Project> Open Work= with priority + tags per the priority-scheme check (core §6). Body summarizes the proposal and links the inbox content if it's been moved to =docs/design/=. Delete the inbox file (or move it to =docs/design/= first if the content survives).
+*Blocking-dependency handoff.* A special shape: another project sends a note that *this* project's work is blocking one of theirs ("your task X is blocked on us — we need Y"). File or link the owning task and add a =:BLOCKS: <their-project>: <what>= property to it (see the cross-project dependency convention in =todo-format.md=). The =:BLOCKS:= marker makes =open-tasks.org= surface that task *first*, since clearing it unblocks the other project. Dedup against an existing task rather than filing a duplicate. When the work later lands, drop =:BLOCKS:= and notify the waiting project (=inbox-send <their-project> --text "Delivered: <what> — you're unblocked."=) so it can lift its own =:blocked:=.
+
** Defer
Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment line at the top: =# Deferred YYYY-MM-DD: <condition>=. Don't accumulate deferred items indefinitely — sweep them on a future process pass when the condition is met or the deferral has aged out.
diff --git a/claude-templates/.ai/workflows/open-tasks.org b/claude-templates/.ai/workflows/open-tasks.org
index 7f1bf1a..873fc0e 100644
--- a/claude-templates/.ai/workflows/open-tasks.org
+++ b/claude-templates/.ai/workflows/open-tasks.org
@@ -178,6 +178,8 @@ Apply the prioritization cascade in order. Stop at the first matching step. This
*Exclude blocked tasks.* A task tagged =:blocked:= has an unmet cross-project dependency (its =:BLOCKED_BY:= property names the project and the work owed, per =todo-format.md=). It can't be worked until that other project delivers, so it is *never* the cascade recommendation — skip it at every cascade step below. Blocked tasks are surfaced on their own in Step 3 so the stalled dependency stays visible instead of silently dropping out of view.
+*Surface blocking tasks first.* The mirror of the above: a task carrying a =:BLOCKS:= property is holding up work in *another* project (the property names which project and what's owed, per =todo-format.md=). Clearing it unblocks that project, so it carries borrowed urgency — surface it at the *top* of the cascade recommendation regardless of its own priority cookie, ahead of the normal In-Progress / deadline / priority order. When several =:BLOCKS:= tasks exist, lead with the one blocking the most, or the longest. This is the "do the thing that unblocks someone else first" rule; a =:BLOCKS:= task left at its own low priority is exactly how a cross-project dependency stalls.
+
**** 1. In-Progress Tasks
- Look for tasks marked =DOING= or partially complete.
- *If found:* Recommend that task (always finish what's started).
@@ -243,6 +245,9 @@ If no =:blocked:= tasks exist, omit this surface entirely (the common case).
Pair the cascade recommendation with the friction block beneath it, and the blocked-on-other-projects surface (Step 3) beneath that when any blocked task exists. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick.
#+begin_example
+Unblocks other projects (do these first):
+- ai-term wrap-teardown companion — :BLOCKS: rulesets (the three ai-term functions)
+
Cascade recommendation (importance/urgency):
- Fix org-noter reliability — [#A], Method 1, 8/18 complete, blocks daily reading/annotation
@@ -255,16 +260,20 @@ Blocked on other projects (can't advance until the blocker delivers):
- Wrap-teardown feature — blocked by emacsd: ai-term companion functions — nudge?
#+end_example
+The =:BLOCKS:= surface sits at the very top — clearing one of those is the highest-leverage thing on the list, since it frees work in another project. Omit it when no =:BLOCKS:= task exists (the common case).
+
Include for each row:
- Task name / description.
- Priority + tag cluster.
- One-line reasoning. For the cascade row, name which cascade step matched. For friction rows, an effort hint when one is obvious.
- Progress indicator (for V2MOM-structured todos) on the cascade row only.
+- For a =:BLOCKS:= row: the project it unblocks and what's owed (from the =:BLOCKS:= property).
- For a blocked row: the blocking project and what it owes (from =:BLOCKED_BY:=), plus the nudge offer.
**** Edge cases
- *Empty friction block.* If no =:quick:= or =:solo:= tagged tasks exist in the open set, omit the friction block entirely. Present only the cascade recommendation.
+- *No =:BLOCKS:= tasks.* Omit the "Unblocks other projects" surface entirely (the common case) — show it only when a task carries a =:BLOCKS:= property.
- *Dedupe.* If the cascade recommendation IS the same task as one of the friction rows (e.g. it's =:quick:solo:= and also won the cascade), show it once at the top with both labels. Don't list it twice.
- *Decline behavior.* If the user declines the cascade recommendation, drop straight to the friction block as the natural next prompt. Do not fall through to lower-cascade-tier tasks; the friction filter IS the override.