chd0a7p4i3tbogg522dgj7kd0mr24qvg · 110 rows
| id | content_hash | title | description | design | acceptance_criteria | notes | status | priority | issue_type | assignee | estimated_minutes | created_at | created_by | owner | updated_at | closed_at | closed_by_session | external_ref | spec_id | compaction_level | compacted_at | compacted_at_commit | original_size | sender | ephemeral | wisp_type | pinned | is_template | mol_type | work_type | source_system | metadata | source_repo | close_reason | event_kind | actor | target | payload | await_type | await_id | timeout_ns | waiters | hook_bead | role_bead | agent_state | last_activity | role_type | rig | due_at | defer_until | no_history | started_at | is_blocked |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ah-07g | bac07329270d597d113053b517e9dfaf960a2c01112410a47ffc02a9fde7bcf3 | Investigate unbounded .task/events.jsonl (202MB for one design run) | task-2/.task/events.jsonl was 202 MB for a single ~2min run producing a 100-line doc. pi event stream persisted verbatim, no truncation/rotation, under work_root. work_root fills fast. Investigate pi verbosity, whether events.jsonl is needed post-run, add rotation/cap or prune-on-Done. | DECIDED 2026-07-18 (user): 'remove worktree, but backup .task and additional docs'. Policy: - When a card reaches Done or Cancelled and its worktree still exists: archive FIRST, then 'git worktree remove --force'. - Archive = tar.gz containing .task/ (PROMPT.md, summary.md, events.jsonl, *.json) PLUS any files in the worktree that are uncommitted relative to the branch head (untracked + modified) — i.e. everything that would be lost with the worktree. Branch itself is the durable artifact and is NOT touched. - Archive location: config archive_dir, default <work_root>/archive/, file task-<id>.tar.gz (overwrite on re-archive). - Failed/In Review/etc: untouched (zellij attach + debugging still want the worktree). - events.jsonl compresses ~10-20x (repeated JSON context), so backup satisfies keep-for-debug without the 202MB/run disk cost. | INVESTIGATION (2026-07-18): events.jsonl is pi's raw event stream, persisted verbatim by pi in <worktree>/.task/. 210MB for task-2's ~2min run; lines up to ~100KB each (full message/context per event). Worktrees are NOT pruned on terminal state (task-1 and task-2 both still on disk under work_root). Two levers: (a) pi-side — reduce event verbosity / don't persist full context per event (pi is external, needs a flag or upstream change); (b) daemon-side — prune the worktree or at least .task/events.jsonl when a task reaches Done (NOT In Review, where zellij-attach/review still want it). Retention policy is a user call: keep-for-debug vs disk. No code changed yet — needs a retention-policy decision. | closed | 3 | task | Eugene Blikh | NULL | 2026-07-18T06:30:22Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T14:48:24Z | 2026-07-18T14:48:24Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Merged 3e2eb3c: Done/Cancelled cards get .task/ + uncommitted files archived to archive_dir/task-<id>.tar.gz (stdlib tar+gzip, atomic overwrite, archive-before-remove), then git worktree remove --force; branch kept; worktree_archived/worktree_gone events. Live verify (drag a Done card, check archive) after next CI deploy. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T13:21:59Z | 0 | |||||||||||||||||||||
| ah-0ge | 2c909b097105e578055b7ee05ca088aa4e70e7f293e15e7aae83f28ef1a5aeda | Stage 4: agent tools API, question loop, AgentsView links | Per SPEC SS14.4: per-task bearer tokens; ahub tool task-create/ask-user/memory-search/memory-add over loopback HTTP; server-side policies (Triage-only, gen<=2, per-session caps, dedup, project allowlist); question.json -> Question bucket -> comment-answer -> pi --session resume as new attempt; parent auto-Block/unblock on subtask completion; AgentsView: pg push from runner host + /sessions/<id> deep links in comments. | STATUS 2026-07-17. The FILE-CHANNEL agent capabilities of Stage 4 are DONE and committed (9ec9418, 5ad253a): task-create (ah-0ge.1), parent block/unblock join + pi-session-style resume (ah-0ge.2/.3), ask-user question loop (ah-0ge.4). These use .task/*.json + board state instead of the per-task-bearer-token ahub-tool-over-HTTP API, which the file channel makes optional for the 'agent stops then resumes' pattern. REMAINING epic scope is infra/external-service gated and NOT autonomously completable by me: - memory search/add tools -> depend on Stage 3 mem0 (ah-ydx); mem0 REST client is unverifiable without a live mem0 instance and I'd be guessing at its API shape. - AgentsView /sessions/ deep links -> need 'pg push from the runner host' + a deployed AgentsView (ah-k23 infra); the deep-link URL alone is useless without the push. - per-task bearer tokens + ahub tool CLI-over-HTTP -> an alternative transport to the file channel; only needed for mid-run (not stop-and-resume) tools, and lower value now that the file channel covers task-create + ask-user. Recommend: keep this epic open for the HTTP-tools-API + AgentsView items, to be done alongside ah-k23 infra. | closed | 3 | epic | NULL | NULL | 2026-07-12T23:36:29Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T17:03:58Z | 2026-07-18T17:03:58Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | All 6 children done: file-channel tools (.1-.4), AgentsView pipeline + deep links (.5), HTTP tools API (.6). Stage 4 complete pending live rollout smoke. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||
| ah-0ge.1 | fe7de8b8454eea7571194b1d573a0f6427b0f4c7b704400ef3ea627f37636484 | Agent-created tasks via .task/tasks.json file channel | First slice of the delegation loop: agent X declares the child roles it needs, X exits, the reconciler creates those tasks. Deliberately NOT the full ah-0ge tools API — no bearer tokens, no ahub tool CLI, no HTTP endpoints. X writes .task/tasks.json on the way out; the reconciler reads it at run end, the same way .task/question.json is already reserved (SPEC:392) in a directory internal/runner/taskfiles.go already owns. A file channel can only be read at run END, which would cripple an interactive ask-user loop but is EXACTLY the semantics wanted here ('agent X stops'). That constraint is what makes this slice small. Server-side policy from the ah-0ge epic still applies: gen<=2, per-session caps, dedup, project allowlist. A child that spawns children that spawn children fills the board. | Child tasks get a role (must exist in cfg.Roles) + a task body. Open: do children land in Ready directly (agent->agent, per the proposal) or in Triage for a human to release (per SPEC §14.4 as written)? Triage is the safer default and can be relaxed later via config. | A run whose agent writes .task/tasks.json with two child role tasks results in two new board tasks with those roles; a malformed or over-cap file bounces the parent rather than creating anything. | IMPLEMENTED 2026-07-17. Agent writes .task/tasks.json ({tasks:[{role,title,body}]}); a SUCCESSFUL run's finalize reads it and creates one board card per entry under its role (frontmatter 'role: X') in the configured target bucket. Decisions made during implementation: - Ready vs Triage: config knob agent_tasks.target_bucket, DEFAULT 'triage' (spec §14.4-aligned, safe: human vets agent-created work). Flip to 'ready' for the full auto agent→agent loop. Documented in config.example.yaml + SPEC §12. - 'Bounce' semantics: malformed/over-cap/unknown-role/empty-title creates NOTHING (atomic) and posts a rejection comment on the parent; the parent's OWN work still finalizes to In Review. Chose comment-not-card-move because moving the parent to Triage collides with the state model (triage is a parked bucket, not a task State, and the parent has a persisted record). Stronger card-move bounce deferred. - gen<=2 cap DEFERRED to ah-0ge.2: needs task lineage (parent link) which doesn't exist yet. Only the per-run COUNT cap (max_per_run, default 5) is enforced now. - Mid-creation transport failure keeps already-created children (can't un-create) and stops; logged. finalize isn't retried post-InReview. New surface: domain.RequestedTask; ports.Board.CreateTask + ports.NewTask; ports.Runner.RequestedTasks; runner.readTasksFile + PiZellij.RequestedTasks; vikunja.Client.CreateTask (PUT /projects/{id}/tasks then MoveToBucket); reconcile.spawnChildTasks/validateRequestedTasks/childDescription; config.AgentTasks. Tests: reconcile (spawn 2, unknown-role bounce, over-cap bounce, malformed bounce), runner (readTasksFile absent/valid/malformed), vikunja (CreateTask HTTP path), config (defaults + 2 validation failures). Full suite + e2e green under GOFLAGS=-mod=readonly. | closed | 3 | feature | Eugene Blikh | NULL | 2026-07-17T14:38:02Z | Eugene Blikh | bigbes@gmail.com | 2026-07-17T16:16:19Z | 2026-07-17T16:16:19Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Implemented and tested: agent-created child tasks via .task/tasks.json file channel. Acceptance met (two children created; malformed/over-cap bounces atomically). Config knob defaults to Triage; gen-cap deferred to ah-0ge.2. | 0 | NULL | NULL | NULL | 0 | 2026-07-17T15:58:45Z | 0 | ||||||||||||||||||||
| ah-0ge.2 | cc37ff5a5860feb86cb3556e7841450c187af1c9a8c3899f32e4f991134e9e1b | Parent auto-Block/unblock join on child completion | The join half of the delegation loop, per SPEC §14.4 'parent auto-Block/unblock'. When X spawns children it parks in the Blocked bucket — which already exists as a parked bucket that never claims and never kills (SPEC:174) — and returns to Ready when all children reach a terminal state. Needs a parent link (tasks.parent_id in internal/store/schema.sql, or Vikunja task relations) plus somewhere to hold each child's result for the resume payload. UNSPECIFIED IN THE SPEC AND NEEDS A DECISION: partial failure. Y done, Z failed — does X resume with the failure reported, bounce to Failed, or go to Triage? §14 says auto-unblock but not what unblock means when a child died. | X with children Y,Z sits in Blocked while either runs and returns to Ready only once both are terminal; the partial-failure path is decided and covered by a transitions test. | COMPLETED 2026-07-17 together with ah-0ge.3 (they were inseparable — see prior note). The full block→wait→resume join is implemented and tested. STATE MODEL: 'blocked' promoted to a real domain.State (7 states now). domain.States/Valid/CanClaim/CanonicalBuckets updated; BucketBlocked const removed (blocked is a state, not a parked bucket); triage/question remain parked. reconcile iterate() routes the blocked bucket to a new handleBlocked. BEHAVIOR: a successful run that validly delegates now PARKS the parent in Blocked (delegateAndBlock) instead of In Review; children are created in target_bucket (triage=human-gated, ready=full-auto). handleBlocked resumes the parent (new attempt, resumeParent) once EVERY child reaches a terminal bucket (in_review/failed/done/cancelled; a vanished child counts terminal to avoid deadlock). Resume re-runs the role prompt + an appended 'Delegated work completed' section reporting each child's outcome and instructing 'do not re-delegate'. Child-done threshold = In Review (decision). Partial failure = resume-and-report; agent may retry (decision), bounded by MaxPerRun as a per-parent LIFETIME cap (existing ChildIDsOf count + requested) so retries can't loop forever. Depth bounded by MaxGeneration. Tests: domain (7 states); reconcile (spawn→blocks, resume-when-children-finish incl. failed child in prompt, waits-while-pending, + updated ah-0ge.1 spawn/reject tests); store migration/lineage. Full unit suite + e2e green under -mod=readonly. SPEC §9-pseudocode, state model, and §12 updated. NOTE/deviation: resume is a fresh attempt in the parent's existing worktree (which holds its committed work) + resume prompt, NOT a literal 'pi --session' continuation — pi session-file mechanics weren't verifiable here, and the worktree+prompt path is robust and correctness-equivalent for avoiding re-delegation. True --session continuation is a possible future optimization (see ah-0ge.3). | closed | 3 | feature | Eugene Blikh | NULL | 2026-07-17T14:38:03Z | Eugene Blikh | bigbes@gmail.com | 2026-07-17T19:05:05Z | 2026-07-17T19:05:05Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Block→wait→resume join implemented and tested together: blocked promoted to a real state; delegating parents park in Blocked and resume as a new attempt (in-worktree + resume prompt) once all children reach a terminal bucket. Decisions honored (child-done=In Review; partial-failure=resume-and-report with lifetime-capped retry). Full suite + e2e green. | 0 | NULL | NULL | NULL | 0 | 2026-07-17T17:45:37Z | 0 | |||||||||||||||||||||
| ah-0ge.3 | 5ba4f4620f3f376c331035f6bea0224fc91e067c39719445c159cae5b8477312 | Resume parent via pi --session with child results as a new attempt | Closes the loop: X comes back from Blocked and continues WITH its original context rather than starting cold. runs.pi_session_id is already a column in internal/store/schema.sql and pi supports --session <path|id> (0.73.1, also --session-dir), so this is mostly plumbing — but two things need care. (1) Session survival: X's pi session must outlive the park. Pin --session-dir explicitly rather than relying on a default location, and confirm X's worktree is not reclaimed while parked. (2) Result transfer contract: Y's output has to serialize out of Y's run, through the board, and into X's resume prompt. Needs a defined shape and a size bound — in-process subagents return text into the parent's context for free, this path does not. | Resume is a NEW attempt on X (runs table already keys on (task_id, attempt)), so watchdog/cost/event plumbing is unchanged. Prompt gets an extra rendered section carrying each child's role + result. | X resumes after its children finish, its pi session id is unchanged across the park, the new attempt shows the children's results in the rendered prompt, and an oversized child result is truncated rather than breaking the resume. | COUPLING (2026-07-17): inseparable from ah-0ge.2's behavioral join — see ah-0ge.2 notes. 'Unblock' is only correct as this resume-with-context, else the parent re-runs from scratch and re-spawns children (infinite re-delegation). Recommend implementing ah-0ge.2 (remaining join) + ah-0ge.3 as one unit, starting by promoting 'blocked' to a real domain.State. Lineage foundation (task_parents, ChildIDsOf) already landed under ah-0ge.2. | closed | 3 | feature | NULL | NULL | 2026-07-17T14:38:04Z | Eugene Blikh | bigbes@gmail.com | 2026-07-17T19:05:05Z | 2026-07-17T19:05:05Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Block→wait→resume join implemented and tested together: blocked promoted to a real state; delegating parents park in Blocked and resume as a new attempt (in-worktree + resume prompt) once all children reach a terminal bucket. Decisions honored (child-done=In Review; partial-failure=resume-and-report with lifetime-capped retry). Full suite + e2e green. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||
| ah-0ge.4 | a129a5d69b25f2dc5692877fa06f42251ed75cd3b43a5fd44cf2cb374fa55378 | Ask-user question loop via .task/question.json file channel | File-channel ask-user (SPEC §14.4), mirroring the delegation loop. A successful run writes .task/question.json; the parent parks in a new 'question' state (promoted from parked bucket); the daemon posts the question as a board comment with a sentinel; a human reply resumes the parent as a new attempt with the answer appended. Answer detection is stateless (latest comment lacking the sentinel), so no marker persistence, token, or HTTP endpoint. Deliberately the file-channel form, NOT the ahub-tool-over-HTTP form — same rationale as ah-0ge.1. | A run that writes question.json parks the card in Question with the question commented; a human comment reply resumes the parent as a new attempt carrying the answer. | closed | 3 | feature | NULL | NULL | 2026-07-17T20:14:02Z | Eugene Blikh | bigbes@gmail.com | 2026-07-17T20:14:17Z | 2026-07-17T20:14:17Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Ask-user question loop implemented and tested (committed 5ad253a): question state, .task/question.json file channel, stateless answer detection via comment sentinel, resume-with-answer. Full suite + e2e green. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||
| ah-0ge.5 | 13cbbe263e9f371367678d1e893b367dba3fad166f059227ee2dbb6d5e9968ff | AgentsView: pg push from agent-1 + per-run session deep links in comments | Make agent runs viewable at agentsview.bigb.es (user asked 'can I view agent logs?'). Two halves: (1) OPS on agent-1 — install go.kenn.io/agentsview (v0.37.5+) for user agenthub, ~/.agentsview/config.toml with [pg] url to phoebe LAN :5433 (published for exactly this; allow_insecure lab tradeoff), systemd timer or 'agentsview pg service' running one-shot AGENTSVIEW_NO_DAEMON=1 pg push (notebook launchd pattern proven: NO_DAEMON avoids 0.37.x writer-daemon deadlock). Verify pi-session source is parsed. (2) DAEMON — finalize comments include the session deep link built from stored PiSessionID (verify central URL shape first). Daemon half must wait for ah-4el merge (same comments.go). | ARCHITECTURE (2026-07-18, after user pushed back on pg-port exposure): use AgentsView's NATIVE HTTP remote sync (v0.37.4+, present in 0.37.5) instead of direct pg push from agent-1 — no fork, no firewall change (phoebe DOCKER-USER allowlists DB ports to 192.168.88.35 only; agent-1 stays out of it). - agent-1: agentsview v0.37.5 (upstream release binary, SHA256-verified) as systemd service, User=agenthub (HOME=/var/lib/agenthub so default pi source .pi/agent/sessions is found), 'serve' bound to LAN, require_auth=true, token via AGENTSVIEW_AUTH_TOKEN EnvironmentFile (root-owned 0600). Archive endpoints are bearer-gated ALWAYS. - phoebe host: same binary as one-shot COLLECTOR on a systemd timer: [[remote_hosts]] {host='agent-1', transport='http', url, token} pulls sessions over HTTP; then AGENTSVIEW_NO_DAEMON=1 pg push to localhost:5433 (phoebe->own docker-proxy is the allowed local path). Notebook launchd flow untouched. - Central agentsview.bigb.es pg serve container: unchanged at 0.37.5. Version-pin ALL nodes at 0.37.5 (pg schema coupling); coordinated bump to 0.38.1 later ('speed up full HTTP sync' is perf-only). - Daemon half (after ah-4el merges): finalize comment gains https://agentsview.bigb.es/sessions/<PiSessionID> deep link, config-gated base URL. VERIFY id shape matches AgentsView session ids once first push lands. - Secrets never enter operator transcript: token minted on agent-1 and piped host-to-host; pg password composed phoebe-locally from the stack .env. | OPS HALF DONE + LIVE (2026-07-18): agent-1 fleet-node daemon (systemd agentsview.service, v0.37.5, token-gated :8080, smoke 401/200 ok) + phoebe host collector (agentsview-collect.timer 10min: HTTP remote sync pull -> pg push loopback 5433). First run pushed 5 agent-1 pi sessions to central pg; id shape agent-1~pi:<pi-session-uuid> => deep link https://agentsview.bigb.es/sessions/agent-1~pi:<PiSessionID>. Infra-as-code committed phoebe-lab 9d4b157 (units + agentsview-node.sh + collector/provision.sh + CLAUDE.md docs, incl. zsh MULTIOS token-leak caution; leaked token was rotated + verified rejected). REMAINING (daemon half, after ah-4el merge): agentsview.base_url config + deep link line in claim/success comments from RunSummary.PiSessionID; verify link renders for a pushed session. | closed | 2 | feature | NULL | NULL | 2026-07-18T13:23:54Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T14:48:24Z | 2026-07-18T14:48:24Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Both halves done: ops (agent-1 fleet-node daemon + phoebe collector, sessions live in central pg since 2026-07-18) and daemon (a8e7586: config-gated agentsview{base_url,machine}, session deep-link line on success/routed/failure comments, URL pinned agent-1~pi:<id>). Live link render check after next CI deploy + config stage. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||
| ah-0ge.6 | b4fcedab02763cd008acf6166f13b5d21f4045245781fa8f20b66db54d065335 | HTTP tools API: per-task bearer tokens + ahub tool CLI over loopback | SPEC §14.4 transport for MID-RUN agent tools (task-create/ask-user without stopping): daemon loopback HTTP with per-task bearer tokens minted at claim (exposed via .task/ env), 'ahub tool <name>' CLI subcommands, server-side policies (Triage-only, gen<=2, per-session caps, dedup, project allowlist). File channel already covers stop-and-resume — this is the deferrable last item of the approved slate. | closed | 3 | feature | Eugene Blikh | NULL | 2026-07-18T13:23:55Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T17:03:57Z | 2026-07-18T17:03:57Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Merged 07bd9d4+03f4496: per-task 256-bit bearer tokens (.task/tool-auth.json, constant-time check, invalidated at finalize, in-memory registry), /api/tool/{task-create,ask-user,answer} reusing file-channel policy code, ahub tool CLI. Additive — file channels intact. Live smoke list in agent report; note stall_timeout must exceed ask-user poll deadline. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T16:22:45Z | 0 | |||||||||||||||||||||||
| ah-0iz | 7425b905ec10a455e6046127ed987332be425930741cb9673855f0d31f498e0c | markdownToHTML: handle blockquotes (ah-bkr/ah-tz0 integration seam) | ah-tz0 renders the agent summary as a > blockquote in successComment; ah-bkr converts comments to HTML but did not handle blockquote markdown, so the summary would show literal > lines. Add blockquote handling to markdownToHTML. Verified live: Vikunja stores <blockquote>/<strong> intact. | closed | 3 | task | NULL | NULL | 2026-07-18T06:48:12Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T06:48:12Z | 2026-07-18T06:48:12Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Added <blockquote> handling to markdownToHTML (mirrors the bullet-run logic; bare > = empty line). Unit tests + live Vikunja round-trip confirm blockquote+strong render. Committed on master. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-166 | 70aef8c9b28e9321acde83be3890df0355b088d88dc80415b3e555ce24eadea9 | pi version drift: SPEC pins 0.70.2, installed is 0.73.1 | SPEC §9 says 'Verified against zellij 0.44.3 and pi 0.70.2' and internal/runner/commands.go repeats the pin in its header comment, but the installed pi is 0.73.1 (@mariozechner/pi-coding-agent). Nothing is known to be broken — but every argv note in commands.go ('pi 0.70.2 still loads a path given as --skill even under --no-skills', 'There is no --name flag') is an empirical claim against a version that is no longer the one running. Re-verify the flag behaviours on 0.73.1 and update the pins, or pin the installed version deliberately. | Partial data point 2026-07-17: on installed pi 0.73.1, --skill paths STILL load under --no-skills (verified via catalog probe), so that SPEC §9 argv note survives the 0.70.2->0.73.1 bump. Full flag re-verification (--no-extensions with explicit -e, --mode json event shape, --name absence) still pending. | closed | 3 | task | NULL | NULL | 2026-07-17T14:37:27Z | Eugene Blikh | bigbes@gmail.com | 2026-07-17T15:48:20Z | 2026-07-17T15:48:20Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Re-verified on pi 0.73.1: no runner-relevant drift. --skill loads under --no-skills; --mode json session-id line and message_end usage.cost.total shape unchanged (confirmed with opencode/claude-haiku-4-5; deepseek reports empty usage as a provider quirk, and the user-echo message_end always had empty usage); no --name flag; -ne keeps explicit -e. Pins annotated in commands.go:8 and SPEC §369. zellij NOT re-checked. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||
| ah-1cx | 7514a2243a0bce4ceca3334a52c5e91ea6e87dfdcc14147353506ee683681c8e | Hardening: post-audit bug sweep (2026-07-20) | Milestone collecting every confirmed defect from the 2026-07-20 four-way audit (marker sweep, SPEC gap analysis, core-runtime and adapter bug hunts by Opus workers). Scope: runner/reconcile/store correctness, config wiring holes, comment rendering, and the outstanding live-verification tasks. ah-tqc (errored-turn-as-success) is re-parented here. | open | 2 | epic | NULL | NULL | 2026-07-19T23:37:15Z | Eugene Blikh | bigbes@gmail.com | 2026-07-19T23:37:15Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||||
| ah-1cx.1 | e94360d1f52dbea42a761d52a15cb74d70d7f0ea533c2469487cf945d307328f | runner.Status re-parses full events.jsonl every poll, stalling the loop on large runs | Status unconditionally calls ParseEventsFile (reads to EOF, decodes every line) on every 20s poll for every in-progress task (runner.go:209-219), but the parsed pi-session/cost are display-only until finalize, which re-reads via completionFactsFromEvents anyway. On a multi-hundred-MB events stream (ah-07g observed 202MB) this blocks the single-goroutine reconcile loop for seconds per tick, starving supervision of every other task. Fix: skip the full parse for a still-running run — read only the first line for the session id (or defer session/cost to finalize) and keep the cheap os.Stat mtime for the watchdog. Found by core-runtime audit 2026-07-20. | closed | 2 | bug | NULL | NULL | 2026-07-19T23:37:28Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:45:37Z | 2026-08-04T23:45:37Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Fixed in f11ef03. Status's per-poll read of events.jsonl is now asymmetric: the still-running path does only the os.Stat it already needed for LastEvent (the stall-watchdog input) plus PeekSessionID, a new bounded head probe that reads at most 256 KiB / 8 leading lines looking for the {"type":"session","id":...} event pi writes as line 1. The full ParseEventsFile no longer sits on the 20s poll path, so a 202 MB stream (ah-07g) costs a running poll the same as an empty one and can no longer freeze the single-goroutine reconcile loop. CostUSD is deliberately left at zero for a running run — cost is a whole-stream sum with no cheap mid-run answer, and the reconciler only persists cost at finalize — and PiSession is explicitly best-effort ("" while the head is unwritten or torn), which is safe because the reconciler only overwrites a stored session id with a non-empty one. Every path that COMPLETES a run still takes the full parse: both exit.json branches via finalizeFromExit (ah-2ef's durable pinning, untouched) and — newly — the crash-confirmed branch, since a confirmed crash writes an immutable finished row and previously relied on the top-of-Status parse for the failure comment's session id and cost. The reader loop was extracted into scanEventLines(r, fn) with an early-stop callback, so the F9a over-long-line rule (skip the line, keep scanning; never bufio.Scanner) is shared by the full parse and the probe and cannot drift; the probe's byte budget is deliberately 4x the read buffer so a leading line wider than the buffer is stepped over rather than ending the probe. Cover: TestStatusRunningDoesNotParseWholeEventStream builds an 8 MB stream with the id on line 1 and a fat cost-bearing turn at the end, asserting Running ⇒ id resolved + CostUSD==0 + LastEvent==mtime, then drops exit.json and asserts the same stream yields the full cost; TestPeekSessionIDIsBounded proves boundedness structurally. Mutation-checked. KNOWN REGRESSION SPLIT OUT: kill-finalized runs now record CostUSD 0 — see the dedicated bead. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:32:43Z | 0 | |||||||||||||||||||||||
| ah-1cx.10 | f4afcd3a4d676d400e4e0f7fe95bf71960d6b6960626598a3471013233594d3c | [bug] askAndPark has the same half-landed-park bug ah-1cx.2 fixed for delegation | Found while fixing ah-1cx.2 and deliberately left alone there to keep that change reviewable. askAndPark parks the parent in Question the way delegateAndBlock parks it in Blocked, and it has the identical failure mode: if its UpsertTask(Question) — and its Yonote/question sentinel writes — fail AFTER the question comment is posted, the refinalize arm has no marker for it, so the parent routes to In Review and the question is silently dropped. The human is left with a card in review that is actually waiting on an answer nobody will give. ah-1cx.2's shape is the fix: arm a durable kv intent marker BEFORE the first irreversible side effect, extract an idempotent park tail, and add a refinalize-only re-park arm that re-attempts the flip from the marker and never repeats the side effect. See reparkDelegatedParent / parkParentBlocked in internal/reconcile/reconcile.go as of 67dcf3c. | A failed UpsertTask(Question) leaves the card in In Progress with a loud comment and audit event, and a later tick re-parks it to Question without re-posting the question comment. Covered by tests mirroring TestDelegationParkPersistFailure. | closed | 2 | bug | NULL | NULL | 2026-08-04T23:43:09Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:23:05Z | 2026-08-05T00:23:05Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 8716a2a. Confirmed BY REPRODUCTION. askAndPark flipped the record, moved the card and posted the question with no durable trace of the intent, so a failed UpsertTask(Question) left the run row finished and the record in_progress — exactly the state adoptOrFail hands to finalize's refinalize arm, which had only a delegation re-park and therefore routed the parent to In Review with the question silently dropped. Rather than clone ah-1cx.2's mechanism it is GENERALIZED: parkKind + parkMarker (armed/alarmed/opaque payload, key <kind>_park/<task>/<attempt>, delegation's wire format byte-identical to before so older markers still decode) and parkParent(plan), a plan-driven idempotent tail both parks share; delegationPark is now just a payload codec over it. askAndPark arms the marker with the question text BEFORE posting the question, runs the tail, and returns true regardless of whether the flip landed; reparkQuestionedParent re-attempts the flip from the marker and re-posts the question only if the asked marker is absent from the thread. A marker that cannot be armed AT ALL deliberately does not park: it posts the question as plain text with NO sentinel and finalizes to In Review, because a card parked with no way to resume is worse than one in review carrying its question. ONE EXTRA BUG FOUND AND FIXED HERE: questionParkFailedComment is the only comment the daemon posts after its own question and before the card reaches Question, and detectAnswer read it as the HUMAN'S REPLY — so a healed card resumed instantly with the daemon quoting its own error text back to the agent. It now ends with askedSentinel so the outstanding-question marker stays at the end of the thread, pinned by a test. Cover: seedAsking fixture, TestQuestionParkPersistFailure (4 subtests), TestQuestionParkGuards (4 subtests), fakeStore.getKVKeyErr. FOLLOW-UP FILED SEPARATELY: ToolAskUser (the mid-run channel) has no marker at all and has the same shape of hole. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:11Z | 0 | ||||||||||||||||||||||
| ah-1cx.11 | 39a21e11c8e682fefa03a720759e6bc1b9c78a3b57baf86938f388843b7556e3 | [bug] createChildTasks swallows RecordChild failures, orphaning a child from the join | Found while fixing ah-1cx.2. createChildTasks logs and continues when RecordChild fails (internal/reconcile/tools.go:376). A child created on the board whose lineage row is lost is invisible to both ChildIDsOf and the parent's join: the parent parks in Blocked waiting on a set that does not include it, so the child can complete without ever unblocking the parent — and a human sees a blocked card with no visible reason. It also weakens the before-count guard ah-1cx.2 added, which distinguishes a real pending join from fire-and-forget children by comparing the parent's child count before and after delegation. | A RecordChild failure either aborts the delegation loudly (card held, comment, audit event) or is retried durably. Either way the parent never parks on an incomplete child set. Covered by a test. | closed | 3 | bug | NULL | NULL | 2026-08-04T23:43:10Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:23:05Z | 2026-08-05T00:23:05Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 8716a2a. Confirmed as filed: createChildTasks logged and continued past a RecordChild failure, so a child could exist on the board with no task_parents row — invisible to ChildIDsOf, to the Blocked join, and to the per-parent lifetime cap — leaving the parent parked on a strictly smaller set than it created and undercounting ah-1cx.2's before/after guard. Took the DURABLE-RETRY option rather than a plain abort, because an abort that holds the card would hold it forever. createChildTasks now stops at the FIRST lost lineage row (creating more children a broken store cannot record only widens the hole) and returns the orphaned ids alongside created. delegateAndBlock refuses to park while any id is orphaned: it records them in the park marker's payload (armed:<before>,<id>,<id> — the no-orphan form is unchanged so older markers decode), holds the card in In Progress, and posts a comment naming every created card plus the untracked ones, stamped alarmed so a persistent fault does not spam. reparkDelegatedParent retries the lineage write before anything counts children (RecordChild is idempotent; the generation is re-derived from GenerationOf(parent)+1 rather than stored), then parks normally and records a delegation_lineage_recovered event. ToolTaskCreate reports the same condition as a 500 naming the card id — a fire-and-forget child deadlocks nobody, but a silent success would still corrupt the cap and any later join. Cover: TestDelegationLineageFailure, TestToolTaskCreateLineageFailureIsReported. FOLLOW-UP: nothing reconciles board reality against task_parents, so a child card created by hand or a lineage row whose card was deleted is still undetectable — a periodic lineage audit would close that. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:11Z | 0 | ||||||||||||||||||||||
| ah-1cx.12 | 87493dc59cdef62816e7d9263a77115f54898c15ccb5682ab6669ef6dc10e0fd | reconcile hygiene: no Store.DeleteKV, and the finished audit event re-appends on a stuck refinalize | Two small findings from the ah-1cx.2 work, neither urgent. 1. Store has no DeleteKV, so ah-1cx.2's delegation-park marker is 'cleared' by writing an empty string. A long-lived instance therefore accumulates one dead kv row per delegating attempt. Bounded and harmless, but a real DeleteKV (in internal/store, outside the reconcile change's file scope) would be cleaner and would let the marker be removed rather than tombstoned. 2. The 'finished' audit event re-appends on every stuck refinalize tick. appendEvent dedups only against the task's MOST RECENT event, and the refinalize path alternates event kinds, so the dedup never fires. Pre-existing, and harmless while refinalize stalls are rare — worth a cheap guard if they ever become common. | Store exposes DeleteKV and the delegation-park marker uses it; appendEvent's dedup survives an alternating-kind refinalize loop. | open | 4 | chore | NULL | NULL | 2026-08-04T23:43:11Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:43:11Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-1cx.13 | 4b771ca56b604525519b8a0e712adeed7e83551d956c2bf7c7df476e080a1614 | [bug] kill-finalized runs record CostUSD 0 since the running path stopped parsing the stream | Regression introduced by ah-1cx.1 and reported by the agent that made it. Reconciler.killAndFinalize (internal/reconcile/reconcile.go:700) finalizes a stall- or deadline-killed run from the ports.RunStatus of the last RUNNING poll. After ah-1cx.1 that status deliberately carries no cost — the running path does only os.Stat + the bounded PeekSessionID, because a whole-stream cost sum has no cheap mid-run answer. Previously the killed run at least recorded a partial-stream sum; now it records 0. Every other terminal path re-reads the complete stream before finalizing (both exit.json branches via finalizeFromExit, and the crash-confirmed branch, which ah-1cx.1 added for exactly this reason). The kill path is the one that was missed, and it cannot be fixed inside internal/runner alone. | The runner needs to expose the finalize-time facts to the kill path. Two candidate shapes: - have ports.Runner.Kill return the EventStreamInfo it can read once the process is down, or - add an exported FinalizeFacts(taskID) the kill path calls after Kill returns. The first is tighter (one round trip, and Kill is already the moment the stream stops growing); the second keeps Kill's signature clean. Note the stream is only guaranteed complete once ahub-run has finished teeing, which a kill does not wait for — so whichever shape is chosen must say plainly whether the cost it records is complete or best-effort, rather than implying the former. | A stall-killed and a deadline-killed run both record a non-zero CostUSD when their stream carried one, and the honesty of that number (complete vs best-effort) is stated in the code and in SPEC section 9. | open | 3 | bug | NULL | NULL | 2026-08-04T23:43:42Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:43:42Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||
| ah-1cx.14 | 2bcefeca5810ee4589983de45c6f9eda40eca330f7c51a579954fa4007896a5f | runner: the crash-confirmed full parse repeats every poll while finalize keeps failing | Noted while fixing ah-1cx.1. ah-1cx.1 added a full ParseEventsFile to Status's crash-confirmed branch, because a confirmed crash writes an immutable finished row and previously relied on the top-of-Status parse for the failure comment's session id and cost. Correct — but once past the grace, every subsequent Status of a still-unfinalized crashed attempt re-reads the whole stream. Bounded in practice (finalize normally succeeds on the first observation), and far less severe than the original ah-1cx.1 bug, which hit every in-progress task on every tick. But it is the one remaining O(stream) call reachable from the poll loop, so it is worth closing once ah-1cx.8's or ah-1cx.9's stream-size work lands — a 202 MB stream re-read in a loop is the same starvation shape. | A crashed-but-unfinalized attempt does not re-read the whole event stream on every poll — either the facts are cached per (task, attempt) or the parse moves to the finalize call itself. | open | 4 | chore | NULL | NULL | 2026-08-04T23:43:43Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:43:43Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-1cx.15 | 7ea33ea771d8c5c30bad682cadf1d09f6b4388d61a2903ce4a9a77768b12afe0 | ahub-run should drop message_update while teeing: 95% of every event stream is unread | Root cause behind the 202 MB event streams (ah-07g) that motivated ah-1cx.1, quantified during the ah-1cx.4 capture. In a fresh real pi 0.82.1 run, message_update events were 123 of 143 lines and about 95% of the 166 KB. They exist so an interactive UI can render a partial message as it streams, and each one embeds the WHOLE partial message — so their volume grows quadratically with message length. Nothing in this codebase reads them: ParseEvents only consumes the session header and message_end. Having ahub-run filter message_update out while teeing events.jsonl (or rotate/compress the file) would shrink real streams by roughly 20×, which would make even the full parse cheap and would retire the tail of this whole class of bug — ah-1cx.1's asymmetric read path, ah-1cx.9's repeated crash-path parse, and the disk footprint of every archived worktree. | EVIDENCE for the design decision, measured on a real pi 0.82.1 capture (the same run that settled ah-1cx.4): Line-type histogram over 143 lines / 166 KB: 123 {"type":"message_update",... <- 86% of lines, ~95% of bytes 4 {"type":"message_start",... 4 {"type":"message_end",... <- the only ones ParseEvents sums 2 {"type":"turn_start"} 2 {"type":"turn_end",... 2 {"type":"tool_execution_update",... 1 {"type":"tool_execution_start",... 1 {"type":"tool_execution_end",... (plus session, agent_start, agent_end, agent_settled) KEY IMPLEMENTATION FACT: 'type' is the FIRST key on every single line, so a filter can decide from a short fixed-length prefix and never has to buffer a whole line. That matters because message_update embeds the whole partial message, so lines grow with the conversation — the parser's own maxEventLine cap is 10 MB. A filtering writer should therefore be prefix-driven and stateful ('am I inside a line I already decided to drop?'), NOT line-buffering, or it reintroduces the memory problem it exists to solve. The current tee is a raw byte MultiWriter set as cmd.Stdout (cmd/ahub-run/main.go:129-141), so this means inserting a small stateful io.Writer in front of the FILE half only. RISK NOTE: events.jsonl is the file every completion fact is read from — session id, cost, and since ah-tqc the final-turn verdict. A filter bug corrupts the daemon's entire view of a run. Whatever lands must (a) keep the output a valid JSONL stream, (b) never drop a line whose type it did not positively recognise as message_update (fail OPEN, not closed), and (c) be covered by a test that replays testdata/pi-events-multi-real.jsonl's raw form through the filter and asserts ParseEvents gets byte-identical facts. OPEN DECISION, needs an operator answer before coding: is events.jsonl only the daemon's fact source, or also a human debugging artifact? message_update is the ONLY record of the streaming assistant text as it was produced; dropping it means a human reading an archived worktree tarball sees the final messages but not the process. If it is only a fact source, filtering is free. If humans read it, prefer rotation/compression, or filter the teed copy while keeping the raw stream separately under a size cap. internal/runner/archive.go is the other consumer to check. | A real multi-minute run's events.jsonl is an order of magnitude smaller, the daemon's parsed facts are unchanged, and whatever is lost for human debugging is stated explicitly in SPEC section 9. | closed | 3 | feature | NULL | NULL | 2026-08-04T23:43:44Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:06:50Z | 2026-08-05T00:06:50Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in c956724. DECISION MADE AND RECORDED: filter the FILE half of the tee only, leave stdout raw. The evidence that this loses nothing is that the final message_end carries the COMPLETE content array (verified on the pi 0.82.1 capture: thinking + text/toolCall, whole), so every message survives in full and only the superseded redraw increments are dropped; and a human watching LIVE is looking at the pane, which is untouched. That made the 'is the stream a human debugging artifact?' question answerable without an operator: it is, and filtering does not damage it. Implementation (cmd/ahub-run/eventfilter.go): a stateful prefix-driven io.Writer wrapping the file, inserted into the MultiWriter's file half. Prefix-driven rather than line-buffering because a single message_update can reach megabytes and buffering one would reintroduce the memory problem this exists to solve — pi writes 'type' as the FIRST key on every event, which is what makes a fixed 24-byte decision window possible. FAILS OPEN: a line is dropped only when its opening bytes positively match the marker, so anything unrecognized, malformed, split oddly or truncated passes through — events.jsonl is where every completion fact comes from (session id, cost, and since ah-tqc the final-turn verdict), so dropping too much costs correctness while dropping too little only costs disk. Write() reports the full input consumed even though fewer bytes reach the sink, because io.MultiWriter treats a short write as io.ErrShortWrite and would abort the tee. Close() flushes an undecided trailing partial line (a SIGKILLed child mid-write) rather than discarding evidence of how the run died. The load-bearing test is chunk-independence: cmd.Stdout hands the filter whatever a pipe read returns, so the marker can arrive split anywhere inside its 24 bytes. The suite replays each stream through EVERY single split point and one byte at a time, plus the fail-open cases (a space after the key, the marker as a longer type's prefix, the word as a later value, garbage/blank lines, lines shorter than the marker) and a 4 MB update line asserting the buffer never grows past the marker length. MEASURED end to end on the real capture, out of band: 166814 → 8212 bytes (20.3x, 4.9% kept), 143 → 20 lines, byte-identical to the same stream filtered by jq. SPEC §9 step 1 records the filter, the fail-open rule and these numbers. Knock-on: this shrinks real streams enough that ah-1cx.14 (the crash-confirmed full parse repeating every poll) stops being a starvation risk in practice, though the O(stream) call is still there. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||
| ah-1cx.16 | 7eff6608348ecbc852835f358a9a699e40ec1398043f10acc52a4b282570f971 | runner: reject a blank rendered PROMPT.md at Start instead of burning an attempt | Found by the adversarial review of ah-tqc and empirically reproduced against the installed pi 0.82.x. pi's dist/modes/print-mode.js:94 guards the entire run with 'if (initialMessage)', so an empty or whitespace-only prompt makes pi do nothing and exit 0 with a one-line event stream containing only the session header. runner.prepareTaskDir writes s.Prompt verbatim with no non-empty check, and nothing in reconcile guards it either. ah-tqc's second Errored() arm (SessionID != "" && AssistantMessages == 0) now CATCHES this, so the card correctly fails instead of sailing to In Review — but only AFTER the attempt is spent, a worktree is created, a zellij pane is spawned and an attempt number is consumed. A strings.TrimSpace(s.Prompt) == "" rejection in Start would fail it loudly and instantly with an actionable message naming the role and template. Deliberately left out of the ah-tqc fix because the check arguably belongs on the reconcile side of the boundary — the reconciler is what renders the template and knows which role/prompt file produced the empty result, so it can say WHY. Decide which side owns it: a Start-side guard is a cheap backstop that cannot explain itself, a reconcile-side check can name the template but leaves the port unguarded against a future caller. | A role whose template renders to whitespace fails before a worktree or pane is created, with an error naming the role and the prompt file. The ah-tqc Errored() arm stays as the backstop and its test still passes. | closed | 3 | bug | NULL | NULL | 2026-08-04T23:46:15Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:23:21Z | 2026-08-05T00:23:21Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 8716a2a. Confirmed: prepareTaskDir writes s.Prompt verbatim and nothing upstream checked it, so a role template rendering to whitespace produced a pi run that did nothing and exited 0 — caught by ah-tqc's second Errored() arm, but only AFTER a worktree, a zellij pane and an attempt number were spent, and with no way to say which role or template was at fault. The guard went on the RECONCILE side, inside renderPrompt: the single seam both claim and launchResume render through, and the only layer holding the role name and the template path — the two facts an operator needs. The error flows into the existing SPEC §12 claim-failure path (card → failed, 'claim failed' comment, claim_failed event) with no new machinery. The port is deliberately NOT guarded twice: ah-tqc's arm is the port's own backstop and stays intact, so a blank prompt reaching Start by some future route still finalizes as an error rather than sailing to In Review; ports.StartSpec.Prompt now documents that split as the contract. Note the check is on the TEMPLATE's output, before the artifact manifest and Context Pack are appended — a role whose own template says nothing is a broken config even if the card happens to carry text. Cover: two prompt_test.go subtests plus TestClaimRejectsBlankRenderedPrompt. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:12Z | 0 | ||||||||||||||||||||||
| ah-1cx.2 | b631a20368ede9c76697a930cc68dfee0415035e0dd41ffb9b8b01666ce3ce31 | Delegation persist failure orphans children: parent re-finalizes to In Review, never Blocked | When delegateAndBlock has already created child cards and recorded lineage but its UpsertTask(Blocked) fails (reconcile.go:921-930), the stored record stays in_progress while the run row is finished. Next tick adoptOrFail takes the interrupted-finalize path and re-finalizes with firstFinalize=false, so delegation is skipped and the card lands In Review — the children run to completion but the parent never joins or resumes to integrate them. The code comment claims the heal path converges to Blocked; it does not (only the sibling MoveToBucket-failure case converges via alignCardToRecord). Fix: re-attempt the Blocked persist (retry signal) instead of letting a finished-row re-finalize strand the children. Rare trigger (store write failure at exactly that step); confirmed by reading, core-runtime audit 2026-07-20. | closed | 3 | bug | NULL | NULL | 2026-07-19T23:37:32Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:41:33Z | 2026-08-04T23:41:33Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Fixed in 67dcf3c. Confirmed by REPRODUCTION, not just reading: finalize computes firstFinalize from run.State != finished and the delegation branch was gated on it; when delegateAndBlock's UpsertTask(Blocked) failed, the run row was already finished (UpdateRun runs first) while the task record stayed in_progress, so the next tick's adoptOrFail interrupted-finalize path re-entered finalize with firstFinalize=false, skipped delegation entirely, and routed the parent to In Review behind live children. The old comment claiming the heal path converges to Blocked was WRONG — alignCardToRecord only covers the sibling MoveToBucket-failure case, where the record is already Blocked. Fix: delegateAndBlock arms a durable intent marker in the existing kv store (delegation_park/<task>/<attempt>, value armed:<n>/alarmed:<n> where n is the parent's child count BEFORE this delegation) ahead of createChildTasks, so the only write that can fail before any child exists is the marker itself, and that failure cancels the delegation outright instead of half-landing it. The park tail is extracted into an idempotent parkParentBlocked used by both the first finalization and a new refinalize-only reparkDelegatedParent, which re-attempts the flip from the marker and NEVER calls createChildTasks — so a heal cannot become a duplicate-child bug. The before-count is what distinguishes a real pending join from fire-and-forget children created by the mid-run ToolTaskCreate channel and from an armed-but-created-nothing delegation; both disarm and finalize to In Review as before. Failure is now loud and recoverable: error log, delegation_park_failed audit event, and a once-per-attempt card comment (guarded by the marker's alarmed flag so a broken store cannot spam the card), with the card held in In Progress so the loop retries each tick. Run-row immutability untouched. Cover: TestDelegationParkPersistFailure (4 subtests) + TestDelegationParkGuards (4 subtests). | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:32:43Z | 0 | |||||||||||||||||||||||
| ah-1cx.3 | b02d6efc693d9bd277063ae566b1ff77db50cfc1652da0418ad14b88bb47ed5c | config/wiring: whitespace-only notifier field passes validation then fatally aborts boot | Ntfy/Telegram blocks are stored untrimmed (config.go:453) and present() decides configuration by trimmed emptiness, but reconcileDeps (cmd/agenthubd/main.go:292,298) selects the notifier with raw non-empty checks. A whitespace-only ntfy.url (literal or via VAR expansion) is absent to validation yet present to wiring: config.Load succeeds, then ntfy.New rejects it and run() exits 1 — killing a deployment whose telegram block is fully valid. This contradicts present()'s own documented invariant. Fix: trim Ntfy/Telegram in resolve (as yonote/mem0 already are) or make reconcileDeps use present(). Found by adapter audit 2026-07-20. | closed | 3 | bug | NULL | NULL | 2026-07-19T23:37:36Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:41:51Z | 2026-08-04T23:41:51Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Fixed in a8f264e. Root cause: the optional blocks' present() predicates decide configuration by TRIMMED emptiness, but resolve copied Ntfy/Telegram straight out of the YAML untrimmed and reconcileDeps (cmd/agenthubd/main.go:292,298) selected the notifier with raw non-empty tests — so a whitespace-only ntfy.url was ABSENT to validation and PRESENT to wiring: config.Load succeeded, ntfy.New then rejected the value and run() exited 1, killing a deployment whose telegram block was perfectly valid. Fixed at the root by making 'set but whitespace-only' a load-time error instead of a value that silently reads as unset: resolve now scans the RAW decoded document (before any trimming erases the evidence) via blankScanFields()+validateNoBlankValues() and reports '<field> is set to whitespace only: remove the key to leave it unset, or give it a real value'. The scan is deliberately GENERIC, covering every field a present()/required check reads through TrimSpace: vikunja.token, vikunja.webhook_secret, both agentsview fields, both ntfy fields, telegram.token/api_base, yonote.base_url/token/publish_collection_id/qa.role/qa.bucket/qa.collections[i], both mem0 fields — collections entries indexed by name because trimNonEmpty would otherwise drop a blank one and silently shrink the watched set. resolve also now trims the four notifier strings the way yonote/mem0 base_urls already were, so the value the adapter constructor receives is exactly the value present() judged; this also stops a merely PADDED value (a ${NTFY_URL} picking up a trailing newline from the env file) from reaching ntfy.New. reconcileDeps keeps its raw tests but now carries a comment recording why they are equivalent to present() and that any new notifier field must be trimmed at load rather than compared there. Cover: TestLoadBlankValues — the original scenario, the ${VAR}-expands-to-blank variant, a table over all sibling fields, and padded-but-real values surviving trimmed with present() true. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:32:44Z | 0 | |||||||||||||||||||||||
| ah-1cx.4 | 4cc6b8f0ddcf69ba72aad626c3b1b374102663f9bf71080ea5d8e6a01106fca9 | runner: verify F9 cost model — is pi message cost.total per-message or cumulative? | costFrom/ParseEvents SUM message.usage.cost.total across all assistant message_end events (events.go:120-128), but no captured multi-message fixture proves the field is per-message; testdata/pi-events-two-messages.jsonl is hand-built (TODO from closed ah-nyl.11). If cost.total is actually cumulative, every multi-turn run's reported cost is inflated and both costFrom and the fixture must be re-pinned to take the last message's value. Capture a real multi-assistant-message pi --mode json run and settle it. Distinct from sibling ah-tqc (errored-turn-as-success + cost_usd parse); marker-sweep audit 2026-07-20. | closed | 3 | bug | NULL | NULL | 2026-07-19T23:37:39Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:46:00Z | 2026-08-04T23:46:00Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | SETTLED in f11ef03: message.usage.cost.total is PER-MESSAGE, not cumulative, so ParseEvents's summation is correct. TODO(ah-nyl.11) removed from both costFrom's doc comment and TestParseEventsTwoAssistantMessagesSumsPerMessageCost. Evidence is a REAL two-assistant-message, one-tool-call run captured from pi 0.82.1 (deepseek/deepseek-v4-flash, exit 0), committed as testdata/pi-events-multi-real.jsonl; the 123 message_update lines (95% of the raw 166 KB, ignored by the parser) were stripped and the trimmed 20-line fixture verified to parse byte-for-byte identically to the raw capture. The proof recorded in costFrom is ARITHMETIC rather than assertive: one flat set of unit rates ($0.14 / $0.28 / $0.0028 per Mtok for input / output / cacheRead) explains each message's cost from that message's OWN token counts alone — msg1 97*1.4e-7=0.00001358 and msg2 85*1.4e-7=0.0000119; msg1 70*2.8e-7=0.0000196 and msg2 79*2.8e-7=0.00002212 — so msg2's total contains no part of msg1's, which a cumulative field could not manage. TestParseEventsRealMultiMessageCapture pins session id, CostUSD == 0.0000374808+0.0000386792, AssistantMessages == 2, FinalStopReason == 'stop' (the first turn ended in toolUse). The hand-built pi-events-two-messages.jsonl and its test are KEPT but re-framed as the pi 0.70.2 shape pin with round numbers. Two incidental findings now documented in the events.go header: turn_end repeats its turn's usage verbatim (so the not-double-counted rule is right), and 0.82.1 adds a trailing agent_settled event plus assistant message_start events that carry a stopReason and a zeroed usage block — the latter would skew AssistantMessages/FinalStopReason if the message_end type check were ever loosened. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:32:44Z | 0 | |||||||||||||||||||||||
| ah-1cx.5 | 3b52211a9bdaa7c6e5ae2ecf70e7f8f7402cd28f4f4ed3ee2da728743e935122 | vikunja markdown: URL autolink collides with bold / trailing-ampersand entity | renderInline (markdown.go:203-211) runs escape, then URL-autolink, then bold. urlRe greedily swallows a trailing ** into the URL and boldRe then matches across the emitted anchor markup, producing interleaved broken tags for input like: see **http://example.com/x** now. Separately a URL ending in a bare ampersand becomes a split, broken amp-entity. Both reproduced against the exact regexes; the realistic mid-URL query case renders fine, so severity is low. Fix: constrain the URL match at **/entity boundaries or reorder the passes; add cases to markdown_test.go. Adapter audit 2026-07-20. | closed | 4 | bug | NULL | NULL | 2026-07-19T23:37:48Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:42:04Z | 2026-08-04T23:42:04Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Fixed in c2e29c1. renderInline ran escape → autolink → bold, and BOTH later passes collided with the anchor the middle pass had already emitted. urlRe treats '*' as an ordinary URL character, so 'see **http://example.com/x** now' swallowed the closing ** into both the href and the link text, and boldRe then matched from the opening ** across the emitted markup, yielding interleaved <strong>/<a> garbage. Separately, because the pass runs on ALREADY-ESCAPED text, a URL ending in a bare & became …& and urlRe — which excludes a trailing ';' as sentence punctuation — stopped at …&, splitting the entity across </a>; same for a URL followed by > or '. Fix: reorder so bold runs FIRST (the ** markers are consumed while still adjacent to the URL, and urlRe's class already excludes the '<' of the resulting <strong> tag, so the link nests strictly inside the bold), and replace the ReplaceAllString autolink with autolinkURLs, which re-cuts every match through the new splitURLTail. splitURLTail alternates two rules until neither fires: push a trailing INCOMPLETE entity (an & with no ; after it) out of the anchor so the ; left in the surrounding text re-joins it, then re-apply the trailing-punctuation rule ('.,;:!?)]' plus '*', so an unpaired bold marker can never ride into an href) to the newly exposed last character. A complete entity mid-URL (?a=1&b=2) contains its ; and is untouched, keeping the realistic query-string case rendering as before; a match trimmed down to a bare scheme (http://&) is left unlinked instead of emitting a dud anchor. Seven table cases added; all five collision cases were written first and confirmed RED against the old converter. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:32:44Z | 0 | |||||||||||||||||||||||
| ah-1cx.6 | d01a5e8265bcd3209e0b76c2b266cc18e3074a5c3393aa9e0624d33d2e7eca68 | config: validate vikunja.url and web_url as http(s) at load, like the other URL fields | validate() only checks Vikunja.URL for non-emptiness (config.go:587-589) while agentsview/yonote/mem0/ntfy/telegram all get validateHTTPURL; web_url is never checked at all. A malformed board URL loads cleanly and fails one layer later at vikunja.New with different diagnostics. Run validateHTTPURL on Vikunja.URL and the explicit WebURL in validate() for consistent config-time errors. Adapter audit 2026-07-20. | closed | 4 | bug | NULL | NULL | 2026-07-19T23:37:51Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:41:52Z | 2026-08-04T23:41:52Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Fixed in a8f264e (same commit as ah-1cx.3 — the hunks interleave in config.go). validate() checked vikunja.url for non-emptiness only while agentsview/yonote/mem0/ntfy/telegram all ran their base URLs through the existing validateHTTPURL helper, and vikunja.web_url was never checked at all; a malformed board URL loaded cleanly and failed one layer later inside vikunja.New, with different wording and no config-file context. The required-check is now strings.TrimSpace(...)=='' (so a blank url still reports 'vikunja.url is required' rather than a confusing scheme error) and the non-empty path REUSES validateHTTPURL — no second implementation was introduced. web_url is validated in the same branch rather than beside it because resolve DERIVES web_url from url when the key is omitted: checking it unconditionally would make one bad url report itself twice under two names. Ruling url out first guarantees the derived value is valid, so anything reported against vikunja.web_url is an explicit key the operator actually wrote. Cover: TestLoadVikunjaURLValidation — scheme-less url, non-http scheme, host-less url, blank url, explicit bad web_url, accepted explicit web_url, and an assertion that a bad url does NOT also echo as vikunja.web_url. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:32:44Z | 0 | |||||||||||||||||||||||
| ah-1cx.7 | 92b34accfb910f1a0059b4a049f641e03ee0ff15be6d11c31f6045f98fc7fb7a | yonote: live-verify collections.add_user bot grant during provisioning | The bot-token runbook's collection-grant step (doc.go:82-85, POST /api/collections.add_user with userId+permission) is the only provisioning call still marked NOT live-verified — it is documented from the v1 spec. Run it once against live bigbes.yonote.ru 1.47.1, confirm the bot gains read_write on a collection, then drop the caveat from the runbook. Until then a fresh deployment cannot trust the publish lane grant step. Marker-sweep audit 2026-07-20. | open | 4 | task | NULL | NULL | 2026-07-19T23:37:55Z | Eugene Blikh | bigbes@gmail.com | 2026-07-19T23:37:55Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||||
| ah-1cx.8 | f9af778a32a0fc2ac9d0d9bf63b5430905203491ae53324cebc0c91faecff187 | [bug] cost_usd is 0 for every live run — pi prices from its own registry, the litellm provider has none | Split out of ah-tqc's second finding so that bead can close on its first finding. /api/v1/status reports cost_usd=0 for ALL runs on agent-1, including real multi-minute ones (observed on tasks 1-2 during the 2026-07-19 Q&A smoke). The run row's CostUSD comes from ParseEvents summing message.usage.cost.total over assistant message_end events. RULED OUT: the parser. A real pi 0.82.1 capture against the DIRECT deepseek provider (deepseek/deepseek-v4-flash) puts cost.total exactly where ParseEvents reads it, and the summation is arithmetically correct (proven per-message, not cumulative — see ah-1cx.4). LEADING HYPOTHESIS: pi computes cost itself from a per-provider model pricing registry. agent-1 runs every role through a CUSTOM OpenAI-compatible provider named 'litellm' (role models are 'litellm/<model>'), and a custom provider carries no pricing metadata, so pi emits cost{input:0,output:0,total:0} while token counts are still real. If that holds, nothing in agent-hub is broken and the fix is to stop trusting pi for cost: either derive it from token counts against a locally configured price table, or read spend from the LiteLLM proxy (it tracks per-key spend), or drop the field from the status API rather than reporting a confident zero. | Settle the hypothesis BEFORE writing code. Needed evidence, from agent-1 (the local ssh probe is blocked by the permission classifier — needs an operator '!' handoff): 1. A real events.jsonl from a completed run: jq -c 'select(.type=="message_end" and .message.role=="assistant") | .message.usage' over the archived stream. Are totalTokens non-zero while every cost.* is 0? That confirms the hypothesis outright. 2. The pi provider config on the box (how the 'litellm' provider is declared) — does pi's config format accept per-model pricing for a custom provider? If it does, the cheapest fix is config-only, on the box, with no code change at all. Only if both come back negative does this become a parser/shape bug. | Either (a) pi is configured/patched so cost.total is real for the litellm provider and a live run reports a non-zero cost_usd, or (b) cost is sourced from somewhere trustworthy, or (c) the field is explicitly documented+surfaced as unavailable rather than a silent 0. In all three cases SPEC and the status API description must match what is actually reported. | OPERATOR HANDOFF — the ssh probe from this dev session is blocked by the local permission classifier, so these need a '!' handoff or a human at a terminal. Run on agent-1 (or via ssh from the mac): 1. Find a completed run's event stream and look at what pi actually reported: ssh agent-1.lab.internal sudo -u agenthub bash -lc 'ls -t /opt/agent-hub/work/*/task-*/.task/events.jsonl 2>/dev/null | head -3' sudo -u agenthub bash -lc 'jq -c "select(.type==\"message_end\" and .message.role==\"assistant\") | .message.usage" <PATH>' The decisive question: are input/output/totalTokens NON-ZERO while every cost.* is 0? That confirms the hypothesis outright — pi is reporting real token counts and no price, i.e. it has no pricing metadata for the custom 'litellm' provider. (If the worktrees are already pruned, the archive lane keeps tarballs — check archive_dir, default <work_root>/archive, for task-<id>.tar.gz.) 2. Look at how the litellm provider is declared in pi's config on the box: sudo -u agenthub bash -lc 'ls ~/.pi; grep -rn litellm ~/.pi 2>/dev/null | head' Does pi's config format accept per-model pricing for a custom provider? If it does, the cheapest fix is CONFIG-ONLY, on the box, with no code change in agents-dev at all — and this bead closes as 'not our bug, fixed in pi config'. 3. Cross-check what LiteLLM itself thinks the spend was, since it tracks per-virtual-key spend. If pi cannot be taught the prices, LiteLLM is the trustworthy source and the fix becomes 'read cost from LiteLLM, not from the event stream'. Only if step 1 comes back with cost fields that are populated-but-mis-shaped does this become a parser bug — and in that case capture the events.jsonl and add it as a testdata fixture, the way ah-1cx.4 did for the 0.82.1 shape. For reference, a REAL pi 0.82.1 capture against the DIRECT deepseek provider (local mac, 2026-08-05) has cost exactly where ParseEvents reads it: message.usage.cost = {input, output, cacheRead, cacheWrite, total}, all non-zero, and the arithmetic checks out against flat per-Mtok rates. So the parser is not the problem on a provider pi has prices for. | open | 3 | bug | NULL | NULL | 2026-08-04T23:32:21Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:55:28Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||
| ah-1cx.9 | 3cd7bb97a8755572aeb1bbbdf902779271b259e483d881fd0040b5a780c3c811 | [bug] failure comment renders 'exit code 0' and drops the provider's rejection text | Found by the independent review of 665e805 (ah-tqc). Since ah-tqc, an error outcome can carry exit code 0. failureComment (internal/reconcile/comments.go:209-214) prints the exit code whenever outcome == domain.OutcomeError, and its own doc comment says 'exit code (only for an error outcome, when the process actually produced one)' — an invariant ah-tqc silently broke. For the exact live incident ah-tqc exists to fix, the operator now sees on the card: 🤖 attempt 1 failed · outcome `error` · exit code 0 with no diff stat (there is none) and nothing else. Meanwhile info.FinalErrorMessage — '401 {"type":"error","error":{"type":"ModelError","message":"Model claude-3-5-haiku is not supported"}}', the one string that makes this diagnosable in five seconds instead of an hour — goes only to the daemon's log.Warn in runner.go finalizeFromExit. The card is the operator-facing surface and the whole reason the bug mattered; 'error, exit code 0' reads like a daemon bug rather than a broken model config. | Two changes, one of them cross-package. 1. Gate the exit-code clause in failureComment on exitCode != 0, restoring the invariant its doc comment states. Cheap, local to internal/reconcile/comments.go. 2. Thread FinalErrorMessage to the card. It is parsed in internal/runner (EventStreamInfo.FinalErrorMessage) and needs to reach failureComment, so it crosses ports.RunStatus → domain.Run → the comment. Decide deliberately whether it should also be PERSISTED on the run row (useful for /api/v1/status and for a post-hoc audit) or merely passed through to the comment. Persisting means a store migration; passing through means the information is lost on a refinalize, which reads the stored row rather than st. Truncate it — a provider can return a large body, and SPEC's comment-size discipline applies. Do this AFTER the ah-tqc follow-ups land in internal/runner, since they touch the same struct. | A run finalized as error with exit code 0 produces a card comment that names the provider's rejection and does NOT claim an exit code. A run with a genuine non-zero exit still shows it. Covered by a reconcile test asserting both shapes. | closed | 2 | bug | NULL | NULL | 2026-08-04T23:42:47Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:22:44Z | 2026-08-05T00:22:44Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 8716a2a. Confirmed as filed: failureComment gated the exit-code clause on 'outcome == OutcomeError' alone — an invariant its own doc comment stated and ah-tqc broke when an error outcome became reachable with exit code 0 — and the rejection text existed only as EventStreamInfo.FinalErrorMessage inside finalizeFromExit's log.Warn, never crossing the port. Fixed in two places: the clause is now gated on exitCode != 0, and ports.RunStatus carries a new ErrorMessage that applyExit fills for error outcomes only. DESIGN CALL: the text is PERSISTED (domain.Run.ErrorMessage, schema migration adding runs.error_message), not merely passed through, for a reason stronger than the bead's — finalize derives outcome and exit code from the STORED row on a refinalize, so a pass-through reason would explain a different observation than the outcome printed beside it; and ArchiveWorktree (ah-07g) deletes the worktree the text was parsed from, so after archiving the pass-through source is gone for good. runErrorMessage answers for BOTH Errored() arms: the provider-rejection arm quotes pi verbatim, the no-assistant-turn arm — which has no provider text — says the run produced nothing and names the blank-prompt cause. Truncated at 480 RUNES (rune-wise, so multi-byte bodies are never split mid-character) before it leaves the runner, so neither the row nor the card can take a bad gateway's HTML page. /api/v1/status exposes it as error_message,omitempty. Cover: runner_test.go assertions in TestStatusPrecedence and TestStatusReStatFinalizesErroredStream plus TestRunErrorMessage; store_test.go TestRunErrorMessageRoundTrip; three comments_test.go subtests; reconcile_test.go TestFinalizeErrorZeroExitNamesTheReason. FOLLOW-UP: ahub status does not render the new field yet. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:11Z | 0 | |||||||||||||||||||||
| ah-1jy | 8ce9b56a889ec3e5b86e47e140a10d94334118b5b72612654819617614e970a9 | config: trim yonote.token and mem0.api_key at load like the notifier credentials | Leftover from ah-1cx.3, which trimmed the four notifier strings (ntfy.url/token, telegram.token/api_base) at load so the value an adapter receives is exactly the value present() judged. yonote.token and mem0.api_key were not included. A padded credential passes the new blank scan correctly — it is not blank — but reaches the client with leading or trailing whitespace in the auth header and fails at REQUEST time rather than at load, which is the diagnostic gap ah-1cx.3 set out to close. The realistic source is a ${VAR} expansion picking up a trailing newline from an env file. Also cosmetic, from the same family: resolve compares vikunja.web_url untrimmed when deciding whether to derive it from url. Post-ah-1cx.6 a whitespace-only value errors out instead of silently defeating the derivation, so this is now harmless — it is just the last untrimmed comparison left in the file. | A padded yonote.token or mem0.api_key is trimmed at load, and no untrimmed string comparison against "" remains in resolve. | closed | 4 | chore | NULL | NULL | 2026-08-04T23:44:00Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:00:43Z | 2026-08-05T00:00:43Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in baa8075. yonote.token and mem0.api_key were the two credentials ah-1cx.3 missed when it trimmed the four notifier strings. A padded credential is NOT blank, so it passes the set-but-blank scan correctly, but it then reached yonote.New / mem0.New with the whitespace still attached and went into the Authorization / X-API-Key header verbatim — failing at REQUEST time with a 401 far from the config file, which is precisely the diagnostic gap ah-1cx.3 set out to close. The realistic source is a ${VAR} expansion picking up the trailing newline of an env-file line. Both are now strings.TrimSpace'd in resolve, in the same literals that already trim their base URLs, so the value the client receives is exactly the value present() judged. The same change removes the last untrimmed == "" comparison in resolve — the vikunja.web_url derivation test — but NOT naively: trimming that test alone would have REGRESSED, because a whitespace-only web_url would then silently take the value derived from vikunja.url, where today it defeats the derivation and gets caught by ah-1cx.6's http(s) check. So vikunja.web_url was added to blankScanFields and is now reported at load by name, and the scan's doc comment records the move. Cover: a vikunja.web_url row in TestLoadBlankValues's sibling table, plus three subtests pinning that padded yonote.token / mem0.api_key — literal and ${VAR}-expanded — arrive trimmed. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:12Z | 0 | ||||||||||||||||||||||
| ah-1nu | aa28a50dad31c400da81f00695ec62931cbd81a55e02095fdd26864a8b123ace | BUG: vikunja Snapshot mis-parses real Vikunja 2.3.0 kanban response (buckets-with-tasks, not flat tasks) | Found by the first live board test on agent-1 (Vikunja v2.3.0, project 2). internal/vikunja snapshotOnce GETs /projects/{id}/views/{kanbanView}/tasks and decodes the body as a FLAT []wireTask. But real Vikunja 2.3.0 returns a LIST OF BUCKETS, each with an embedded tasks[] array: item fields are {id(=bucket id), title(=bucket title), project_view_id, limit, count, position, tasks:[...]}. So the daemon reads the 9 buckets as 9 pseudo-tasks with bucket_id=0 (unmapped -> ignored) and NEVER descends into bucket.tasks[]. A task correctly placed in Ready (verified: task 1 nested in bucket 8 with bucket_id 8) is invisible to the daemon; it claims nothing and its store has zero task rows. The unit/e2e fakes (internal/vikunja/vikunja_test.go serveTasks, e2e fake_vikunja) return the FLAT shape the daemon expects, so all tests pass against a fixture that does not match reality — the Snapshot path has never actually run against a real board despite SPEC §9 claiming 'verified against Vikunja 2.3.0'. FIX: snapshotOnce must parse the bucket-with-tasks response — iterate buckets, and for each task in bucket.tasks set BucketID = bucket.id (task.bucket_id is also populated). Reconsider pagination (the kanban response is bucket-structured, likely one page of buckets, tasks possibly paginated per bucket via limit/count/position). Update BOTH fakes to emit the real shape so tests validate reality, and re-verify live. Sample of the real shape is available from the live API. DEPLOY: origin git@git.srht.bigb.es:~bigbes/agents-dev; a push triggers builds.sr.ht CI which scp's the binary to agent-1. Fix is code-only; deploy needs a push. | After the fix, dragging a card to Ready on Vikunja project 2 results in the daemon claiming it (worktree + run) and landing it In Review; fakes emit the bucket-with-tasks shape and a regression test covers it. | FIXED IN CODE + tested 2026-07-18 (committed f0d6634). snapshotOnce now parses []wireBucketTasks (buckets with embedded tasks[]), flattens, maps task.bucket_id->canonical, with a truncation guard replacing the old page-cap. Removed the flat-task pagination machinery (maxPages, pagination-header cross-checks). BOTH fakes (unit vikunja_test + e2e fake_vikunja) rewritten to emit the real bucket-with-tasks shape, so tests validate reality; added flatten/unmapped + truncation regression tests. Full unit suite + e2e green. LIVE re-verification on agent-1 still pending a deploy of the fixed binary. | closed | 1 | bug | NULL | NULL | 2026-07-17T20:44:03Z | Eugene Blikh | bigbes@gmail.com | 2026-07-17T21:29:24Z | 2026-07-17T21:29:24Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Fixed (f0d6634) AND verified live on agent-1 2026-07-18. After deploying the fixed binary, the daemon claimed task 1 from Ready, created worktree agent/task-1, ran pi (deepseek via LiteLLM), the agent wrote GREETING.md ('Hello from agenthubd.'), committed 7bb3e26, and the task landed In Review with a diff-stat. The Snapshot bug is dead and the bucket-shape parse is proven against real Vikunja 2.3.0. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||
| ah-1qq | fc68d9a4ec58cc99b6f5568e9d07512e8df1edd1c60b9b1eeb5c8bfd074d38aa | Release infra: version stamping via ldflags + first tagged release | The daemon hardcodes version = dev (cmd/agenthubd/main.go:44) and nothing stamps it: the repo has zero git tags and neither the justfile nor .build.yml passes -ldflags -X main.version. Live agent-1 therefore logs version=dev on every boot and there is no way to tell which build is deployed. Add ldflags stamping (git describe) to the justfile and the CI deploy lane, surface the version in the status API, and cut the first tag. Release-blocking for v0.1.0. Filed 2026-07-20. | STAMPING DONE in 1c96c00; only the tag itself remains, and it is deliberately blocked on the rest of milestone v0.1.0 (a milestone is frozen at its tag, so nothing may be added after). What landed: the justfile derives 'git describe --tags --always --dirty' into a version variable and passes -X main.version to go build ./cmd/... — ONE flag stamps all three mains, because -X's 'main' resolves per link. --always degrades to a short sha before the first tag exists, --dirty marks a build made from an uncommitted tree so a hand-built binary can never be mistaken for a release, and a '|| echo dev' arm covers a tree with no git at all. 'just show-version' prints what the tree would stamp. .build.yml does the same and then runs 'ahub version' as a build-time assertion that the stamp actually landed — so a broken stamp fails CI instead of silently deploying a 'dev' binary. ahub-run gained a version var and a --version flag (answered before the required-flag check, since it is a question about the binary not a request to supervise anything): it is the binary that runs INSIDE the pane, so that is how a human attached to a keep-pane shell identifies the build that supervised the run in front of them. GET /api/v1/status now carries the build string via httpapi.Deps.Version, which defaults to 'dev' when empty so a test server or unstamped build never reports blank — a blank version reads as a serialization bug rather than as 'nobody stamped this'. The boot log line is invisible once the journal has rotated, and status is the one surface an operator can reach without shell access to the box. Cover: TestStatusVersion, both arms. Verified end to end locally: just build produced binaries reporting f11ef03-dirty. REMAINING: cut v0.1.0 once the milestone's other beads close, then confirm on agent-1 that the deployed binary reports the tag rather than a sha. | in_progress | 2 | task | NULL | NULL | 2026-07-20T00:10:40Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:48:55Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:48:55Z | 0 | |||||||||||||||||||||||
| ah-25e | 81a248a73cc547b68a28816e4f3c67ea4f5ca77bf4c95f40233a17b35b901880 | Yonote publish lane: .task/publish.json → bot-authored docs at finalize | Why: the operator wants agents to publish specs/reports/answers INTO Yonote instead of committing them to the project repo — with the write credential never entering the agent's environment. What: a third finalize-time file channel in the SPEC §14.4 family (tasks.json, question.json): the agent writes markdown under .task/publish/ and declares it in .task/publish.json {documents:[{title, file, collection_id?, parent_url?}]}; on successful finalize the daemon creates the docs via the internal/yonote client under the BOT token (publish:true, target = yonote.publish_collection_id or per-entry override, optional nesting under a referenced doc), comments the resulting URLs on the card, emits a yonote_published event. Publish failures never change the run outcome. wave 2.5 — implement after feat/wave2-archive-links merges. Depends on ah-gxa (client: CreateDocument/DocumentInfo) and ah-2lh (yonote config block + prepareTaskDir channel-clearing site). | # Yonote publish lane: .task/publish.json → bot-authored docs at finalize Agents publish specs/reports/answers INTO Yonote instead of committing them to the project repo. The agent declares intent via a file channel; the daemon executes with ITS bot token at finalize and links results on the card. ## Why file-channel, not direct API access from the agent (DECISION) The agent must never hold the Yonote token: worktrees run arbitrary generated code; `.task/` is archived to tar.gz at Done (a token in env or files would fossilize into archives); per-run env plumbing would put a write-capable credential into every zellij session. The daemon already owns a finalize-time file-channel protocol with exactly the right semantics — `.task/tasks.json` (delegation) and `.task/question.json` (ask-user), SPEC §14.4: agent writes declarative intent, daemon acts with its own credentials, results land as card comments. Publishing is the third channel of that family. Attribution lands on the BOT user — uniform and auditable. ## Channel format `.task/publish.json` (attempt-scoped; cleared in prepareTaskDir alongside summary/tasks/question): {"documents": [ {"title": "Design: frobnicator", "file": "publish/design.md", "collection_id": "", "parent_url": ""} ]} - title: required, non-empty, ≤255 chars (Yonote bot/user name limits suggest 255 conventions). - file: required; RELATIVE path resolved under `.task/` (convention: agents put content in `.task/publish/<name>.md`). Guard rigor as repo-slug F8: reject absolute paths, filepath.Clean must stay under .task/, and the opened file must not escape via symlink (os.Root / EvalSymlinks containment check). - collection_id: optional uuid overriding the config default target. - parent_url: optional Yonote doc URL → resolve via yonote.ParseDocRefs(single) + DocumentInfo → parentDocumentId, so a report can nest under the spec it answers (documents.create parentDocumentId is v1-spec'd; child creation itself NOT live-verified — verify in tests against httptest only, live during rollout). - Caps: maxPublishDocs = 3 per run; maxPublishBytes = 512 KiB per file. No server-side text limit was verified — cap defensively; an oversize entry FAILS (listed in the comment), never truncated. Read at SUCCESSFUL finalize only, same site and semantics as readTasksFile: absent → nil (common case); present-but-unparseable → same bounce treatment as a malformed tasks.json (do not silently drop agent intent). ## Config The `yonote` block (introduced by the materialization bead) gains: publish_collection_id: "…" # uuid; the default target collection Validation: when set, must be a UUID shape; lane is ACTIVE only when the yonote client is configured AND publish_collection_id is set. When the client is configured but the collection is not, publish.json entries all fail visibly with "publishing not configured" in the card comment — never silently ignored. Operational prerequisite (document next to the config): the BOT must have read_write on the target collection (collections.add_user — client-bead runbook step 3). ## Daemon flow (finalize, success path, after summary/tasks/question handling) Per entry (≤3, in file order): resolve target collection (override or default) → resolve parent_url if set (its failure fails ONLY that entry) → read + cap content → CreateDocument{Title, CollectionID, ParentDocumentID, Text: content, Publish: true} → record {Title, absURL = cfg base_url + doc.URL} or {Title, err}. - Card comment (goes through the existing markdownToHTML comment path): published to Yonote: - <Title> → <abs url> - <Title> → FAILED: <message> - appendEvent "yonote_published" {"ok": n, "failed": m}. - Publish failures NEVER change the run outcome — the run already succeeded; publication is a side effect. Operator retries by re-running, or publishes manually from the archived tar.gz (the content survives there by construction). - Idempotency (DECISION): re-running a card re-publishes and creates a NEW doc. Accepted for v1 — re-runs are rare, docs are cheap, and dedup would need a doc-id echo channel; revisit only if it annoys in practice (then: UpdateDocument by echoed id). ## Agent contract (role-prompt snippet, config-side; document in deploy notes) To publish a document to Yonote: write markdown to .task/publish/<name>.md and declare it in .task/publish.json as {"documents":[{"title":"…","file":"publish/<name>.md"}]}. Publication happens AFTER your run succeeds; resulting links are posted on the task card. ## Tests - reader: absent → nil; malformed → tasks.json-parity bounce; >3 entries; empty title; path guards (absolute, .., symlink escape); oversize file. - finalize with fake yonote writer (interface seam: CreateDocument + DocumentInfo): 2 ok + 1 fail → comment body lines, event payload, run outcome unchanged; lane-off (no collection id) → "publishing not configured" per entry; parent_url resolve failure isolates the entry; collection_id override honored. - prepareTaskDir clears publish.json + the publish/ dir between attempts. wave 2.5 — implement after feat/wave2-archive-links merges. Depends on: internal/yonote client bead (CreateDocument/DocumentInfo), materialization bead (yonote config block + prepareTaskDir channel-clearing site). | ROLLOUT LIVE-SMOKE CHECKLIST (from wave-2.5 implementer, against bigbes.yonote.ru after deploy): 1) mint bot per ah-gxa runbook (POST /api/v2/bots + token, collections.add_user grant — grant call is spec-only, NOT live-verified), AuthInfo under bot token must show IsBot=true (daemon WARNs at startup otherwise); 2) documents.create with parentDocumentId (child nesting unverified live) + bot WRITE rights on publish_collection_id + publish:true attribution to bot; 3) archived-doc export ((archived) marker path); 4) ExportMarkdown empty-string cases (API-created never-edited draft, database-type doc) fall back to .text; 5) comments.create/list/resolve field names (only Q&A bead ah-ptu uses them). SEMANTICS NOTE: publish fires on ROUTED successful runs too (inside firstFinalize&&success before maybeRoute) — a design stage with publish.json publishes before routing to review; intentional. publish_collection_id (UUID) must be chosen by operator at config staging. DEPENDS ON → ✓ ah-2lh: Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest ● P2 → ✓ ah-gxa: internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth) ● P2 BLOCKS ← ○ ah-ptu: Yonote Q&A bot loop: poll doc comments, answer via board tasks (post-wave-2.5) ● P3 ROLLOUT FACTS (2026-07-18, live-verified): bot agenthub id=34fbc9a3-ec75-4722-a01f-805db3f3ff1b (v2 create needs username field — used bigbes@gmail.com); token agent-1-daemon installed as YONOTE_TOKEN in /etc/agent-hub/env; auth.info isBot=true CONFIRMED; collections.add_user CONFIRMED; Agents collection 8656642e-0297-4d69-b6fb-4246517a015b = publish_collection_id, bot read_write. CAVEAT: bot sees ALL team-default-readable collections (not grant-scoped) — operator may tighten per-collection perms in UI. Config staging after CI deploy: yonote{base_url: https://bigbes.yonote.ru, token: ${YONOTE_TOKEN}, publish_collection_id: 8656642e-0297-4d69-b6fb-4246517a015b}. | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-18T15:04:25Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T16:09:01Z | 2026-07-18T15:59:08Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T15:06:57Z | 0 | |||||||||||||||||||||
| ah-2ef | ff98b88593d96664a9626ff5ba7750acc64df6bde5526cca7d05a55ecc89e4aa | e2e suite rare timing flake (1 FAIL in ~5 runs observed 2026-07-18) | During wave-4 verification one 'go test -tags e2e ./e2e' run printed FAIL while 4 adjacent runs passed (12.6-13.3s). Failing subtest name was not captured (output was filtered). The suite has timing-sensitive subtests (500ms-deadline timeout path, stall watchdog). Next occurrence: rerun with -v, capture the failing subtest, then either widen its deadline margins or de-flake the poll. Not tied to wave-4 changes (harness timing predates them). | closed | 3 | bug | Eugene Blikh | NULL | 2026-07-18T17:03:58Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T18:15:54Z | 2026-07-18T18:15:54Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Merged (fix/runner, reworded from 5f70d17): Status() read events.jsonl before exit.json confirmed completion; a poll straddling a fast exit froze empty PiSessionID/CostUSD into the immutable run row. Both applyExit sites now re-parse the confirmed-complete stream. Reproduced under stress with instrumentation; 26/26 clean unstressed + 13 race runs clean; e2e x3 green at merge. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T17:28:20Z | 0 | |||||||||||||||||||||||
| ah-2lh | 6dae3f01f06b06dcd0357975dff946bac3138df4a0a351273573d592977ecb6e | Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest | Why: the operator authors specs/big documents in Yonote and wants task cards to reference them by URL; the claimed agent must see the CURRENT doc content without the repo ever carrying it. What: optional yonote config block (base_url, token via ${YONOTE_TOKEN} from /etc/agent-hub/env, claim_budget); at claim, scan the frontmatter-stripped description body for https://<yonote-host>/doc/<seg> URLs (works on both raw-markdown and Vikunja TipTap-HTML descriptions), resolve via documents.info, export markdown (with .text fallback), write .task/artifacts/<NN>-<slug>.md via StartSpec.Artifacts in prepareTaskDir, append a '## Reference documents' manifest to the prompt, add an artifacts count to the claim comment + an event. Dead references NEVER fail the claim; every attempt re-exports fresh. wave 2.5 — implement after feat/wave2-archive-links merges. Depends on ah-gxa (client). | # Claim-time Yonote artifact materialization Operator authors specs/big docs in Yonote; a task card references them by URL; at claim the daemon exports each referenced doc to `.task/artifacts/<NN>-<slug>.md` in the worktree and lists them in the prompt. `.task/` is git-excluded (excludeTaskDir) and archived to tar.gz at Done, so artifacts never touch the repo. ## Config (internal/config) New OPTIONAL top-level block, feature fully inert when absent (telegram/ntfy presence pattern): yonote: base_url: "https://bigbes.yonote.ru" # required when present; validateHTTPURL token: "${YONOTE_TOKEN}" # required when present; BOT token (see client bead runbook); ${VAR} from /etc/agent-hub/env via existing expandEnv claim_budget: "90s" # optional; TOTAL wall clock for all exports in one claim; default 90s; positive (parseDuration) `Yonote{BaseURL, Token string; ClaimBudget time.Duration}` + `present()` (trimmed BaseURL or Token non-empty) + validation (both required when present; URL check; strict KnownFields comes free from rawConfig). Trim trailing "/" off BaseURL at resolve (AgentsView precedent). Wiring (internal/deps or cmd wiring, wherever mem0/vikunja clients are built): when present → `yonote.New(BaseURL, Token)`; call AuthInfo once at startup: log identity, WARN if !IsBot; a startup AuthInfo FAILURE logs an error and continues — Yonote outage must never block board work. ## Reference convention (DECISION) Every substring of the card description matching `https?://<host-of-base_url>/doc/<seg>` is a reference; `<seg>` = last path segment `[A-Za-z0-9._~-]+` (query/fragment excluded by charset). Extraction runs over the frontmatter-STRIPPED body (`res.Body` from spec.Resolve — the same text the prompt template receives). Order of first occurrence; dedup by RESOLVED document id (two URL forms of one doc collapse); cap `maxArtifactRefs = 10` (const, not config) — refs past the cap get manifest lines "skipped: over per-task artifact cap". Why bare-URL matching, not an `artifact:` prefix line: (a) descriptions reach the daemon in TWO shapes — raw markdown on daemon-created child cards, and TipTap HTML (`<p>…<a href="URL">text</a></p>`) on operator-edited cards; internal/vikunja passes Description verbatim (board.go), nothing normalizes HTML — a line-anchored convention breaks under HTML rewrapping, host+path substring matching survives both; (b) zero ceremony: paste a link into the card and it just works; (c) false positives are benign — a linked Yonote doc IS relevant context by definition. `/collection/...` and `/share/...` URLs deliberately NOT matched in v1. Resolution: pass `<seg>` verbatim to documents.info (accepts uuid | urlId | slug-urlId — live-verified) → canonical uuid, title, archivedAt. ## Claim flow (internal/reconcile claim(), after renderPrompt succeeds, before runner.Start) refs := yonote.ParseDocRefs(cfg.Yonote.BaseURL, res.Body) // nil client or 0 refs → unchanged behavior ctx2 := context.WithTimeout(ctx, cfg.Yonote.ClaimBudget) // one budget for ALL refs; reconcile loop is single-goroutine (PublishTimeout precedent) serially per ref: doc, err := DocumentInfo(ctx2, ref) // err (404 deleted, 401, timeout…) → unavailable(reason), continue md, err := ExportMarkdown(ctx2, doc.ID) // MUST use doc.ID (uuid) — urlId 400s if err (incl. whiteboard 500) → unavailable(reason) if md == "" → md = doc.Text // API-created never-edited docs keep markdown in .text; database docs are "" both ways if md still "" → unavailable("document exported empty") marker "(archived)" when doc.ArchivedAt != nil — still materialized (archived docs stay readable) Artifact{FileName: fmt.Sprintf("%02d-%s.md", n, slug(doc.Title)), Content: md, Title: doc.Title, SourceURL: cfg.Yonote.BaseURL + doc.URL} budget exhausted → remaining refs unavailable("artifact budget exhausted") slug(): lowercase; non-[a-z0-9] runs → "-"; trim "-"; cap 60 chars; empty → doc.URLID. The NN- ordinal prefix makes collisions impossible. Cyrillic titles will slug to "" often → URLID fallback matters. FAILURE SEMANTICS (spec-style): NOTHING in materialization ever fails or bounces the claim. A dead / archived / empty / oversized-budget reference degrades to a manifest warning line + a claim- comment count + a warn log. Rationale: the doc is context, not a precondition; the operator sees the warning immediately (prompt + card comment) and can fix the link and re-run. No triage bounce (spec is not malformed), no failed state (nothing ran). ## Prompt manifest Reconciler APPENDS to the rendered prompt (role templates untouched; keeps SPEC §12 rendering contract intact): ## Reference documents (.task/artifacts/) Exported from Yonote at claim time; read them before starting; treat as read-only input. 1. "<Title>" — .task/artifacts/01-<slug>.md (source: <abs url>) 2. "<Title>" (archived) — .task/artifacts/02-<slug>.md (source: <abs url>) 3. "<Title or ref>" — UNAVAILABLE (<reason>) (source: <abs url>) ## Ports + runner - ports.StartSpec gains `Artifacts []Artifact`; `type Artifact struct{ FileName, Content, Title, SourceURL string }`. - runner prepareTaskDir: ALWAYS `os.RemoveAll(.task/artifacts)` first (attempt-scoped channel — same rationale as clearing summary/tasks/question: attempt N-1 exports must not leak into attempt N). Then when len(Artifacts)>0: MkdirAll + writeFileAtomic each. Guard: reject FileName containing "/" or ".." (defense in depth; daemon generates them). - RE-CLAIM SEMANTICS (DECISION): every attempt RE-EXPORTS fresh content. Freshness wins over snapshot stability because the whole point is "the current spec", specs get edited between attempts, and each finished attempt's snapshot is already preserved by the Done-archive tar.gz. A doc that died between attempts becomes an UNAVAILABLE manifest line (stale file removed by the RemoveAll), so the agent never reads outdated content silently. ## Observability - Claim comment (existing "attempt N started · …" line) gains, only when refs were found: `· artifacts: N exported[, M unavailable]`. - appendEvent "artifacts" {"exported": N, "unavailable": M} (deduped like existing events). ## Interface seam reconcile depends on a narrow local interface (fake-friendly, matches existing port style): type yonoteExporter interface { DocumentInfo(ctx context.Context, id string) (*yonote.Document, error) ExportMarkdown(ctx context.Context, uuid string) (string, error) } nil = feature off. *yonote.Client satisfies it. ## Tests - config: block absent (inert) / present-partial (errors join) / bad URL / ${VAR} unset problem / budget default + non-positive rejection (mirror telegram/ntfy config tests). - reconcile claim with fake exporter: 2-ref happy path (manifest text, StartSpec.Artifacts, comment suffix, event payload); 404 ref → UNAVAILABLE + claim proceeds; markdown "" → Text fallback; both empty → unavailable; budget timeout → remaining skipped; dedup two URL forms of one doc; TipTap-HTML description with <a href=...>; cap at 10; archived marker; nil exporter → byte-identical prompt to today. - runner: artifacts written under .task/artifacts/; stale dir removed when Artifacts empty; traversal FileName rejected. wave 2.5 — implement after feat/wave2-archive-links merges. Depends on the internal/yonote client bead. | Code-recon evidence: (1) descriptions reach the daemon VERBATIM — internal/vikunja/board.go:110 copies wire Description straight into ports.BoardTask; there is NO html→markdown normalization on read (markdownToHTML in internal/vikunja/markdown.go is write-side, comments only) — so operator-edited cards arrive as TipTap HTML while daemon-created child cards (reconcile.go childDescription) are raw markdown: the URL scanner must handle both, which is why the convention is host+path substring matching, not an 'artifact:' line. (2) Hook point: internal/reconcile/reconcile.go claim(), after renderPrompt success (~line 362-404) and before runner.Start; prompt is plain string concatenation — role templates (SPEC §12) stay untouched. (3) File writing: internal/runner/runner.go prepareTaskDir (~591-633) already clears attempt-scoped channels (summary/tasks/question) — artifacts dir clearing joins that list; writeFileAtomic + excludeTaskDir + Done-archive tar.gz already cover durability/git-exclusion/archival (SPEC §9). (4) API evidence for the fallback chain and failure modes lives in ah-gxa notes: /markdown is uuid-only, returns '' for API-created-never-edited drafts and database docs, 500s for whiteboards, and .text is plaintext after editor edits — hence markdown → .text → UNAVAILABLE. (5) claim runs on the single reconcile goroutine — claim_budget bounds total materialization wall-clock (PublishTimeout precedent, config.go). (6) documents.info of an ARCHIVED doc was NOT live-verifiable (workspace has no archived docs); Outline heritage says it returns the doc with archivedAt set — treated as exportable + '(archived)' marker; implementer verifies by archiving a scratch doc in the UI once. Nested docs: no doc with childrenCount>0 exists in the workspace; /markdown has no children param (single-doc export is spec-consistent) — v1 semantics = SINGLE doc per reference, children never walked (documents.list {parentDocumentId} exists if a future version wants a tree). | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-18T15:04:08Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T15:59:08Z | 2026-07-18T15:59:08Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T15:06:57Z | 0 | |||||||||||||||||||||
| ah-46h | df6063b052e63e08cf5bf8e437f4ed6ee2b8e6f2e100d61d9f0b3cfda85ddd9b | httpapi: the tools 500 path echoes err.Error() into the response body | Found while refreshing SPEC §15 (ah-e5l). internal/httpapi/tools.go:178 does writeToolErr(w, http.StatusInternalServerError, err.Error(), nil), unlike the 401/400 paths in the same file, which use constant strings precisely so the response echoes nothing back to the caller. Not a token leak today: the errors it can carry are Vikunja/store wraps that include method + path but no credential. It is still the one place the tools API returns internal detail to a caller that is, by design, an LLM running attacker-influenced content — and it is inconsistent with the constant-body discipline the same file applies to auth failures, which SPEC §15 now documents as the rule. Fix: log the real error at the daemon (where the operator can see it) and return a constant 500 body. | No /api/tool/* response body contains a wrapped internal error. The detail is logged instead, and a test asserts the 500 body is the constant. | open | 4 | chore | NULL | NULL | 2026-08-05T00:10:04Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:10:04Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-4el | a7f08ae44a7b072a88ceeb4cbb9b41c058403281fc3018ecd03f004f08069332 | Review-verdict-driven transition: changes-requested back to Ready for coder | Every successful run lands In Review; no verdict-aware transition, so a validator changes-requested just sits for a human to drag back. Intentional for now (ah-gs7 human-driven handoff) but user asked for it. Build opt-in loop: parse validator verdict (needs machine-readable verdict), on changes-requested move card back to Ready under coder/fix role with the review as context. Ties into Stage 4 (ah-0ge). | DECIDED 2026-07-18 (user): dedicated FIXER role. Generalized routing design: - task_types.<t> gains on_success: <type> and on_changes_requested: <type> (target is a TASK TYPE name; validated at config load). - Routing = swap board label type:<old> -> type:<target> (Vikunja label API, lookup by title), move card to Ready, comment 'routed to <type> (round n/cap)'. Label-swap failure -> fall back In Review + warn comment. - Verdict channel: first line of .task/summary.md must be 'Verdict: pass' or 'Verdict: changes-requested' (case-insensitive). Parsed into RunSummary.Verdict. Missing/absent verdict = pass-equivalent (lands In Review as today). - changes-requested + on_changes_requested set -> route; pass/none + on_success set -> route; otherwise In Review (current behavior). Failure paths unchanged. - Loop guard: per-task auto_routes counter persisted in store, incremented on EVERY automated route (both edges); config routing.max_auto_routes default 4 (design->review->fix->review = 3). At cap -> In Review + 'routing cap reached' comment. Human manual relabel+Ready still works past cap. - New prompts/fixer.md (address ## Review findings on same branch); validator.md updated to mandate the Verdict first line. - Intended live pipeline: design --on_success--> review --changes-requested--> fix --on_success--> review --pass--> In Review (human). | DESIGN PROPOSAL (held for user sign-off — changes the core reconcile state machine; not live-verifiable while pushes/deploy are blocked). Now unblocked by ah-tz0 (.task/summary.md exists). Proposed: 1. MACHINE-READABLE VERDICT: don't parse free-text. Have the validator write a structured line the daemon can key on — e.g. a first line 'Verdict: pass' | 'Verdict: changes-requested' in .task/summary.md (update prompts/validator.md + validate skill), and add a domain field Verdict (parsed in runner.Summary, empty for non-validator roles). 2. OPT-IN CONFIG: task_types.review gains 'on_changes_requested: <bucket|role>' (default: none = current behavior, lands In Review for a human). When set e.g. to role 'coder', a changes-requested validator run moves the card to Ready under that role instead of In Review, carrying the review as context (the ## Review is already on the branch). 3. LOOP GUARD: reuse the delegation generation cap (max_generation) or add a review_rounds cap so design->review->coder->review can't cycle forever. A 'pass' verdict always lands In Review (human ships it). 4. STATE MACHINE: this is a new transition in internal/reconcile (finalize path) + internal/domain/transitions.go. Cover with reconcile_test.go (injected clock/fakes) AND an e2e flow before shipping. Open questions for user: (a) auto-loop to 'coder' or a dedicated 'fixer' role? (b) opt-in per task_type (proposed) vs global? (c) cap value / mechanism. HOLDING until answered. | closed | 3 | feature | Eugene Blikh | NULL | 2026-07-18T06:30:21Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T14:17:36Z | 2026-07-18T14:17:36Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Merged to master 658f128: verdict-driven auto-routing (on_success/on_changes_requested per task_type, label swap + Ready, AutoRoutes cap 4, fixer role, e2e pipeline test design->review->fix->review->In Review). Live rollout (agent-1 config + type:fix board label) pending next CI deploy — noted in ah-0ge.5/ah-07g wave. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T13:21:51Z | 0 | |||||||||||||||||||||
| ah-58f | ac69891cb925ac8c16f81cb926cc68234fbe2a845af62d30f267e19342797b87 | ahub tool answer without an outstanding question echoes the latest comment as the answer | Live smoke 2026-07-18: on a freshly claimed card (no ask-user issued), 'ahub tool answer' exited 0 and printed the claim comment's raw HTML as the answer. detectAnswer treats any latest-comment-without-sentinel as an answer; it should require that an asked-sentinel comment exists (an outstanding question) before anything counts as an answer, else 204/no-answer. Also consider stripping HTML for CLI output. Low impact (agents call answer after ask-user), found during rollout smoke. | closed | 3 | bug | Eugene Blikh | NULL | 2026-07-18T17:10:20Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T17:43:23Z | 2026-07-18T17:43:23Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Merged 75b9a54: detectAnswer now requires an outstanding question (last asked-marker must exist with a comment after it) and returns plain text (dep-free HTML strip). BONUS CATCH: today's ah-bkr markdown→HTML conversion had silently broken raw-sentinel matching against live Vikunja-stored comments (escaped form) — detection now keys on the delimiter-free marker core present in both forms, repairing the live question loop before anyone hit it. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T17:28:20Z | 0 | |||||||||||||||||||||||
| ah-6ei | d903b7798442ca41f9cab7dc12f1e9ded4c222c4860ea1cad13f7311720861eb | Live end-to-end verification of designer/validator roles on a real board | ah-gs7 delivered the design/validate skills + designer/validator role prompts + config wiring, all component-verified (skills load in pi 0.73.1; both roles produce the right artifacts headless; config.example loads). The ONE piece not yet exercised is the literal daemon flow: board card labeled type:design -> daemon claims -> designer role runs via LiteLLM -> lands In Review with a Design doc -> moved to Ready under type:review -> validator writes a ## Review. Needs the phoebe-lab infra (Vikunja board + LiteLLM + real pi/zellij). Depends on ah-k23. | On a live board: a type:design card lands In Review with docs/tasks/<slug>.md ## Design; relabeled type:review and set Ready, it produces a ## Review against that same file on the same branch. | closed | 3 | task | Eugene Blikh | NULL | 2026-07-17T19:49:21Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T06:27:20Z | 2026-07-18T06:27:20Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Verified live on project 2 / agent-1. Flushed out the real gap: (1) live config lacked design/review task_types+designer/validator roles+skills_dir; (2) NO CI deploy lane existed (.build.yml) and the agent1-deploy key was lost. Built the CI lane (.build.yml, commit 38dd035, build #226 SUCCESS: builds 3 cmds + ships bin/prompts/skills to /opt/agent-hub + restarts), regenerated+registered the deploy key (builds secret cc6bd011), wired designer/validator into the live config. Then ran the flow: task 2 type:design -> designer -> In Review with ## Design (ae7db66); relabel type:review+Ready -> validator -> ## Review changes-requested on the SAME file/branch (374b583, agent/task-2). Validator even caught a real arithmetic bug in the design's edge-case table. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T05:09:01Z | 0 | ||||||||||||||||||||||
| ah-6u0 | aecd5599069260da43810d3d7ad0db775e36028ce4175b282a0cd26f6aa1a911 | docs: SPEC section 6 RunStatus struct omits MetaAttempt | Pre-existing Stage 1 drift found during the Stage 2 SPEC sync (ah-xuc.12): the RunStatus struct listing in SPEC section 6 lacks the MetaAttempt field even though section 9 prose relies on RunStatus.MetaAttempt (the attempt recorded in .task/meta.json, 0 when absent). Make the section 6 struct field-complete to match internal/ports/ports.go. | closed | 4 | task | Eugene Blikh | NULL | 2026-07-13T08:10:10Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T09:26:46Z | 2026-07-13T09:26:46Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 279b733: MetaAttempt added to SPEC section 6 RunStatus listing, alignment verified against gofmt; accuracy-reviewed in the follow-up gate | 0 | NULL | NULL | NULL | 0 | 2026-07-13T08:46:20Z | 0 | |||||||||||||||||||||||
| ah-943 | b234e7fe8bc9f64d11011d2b27d1991623feb067d1858d5d2b1d605d27665fd5 | Refresh phoebe-lab agent-hub provisioning template + bootstrap for designer/validator | The live /etc/agent-hub/agenthub.yaml on agent-1 was hand-wired for project 2, repo agent-demo, deepseek model, AND now designer/validator roles + design/review task_types + skills_dir=/opt/agent-hub/skills. The phoebe-lab/agent-hub/config/agenthub.yaml.example (first-boot seed) is stale vs this: still project_id 1, repo demo, coder-only, no skills_dir/task_types. bootstrap.sh also only pre-creates /opt/agent-hub/{bin,prompts} (skills/ gets created by the CI tar overlay, which works but isn't declared). Update the example + bootstrap so a fresh box provisions the full role set. Also: keys/agent1-deploy.pub was regenerated this session (new ed25519, matching builds.sr.ht secret cc6bd011) and is modified-uncommitted in the phoebe-lab repo — commit it or a redeploy/bootstrap reinstalls the OLD dead key. | closed | 3 | task | Eugene Blikh | NULL | 2026-07-18T06:27:20Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T06:41:21Z | 2026-07-18T06:41:21Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Provisioning template (config/agenthub.yaml.example) now scaffolds skills_dir + designer/validator roles + design/review task_types; bootstrap.sh pre-creates /opt/agent-hub/skills. Committed phoebe-lab 34bcec3 (local; deploy via labng). Pubkey rotation committed earlier (abab805). | 0 | NULL | NULL | NULL | 0 | 2026-07-18T06:40:04Z | 0 | |||||||||||||||||||||||
| ah-9r4 | c8e9bb1e135799eacb8565a6f41478d32efa61f0c311dedb27b638a4b6f912c0 | DECISION: worktree/branch topology for agent-created child tasks | Blocks the delegation loop. Parent X owns worktree <work_root>/<slug>/task-X on branch agent/task-X. Where does child Y work? git refuses to check the same branch out into two worktrees, so Y cannot enter X's. Branching Y off the default branch means a later merge into X's divergent branch; branching Y off agent/task-X breaks the review/publish path, because gitLogOneline/gitDiffStat (internal/runner/commands.go) compute <default>..HEAD against the repo default branch and would report all of X's work as Y's diff. Proposed split by whether the role writes code: ADVISORY roles (research/review/design — return text, touch no code) get a detached-HEAD worktree at X's tip. git permits many worktrees on the same COMMIT, just not the same BRANCH, so Y reads X's WIP with no collision and no merge. Only WRITER roles need real branches and a merge story — and the advisory-only subset may cover the useful cases, deferring the writer problem entirely. Verify the detached-HEAD claim against the pinned git before building on it. | DECISION (verified on git 2.55.0, 2026-07-17): SPLIT roles by whether they write code. ADVISORY roles (research/review/design — read parent WIP, return TEXT, publish nothing): each child gets a DETACHED-HEAD worktree at the parent's tip commit: git worktree add --detach <child-wt> <parent-tip-sha> Proven: child sees parent's uncommitted-into-branch WIP (the parent's committed tip), runs on 'HEAD (no branch)', and any commits it makes do NOT move the parent's agent/task-<id> branch. Teardown (git worktree remove) leaves only unreferenced commits — no branch leak, no merge. This is the recommended FIRST and possibly ONLY implementation. WRITER roles (produce code that must land on the parent's branch): DEFERRED. They need a real branch plus a merge story, and note that gitDiffStat/gitLogOneline compute <default>..HEAD — a writer child branched off agent/task-<id> would misattribute ALL of the parent's work as the child's diff, so a writer child needs a different diff base (merge-base against the parent branch, not the repo default). Do not build until an advisory-only loop proves insufficient. Confirmed anti-pattern: git refuses 'worktree add <path> agent/task-X' while X's worktree holds that branch ('already used by worktree at ...'), so a child can never simply enter the parent's branch. CODE SEAM for ah-0ge.1: the runner's worktree creation (internal/runner/commands.go gitWorktreeAddNewBranch, base=default) needs a base-override path for child tasks: detached add at the parent tip instead of a new branch off default. Child worktree path can follow the normal <work_root>/<slug>/task-<childid> convention; only the base differs. | A written decision recording: which roles are advisory vs writer, how each child's worktree base is chosen, and what the diffstat base is for a child (if writer roles are in scope at all). | closed | 2 | task | NULL | NULL | 2026-07-17T14:37:26Z | Eugene Blikh | bigbes@gmail.com | 2026-07-17T15:49:21Z | 2026-07-17T15:49:21Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Decided: advisory children use detached-HEAD worktree at parent tip (verified git 2.55.0); writer children deferred. Full rationale + code seam in the design field. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||
| ah-a0y | 1f451cf42cd3f15fe24298bebe9e07a8aa2bcd7c5ec4d3bcd9e538a26d33a3fb | store: MarkQAAnswered's once-only claim is the caller's, not the SQL's | Found while refreshing SPEC §15/§7 (ah-e5l). internal/store/store.go MarkQAAnswered's docstring says it stamps answered_at 'making the answer delivery once-only'. The SQL is an unconditional 'UPDATE yonote_qa SET answered_at = ? WHERE task_id = ?' with no 'AND answered_at IS NULL'. The once-only property actually comes from the CALLER: internal/reconcile/qa.go:385 guards with 'if q == nil || q.AnsweredAt != nil { ... }'. SPEC §6 repeats the same overstatement. Low severity today — the single reconcile goroutine makes the caller-side guard sufficient — but a second caller (a retry path, a manual re-deliver, the mid-run tools channel) would silently break it and post a duplicate Yonote reply under the bot identity. | Deciding between the two fixes is the actual work, and they are not equivalent. (a) Make the SQL the enforcement point: add 'AND answered_at IS NULL'. This is stronger — a future second caller cannot break it — but it collides with the method's OTHER documented contract: 'Idempotent: re-marking refreshes the stamp on the same row; a missing row is an error (the caller just read it)'. With the guard added, a re-mark affects 0 rows, which the existing n==0 branch reports as 'no qa row' — wrong, the row exists. So (a) also requires deciding what a re-mark means (a no-op returning nil is the natural once-only semantics) and distinguishing 'already answered' from 'row absent', e.g. with a follow-up existence probe or RETURNING. (b) Fix the docstring (and SPEC §6) to state the real contract: the stamp is a RECORD, and once-only comes from the caller's read-then-guard. (a) is the better end state. Do it in a change that also touches reconcile/qa.go, so the caller's guard and the store's guarantee are decided together — this was deliberately not done on 2026-08-05 because internal/reconcile was mid-edit by another agent. | MarkQAAnswered's doc comment, SPEC §6 and the SQL all describe the same contract. If the SQL becomes the enforcement point, a second MarkQAAnswered call on an answered row is a no-op that does not report a missing row, and a genuinely missing row still errors. Covered by a store test. | open | 4 | bug | NULL | NULL | 2026-08-05T00:10:04Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:10:04Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||
| ah-a6f | 6af2a6590cb99466cc1eb91edd91cc384fee7a0da9d277862587d8eeda63d644 | e2e: update comment assertions to HTML (ah-bkr fallout) | ah-bkr converts comments to HTML at the Comment() boundary; the build-tagged e2e suite (not run by ah-bkr subagent, which only tested its package) still asserted the markdown form (branch `x`, outcome `timeout`). Updated 5 assertions to the <code> HTML form. Full e2e green. | closed | 3 | task | NULL | NULL | 2026-07-18T06:54:54Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T06:54:54Z | 2026-07-18T06:54:54Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Updated 5 e2e comment assertions (branch/worktree/outcome) from markdown backticks to <code> HTML. Full e2e suite passes (10.95s). Root cause: ah-bkr subagent scoped to its own package; build-tagged e2e wasn't exercised until the integration sweep. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-bkr | 71a63b844de91dc83b0eb9f07dc18564b27c29ebdff9d7337726d421f219f268 | Vikunja comments render literal markdown fences — post HTML instead | The In Review notification comment is built as markdown (internal/reconcile/comments.go successComment: triple-backtick fences around the diff stat, backtick spans). Vikunja stores/renders task comments as HTML (TipTap rich-text), so the markdown shows LITERALLY in the UI (screenshot: raw fences and + diffstat shown verbatim). internal/vikunja Comment() sends the string as-is to PUT /tasks/{id}/comments. Fix: render markdown to HTML (or emit HTML) before posting. Affects every daemon comment. | closed | 2 | bug | Eugene Blikh | NULL | 2026-07-18T06:30:20Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T06:44:12Z | 2026-07-18T06:44:12Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | markdownToHTML converts outbound comments to HTML at the vikunja Comment() boundary (dep-free, escaping + placeholder-safe). Builders still emit markdown (comments_test green). Verified: 89 subtests pass; live Vikunja round-trip confirmed the API stores <p>/<pre><code>/<ul>/<a> intact (markdown showed literally before). Merged to master (f6098a9). | 0 | NULL | NULL | NULL | 0 | 2026-07-18T06:38:47Z | 0 | |||||||||||||||||||||||
| ah-ddd | 2b245b73220440977211c08547be4a22c630b6b314adfc53409f16a96a60f414 | ntfy: notifier for ntfy-compatible endpoints (Prism) | The operator runs Prism (phoebe-lab/prism), an ntfy-compatible notification gateway at https://prism.bigb.es/{topic} that delivers to Telegram and owns the TG proxy egress itself (patched Telego + lab singbox). Publishing to it is a plain lab-local HTTPS POST — no HTTPS_PROXY handling needed in the daemon (prism.bigb.es is inside the NO_PROXY zone). This replaces the operational need for the direct Telegram path; internal/telegram stays as an alternative. New package internal/ntfy implementing ports.Notifier: POST the configured topic URL with Authorization: Bearer <token>, Content-Type application/json, body {"message": <text>} (title omitted — ntfy treats it as optional; per the Prism README the JSON publish shape is {"title","message"}). 10s client timeout, bounded response read, non-2xx = error with a short body prefix, bearer token never in logs or error strings (redact like internal/telegram does), no retries (Notify is best-effort by contract). Config: ntfy block {url, token} — present when either is set, then both required; url must parse as http(s). Configuring BOTH ntfy and telegram is a config error (exactly one notifier; explicit over precedence). config.example.yaml gains a commented block pointing at a Prism topic URL with ${PRISM_API_KEY}. cmd/agenthubd: notifier selection becomes ntfy | telegram | slog no-op (config validation guarantees not-both); construction error fatal at startup like telegram. Docs: SPEC section 4 layout (internal/ntfy), section 6 Notifier comment, section 12 config example + validation rules, section 15 security note (bearer key via env ref, token redaction). | go test ./internal/ntfy/... ./internal/config/... ./cmd/... green with -race on ntfy; httptest covers success, non-2xx, token redaction, context cancellation; config table tests cover both-or-neither, bad url, both-notifiers-configured error; wiring test asserts ntfy selected when configured; full go test ./... and -tags e2e green; SPEC updated in the same style as the Stage 2 sync | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-13T09:44:41Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T10:20:36Z | 2026-07-13T10:20:36Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 378216d+6734986+4c3c350+5438f7c + review fix (proxy wording, trimmed presence checks); reviewer merge-ready (redirect bearer-strip verified against stdlib, no token leakage); validator 11/11 incl. live wire probe matching the Prism publish shape exactly | 0 | NULL | NULL | NULL | 0 | 2026-07-13T09:44:55Z | 0 | ||||||||||||||||||||||
| ah-e5l | df6ff8af1bf5b6da0c0ec76de99576e8339604959b0fbf074660dfe418de3d22 | SPEC section 15: security notes miss the entire Stage 4 credential surface | Found during the ah-efe sweep; outside that epic's assigned sections. §15 is still headed 'Security notes (Stages 1-2)' and covers none of what Stage 3-4 added: - the per-task bearer tokens (256-bit, bound to (task id, attempt), in-memory registry only, constant-time compare, uniform 401 that echoes nothing, dropped on daemon restart); - .task/tool-auth.json at mode 0600 — the file that puts a live credential inside the agent's own worktree; - the Yonote bot token, and the token-in-URL vs token-in-header distinction between Telegram and ntfy that the redaction rules depend on. §9 and §11 now both POINT AT §15 for the 0600 rationale after the ah-efe refresh, so the gap is a dangling reference rather than merely an omission. | §15 is re-headed for the delivered stages and documents the bearer-token lifetime and blast radius, the 0600 tool-auth file, and every credential in the §1 services table with how each is transported and redacted. The §9 and §11 cross-references resolve to real text. | closed | 3 | chore | NULL | NULL | 2026-08-04T23:52:27Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:04:02Z | 2026-08-05T00:04:02Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 6b7bacf. §15 was headed 'Stages 1-2' and described a credential surface predating the entire Stage 3-4 story; §9's 'it carries a bearer token (§15)' pointed into text that said nothing about bearer tokens. §15 is now headed 'Stages 1-4' and opens with an EXPLICIT THREAT MODEL — daemon, zellij server, ahub-run, pi and worktrees are all one unix user, loopback is an assumption not a boundary — so the rest reads as 'don't write secrets down' rather than as containment. It sweeps every credential in §1's services table as a list with transport and redaction: Vikunja token (Bearer header; errors carry method/path/status/body-prefix), webhook secret (inbound HMAC, constant-time, uniform 401), LiteLLM key (never reaches the daemon), Telegram (token IN THE URL, redactURLError unwraps the *url.Error), ntfy (token in the HEADER so the URL is safe, plus the NO_PROXY carve-out), mem0 X-API-Key, the Yonote BOT token (Bearer header, APIError never carries headers, bot identity owns every published doc and Q&A reply), sourcehut SSH (daemon never handles key material), and AgentsView (no credential). Dedicated paragraphs document the per-task bearer token — 32 bytes of crypto/rand hex-encoded, bound to one (task id, attempt), mint idempotent per attempt, invalidate at finalize, in-memory-only registry so a restart drops every in-flight token, subtle.ConstantTimeCompare with no early break, uniform 401 that echoes nothing, blast radius of exactly three calls on one task — and .task/tool-auth.json at 0600: the one live credential inside the agent's own worktree, defense in depth rather than a boundary, attempt-scoped and cleared so tools_api:false leaves no stale credential and tool calls fail closed, git-excluded, but carried into ArchiveWorktree's owner-only tarball with an already-invalidated token. Also added: the three routes that remain unauthenticated on purpose. ONE CLAIM HAD TO BE CORRECTED RATHER THAN EXTENDED — the old 'the pi process inherits only the env it needs' is FALSE (nothing sets cmd.Env anywhere), so every token the daemon expands is readable from inside the agent's pane. The reality is now written down and the fix is filed as a P1 bead. Also reported, not fixed: internal/store MarkQAAnswered's docstring claims it makes delivery once-only but the SQL has no 'AND answered_at IS NULL' — the property actually comes from the caller's guard in reconcile/qa.go:385; and internal/httpapi/tools.go:178 puts err.Error() into the 500 body, the one place the tools API echoes internal detail, inconsistent with the constant-body discipline the same file applies to auth failures. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:12Z | 0 | ||||||||||||||||||||||
| ah-efe | b6624fe8ff78d857e161202ca8ce97b775e0f0165a66178ad1a009f8cf78859f | Docs: SPEC refresh to Stage 3-4 + Yonote reality | Milestone for documentation drift found by the 2026-07-20 SPEC-vs-implementation audit. SPEC.md declares itself authoritative for Stages 1-2 but its normative sections (domain, ports, HTTP surface, config) were never updated as Stages 3-4, verdict routing, Yonote publish/QA and mem0 landed. All children are update-SPEC chores; no code changes. | closed | 3 | epic | NULL | NULL | 2026-07-19T23:37:18Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:50:21Z | 2026-08-04T23:50:21Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Epic complete: all five children closed (ah-efe.1 §5/§6, ah-efe.2 §12, ah-efe.3 §1/§4/§11, ah-efe.4 CLAUDE.md/AGENTS.md, ah-efe.5 §9/§14). SPEC.md now describes delivered reality across every section the epic covered, and three outright errors were corrected rather than just filled in: ask-user marked future work when it shipped, blocked/question called parked when they are real states since Stage 4, and the 'pi --session resume' design that was superseded by a blocking CLI poll. Two SPEC gaps found during the sweep fall OUTSIDE this epic's scope and were filed separately: §7 (storage schema, stale by four migrations) and §15 (security notes, missing the whole Stage-4 credential surface). | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-efe.1 | 884bcca39d4aaee0fdbe2605220ac5c3264a452b21a1eaf9fff99288358faab6 | SPEC section 5/6: refresh domain model and port interfaces to delivered reality | Normative sections are stale: CanClaim omits ready as a claimable state (transitions.go:19 allows it for the ah-4el verdict-routing re-claim path); the State const block is missing blocked/question; TaskRecord lacks AutoRoutes, RunSummary lacks Summary/Verdict; RequestedTask/QARequest/PublishDoc types are absent; Board is missing CreateTask/Comments/SwapLabel, Runner missing RequestedTasks/AskedQuestion/Answer/PublishRequests/ArchiveWorktree, Store missing the lineage/kv/qa methods, StartSpec gained Artifacts+ToolToken. Sync both sections to internal/domain + internal/ports or add an explicit Stage 3-4 additions subsection. SPEC-gap audit 2026-07-20. | closed | 3 | chore | NULL | NULL | 2026-07-19T23:38:08Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:50:20Z | 2026-08-04T23:50:20Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 119b84d. §5/§6 contradicted nothing in §12 but were roughly two stages behind the code. §5's State block was missing StateBlocked and StateQuestion (the two Stage-4 real states); the RunState* and Verdict* const blocks were absent; TaskRecord lacked AutoRoutes; RunSummary lacked Summary/Verdict; and RequestedTask, QARequest and PublishDoc did not appear at all — every one now does, field-for-field against internal/domain/types.go. OutcomeError's comment still read 'exit != 0', which 665e805 (ah-tqc) falsified: a zero exit contradicted by the event stream's last message_end stopReason 'error' now also produces error, so §5 was contradicting the §9 prose that same commit added. Corrected, and Run.State now names the RunState* constants instead of bare strings. The CanClaim paragraph omitted 'ready' — claimable since the ah-4el verdict-routing re-claim path — so it now lists the full claimable set, names the deliberately-unclaimable complement {in_progress, blocked, question} and why, and mentions States()/Valid()/CanonicalBuckets(). §6's preamble claimed reconcile imports only domain/ports/config/spec; it now records the three Stage-3/4 additions (ctxpack, mem0, yonote) and why none is a port. Board gained CreateTask/Comments/SwapLabel plus NewTask; StartSpec gained Artifacts and ToolToken plus the Artifact type; Runner gained RequestedTasks/AskedQuestion/Answer/PublishRequests (documented as one zero-value-means-not-requested family) plus ArchiveWorktree and ArchiveResult; Store gained the lineage (RecordChild/GenerationOf/ParentOf/ChildIDsOf), kv (GetKV/SetKV) and Q&A (CreateQA/QAByTask/QAByComment/MarkQAAnswered) methods. §5 principle 5 was already correct and left alone. Every signature and comment is traceable to internal/domain/ or internal/ports/ as of this commit. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:39:08Z | 0 | |||||||||||||||||||||||
| ah-efe.2 | ee6d9e1db341436bcc0f1713a251ccfa45fc1d47a01f9dbd02f7f0efcd7caef2 | SPEC section 12: config surface missing Stage 3-4 + Yonote blocks; ask-user wrongly marked future work | Section 12 lacks archive_dir, task_types on_success/on_changes_requested + routing.max_auto_routes, agentsview, yonote (claim_budget, publish_collection_id, nested qa block), mem0, tools_api, and the designer/validator/fixer roles — all shipped and validated in internal/config + config.example.yaml (which agree with each other; only SPEC lags). Also line 784 still calls the ahub tool ask-user CLI-over-HTTP variant future work, directly contradicting delivered ah-0ge.6 and SPEC section 5 principle 5 — delete the stale parenthetical. SPEC-gap audit 2026-07-20. | closed | 3 | chore | NULL | NULL | 2026-07-19T23:38:12Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:38:07Z | 2026-08-04T23:38:07Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 54b2176. Section 12 had drifted ~2 stages behind the code. ADDED: archive_dir; the on_success/on_changes_requested routing keys and routing.max_auto_routes; the designer/validator/fixer roles and their design→review→fix task types; and the whole agentsview, yonote (claim_budget, publish_collection_id, nested qa), mem0 and tools_api blocks — all of which already existed in internal/config and config.example.yaml, which agreed with each other. Each got its own validation bullet with the real default (archive_dir → <work_root>/archive, max_auto_routes → 4, claim_budget → 90s, qa.bucket → ready, qa.poll_interval → 60s, tools_api → ON via a *bool), plus a lead-in naming the shared presence pattern the optional integration blocks follow. CORRECTED two outright errors: the parenthetical calling the ahub tool ask-user CLI-over-HTTP variant future work (it shipped in 07bd9d4/03f4496 — the file channel and the mid-run channel are now presented as two transports over identical board state), and the bucket-map comment calling blocked/question parked when §10 has made them real states since Stage 4 (triage is now the one parked bucket, map reordered to match domain.CanonicalBuckets()). NEW PROSE on what the config gates: the mid-run tools API, the three Yonote lanes, the mem0 lifecycle, and the Context Pack documented as deliberately having NO config surface (order and budgets are package constants). Defaults spot-verified against internal/config/config.go before commit. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:32:44Z | 0 | |||||||||||||||||||||||
| ah-efe.3 | 63b0d2b6e60ab09dddf7f4c58bb8e06ab9fd1fd7f80ce807c8f484e29595c40f | SPEC sections 1/4/11: external-services table, repo layout tree, HTTP surface sweep | Low-priority staleness bundle: section 1 services table omits ntfy/Prism and Yonote; section 4 layout tree omits internal/mem0, ctxpack, yonote, deps; section 11 (and the httpapi package doc-comment) lists only healthz/run-exit/status/webhook, missing the three Stage-4 /api/tool/* routes. One sweep commit. SPEC-gap audit 2026-07-20. | Concrete §11 defects found during the §12 refresh (ah-efe.2), to fold into this bead's work: - The §11 heading says 'Stages 1-2' but internal/httpapi now also serves the Stage 4 tools surface. - The endpoint list omits POST /api/tool/task-create, POST /api/tool/ask-user and GET /api/tool/answer entirely. Document that they are BEARER-authenticated (unlike /api/v1/status, which stays unauthenticated and loopback-trusted) and that they are registered only when Deps.Tools is wired — i.e. only when tools_api is on. Nothing contradicting §12 was found in §5/§6 (ah-efe.1); §5 principle 5 ('Agent-facing tools are CLI-over-HTTP (Stage 4)') is consistent with the delivered surface. §9 and §14 defects were split into ah-efe.5. | closed | 4 | chore | NULL | NULL | 2026-07-19T23:38:15Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:50:04Z | 2026-08-04T23:50:04Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 119b84d. §1's services table omitted the ntfy/Prism gateway and Yonote, and described AgentsView's auth as 'pg push from client side' without saying the daemon never calls it. The table now has eight rows ordered by stage, distinguishes Telegram's token-in-URL from ntfy's token-in-header (the §15 redaction distinction), marks Yonote as a BOT token at 1.47.1, and a new paragraph states that only Vikunja is mandatory, that telegram/ntfy are the mutually exclusive notifier options, and that AgentsView receives NOTHING from agenthubd — the daemon only renders <base_url>/sessions/<machine>~pi:<id> links. §4's tree was missing internal/mem0, internal/ctxpack, internal/yonote and internal/deps, still showed cmd/ahub/main.go alone, and omitted skills/; all added, with a new paragraph explaining that mem0/ctxpack/yonote are deliberately NOT ports (pure renderer; optional single-consumer clients consumed via local interfaces in reconcile), and the dependency note corrected to include go.bigb.es/auxilia and testify while confirming Stages 2-4 added no new external dependency. §11's heading claimed 'Stages 1-2' and its list stopped at four routes; it now covers all seven, documents POST /api/tool/task-create, POST /api/tool/ask-user and GET /api/tool/answer with their real bodies and status codes, states that all three are BEARER-authenticated with the per-task token (constant-time compare, uniform 401 that echoes nothing) while GET /api/v1/status stays unauthenticated and loopback-trusted, and that they register only when Deps.Tools is wired — i.e. only when tools_api is on, the same gate shape as webhook_secret. The endpoint list was swept against the real mux.HandleFunc registrations; no other route exists. Also fixed §3's binaries table, which still described ahub tool as a future subcommand, and folded in ah-1qq's new status version field. UNVERIFIED, left alone: §1 lists sourcehut as git@git.srht.bigb.es while §12's example review_remote is git@srht.bigb.es:~bigbes/demo-repo — one of the two is wrong and it needs an operator to say which. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:39:08Z | 0 | ||||||||||||||||||||||
| ah-efe.4 | dad303fab4c573330090b8655ae49dba0cde42ce8308851bcc37c0007cbb7d7a | Fill CLAUDE.md Build/Architecture/Conventions placeholder sections | Project CLAUDE.md still carries three unedited scaffold stubs (Build and Test, Architecture Overview, Conventions and Patterns) while the real commands live in AGENTS.md and the justfile. Populate Build and Test from the justfile, add the one-line architecture summary from SPEC section 1, point Conventions at AGENTS.md. Remember the AGENTS.md/CLAUDE.md mirror rule (independent files, not symlinked). Marker-sweep audit 2026-07-20. | closed | 4 | chore | NULL | NULL | 2026-07-19T23:38:17Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:37:15Z | 2026-08-04T23:37:15Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in 199d85e. Both files were untracked bd-setup leftovers; now tracked. CLAUDE.md's three scaffold stubs are filled and mirrored into AGENTS.md: Build and Test from the justfile plus the GOFLAGS=-mod=mod go.sum trap this dev env has; Architecture Overview as the reconciler-first summary from SPEC section 1 with docs/SPEC.md named authoritative; Conventions recording the practices the repo actually follows (one bead per change with the durable record in its notes, the [bead-id] commit-message suffix, the deliberately high comment density, the milestone-is-a-release rule). The bd-generated managed blocks in both files were left untouched, and the two files remain independent rather than symlinked. | 0 | NULL | NULL | NULL | 0 | NULL | 0 |