Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.5.28
1.5.29
6 changes: 4 additions & 2 deletions assets/skills/continue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
name: continue
description: |
devbrain resume cursor (Stage C — Assemble) that also works the queue. First
folds new prompt-log entries into the project's brain pages AND extracts open
items into the TODO queue, pulls the brain, refreshes the live world
folds new prompt-log entries into the project's brain pages AND extracts committed
open items into the TODO queue (per /distill's gates), pulls the brain, refreshes the live world
(git/issues/CI), and gives a short briefing. Then it picks up the highest-priority
task, queries gbrain to synthesize that task's context and attaches it to the TODO
(shown to you), builds a MINIMAL MVP for it, opens a PR for review, and asks
Expand Down Expand Up @@ -115,6 +115,8 @@ A few lines, then move straight into the work:
- **From the brain:** the 2-4 most relevant in-scope facts/decisions/open items
(with page slug pointers, e.g. `<project>/<topic>`).
- **From the world:** uncommitted changes, ahead/behind, open issues/PRs, CI.
- **Open questions:** decisions the fold-in parked on a brain page because only you can
make them (`/distill` never queues these) — one line each, so they get answered.
- **Top of the queue:** the highest-priority task you're about to pick up.

Briefing plus pointers — do not dump whole pages. The flusher pushes pages/tasks you
Expand Down
53 changes: 40 additions & 13 deletions assets/skills/distill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ description: |
devbrain curation step (Stage B — Brain) — the explicit "save this now" path. This
is the design's "/checkpoint" role, named /distill to avoid Claude Code's native
/checkpoint rewind alias. Reads new raw prompt-log entries for the current
project, distills them into brain pages, and extracts actionable open items into
the project's TODO queue (the queue's only source). Writes directly (no approval
project, distills them into brain pages, and extracts explicitly committed, untracked
open items into the project's TODO queue (the queue's only source; musings, tracker
issues, dated readouts, and user-only decisions stay on the brain page). Writes directly (no approval
gate — review by git diff). /continue runs this same fold-in automatically on
an explicit /continue invocation. Never invoke proactively. An explicit /continue
or /distill invocation is consent; run immediately without reconfirming. Do not
Expand Down Expand Up @@ -162,16 +163,44 @@ Step 2 listed by `cksum` — skip the rest; unchanged memory is already folded i
dedupe against existing pages, and fold genuinely-new facts into the relevant topic
page (or an `operational-memory-recovered.md` page). Skip `MEMORY.md` (just an index).

**Queue tasks.** The brain records *what happened*; the queue records *what's next*. As
you read the new log, also pull out **actionable open items** — anything phrased as work
still to do: "still open", "TODO", "we should…", "next step", a bug noted but not fixed, a
follow-up the user asked for and you haven't done. Turn each into a queue task. This is the
**Queue tasks.** The brain records *what happened*; the queue records *what's next* —
and only the part of what's next that someone actually **committed to**. This is the
queue's **only source** — tasks are born here (and `/continue` runs this same fold-in, so
it refreshes the queue on resume).
it refreshes the queue on resume) — so every task admitted is a worker turn spent. Read
loosely ("still open", "we should…", "next step"), this rule minted 321 tasks in one
project in six weeks; a triage found 17 of the 72 still open were real work — the rest
already done, stale, duplicates of tracker issues, dated readouts, or decisions only the
user could make. So the default is **no task**: an item is queued only when it passes
**every** gate below, and anything that fails one goes into the brain page (as a note,
a pointer, or an open question), never the queue.
```bash
devbrain todo list # see what's already queued — DEDUPE against this before adding
devbrain todo list all # every task, any status — DEDUPE against this before adding
devbrain todo list # open tasks — count them for the soft cap below
```
For each genuinely new open item:
1. **Explicit commitment.** The user asked for it, or the session decided it *and*
named an owner and a done-check. Musings — "we should…", "could later", "worth
looking at", a "next step" thought aloud — are notes on the brain page.
2. **Not already tracked.** Same intent anywhere in `todo list all` (done included) →
skip. Lives in the project's external tracker (Linear, GitHub issues) → a pointer
on the brain page, not a task; the tracker owns it.
3. **Not a dated readout or reminder.** "Check the experiment on the 24th",
"re-measure in two weeks": append one row (date · what to check · where the numbers
live) to the project's single `Experiment readout calendar` task — `todo show` then
`todo edit <id> -b` — creating it once (`-p 20`) if absent. Never one task per date.
4. **Not owned by a runner.** If an automated job already tracks the follow-through
(a scheduled service, an outreach runner's thread states, a cron), it is not
queue work.
5. **Not a decision only the user can make.** Record it as an open question on the
brain page; `/continue` surfaces it in the briefing. A task a worker can't finish
without the user is not a task.

**Soft cap: 20 open tasks.** Count `devbrain todo list` first. Past 20 the fold-in may
only **merge** a new item into an existing task (`todo edit <id> -b`) or **drop** it to
the brain page — never add — and the report must say so ("queue at 23/20: merged 2,
dropped 3, added 0"). `todo add` also warns on stderr past the cap; treat the warning
as a failed gate, not noise.

For each item that clears every gate:
```bash
devbrain todo add "<imperative one-line task>" -p <0-100> -b "<why / acceptance criteria / log provenance>"
```
Expand All @@ -181,10 +210,8 @@ devbrain todo add "<imperative one-line task>" -p <0-100> -b "<why / acceptance
body — MANDATORY when quality depends on taste or judgment (essays, grading, design, UX
copy), encouraged elsewhere. This is the task-specific bar a delegated worker builds to
and restates in its PR body; without it, workers fall back to the generic protocol.
- **Dedupe is mandatory** — if `list` already has the task (same intent), skip it; do
not re-add. Don't queue vague aspirations, done work, or things smaller than a
commit.
- Creating tasks is the job here; **closing** merged ones is Step 4.
- Don't queue done work or things smaller than a commit. Creating tasks is the job here;
**closing** merged ones is Step 4.

### 4. Reconcile the queue against merged PRs
Three checks that sync task state with what actually merged. "Merged" always comes from
Expand Down
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ one commit per active minute; an idle tick costs milliseconds.

`/distill` reads new log entries and folds them into linked, tagged brain pages —
markdown on disk, queried through gbrain (a per-machine, rebuildable search index).
The same pass extracts actionable open items into the queue. There's no approval
gate: pages are a projection of the log, so review is by `git diff`, not a prompt.
The same pass extracts open items into the queue — only explicit commitments that no
tracker or runner already owns; musings, dated readouts, and user-only decisions stay
on the page, and past 20 open tasks the pass merges or drops rather than adds. There's
no approval gate: pages are a projection of the log, so review is by `git diff`, not a
prompt.

## Queue

Expand Down
24 changes: 7 additions & 17 deletions internal/dashboard/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,29 +254,16 @@ func (q *Queue) Write(project, tid string, updates *Updates, title, body string)
return task.Load(path, project)
}

var leadingDigits = regexp.MustCompile(`^(\d+)`)
var slugJunk = regexp.MustCompile(`[^a-z0-9]+`)

// Create writes a new task file with the next sequential id.
// Create writes a new task file with the next sequential id. The id comes
// from task.AllocID — the same locked allocator as `devbrain todo add` — so a
// dashboard create racing a CLI add can't share a prefix.
func (q *Queue) Create(project, title string, priority int, body string) (*task.Task, error) {
d := q.todoDir(project)
if d == "" {
return nil, errors.New("unknown project")
}
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
mx := 0
files, _ := filepath.Glob(filepath.Join(d, "*.md"))
arch, _ := filepath.Glob(filepath.Join(d, "archive", "*.md")) // keep archived ids counted
files = append(files, arch...)
for _, f := range files {
if m := leadingDigits.FindStringSubmatch(filepath.Base(f)); m != nil {
if n, err := strconv.Atoi(m[1]); err == nil && n > mx {
mx = n
}
}
}
slugSrc := title
if slugSrc == "" {
slugSrc = "task"
Expand All @@ -288,7 +275,10 @@ func (q *Queue) Create(project, title string, priority int, body string) (*task.
if slug == "" {
slug = "task"
}
tid := fmt.Sprintf("%04d-%s", mx+1, slug)
tid, err := task.AllocID(d, slug)
if err != nil {
return nil, err
}
path := filepath.Join(d, tid+".md")
prio := priority
if prio < 0 {
Expand Down
31 changes: 28 additions & 3 deletions internal/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/TheWeiHu/devbrain/internal/config"
"github.com/TheWeiHu/devbrain/internal/dashboard"
"github.com/TheWeiHu/devbrain/internal/jsonedit"
"github.com/TheWeiHu/devbrain/internal/task"
"github.com/TheWeiHu/devbrain/internal/version"
)

Expand Down Expand Up @@ -518,22 +519,22 @@ func (c *ctx) tccGuard(o *options) int {
func (c *ctx) ensureDataRepo() error {
if exists(filepath.Join(c.data, ".git")) {
fmt.Fprintf(c.stdout, " data repo : exists (%s)\n", c.data)
return nil
return c.ignoreTaskLock()
}
if remote := os.Getenv("DEVBRAIN_DATA_REMOTE"); remote != "" {
if err := run("git", "clone", remote, c.data); err != nil {
return fmt.Errorf("clone %s failed: %v", remote, err)
}
fmt.Fprintf(c.stdout, " data repo : cloned %s -> %s\n", remote, c.data)
return nil
return c.ignoreTaskLock()
}
if err := os.MkdirAll(filepath.Join(c.data, "projects"), 0o755); err != nil {
return err
}
if err := run("git", "-C", c.data, "init", "-q"); err != nil {
return fmt.Errorf("git init %s failed: %v", c.data, err)
}
_ = os.WriteFile(filepath.Join(c.data, ".gitignore"), []byte("*.pglite\n.DS_Store\n"), 0o644)
_ = os.WriteFile(filepath.Join(c.data, ".gitignore"), []byte("*.pglite\n.DS_Store\n"+task.LockName+"\n"), 0o644)
// Append-only sidecars union-merge instead of conflicting (flush reseeds
// this on existing repos too).
_ = os.WriteFile(filepath.Join(c.data, ".gitattributes"),
Expand All @@ -558,6 +559,30 @@ func (c *ctx) ensureDataRepo() error {
// offerGbrain: the optional ranked/semantic search engine. Offered only in a
// real terminal; non-interactive runs and bun-less machines skip silently
// (offline `devbrain brain search/get` works with zero engine).
func (c *ctx) ignoreTaskLock() error {
name := filepath.Join(c.data, ".gitignore")
b, err := os.ReadFile(name)
if err != nil && !os.IsNotExist(err) {
return err
}
for _, line := range strings.Split(string(b), "\n") {
if strings.TrimSpace(line) == task.LockName {
return nil
}
}
f, err := os.OpenFile(name, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
prefix := ""
if len(b) > 0 && b[len(b)-1] != '\n' {
prefix = "\n"
}
_, err = fmt.Fprintln(f, prefix+task.LockName)
return err
}

func (c *ctx) offerGbrain(o *options) {
if o.gbrain == "0" {
fmt.Fprintln(c.stdout, " gbrain : skipped (opted out) — offline 'devbrain brain search/get' still works")
Expand Down
32 changes: 32 additions & 0 deletions internal/install/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,40 @@ import (
"testing"

"github.com/TheWeiHu/devbrain/internal/config"
"github.com/TheWeiHu/devbrain/internal/task"
)

func TestEnsureDataRepoIgnoresTaskLockOnUpgrade(t *testing.T) {
for _, existing := range []string{"", "custom-output/", "custom-output/\n", task.LockName + "\n"} {
t.Run(existing, func(t *testing.T) {
data := t.TempDir()
if err := os.Mkdir(filepath.Join(data, ".git"), 0o755); err != nil {
t.Fatal(err)
}
name := filepath.Join(data, ".gitignore")
if existing != "" {
if err := os.WriteFile(name, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
}
var out bytes.Buffer
c := &ctx{data: data, stdout: &out}
for range 2 {
if err := c.ensureDataRepo(); err != nil {
t.Fatal(err)
}
}
got := mustRead(t, name)
if !strings.HasPrefix(got, existing) || strings.Count(got, task.LockName) != 1 {
t.Fatalf("gitignore = %q: want preserved rules and one lock exclusion", got)
}
if !strings.Contains("\n"+got, "\n"+task.LockName+"\n") {
t.Fatalf("lock exclusion is not on its own line: %q", got)
}
})
}
}

// setupHome builds a throwaway HOME with stubbed schedulers on PATH (so no
// test can ever touch the host's launchd/systemd/cron) and pins every env
// knob the installer reads. Returns the home dir.
Expand Down
69 changes: 69 additions & 0 deletions internal/task/alloc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package task

import (
"fmt"
"os"
"path"
"path/filepath"
"strconv"
"syscall"
)

// LockName is the advisory-lock file AllocID takes inside a todo dir. It is a
// dotfile so the `*.md` listings and the bash-glob-style scans never see it,
// and the data repo's .gitignore (written by install) keeps it out of git.
const LockName = ".alloc.lock"

// AllocID mints the next `NNNN-<slug>` task id under dir and reserves it by
// creating an empty `<id>.md`. It is the ONE allocator for the queue (the todo
// CLI and the dashboard both mint through it), and the NNNN prefix — not the
// full file name — is the unit of exclusivity: the scan of the current max
// and the create run under an exclusive flock on dir/.alloc.lock, so two
// parallel `todo add` calls with different slugs can no longer both read the
// same max and both win their O_EXCL create (which is how one queue ended up
// with two files each for 0240, 0252, 0281 … in production). The O_EXCL create
// stays as belt-and-braces against a writer that bypasses the lock.
//
// Ids are counted across dir AND dir/archive so a moved-away top id can't be
// reissued; only canonical `NNNN-*.md` names take part in the scan, so a stray
// `12-short.md` or a non-task file never shifts the sequence.
func AllocID(dir, slug string) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
lock, err := os.OpenFile(filepath.Join(dir, LockName), os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return "", err
}
defer lock.Close()
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
return "", fmt.Errorf("lock %s: %w", lock.Name(), err)
}
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck // Close releases it anyway

seq := 0
for _, d := range []string{dir, filepath.Join(dir, "archive")} {
ents, _ := os.ReadDir(d)
for _, e := range ents {
name := e.Name()
if ok, _ := path.Match("[0-9][0-9][0-9][0-9]-*.md", name); !ok {
continue
}
if n, err := strconv.Atoi(name[:4]); err == nil && n > seq {
seq = n
}
}
}
for {
seq++
id := fmt.Sprintf("%04d-%s", seq, slug)
f, err := os.OpenFile(filepath.Join(dir, id+".md"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
f.Close()
return id, nil
}
if !os.IsExist(err) {
return "", err
}
}
}
Loading
Loading