Skip to content

Task System (M6) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. One feature per loop iteration (TDD); flip a feature_list.json entry's passes only after the named test AND go test ./... AND bash harness/init.sh are green.

Goal: Ship v1 of the evidence-derived task tracker designed in docs/specs/2026-06-15-task-system-design.md — epic/feature/task items whose terminal verified status is set by a deterministic checker from tamper-resistant evidence, never agent-asserted.

Architecture: Lives entirely in the hub service (it owns the runs ledger, watchdog, and UI shell). New Postgres tables (task_items, task_project_counters, task_evidence, task_events) via migration 0027. A hand-written ItemStore (pool *db.Pool, mirrors internal/hub/runs.go). The verifier is a Go sweep behind a token-gated POST /tasks/verify endpoint, triggered by a scheduled tasks/verify_evidence.py (the run.py→endpoint pattern, same as distill.py/process). CI truth comes through a CISource interface (a FakeCISource for tests; a ForgejoCISource REST impl) — there is no in-repo Forgejo integration today, so this is new and isolated behind the interface. The read-only /ui/tasks page + Approve action mirror /ui/parked.

Tech stack: Go 1.25, pgx/v5 (pgxpool), chi, html/template + HTMX, dockertest (internal/shared/testdb). Migrations are plain numbered .sql run by db.Migrate().


Architectural decisions (locked by the design doc)

  1. Per-project IDs<PREFIX>-<seq> (e.g. ARGUS-101, TURFTRACK-12). task_project_counters holds next_seq per project; allocation happens in the create transaction. The id text PK stores the rendered string; (project, seq) is unique.
  2. lifecycle has no "done"backlog|ready|in_progress|blocked|cancelled, agent-writable. Terminal truth is the separate verified boolean, checker-set only.
  3. Tamper boundary at the API surface — there is NO route that lets a caller set verified directly. The agent-facing API can create items, attach evidence, set lifecycle, update steps. Only POST /tasks/verify (the checker) re-derives verified from evidence; only the human Approve action creates approval evidence. (DB-role hardening to lock the column to a checker role is a noted future option, not v1.)
  4. verified = AND across all bound evidence (Decision 3) — an item is verified iff every row in task_evidence for it is satisfied. No weighting. Re-derivable: a reverted PR flips its evidence unsatisfied, which flips the item un-verified on the next sweep.
  5. Evidence tiersci_run / pr / deploy_log (Tier 1, checker-set from external state), artifact (Tier 2, checker-set from file-exists), approval (Tier 3, human-only via the UI Approve action; the agent can never set it).
  6. Rollup is a derived SQL view — epic/feature done-ness is computed (task_rollup), never stored.
  7. Required why on every item; required acceptance on tasks (the two-pronged admission gate).
  8. Bootstrapping note: M6 itself is built under the EXISTING feature_list.json discipline (CI-green-gated). Each task below maps to one feature_list.json entry. Once shipped, future work is tracked in the new system. (Self-hosting the tracker on its own verification is a post-v1 step.)

File structure

  • Create migrations/0027_task_items.sql — the four tables + append-only trigger on task_events.
  • Create internal/hub/items.goItemStore (create/get/tree/lifecycle/steps/evidence) + types.
  • Create internal/hub/items_test.go, internal/hub/verify_test.go, internal/hub/rollup_test.go.
  • Create internal/hub/cisource.goCISource interface, FakeCISource, ForgejoCISource.
  • Create internal/hub/verify.go — the verifier sweep.
  • Create migrations/0028_task_rollup_view.sql — the task_rollup recursive view (separate migration so the view can be reasoned about/replaced independently).
  • Create internal/hub/ui_tasks.go, internal/hub/ui/templates/tasks.html, tasks_row.html, internal/hub/ui_tasks_test.go; modify internal/hub/ui/templates/base.html (nav link).
  • Modify internal/hub/server.go — register /tasks* API routes + /ui/tasks* + POST /tasks/verify.
  • Modify cmd/hub/main.go — wire ItemStore + verifier into the server.
  • Create tasks/verify_evidence.py; modify tasks/config.ini (new verify-evidence stanza); scripts/check_tasks_config.py already validates identity.
  • Append entries to harness/feature_list.json (one per task below).

Task 1 — Schema migration (ARGUS-101)

Files: Create migrations/0027_task_items.sql, internal/hub/items_schema_test.go.

  • [ ] Step 1: Write the failing testinternal/hub/items_schema_test.go
package hub_test

import (
    "context"
    "testing"

    "github.com/runonyourown/argus/internal/shared/testdb"
)

func TestTaskSchema(t *testing.T) {
    if testing.Short() {
        t.Skip("requires docker")
    }
    pool := testdb.StartPostgres(t)
    testdb.Migrate(t, pool)
    ctx := context.Background()

    for _, tbl := range []string{"task_items", "task_project_counters", "task_evidence", "task_events"} {
        var exists bool
        if err := pool.QueryRow(ctx,
            `SELECT EXISTS (SELECT 1 FROM information_schema.tables
               WHERE table_schema='public' AND table_name=$1)`, tbl).Scan(&exists); err != nil {
            t.Fatalf("check %s: %v", tbl, err)
        }
        if !exists {
            t.Errorf("table %q missing after migrate", tbl)
        }
    }

    // task_events is append-only: UPDATE and DELETE must be rejected by trigger.
    var id int64
    if err := pool.QueryRow(ctx,
        `INSERT INTO task_events (item_id, actor, event) VALUES ('ARGUS-1','agent','created') RETURNING id`).Scan(&id); err != nil {
        t.Fatalf("insert event: %v", err)
    }
    if _, err := pool.Exec(ctx, `UPDATE task_events SET actor='x' WHERE id=$1`, id); err == nil {
        t.Error("UPDATE on task_events succeeded; want rejected (append-only)")
    }
    if _, err := pool.Exec(ctx, `DELETE FROM task_events WHERE id=$1`, id); err == nil {
        t.Error("DELETE on task_events succeeded; want rejected (append-only)")
    }

    // type CHECK rejects a bad type.
    if _, err := pool.Exec(ctx,
        `INSERT INTO task_items (id,project,seq,type,title,why) VALUES ('X-1','x',1,'bogus','t','w')`); err == nil {
        t.Error("insert with bogus type succeeded; want CHECK rejection")
    }
}
  • [ ] Step 2: Run it, confirm it failsPATH=~/sdk/go/bin:$PATH go test ./internal/hub/ -run TestTaskSchema → FAIL (tables missing).

  • [ ] Step 3: Write the migrationmigrations/0027_task_items.sql

CREATE TABLE task_items (
    id          text PRIMARY KEY,
    project     text NOT NULL,
    seq         bigint NOT NULL,
    type        text NOT NULL CHECK (type IN ('epic','feature','task')),
    parent_id   text REFERENCES task_items(id),
    title       text NOT NULL,
    why         text NOT NULL,
    body        text NOT NULL DEFAULT '',
    acceptance  text NOT NULL DEFAULT '',
    steps       jsonb NOT NULL DEFAULT '[]',
    optional    boolean NOT NULL DEFAULT false,
    lifecycle   text NOT NULL DEFAULT 'backlog'
                  CHECK (lifecycle IN ('backlog','ready','in_progress','blocked','cancelled')),
    priority    int NOT NULL DEFAULT 0,
    verified    boolean NOT NULL DEFAULT false,
    verified_at timestamptz,
    verified_by text,
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now(),
    UNIQUE (project, seq),
    CHECK (type <> 'task' OR length(acceptance) > 0)  -- tasks must declare acceptance
);
CREATE INDEX task_items_parent_idx ON task_items (parent_id);

CREATE TABLE task_project_counters (
    project  text PRIMARY KEY,
    next_seq bigint NOT NULL DEFAULT 1
);

CREATE TABLE task_evidence (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    item_id      text NOT NULL REFERENCES task_items(id) ON DELETE CASCADE,
    kind         text NOT NULL CHECK (kind IN ('ci_run','pr','deploy_log','artifact','approval')),
    repo         text,
    pr_number    int,
    commit_sha   text,
    test_or_job  text,
    identifier   text,
    satisfied    boolean NOT NULL DEFAULT false,
    satisfied_at timestamptz,
    created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX task_evidence_item_idx ON task_evidence (item_id);

CREATE TABLE task_events (
    id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    item_id text NOT NULL,
    at      timestamptz NOT NULL DEFAULT now(),
    actor   text NOT NULL,
    event   text NOT NULL,
    detail  jsonb NOT NULL DEFAULT '{}'
);
CREATE INDEX task_events_item_idx ON task_events (item_id, at);

-- append-only: reuse the raw_items immutability convention.
CREATE OR REPLACE FUNCTION task_events_immutable() RETURNS trigger AS $$
BEGIN
    RAISE EXCEPTION 'task_events is append-only';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER task_events_no_mutate
    BEFORE UPDATE OR DELETE OR TRUNCATE ON task_events
    EXECUTE FUNCTION task_events_immutable();

Note: if the existing raw_items append-only trigger uses a shared helper, reuse it instead of redefining; grep migrations/ for the existing immutability function first and match its style.

  • [ ] Step 4: Run it, confirm passgo test ./internal/hub/ -run TestTaskSchema → PASS. Then go test ./internal/shared/db/ -run TestMigrate to confirm the runner still applies cleanly.
  • [ ] Step 5: Commitfeat(tasks): 0027 schema — items/evidence/events/counters (append-only events).

Task 2 — ItemStore: create with per-project ID (ARGUS-102)

Files: Create internal/hub/items.go, internal/hub/items_test.go.

  • [ ] Step 1: Write the failing test — contract:
  • NewItemStore(pool) then CreateItem(ctx, NewItem{Project:"argus", Type:"epic", Title:"...", Why:"..."}) returns an Item with ID=="ARGUS-1", Seq==1, Lifecycle=="backlog", Verified==false.
  • A second create for project argus returns ARGUS-2; a create for turftrack returns TURFTRACK-1 (independent counters).
  • CreateItem with Type:"task" and empty Acceptance returns an error (admission gate); same for empty Why on any type.
  • Each successful create writes one task_events row with event='created', actor='agent'.
func TestItemStoreCreate(t *testing.T) {
    if testing.Short() { t.Skip("requires docker") }
    pool := testdb.StartPostgres(t); testdb.Migrate(t, pool)
    s := hub.NewItemStore(pool)
    ctx := context.Background()

    e, err := s.CreateItem(ctx, hub.NewItem{Project: "argus", Type: "epic", Title: "M6", Why: "trust"})
    if err != nil { t.Fatal(err) }
    if e.ID != "ARGUS-1" || e.Seq != 1 || e.Lifecycle != "backlog" || e.Verified {
        t.Fatalf("unexpected item: %+v", e)
    }
    e2, _ := s.CreateItem(ctx, hub.NewItem{Project: "argus", Type: "feature", Title: "x", Why: "y", ParentID: "ARGUS-1"})
    if e2.ID != "ARGUS-2" { t.Errorf("seq not per-project monotonic: %s", e2.ID) }
    tt, _ := s.CreateItem(ctx, hub.NewItem{Project: "turftrack", Type: "epic", Title: "x", Why: "y"})
    if tt.ID != "TURFTRACK-1" { t.Errorf("cross-project counter leaked: %s", tt.ID) }

    if _, err := s.CreateItem(ctx, hub.NewItem{Project: "argus", Type: "task", Title: "t", Why: "w", ParentID: "ARGUS-2"}); err == nil {
        t.Error("task without acceptance was accepted; want admission-gate error")
    }
    if _, err := s.CreateItem(ctx, hub.NewItem{Project: "argus", Type: "epic", Title: "t"}); err == nil {
        t.Error("item without why was accepted; want admission-gate error")
    }
}
  • [ ] Step 2: Run, confirm fail (no such package symbols).
  • [ ] Step 3: Implement internal/hub/items.goItemStore{pool}, NewItem/Item structs, and CreateItem:
  • Validate Why != "" (all) and Acceptance != "" when Type=="task" → return a sentinel error.
  • Prefix = strings.ToUpper(project).
  • In a tx: INSERT INTO task_project_counters (project) VALUES ($1) ON CONFLICT DO NOTHING; then UPDATE task_project_counters SET next_seq = next_seq + 1 WHERE project=$1 RETURNING next_seq - 1 to claim a seq atomically; build id := fmt.Sprintf("%s-%d", prefix, seq); INSERT INTO task_items (...); INSERT INTO task_events (...,'created',...); commit.
  • Follow the runs.go error-wrapping style.
  • [ ] Step 4: Run, confirm pass; then full go test ./internal/hub/.
  • [ ] Step 5: Commitfeat(tasks): ItemStore.CreateItem with per-project IDs + admission gate.

Task 3 — Lifecycle, steps, evidence attach (ARGUS-103)

Files: Modify internal/hub/items.go, internal/hub/items_test.go.

  • [ ] Step 1: Failing test — contract:
  • SetLifecycle(ctx, id, "in_progress") updates lifecycle, bumps updated_at, writes a task_events row event='lifecycle' with detail {"from":"backlog","to":"in_progress"}. Rejects an invalid value and a non-existent id.
  • SetSteps(ctx, id, []Step{{Text:"write test",Done:false}}) persists the JSON and writes an event.
  • AddEvidence(ctx, id, Evidence{Kind:"ci_run", Repo:"runonyourown/argus", CommitSHA:"abc", TestOrJob:"TestX"}) inserts a task_evidence row (satisfied=false) and writes event='evidence_added'. There is intentionally NO method that sets verified (assert by design in a comment + the API task).
  • [ ] Step 2: Run, fail.
  • [ ] Step 3: Implement SetLifecycle, SetSteps, AddEvidence — each in a tx with the matching task_events write; Step struct {Text string; Done bool} marshalled to the steps jsonb.
  • [ ] Step 4: Pass + full package suite.
  • [ ] Step 5: Commitfeat(tasks): lifecycle/steps/evidence mutations with audit events.

Task 4 — CISource interface + fake (ARGUS-104)

Files: Create internal/hub/cisource.go, internal/hub/cisource_test.go.

  • [ ] Step 1: Failing test — define the contract the verifier depends on:

type CISource interface {
    // PRStatus reports whether a PR is merged to the default branch and whether
    // its body/title references the given item id (anti-gaming).
    PRStatus(ctx context.Context, repo string, pr int, itemID string) (merged bool, referencesItem bool, err error)
    // CheckRun reports whether a named test/job concluded successfully for a commit.
    CheckRun(ctx context.Context, repo, commitSHA, testOrJob string) (green bool, err error)
}
- Test the FakeCISource (table of canned answers) round-trips; this is what every later test injects. - [ ] Step 2: Run, fail. - [ ] Step 3: Implement cisource.go — the interface, FakeCISource (maps keyed by repo/pr/sha), and a ForgejoCISource{baseURL, token} skeleton that calls the Forgejo REST API: PR-merged via GET /repos/{repo}/pulls/{pr} (merged field) + reference check on the PR title/body for the item id; CheckRun via the commit-status/check API GET /repos/{repo}/commits/{sha}/status (or check-runs) matching testOrJob. Flag in a code comment: the Forgejo Actions runs API has 404'd in practice (see metamcp notes) — ForgejoCISource must use commit-status/PR endpoints, and its own integration test is deferred (kept out of the unit suite; the verifier is tested against FakeCISource). - [ ] Step 4: Pass. - [ ] Step 5: Commitfeat(tasks): CISource interface + FakeCISource + Forgejo REST skeleton.


Task 5 — Verifier sweep (ARGUS-105)

Files: Create internal/hub/verify.go, internal/hub/verify_test.go.

  • [ ] Step 1: Failing test — contract (inject FakeCISource):
  • For a task with two evidence rows (a pr merged+referencing, and a ci_run green), VerifySweep sets both satisfied=true, sets the item verified=true, verified_by='checker:ci', writes event='verified'.
  • AND rule: if the ci_run is red (or the PR not merged / not referencing the item), the item stays verified=false.
  • Re-derivable / un-verify: flip the fake so the PR is no longer merged; re-running VerifySweep sets the item verified=false and writes event='unverified'.
  • approval evidence is NOT touched by the sweep (its satisfied is set only by the human API) — a task whose only evidence is an unsatisfied approval stays unverified; once that approval row is satisfied, the sweep marks the item verified verified_by='approval'.
  • artifact evidence is satisfied by a file-exists check (inject a fake fs check or use a temp path).
  • [ ] Step 2: Run, fail.
  • [ ] Step 3: Implement VerifySweep(ctx, src CISource) (checked, verified int, err error) — load tasks with evidence; for each evidence row evaluate satisfied by kind (pr→PRStatus merged&&references; ci_run→CheckRun green; artifact→file exists; deploy_log→treat like ci for v1 via CISource or skip; approval→leave as-is); persist satisfied; then set item verified = (count(evidence)>0 AND all satisfied); on transition write the matching event. Idempotent (safe to run every tick).
  • [ ] Step 4: Pass + full suite.
  • [ ] Step 5: Commitfeat(tasks): evidence verifier sweep (AND rule, re-derivable, approval/artifact tiers).

Task 6 — Rollup view (ARGUS-106)

Files: Create migrations/0028_task_rollup_view.sql, internal/hub/rollup_test.go, ItemStore.Tree.

  • [ ] Step 1: Failing test — build an epic → feature → 2 tasks; verify both tasks (via the store, simulating the checker); Tree(ctx,"ARGUS-1") reports the epic and feature as Done=true. Mark one task optional=true, leave it unverified: parent still Done (non-optional children all done). Un-verify a non-optional task: parent flips Done=false.
  • [ ] Step 2: Run, fail.
  • [ ] Step 3: Implement migrations/0028_task_rollup_view.sql — a recursive view task_rollup(id, done) where a task row's done = verified, and an epic/feature row's done = NOT EXISTS (child where NOT optional AND NOT done). Implement ItemStore.Tree joining task_items to task_rollup, returning a nested structure (or flat rows + client nests).
  • [ ] Step 4: Pass.
  • [ ] Step 5: Commitfeat(tasks): 0028 derived rollup view + Tree read.

Task 7 — HTTP API (ARGUS-107)

Files: Modify internal/hub/server.go, cmd/hub/main.go; create internal/hub/tasks_api_test.go.

  • [ ] Step 1: Failing testhttptest server with the real ItemStore (dockertest):
  • POST /tasks (Bearer) creates an item, returns 201 + JSON {id:"ARGUS-1",...}. Missing why → 400. DisallowUnknownFields.
  • POST /tasks/{id}/evidence (Bearer) adds evidence → 201.
  • POST /tasks/{id}/lifecycle (Bearer) {to:"in_progress"} → 200; invalid → 400.
  • GET /tasks.json returns the tree with rollup done flags (no auth, read-only, like /runs.json).
  • No route accepts a verified field — assert that POST /tasks ignores/—rejects a verified:true body field (DisallowUnknownFields makes it 400). This is the tamper boundary test.
  • [ ] Step 2: Run, fail.
  • [ ] Step 3: Implement handlers (mirror the /runs POST decoder + requireBearer), register under the auth group in server.go; wire ItemStore through cmd/hub/main.go (mirror how runs is constructed and passed).
  • [ ] Step 4: Pass + full suite.
  • [ ] Step 5: Commitfeat(tasks): hub task API (create/evidence/lifecycle/tree, no verified write path).

Task 8 — Verify endpoint + scheduled task (ARGUS-108)

Files: Modify internal/hub/server.go, cmd/hub/main.go; create tasks/verify_evidence.py; modify tasks/config.ini; create internal/hub/verify_endpoint_test.go.

  • [ ] Step 1: Failing testPOST /tasks/verify (Bearer) runs VerifySweep against the configured CISource and returns 200 with {checked,verified}. In the test, construct the server with a FakeCISource and assert a pre-seeded satisfiable task becomes verified after the call.
  • [ ] Step 2: Run, fail.
  • [ ] Step 3: Implement the endpoint (token-gated); wire the chosen CISource in cmd/hub/main.go (ForgejoCISource from env ARGUS_FORGEJO_URL/token, falling back to a no-op source if unset so dev/smoke doesn't fail). Add tasks/verify_evidence.py (mirror distill.py: POST /tasks/verify to the hub, print the result, exit non-zero on HTTP error). Add the config.ini stanza:
    [job-exec "verify-evidence"]
    schedule = @every 10m
    no-overlap = true
    container = argus-task-runner
    command = python /tasks/run.py verify-evidence -- python /tasks/verify_evidence.py
    
  • [ ] Step 4: Pass; run python3 scripts/check_tasks_config.py (task identity) and python3 -m unittest discover -s tasks if present.
  • [ ] Step 5: Commitfeat(tasks): POST /tasks/verify + verify-evidence scheduled task.

Task 9 — Read-only /ui/tasks + Approve action (ARGUS-109)

Files: Create internal/hub/ui_tasks.go, internal/hub/ui/templates/tasks.html, tasks_row.html; modify base.html; modify server.go; create internal/hub/ui_tasks_test.go.

  • [ ] Step 1: Failing test (mirror ui_parked_test.go):
  • GET /ui/tasks renders the tree: each epic/feature shows a rollup badge (done/N/M verified), each task shows lifecycle + verified state + an Approve button only when it has an unsatisfied approval evidence row.
  • POST /ui/tasks/{id}/approve with HX-Request satisfies that item's approval evidence, re-runs the sweep for the item, and swaps the row to show verified. Non-HTMX POST → 403 (CSRF fence). Garbage id → 400.
  • [ ] Step 2: Run, fail.
  • [ ] Step 3: Implement ui_tasks.go (page handler reads ItemStore.Tree; approve handler sets the approval evidence satisfied=true, writes event='approved' actor='aaron', re-derives the item). Templates mirror parked.html/parked_row.html (HTMX hx-post, hx-target, hx-swap=outerHTML, row id task-{id}). Add the nav link in base.html. Register routes in server.go.
  • [ ] Step 4: Pass + full suite + bash harness/init.sh (smoke stays green — new tables migrate, no behavior change to existing paths).
  • [ ] Step 5: Commitfeat(tasks): read-only /ui/tasks tree + human Approve action.

Execution handoff

  • Each task above becomes one harness/feature_list.json entry (id m6-task-NNN, title, steps, test, test_contract, passes:false), built in order under the existing TDD ritual — flip passes only after the named test + go test ./... + init.sh are green.
  • After Task 9: the v1 engine is live (create items via API, attach evidence, scheduled verifier derives verified, rollup + read-only UI + human Approve).
  • Deferred to v2 (tracked, not built here): dependency/blocks edges + ready-queue; bug-as-distinct-type with regression-test gate; the discovered-scope intake; the visual-plan/visual-recap presentation layer (see the design doc's Presentation section); DB-role hardening of the verified column; the real ForgejoCISource integration test.

Self-review

  • Spec coverage: items+events schema (T1), per-project IDs + admission gate (T2), lifecycle/steps/evidence (T3), evidence tiers + AND rule + re-derive (T5), rollup (T6), tamper boundary = no verified-write route + checker-only sweep (T3/T5/T7), approval human-only (T9), required why/acceptance (T2). All design-doc decisions map to a task.
  • No placeholders: every step names exact files, a failing test contract, and the implementation shape; CISource isolates the one unknown (Forgejo CI truth) behind a fake so the suite is deterministic.
  • Naming consistency: ItemStore, Item, NewItem, Evidence, Step, CISource, VerifySweep used consistently across T2–T9.