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 afeature_list.jsonentry'spassesonly after the named test ANDgo test ./...ANDbash harness/init.share 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)¶
- Per-project IDs —
<PREFIX>-<seq>(e.g.ARGUS-101,TURFTRACK-12).task_project_countersholdsnext_seqper project; allocation happens in the create transaction. Theidtext PK stores the rendered string;(project, seq)is unique. lifecyclehas no "done" —backlog|ready|in_progress|blocked|cancelled, agent-writable. Terminal truth is the separateverifiedboolean, checker-set only.- Tamper boundary at the API surface — there is NO route that lets a caller set
verifieddirectly. The agent-facing API can create items, attach evidence, set lifecycle, update steps. OnlyPOST /tasks/verify(the checker) re-derivesverifiedfrom evidence; only the human Approve action createsapprovalevidence. (DB-role hardening to lock the column to a checker role is a noted future option, not v1.) verified= AND across all bound evidence (Decision 3) — an item is verified iff every row intask_evidencefor it issatisfied. No weighting. Re-derivable: a reverted PR flips its evidence unsatisfied, which flips the item un-verified on the next sweep.- Evidence tiers —
ci_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). - Rollup is a derived SQL view — epic/feature done-ness is computed (
task_rollup), never stored. - Required
whyon every item; requiredacceptanceon tasks (the two-pronged admission gate). - Bootstrapping note: M6 itself is built under the EXISTING
feature_list.jsondiscipline (CI-green-gated). Each task below maps to onefeature_list.jsonentry. 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 ontask_events. - Create
internal/hub/items.go—ItemStore(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.go—CISourceinterface,FakeCISource,ForgejoCISource. - Create
internal/hub/verify.go— the verifier sweep. - Create
migrations/0028_task_rollup_view.sql— thetask_rolluprecursive 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; modifyinternal/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— wireItemStore+ verifier into the server. - Create
tasks/verify_evidence.py; modifytasks/config.ini(newverify-evidencestanza);scripts/check_tasks_config.pyalready 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 test —
internal/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 fails —
PATH=~/sdk/go/bin:$PATH go test ./internal/hub/ -run TestTaskSchema→ FAIL (tables missing). -
[ ] Step 3: Write the migration —
migrations/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_itemsappend-only trigger uses a shared helper, reuse it instead of redefining; grepmigrations/for the existing immutability function first and match its style.
- [ ] Step 4: Run it, confirm pass —
go test ./internal/hub/ -run TestTaskSchema→ PASS. Thengo test ./internal/shared/db/ -run TestMigrateto confirm the runner still applies cleanly. - [ ] Step 5: Commit —
feat(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)thenCreateItem(ctx, NewItem{Project:"argus", Type:"epic", Title:"...", Why:"..."})returns anItemwithID=="ARGUS-1",Seq==1,Lifecycle=="backlog",Verified==false.- A second create for project
argusreturnsARGUS-2; a create forturftrackreturnsTURFTRACK-1(independent counters). CreateItemwithType:"task"and emptyAcceptancereturns an error (admission gate); same for emptyWhyon any type.- Each successful create writes one
task_eventsrow withevent='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.go—ItemStore{pool},NewItem/Itemstructs, andCreateItem: - Validate
Why != ""(all) andAcceptance != ""whenType=="task"→ return a sentinel error. - Prefix =
strings.ToUpper(project). - In a
tx:INSERT INTO task_project_counters (project) VALUES ($1) ON CONFLICT DO NOTHING; thenUPDATE task_project_counters SET next_seq = next_seq + 1 WHERE project=$1 RETURNING next_seq - 1to claim a seq atomically; buildid := fmt.Sprintf("%s-%d", prefix, seq);INSERT INTO task_items (...);INSERT INTO task_events (...,'created',...); commit. - Follow the
runs.goerror-wrapping style. - [ ] Step 4: Run, confirm pass; then full
go test ./internal/hub/. - [ ] Step 5: Commit —
feat(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")updateslifecycle, bumpsupdated_at, writes atask_eventsrowevent='lifecycle'withdetail{"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 atask_evidencerow (satisfied=false) and writesevent='evidence_added'. There is intentionally NO method that setsverified(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 matchingtask_eventswrite;Stepstruct{Text string; Done bool}marshalled to thestepsjsonb. - [ ] Step 4: Pass + full package suite.
- [ ] Step 5: Commit —
feat(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)
}
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: Commit — feat(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
prmerged+referencing, and aci_rungreen),VerifySweepsets bothsatisfied=true, sets the itemverified=true,verified_by='checker:ci', writesevent='verified'. - AND rule: if the
ci_runis red (or the PR not merged / not referencing the item), the item staysverified=false. - Re-derivable / un-verify: flip the fake so the PR is no longer merged; re-running
VerifySweepsets the itemverified=falseand writesevent='unverified'. approvalevidence is NOT touched by the sweep (itssatisfiedis set only by the human API) — a task whose only evidence is an unsatisfiedapprovalstays unverified; once that approval row is satisfied, the sweep marks the item verifiedverified_by='approval'.artifactevidence 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 evaluatesatisfiedby 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); persistsatisfied; then set itemverified = (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: Commit —
feat(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 asDone=true. Mark one taskoptional=true, leave it unverified: parent stillDone(non-optional children all done). Un-verify a non-optional task: parent flipsDone=false. - [ ] Step 2: Run, fail.
- [ ] Step 3: Implement
migrations/0028_task_rollup_view.sql— a recursive viewtask_rollup(id, done)where ataskrow'sdone = verified, and anepic/featurerow'sdone = NOT EXISTS (child where NOT optional AND NOT done). ImplementItemStore.Treejoiningtask_itemstotask_rollup, returning a nested structure (or flat rows + client nests). - [ ] Step 4: Pass.
- [ ] Step 5: Commit —
feat(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 test —
httptestserver with the realItemStore(dockertest): POST /tasks(Bearer) creates an item, returns 201 + JSON{id:"ARGUS-1",...}. Missingwhy→ 400.DisallowUnknownFields.POST /tasks/{id}/evidence(Bearer) adds evidence → 201.POST /tasks/{id}/lifecycle(Bearer){to:"in_progress"}→ 200; invalid → 400.GET /tasks.jsonreturns the tree with rollupdoneflags (no auth, read-only, like/runs.json).- No route accepts a
verifiedfield — assert thatPOST /tasksignores/—rejects averified:truebody field (DisallowUnknownFields makes it 400). This is the tamper boundary test. - [ ] Step 2: Run, fail.
- [ ] Step 3: Implement handlers (mirror the
/runsPOST decoder +requireBearer), register under the auth group inserver.go; wireItemStorethroughcmd/hub/main.go(mirror howrunsis constructed and passed). - [ ] Step 4: Pass + full suite.
- [ ] Step 5: Commit —
feat(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 test —
POST /tasks/verify(Bearer) runsVerifySweepagainst the configuredCISourceand returns 200 with{checked,verified}. In the test, construct the server with aFakeCISourceand 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
CISourceincmd/hub/main.go(ForgejoCISourcefrom envARGUS_FORGEJO_URL/token, falling back to a no-op source if unset so dev/smoke doesn't fail). Addtasks/verify_evidence.py(mirrordistill.py: POST/tasks/verifyto the hub, print the result, exit non-zero on HTTP error). Add theconfig.inistanza:[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) andpython3 -m unittest discover -s tasksif present. - [ ] Step 5: Commit —
feat(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/tasksrenders 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 unsatisfiedapprovalevidence row.POST /ui/tasks/{id}/approvewithHX-Requestsatisfies that item'sapprovalevidence, 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 readsItemStore.Tree; approve handler sets theapprovalevidencesatisfied=true, writesevent='approved' actor='aaron', re-derives the item). Templates mirrorparked.html/parked_row.html(HTMXhx-post,hx-target,hx-swap=outerHTML, row idtask-{id}). Add the nav link inbase.html. Register routes inserver.go. - [ ] Step 4: Pass + full suite +
bash harness/init.sh(smoke stays green — new tables migrate, no behavior change to existing paths). - [ ] Step 5: Commit —
feat(tasks): read-only /ui/tasks tree + human Approve action.
Execution handoff¶
- Each task above becomes one
harness/feature_list.jsonentry (idm6-task-NNN,title,steps,test,test_contract,passes:false), built in order under the existing TDD ritual — flippassesonly after the named test +go test ./...+init.share 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/
blocksedges + 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 theverifiedcolumn; the realForgejoCISourceintegration 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),
approvalhuman-only (T9), requiredwhy/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,VerifySweepused consistently across T2–T9.