Skip to content

Commit cfb6326

Browse files
cailmdaleyclaude
andcommitted
reopen --as-draft: closed cards reopen as paused drafts, not armed
Kanban placement family fix (Portolan kanban-ux-rework/placement-pipeline- invariants): the gestures that move a closed card onto a planning context mean 'plan this again,' not 'run it now,' so they must not write status:active (the sole dispatch gate — a naive reopen both misplaced the card and auto-spawned a worker). - shuttle-ctl: `reopen --as-draft` → status:open, verdict + closed-at cleared. - Actions mapping: closed + drafts → reopen-draft (all kinds, including a cyclical role's awaiting run — parking a role is not a compost verdict); closed + awaitingReview → close-awaiting-review (revoke verdict, stay in review — reopen used to arm it and snap it to In flight); open + drafts → pause (reopen used to arm a draft dropped back on Drafts from the timeline/stash). - Transition: reopen-draft shells `reopen --as-draft` offline. Portolan's no-snap-back property suite (KanbanGestures.property.test.ts) models this mapping clause-for-clause; change them together. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8074f4d commit cfb6326

7 files changed

Lines changed: 189 additions & 30 deletions

File tree

cmd/shuttle/lifecycle.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ until they are reopened.`,
285285
},
286286
}
287287

288+
var reopenAsDraft bool
289+
288290
var reopenCmd = &cobra.Command{
289291
Use: "reopen <fiber>",
290292
Short: "Requeue a closed or reviewed fiber back into active work",
@@ -293,7 +295,12 @@ card re-enters the in-flight loop. status:active is the sole dispatch gate
293295
(slice 5: no enabled flag).
294296
295297
This is the canonical reopen path for Kanban requeues from Awaiting review,
296-
Tempered, or Composted back to In flight.`,
298+
Tempered, or Composted back to In flight.
299+
300+
With --as-draft, sets status = open instead: the card reopens as a PAUSED
301+
DRAFT — visible on the board, never auto-dispatched. This is the verb behind
302+
the kanban's "drag a closed card onto Drafts / a future date / the stash"
303+
gestures, where the user means "plan this again," not "run it now."`,
297304
Args: cobra.ExactArgs(1),
298305
RunE: func(cmd *cobra.Command, args []string) error {
299306
path, _, _ := resolveFiber(args[0])
@@ -306,19 +313,23 @@ Tempered, or Composted back to In flight.`,
306313
return err
307314
}
308315

316+
status := "active"
317+
if reopenAsDraft {
318+
status = "open"
319+
}
309320
statusBefore := f.Status()
310-
f.SetStatus("active")
321+
f.SetStatus(status)
311322
f.SetTempered(nil)
312323
f.ClearClosedAt()
313324
if err := f.WriteBlock(f.Block); err != nil {
314325
return fmt.Errorf("writing fiber: %w", err)
315326
}
316327

317-
fmt.Printf("reopened %s (status: active)\n", args[0])
328+
fmt.Printf("reopened %s (status: %s)\n", args[0], status)
318329
if statusBefore == "" {
319-
fmt.Println(" status: active (set; was missing)")
320-
} else if statusBefore != "active" {
321-
fmt.Printf(" status: %s → active\n", statusBefore)
330+
fmt.Printf(" status: %s (set; was missing)\n", status)
331+
} else if statusBefore != status {
332+
fmt.Printf(" status: %s → %s\n", statusBefore, status)
322333
}
323334
fmt.Println(" cleared: tempered, closed-at")
324335
return nil
@@ -676,6 +687,8 @@ func init() {
676687
rootCmd.AddCommand(pauseCmd)
677688
rootCmd.AddCommand(resumeCmd)
678689
rootCmd.AddCommand(closeCmd)
690+
reopenCmd.Flags().BoolVar(&reopenAsDraft, "as-draft", false,
691+
"reopen to status: open (a paused draft, not auto-dispatched) instead of status: active")
679692
rootCmd.AddCommand(reopenCmd)
680693
rootCmd.AddCommand(setOutcomeCmd)
681694
rootCmd.AddCommand(acceptCmd)

cmd/shuttle/lifecycle_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,48 @@ Body.
14501450
}
14511451
}
14521452

1453+
func TestReopenCmd_AsDraftReopensToOpenNotArmed(t *testing.T) {
1454+
host, cleanup := withTempHost(t)
1455+
defer cleanup()
1456+
1457+
// --as-draft is the kanban's "plan this again" verb: a closed card dragged
1458+
// to Drafts / a future date / the stash reopens as a PAUSED DRAFT
1459+
// (status:open, verdict + closed-at cleared) — never status:active, which
1460+
// is the dispatch gate and would auto-spawn a worker (the slides
1461+
// snap-back; Portolan kanban-ux-rework/placement-pipeline-invariants).
1462+
path := writeFiber(t, host, "slides", `---
1463+
name: Slides
1464+
status: closed
1465+
tempered: true
1466+
closed-at: 2026-06-01T09:30:00Z
1467+
shuttle:
1468+
kind: oneshot
1469+
---
1470+
1471+
Body.
1472+
`)
1473+
1474+
reopenAsDraft = true
1475+
defer func() { reopenAsDraft = false }()
1476+
if err := reopenCmd.RunE(reopenCmd, []string{"slides"}); err != nil {
1477+
t.Fatalf("RunE: %v", err)
1478+
}
1479+
1480+
text := readFiberText(t, path)
1481+
if !strings.Contains(text, "status: open") {
1482+
t.Fatalf("expected status: open after reopen --as-draft:\n%s", text)
1483+
}
1484+
if strings.Contains(text, "status: active") {
1485+
t.Fatalf("reopen --as-draft must NOT arm the dispatch gate:\n%s", text)
1486+
}
1487+
if strings.Contains(text, "tempered") {
1488+
t.Fatalf("reopen --as-draft should clear the verdict:\n%s", text)
1489+
}
1490+
if strings.Contains(text, "closed-at") || strings.Contains(text, "closed_at") {
1491+
t.Fatalf("reopen --as-draft should clear closed-at:\n%s", text)
1492+
}
1493+
}
1494+
14531495
func TestCloseCmd_OneshotWritesNoReviewBlock(t *testing.T) {
14541496
host, cleanup := withTempHost(t)
14551497
defer cleanup()

lib/shuttle/actions.ex

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ defmodule Shuttle.Actions do
1010
@type action_id ::
1111
:pause
1212
| :reopen
13+
| :reopen_draft
1314
| :accept_run
1415
| :dispatch_ad_hoc
1516
| :close_awaiting_review
1617
| :close_tempered
1718
| :close_composted
1819

1920
@transition_targets ~w(drafts inFlight queued active awaitingReview tempered composted)
20-
@action_ids ~w(pause reopen accept-run dispatch-ad-hoc close-awaiting-review close-tempered close-composted)
21+
@action_ids ~w(pause reopen reopen-draft accept-run dispatch-ad-hoc close-awaiting-review close-tempered close-composted)
2122

2223
@spec actions_for(map(), boolean()) :: [map()]
2324
def actions_for(fiber, running? \\ false) when is_map(fiber) do
@@ -97,8 +98,10 @@ defmodule Shuttle.Actions do
9798
# pending a human verdict" — felt-native, no `review.state`. Only a
9899
# STANDING role re-arms (a oneshot closes for good). The verdict gestures:
99100
# inFlight/tempered = keep it (accept-run → advance the schedule),
100-
# drafts/composted = reject (close-composted), awaitingReview = no-op (a
101-
# same-column drop stays in review). This clause MUST precede the generic
101+
# composted = reject (close-composted), drafts = park the role as a
102+
# paused draft (reopen-draft — stopping a role for now is not the same
103+
# verdict as composting it), awaitingReview = no-op (a same-column drop
104+
# stays in review). This clause MUST precede the generic
102105
# `status == "closed"` clauses below — those resolve a closed fiber to
103106
# reopen/close, which would TERMINATE the role instead of re-arming it
104107
# (the slice-1 entanglement). Tempered-true and composted (tempered:false)
@@ -108,24 +111,36 @@ defmodule Shuttle.Actions do
108111
:accept_run
109112

110113
status == "closed" and cyclical?(shuttle) and untempered?(fiber) and
111-
target in ["drafts", "composted"] ->
114+
target == "composted" ->
112115
:close_composted
113116

114117
status == "closed" and cyclical?(shuttle) and untempered?(fiber) and
115118
target == "awaitingReview" ->
116119
:close_awaiting_review
117120

118-
# A closed fiber's only forward move is reopen (which clears closed_at /
119-
# tempered); shuttle-ctl refuses a direct pause/close on an already-closed
120-
# fiber. Portolan's un-temper sequence drags through inFlight first for
121-
# the other open-lifecycle targets, so every non-close target collapses to
122-
# reopen here. The close columns re-close with the chosen verdict. This
123-
# group now catches oneshot termini and tempered/composted standing
124-
# termini; the awaiting (closed + untempered + standing) case is handled
125-
# above and never reaches here.
126-
status == "closed" and target in ["drafts", "inFlight", "awaitingReview"] ->
121+
# A closed fiber dragged to Drafts reopens AS A DRAFT (status:open,
122+
# verdict cleared — `shuttle reopen --as-draft`): the gesture means
123+
# "plan this again," not "run it now," so it must not arm the dispatch
124+
# gate. This is also the park-as-draft verb the kanban composes before
125+
# a planning-surface drop (timeline date / stash) on a closed card —
126+
# the slides snap-back fix (see Portolan
127+
# kanban-ux-rework/placement-pipeline-invariants).
128+
status == "closed" and target == "drafts" ->
129+
:reopen_draft
130+
131+
# inFlight is the one closed-card target that ARMS: reopen → active.
132+
# (The kanban actually launches via force-dispatch, which reopens en
133+
# route; this mapping keeps the bare transition coherent.)
134+
status == "closed" and target == "inFlight" ->
127135
:reopen
128136

137+
# Dragging a tempered/composted card back to Awaiting review revokes the
138+
# verdict but keeps the card closed: re-close with tempered cleared.
139+
# (Resolving this to reopen — as it once did — armed the card and
140+
# snapped it to In flight instead of Awaiting review.)
141+
status == "closed" and target == "awaitingReview" ->
142+
:close_awaiting_review
143+
129144
status == "closed" and target == "tempered" ->
130145
:close_tempered
131146

@@ -134,9 +149,13 @@ defmodule Shuttle.Actions do
134149

135150
# A draft (`status: open`) is paused/not-yet-armed (slice 5: status is the
136151
# sole gate, no enabled flag). Dragging it out of drafts arms it via
137-
# reopen (→ status:active); a drop back in drafts is a no-op pause. The
138-
# close columns close it with the chosen verdict.
139-
status == "open" and target in ["drafts", "inFlight"] ->
152+
# reopen (→ status:active). A drop ON drafts (reachable from the timeline
153+
# / stash surfaces) parks it — pause, an idempotent no-op for an open
154+
# fiber — never reopen, which would arm and snap the card to In flight.
155+
status == "open" and target == "drafts" ->
156+
:pause
157+
158+
status == "open" and target == "inFlight" ->
140159
:reopen
141160

142161
# An armed fiber (`status: active`). drafts parks it (pause → status:open).
@@ -166,6 +185,7 @@ defmodule Shuttle.Actions do
166185

167186
defp invocation(:pause), do: %{verb: "pause"}
168187
defp invocation(:reopen), do: %{verb: "reopen"}
188+
defp invocation(:reopen_draft), do: %{verb: "reopen", as_draft: true}
169189
defp invocation(:accept_run), do: %{verb: "accept"}
170190
defp invocation(:dispatch_ad_hoc), do: %{verb: "dispatch", ad_hoc: true}
171191
defp invocation(:close_awaiting_review), do: %{verb: "close"}

lib/shuttle/transition.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,13 @@ defmodule Shuttle.Transition do
144144

145145
defp invoke_action(fiber_id, "reopen", host), do: run_offline(["reopen", fiber_id], host)
146146

147+
# reopen-draft: status:open + verdict cleared — a paused draft, NOT armed.
148+
# The kanban's "drag a closed card to Drafts" verb, and the park-as-draft
149+
# half it composes before a planning-surface drop on a closed card (the
150+
# slides snap-back fix; see Portolan kanban-ux-rework/placement-pipeline-invariants).
151+
defp invoke_action(fiber_id, "reopen-draft", host),
152+
do: run_offline(["reopen", fiber_id, "--as-draft"], host)
153+
147154
# accept-run goes through the in-process lifecycle path so the felt-document
148155
# re-arm happens atomically against poll cycles, not the Go `shuttle-ctl
149156
# accept` (which can race a concurrent poll's document read).

lib/shuttle_web/controllers/lifecycle_controller.ex

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ defmodule ShuttleWeb.LifecycleController do
2121

2222
alias Shuttle.{FeltStores, LifecycleService, OriginRouter}
2323

24-
@allowed ~w(install pause resume repeat pin accept set-model set-outcome uninstall)
24+
@allowed ~w(install pause resume repeat pin accept set-model set-agent set-outcome uninstall)
2525

2626
def create(conn, params) do
2727
case OriginRouter.route(Map.get(params, "origin")) do
@@ -87,7 +87,7 @@ defmodule ShuttleWeb.LifecycleController do
8787
end
8888

8989
defp execute(action, %{"fiber" => fiber} = params)
90-
when action in ~w(pause set-model set-outcome uninstall) do
90+
when action in ~w(pause set-model set-agent set-outcome uninstall) do
9191
with {:ok, fiber_id} <- fiber_address(fiber) do
9292
action
9393
|> args_for(%{params | "fiber" => fiber_id})
@@ -156,6 +156,31 @@ defmodule ShuttleWeb.LifecycleController do
156156
defp args_for("set-model", %{"fiber" => fiber, "agent" => agent}),
157157
do: {:ok, ["set-model", fiber, agent]}
158158

159+
# set-agent composes base agent × effort × chrome in one validated write.
160+
# The agent positional is optional (omit to mutate only axes); effort and
161+
# chrome are forwarded only when present so an unspecified axis is left
162+
# untouched. Chrome renders `--chrome` / `--chrome=false` (cobra reads
163+
# Changed), and effort passes through verbatim — including `--effort ""` to
164+
# clear back to the harness default.
165+
defp args_for("set-agent", %{"fiber" => fiber} = params) do
166+
args = ["set-agent", fiber]
167+
args = if(is_binary(params["agent"]) and params["agent"] != "", do: args ++ [params["agent"]], else: args)
168+
169+
args =
170+
case params do
171+
%{"effort" => effort} when is_binary(effort) -> args ++ ["--effort", effort]
172+
_ -> args
173+
end
174+
175+
args =
176+
case params do
177+
%{"chrome" => chrome} when is_boolean(chrome) -> args ++ ["--chrome=#{chrome}"]
178+
_ -> args
179+
end
180+
181+
{:ok, args}
182+
end
183+
159184
# The outcome string round-trips as a single argv element, so multi-line
160185
# values (block scalars) survive without stdin piping. set-outcome refuses a
161186
# block-less fiber and runs `ensure_owned_here`, so a misrouted edit surfaces

test/shuttle/actions_test.exs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ defmodule Shuttle.ActionsTest do
3838
assert {:ok, %{id: "close-composted", invocation: %{verb: "close", tempered: false}}} =
3939
Actions.resolve_transition(fiber, "composted")
4040

41-
assert {:ok, %{id: "close-composted"}} = Actions.resolve_transition(fiber, "drafts")
41+
# Drafts parks the role as a paused draft — a "stop for now," not a
42+
# compost verdict (reopen-draft → status:open, never armed).
43+
assert {:ok, %{id: "reopen-draft", invocation: %{verb: "reopen", as_draft: true}}} =
44+
Actions.resolve_transition(fiber, "drafts")
4245

4346
assert {:ok, %{id: "close-awaiting-review"}} =
4447
Actions.resolve_transition(fiber, "awaitingReview")
@@ -229,11 +232,18 @@ defmodule Shuttle.ActionsTest do
229232
assert {:ok, %{id: "close-composted", invocation: %{verb: "close", tempered: false}}} =
230233
Actions.resolve_transition(fiber, "composted")
231234

232-
# The open-lifecycle columns still reopen (clears closed_at / tempered);
233-
# accept-run must NOT appear for a closed role that already has a verdict.
234-
for target <- ~w(drafts inFlight awaitingReview) do
235-
assert {:ok, %{id: "reopen"}} = Actions.resolve_transition(fiber, target)
236-
end
235+
# The open-lifecycle columns clear the verdict, each with the meaning of
236+
# its own column: inFlight arms (reopen → active), drafts parks as a
237+
# paused draft (reopen-draft → open), awaitingReview re-closes with the
238+
# verdict cleared (back to review). accept-run must NOT appear for a
239+
# closed role that already has a verdict.
240+
assert {:ok, %{id: "reopen"}} = Actions.resolve_transition(fiber, "inFlight")
241+
242+
assert {:ok, %{id: "reopen-draft", invocation: %{verb: "reopen", as_draft: true}}} =
243+
Actions.resolve_transition(fiber, "drafts")
244+
245+
assert {:ok, %{id: "close-awaiting-review"}} =
246+
Actions.resolve_transition(fiber, "awaitingReview")
237247

238248
available = Actions.actions_for(fiber) |> Enum.map(& &1.id)
239249
refute "accept-run" in available

test/shuttle_web/controllers/lifecycle_controller_test.exs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,48 @@ defmodule ShuttleWeb.LifecycleControllerTest do
109109
"set-outcome\ntests/outcome-edit\n--outcome\nBlocked: waiting on ADS token\nsecond line\n"
110110
end
111111

112+
# set-agent composes base agent × effort × chrome in one validated write.
113+
# The agent positional is optional and the axes ride as flags; chrome always
114+
# renders explicitly (`--chrome=true|false`) so a toggle-off is unambiguous,
115+
# and effort passes through verbatim.
116+
test "set-agent forwards agent plus effort and chrome axes to shuttle-ctl" do
117+
root =
118+
System.tmp_dir!()
119+
|> Path.join("shuttle-lifecycle-set-agent-#{System.unique_integer([:positive])}")
120+
121+
store = Path.join(root, "loom")
122+
fiber_dir = Path.join([store, ".felt", "tests", "axes-edit"])
123+
File.mkdir_p!(fiber_dir)
124+
File.write!(Path.join(fiber_dir, "axes-edit.md"), "---\nname: Axes edit\n---\n\n")
125+
126+
args_file = install_fake_shuttle_ctl!()
127+
old_loom_homes = System.get_env("LOOM_HOMES")
128+
System.put_env("LOOM_HOMES", store)
129+
130+
on_exit(fn ->
131+
restore_env("LOOM_HOMES", old_loom_homes)
132+
File.rm_rf(root)
133+
end)
134+
135+
conn =
136+
post(
137+
api_conn(),
138+
"/api/v1/lifecycle",
139+
Jason.encode!(%{
140+
"action" => "set-agent",
141+
"fiber" => "tests/axes-edit",
142+
"agent" => "claude-opus",
143+
"effort" => "xhigh",
144+
"chrome" => true
145+
})
146+
)
147+
148+
assert conn.status == 200
149+
150+
assert File.read!(args_file) ==
151+
"set-agent\ntests/axes-edit\nclaude-opus\n--effort\nxhigh\n--chrome=true\n"
152+
end
153+
112154
test "accept for standing roles re-arms from the doc and evicts runtime frontmatter" do
113155
root =
114156
System.tmp_dir!()

0 commit comments

Comments
 (0)