Skip to content

Commit 0c2bd80

Browse files
committed
feat: spinoff subcommand — spawn linked child changes from discoveries (#9)
1 parent b6f7136 commit 0c2bd80

25 files changed

Lines changed: 1322 additions & 118 deletions

File tree

.claude/skills/specsync/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ specsync [-dry-run] [-change <change>] [-repo owner/name] [-reconcile=false] [-c
2828
- `-close-completed` (default false): keep tracker open/closed state aligned with completion. Completing every task closes the item; adding new unchecked work reopens it. Without the flag, completion updates `stage:complete` but leaves tracker state alone. An explicit `.status` overrides task-derived stage, so only `.status` value `complete` closes with this flag.
2929
- `-repo owner/name`: override auto-detected repo from `git remote`.
3030

31+
**Task states.** Beyond `[ ]` (todo) and `[x]` (done), tasks.md supports `[~]` (dropped — superseded or removed) and `[>]` (moved — relocated to another change). Dropped and moved tasks are excluded from progress; only todo and done are "live". The issue body shows a "Plan changes" footer with the breakdown: `+3 added · 2 done · 1 dropped · 1 moved`.
32+
33+
**Discoveries.** Capture findings during implementation without scope decisions. `specsync note -change <slug> "<text>"` appends a line to `discoveries.md`, rendered as `## Discoveries` in the issue. On pull, the section is stripped (like `## Tasks`). The original issue body is saved as `original-ask.md` on first pull (write-once, never overwritten) and rendered as `## Original ask`.
34+
3135
**Lifecycle stages.** specsync labels each issue `stage:<stage>`. The stage is derived automatically: `active` while any task is unchecked, `complete` once every task is checked (before archiving), and `archived` once the change moves under `changes/archive/`. A `.status` file in the change folder overrides the derived stage. This means finishing the last task flips the issue out of `stage:active` on the next sync — no manual bookkeeping.
3236

3337
### Pull an issue into a local change
@@ -70,6 +74,15 @@ specsync link [-dry-run] [-openspec <dir>] <change1> <change2> [<change3>...]
7074

7175
At least 2 changes required. Writes `links.md` into each change directory and syncs them so a "## Related" section appears in each GitHub issue.
7276

77+
### Spin off emergent work
78+
79+
```
80+
specsync spinoff -from <slug> -task <n> [-kind bug|followup|task] [-repo owner/name] [-change <slug>] [-dry-run]
81+
specsync spinoff -from <slug> -text "<discovery>" [-kind bug|followup|task] [-repo owner/name] [-change <slug>] [-dry-run]
82+
```
83+
84+
Spawns a new linked change from a discovery, keeping the parent scoped. `-task <n>` extracts text from the nth task line in the parent's `tasks.md` and marks it as moved (`[>] moved: <child-slug>`). `-text` provides free-form discovery text. `-kind` sets an issue label on the child. `-repo` for cross-repo spawn. `-change` overrides the auto-derived slug. The child `proposal.md` is seeded with the discovery text and a provenance line linking to the parent.
85+
7386
### Inspect release impact
7487

7588
```

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,15 @@ specsync pull # pull an issue into a local change
9191
specsync scan # what already exists in an area?
9292
specsync trace # print the raw spec<->commit<->issue link graph
9393
specsync link # cross-link two or more changes
94+
specsync spinoff # spawn emergent work as a linked sibling
9495
specsync release-plan # shipped changes + advisory semver bump
9596
specsync changelog # Keep a Changelog section from shipped changes
9697
specsync audit # archived changes vs. merged PRs
9798
specsync install-skill # install the bundled agent skill
9899
specsync version # print the binary version
99100
```
100101

101-
**Dry-run flags**`sync`, `pull`, and `link` support `-dry-run`. Beads can be
102+
**Dry-run flags**`sync`, `pull`, `link`, and `spinoff` support `-dry-run`. Beads can be
102103
previewed through `specsync -dry-run -provider beads`. `scan`, `trace`,
103104
`release-plan`, and `changelog` (unless `-apply`) are read-only and do not take
104105
a dry-run flag.
@@ -156,6 +157,20 @@ specsync link -dry-run slug-a slug-b # preview links.md + Related sections
156157
specsync link slug-a slug-b # write links and update both issues
157158
```
158159

160+
### `spinoff` — spawn emergent work as a linked sibling
161+
162+
When a discovery doesn't belong in the current change, spin it off into its own
163+
scoped change instead of scope-creep:
164+
165+
```bash
166+
specsync spinoff -from my-change -task 3 -kind bug # from task 3
167+
specsync spinoff -from my-change -text "fix X" -dry-run # free text
168+
```
169+
170+
Extracts text from the parent's task (or uses `-text`), scaffolds a new change
171+
with a seeded `proposal.md`, marks the parent task as moved (`[>] moved: <child>`),
172+
and links the two.
173+
159174
### `trace` — the raw link graph
160175

161176
Prints the resolved trace graph — changes, commits, issues, and the links
@@ -338,6 +353,20 @@ WorkItem -> issue (via a pluggable provider)
338353
reverting un-pushed local progress. Spec still wins task *wording* and order;
339354
only the checkbox flips. Disable with `-reconcile=false`. Dry runs never read
340355
or write, so reconcile applies only on real syncs.
356+
- **Task states** — beyond `[ ]` (todo) and `[x]` (done), tasks.md supports
357+
`[~]` (dropped — superseded or removed) and `[>]` (moved — relocated to
358+
another change). Dropped and moved tasks are excluded from progress
359+
calculations; only todo and done are "live". A compact "Plan changes" footer
360+
in the issue body shows the breakdown: `+3 added · 2 done · 1 dropped · 1
361+
moved`.
362+
- **Discoveries** — a `## Discoveries` section captures findings during
363+
implementation without requiring scope decisions. Use `specsync note -change
364+
<slug> "<text>"` to append a discovery. The section is rendered into the
365+
issue and stripped on pull, keeping local and tracker state in sync.
366+
- **Original ask** — when you `specsync pull` an issue, the original body is
367+
saved as `original-ask.md` (write-once, never overwritten). It renders as
368+
`## Original ask` in the issue body, providing a stable reference to what was
369+
requested before scope evolved.
341370
- **Stage** — each issue gets a `stage:<stage>` label, derived automatically:
342371
`active` while any task is unchecked, `complete` once every task is checked
343372
(before archiving), and `archived` once the change moves under

audit.go

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package specsync
22

33
import (
4-
"bufio"
54
"context"
65
"fmt"
76
"os"
@@ -155,19 +154,19 @@ func (r AuditResult) HasUnmerged() bool {
155154

156155
// TaskAuditFinding represents the result of auditing a single change's tasks.
157156
type TaskAuditFinding struct {
158-
Slug string // change slug
159-
Unchecked int // number of unchecked tasks
160-
Total int // total number of tasks
161-
HasCode bool // whether code files reference this change
162-
CodeRefs int // number of code references found
163-
Progress string // task progress string
164-
Stage string // current stage
165-
Note string // additional note (e.g., "spun off", "external repo")
157+
Slug string // change slug
158+
Unchecked int // number of unchecked tasks
159+
Total int // total number of tasks
160+
HasCode bool // whether code files reference this change
161+
CodeRefs int // number of code references found
162+
Progress string // task progress string
163+
Stage string // current stage
164+
Note string // additional note (e.g., "spun off", "external repo")
166165
}
167166

168167
// TaskAuditResult holds the findings from a task audit run.
169168
type TaskAuditResult struct {
170-
Findings []TaskAuditFinding
169+
Findings []TaskAuditFinding
171170
Mismatches []TaskAuditFinding // subset: unchecked tasks but code exists
172171
}
173172

@@ -177,21 +176,21 @@ func AuditTasks(changes []Change) TaskAuditResult {
177176
var result TaskAuditResult
178177

179178
for _, c := range changes {
180-
unchecked, total := countTasks(c.TasksMarkdown)
181-
if total == 0 {
179+
tc := countTaskStates(c.TasksMarkdown)
180+
if tc.LiveTotal() == 0 {
182181
continue
183182
}
184183

185184
finding := TaskAuditFinding{
186185
Slug: c.Slug,
187-
Unchecked: unchecked,
188-
Total: total,
186+
Unchecked: tc.Todo,
187+
Total: tc.LiveTotal(),
189188
Progress: string(c.Progress),
190189
Stage: string(c.Stage),
191190
}
192191

193192
// Check for implementation evidence
194-
if unchecked > 0 {
193+
if tc.Todo > 0 {
195194
if hasImplementationEvidence(c.Dir) {
196195
finding.HasCode = true
197196
finding.CodeRefs = 1
@@ -201,7 +200,7 @@ func AuditTasks(changes []Change) TaskAuditResult {
201200
result.Findings = append(result.Findings, finding)
202201

203202
// Flag as mismatch: unchecked tasks but code exists
204-
if unchecked > 0 && finding.HasCode {
203+
if tc.Todo > 0 && finding.HasCode {
205204
result.Mismatches = append(result.Mismatches, finding)
206205
}
207206
}
@@ -215,20 +214,6 @@ func (r TaskAuditResult) HasMismatches() bool {
215214
}
216215

217216
// countTasks returns (unchecked, total) from tasks.md markdown.
218-
func countTasks(markdown string) (unchecked, total int) {
219-
scan := bufio.NewScanner(strings.NewReader(markdown))
220-
for scan.Scan() {
221-
line := scan.Text()
222-
trimmed := strings.TrimSpace(line)
223-
if strings.HasPrefix(trimmed, "- [ ]") {
224-
unchecked++
225-
total++
226-
} else if strings.HasPrefix(trimmed, "- [x]") {
227-
total++
228-
}
229-
}
230-
return unchecked, total
231-
}
232217

233218
// hasImplementationEvidence checks for strong evidence that code was written
234219
// for this change. Relies on .specsync/metadata.json with stage "complete"

beads.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ func (p *BeadsProvider) create(ctx context.Context, title, description, parent s
254254
// epicDescription is the proposal body with the identity marker appended — the
255255
// inverse of what splitBody strips on the way back in.
256256
func (p *BeadsProvider) epicDescription(item WorkItem) string {
257-
proposal, _, _ := splitBody(item.Body, item.Title)
257+
proposal, _, _, _, _ := splitBody(item.Body, item.Title)
258258
return strings.TrimSpace(proposal) + "\n\n" + marker(item.Slug)
259259
}
260260

@@ -269,7 +269,7 @@ type taskState struct {
269269
// WorkItem body, reusing the same body splitter and task-line parser the
270270
// reconcile path uses so child-bead titles match exactly what reconcile keys on.
271271
func itemTasks(item WorkItem) []taskState {
272-
_, tasks, _ := splitBody(item.Body, item.Title)
272+
_, tasks, _, _, _ := splitBody(item.Body, item.Title)
273273
var out []taskState
274274
for _, line := range strings.Split(tasks, "\n") {
275275
if text, checked, ok := parseTaskLine(line); ok {

change.go

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,57 @@ const (
2020
TaskProgressComplete TaskProgress = "complete" // N/N tasks complete
2121
)
2222

23+
// TaskState represents the state of a single task in tasks.md.
24+
// [ ] and [x] are live tasks that count toward progress.
25+
// [~] dropped tasks are superseded or removed — not counted.
26+
// [>] moved tasks have been migrated to another change — not counted.
27+
type TaskState int
28+
29+
const (
30+
TaskStateTodo TaskState = iota // [ ]
31+
TaskStateDone // [x]
32+
TaskStateDropped // [~]
33+
TaskStateMoved // [>]
34+
)
35+
36+
func (s TaskState) String() string {
37+
switch s {
38+
case TaskStateTodo:
39+
return "todo"
40+
case TaskStateDone:
41+
return "done"
42+
case TaskStateDropped:
43+
return "dropped"
44+
case TaskStateMoved:
45+
return "moved"
46+
default:
47+
return "unknown"
48+
}
49+
}
50+
51+
// TaskCounts summarizes the task states in a tasks.md file.
52+
type TaskCounts struct {
53+
Todo int
54+
Done int
55+
Dropped int
56+
Moved int
57+
}
58+
59+
// Total returns the total number of tasks including dropped and moved.
60+
func (c TaskCounts) Total() int {
61+
return c.Todo + c.Done + c.Dropped + c.Moved
62+
}
63+
64+
// LiveTotal returns the total number of live tasks (todo + done, excludes dropped/moved).
65+
func (c TaskCounts) LiveTotal() int {
66+
return c.Todo + c.Done
67+
}
68+
69+
// IsComplete returns true when all live tasks are done.
70+
func (c TaskCounts) IsComplete() bool {
71+
return c.LiveTotal() > 0 && c.Todo == 0
72+
}
73+
2374
// Stage is the workflow placement of a change. It is distinct from task progress.
2475
// Workflow stage can be explicitly set via .specsync/metadata.json or derived
2576
// from tasks/location.
@@ -69,9 +120,10 @@ func CanonicalStageOrder() []Stage {
69120

70121
// ChangeMetadata holds shared workflow metadata from .specsync/metadata.json.
71122
type ChangeMetadata struct {
72-
Version int `json:"version"`
73-
Stage *Stage `json:"stage,omitempty"`
74-
Priority *int `json:"priority,omitempty"`
123+
Version int `json:"version"`
124+
Stage *Stage `json:"stage,omitempty"`
125+
Priority *int `json:"priority,omitempty"`
126+
BaselineTaskCount *int `json:"baselineTaskCount,omitempty"`
75127
}
76128

77129
// StageSource indicates how the current stage was derived.
@@ -94,11 +146,14 @@ type Change struct {
94146
Body string // proposal.md contents
95147
TasksMarkdown string // tasks.md contents, may be ""
96148
Links []Ref // resolved related issue refs from links.md
149+
OriginalAsk string // original-ask.md contents, may be ""
150+
Discoveries string // discoveries.md contents, may be ""
97151
Archived bool
98152
Progress TaskProgress // what the task checklist says
99153
Stage Stage // current workflow placement
100154
StageSource StageSource // how we arrived at Stage (default/tasks/metadata/legacy-status/folder)
101155
Priority *int // optional 1-100; nil if unset
156+
BaselineTasks *int // task count at time of pull; nil if unknown
102157
}
103158

104159
// LoadChanges reads every change under <openspecDir>/changes, including those
@@ -169,6 +224,16 @@ func LoadChange(dir string, archived bool, openspecDir string) (*Change, error)
169224
if tasks, err := os.ReadFile(filepath.Join(dir, "tasks.md")); err == nil {
170225
c.TasksMarkdown = string(tasks)
171226
}
227+
if ask, err := os.ReadFile(filepath.Join(dir, "original-ask.md")); err == nil {
228+
c.OriginalAsk = string(ask)
229+
}
230+
if disc, err := os.ReadFile(filepath.Join(dir, "discoveries.md")); err == nil {
231+
c.Discoveries = string(disc)
232+
}
233+
// Load baseline task count from metadata.json (set on pull).
234+
if meta, err := LoadChangeMetadata(dir); err == nil && meta != nil && meta.BaselineTaskCount != nil {
235+
c.BaselineTasks = meta.BaselineTaskCount
236+
}
172237

173238
if err := refreshState(c); err != nil {
174239
return nil, fmt.Errorf("load state for %s: %w", slug, err)
@@ -308,36 +373,15 @@ func refFromURL(url string) *Ref {
308373
// the "- [ ]"/"- [x]" lines reconcile does — other checkbox markers (living
309374
// plan's [~]/[>]) and prose are ignored. An empty or task-less list is not
310375
// "complete": there is nothing to have finished.
311-
// CountCheckboxes returns (total, completed) checkbox counts from markdown.
376+
// CountCheckboxes returns (liveTotal, completed) checkbox counts from markdown.
377+
// Only [ ] and [x] tasks are counted; [~] (dropped) and [>] (moved) are excluded.
312378
func CountCheckboxes(md string) (total, completed int) {
313-
if strings.TrimSpace(md) == "" {
314-
return 0, 0
315-
}
316-
for _, line := range strings.Split(md, "\n") {
317-
if _, checked, ok := parseTaskLine(line); ok {
318-
total++
319-
if checked {
320-
completed++
321-
}
322-
}
323-
}
324-
return total, completed
379+
c := countTaskStates(md)
380+
return c.LiveTotal(), c.Done
325381
}
326382

327383
func tasksComplete(md string) bool {
328-
if strings.TrimSpace(md) == "" {
329-
return false
330-
}
331-
any := false
332-
for _, line := range strings.Split(md, "\n") {
333-
if _, checked, ok := parseTaskLine(line); ok {
334-
if !checked {
335-
return false
336-
}
337-
any = true
338-
}
339-
}
340-
return any
384+
return countTaskStates(md).IsComplete()
341385
}
342386

343387
// refreshStage derives lifecycle from the change's current task state, then

cmd/specsync/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ specsync [-dry-run] [-change <change>] [-repo owner/name] [-reconcile=false] [-c
2828
- `-close-completed` (default false): keep tracker open/closed state aligned with completion. Completing every task closes the item; adding new unchecked work reopens it. Without the flag, completion updates `stage:complete` but leaves tracker state alone. An explicit `.status` overrides task-derived stage, so only `.status` value `complete` closes with this flag.
2929
- `-repo owner/name`: override auto-detected repo from `git remote`.
3030

31+
**Task states.** Beyond `[ ]` (todo) and `[x]` (done), tasks.md supports `[~]` (dropped — superseded or removed) and `[>]` (moved — relocated to another change). Dropped and moved tasks are excluded from progress; only todo and done are "live". The issue body shows a "Plan changes" footer with the breakdown: `+3 added · 2 done · 1 dropped · 1 moved`.
32+
33+
**Discoveries.** Capture findings during implementation without scope decisions. `specsync note -change <slug> "<text>"` appends a line to `discoveries.md`, rendered as `## Discoveries` in the issue. On pull, the section is stripped (like `## Tasks`). The original issue body is saved as `original-ask.md` on first pull (write-once, never overwritten) and rendered as `## Original ask`.
34+
3135
**Lifecycle stages.** specsync labels each issue `stage:<stage>`. The stage is derived automatically: `active` while any task is unchecked, `complete` once every task is checked (before archiving), and `archived` once the change moves under `changes/archive/`. A `.status` file in the change folder overrides the derived stage. This means finishing the last task flips the issue out of `stage:active` on the next sync — no manual bookkeeping.
3236

3337
### Pull an issue into a local change
@@ -70,6 +74,15 @@ specsync link [-dry-run] [-openspec <dir>] <change1> <change2> [<change3>...]
7074

7175
At least 2 changes required. Writes `links.md` into each change directory and syncs them so a "## Related" section appears in each GitHub issue.
7276

77+
### Spin off emergent work
78+
79+
```
80+
specsync spinoff -from <slug> -task <n> [-kind bug|followup|task] [-repo owner/name] [-change <slug>] [-dry-run]
81+
specsync spinoff -from <slug> -text "<discovery>" [-kind bug|followup|task] [-repo owner/name] [-change <slug>] [-dry-run]
82+
```
83+
84+
Spawns a new linked change from a discovery, keeping the parent scoped. `-task <n>` extracts text from the nth task line in the parent's `tasks.md` and marks it as moved (`[>] moved: <child-slug>`). `-text` provides free-form discovery text. `-kind` sets an issue label on the child. `-repo` for cross-repo spawn. `-change` overrides the auto-derived slug. The child `proposal.md` is seeded with the discovery text and a provenance line linking to the parent.
85+
7386
### Inspect release impact
7487

7588
```

0 commit comments

Comments
 (0)