ojrcqp8tbd3f6ilmj9dhrsq1liasnu1i · 102 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-g39 | 4c8f2bcd36f6af9659209ee12fafe09dcc3b552de41990822769510f698952b2 | SPEC section 7: storage schema stale by four migrations | Found during the ah-efe sweep; outside that epic's assigned sections, so filed separately. §7 documents the schema through v2 only — 'step 1→2 (Stage 2, schema v2) adds runs.timeout_seconds' — and its DDL block shows only tasks/runs/events. internal/store/store.go:94 has SIX entries in schemaMigrations: base, runs.timeout_seconds, task_parents, tasks.auto_routes, kv, yonote_qa. This matters more now than it did: ah-efe.1 just documented the port methods those tables back (RecordChild/GenerationOf/ParentOf/ChildIDsOf, GetKV/SetKV, CreateQA/QAByTask/QAByComment/MarkQAAnswered), so §7 is the last place the storage layer still reads as Stage 2 — a reader who follows §6's Store interface into §7 finds no table behind half of it. | §7's DDL and its migration-step list match schemaMigrations element for element, with each step naming the feature and bead that introduced it. Verified against internal/store/store.go and the embedded schema.sql. | in_progress | 3 | chore | NULL | NULL | 2026-08-04T23:52:27Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:54:12Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:12Z | 0 | |||||||||||||||||||||||
| ah-gs7 | c0bcd671b99d03b8fb704cffc0b49ffe839b72cbf1a7a9bd61ccaf8166cc850e | Ultrapack designer/validator roles via config + skills (human-scheduled) | Ultrapack (github.com/bigbes/ultrapack) is an OpenCode pack, but its value is the skills (udesign/uplan/uexecute/uverify/ureview), and pi implements the Agent Skills standard — so the skills load unmodified via --skill with no porting. Stage 2 already delivers --skill materialization, task_types and per-task frontmatter roles, so multi-role work needs ZERO daemon code today: add designer/validator roles to config pointing at ultrapack skill packages under skills_dir, and let a human drag cards between them. State carries between runs in docs/tasks/<slug>.md on the task branch. Do this BEFORE automating the handoff (ah-0ge slice): it validates whether the roles are actually good while the orchestration is still free. Ultrapack agents/ do NOT get ported — see the delegation-loop beads; agents become roles, not subagents. | Roles are {model, prompt} today (internal/config/config.go:72). Fork ultrapack skills into skills_dir packages; rewrite @implementer/@explorer/@reviewer subagent references (uplan/SKILL.md:126-128, uexecute/SKILL.md:37, handsoff/SKILL.md:35) out of the skill bodies — pi has no @-mention dispatch. Designer role MUST use ultrapack's handsoff skill: udesign/SKILL.md says 'Nothing is planned or written until the user approves', which deadlocks a board-driven run until the watchdog kills it as stalled. | A task labeled for the designer role produces a docs/tasks/<slug>.md with a Design section and lands In Review without stalling; dragging it to Ready under the validator role produces a review against that same file on the same branch. | DELIVERED + COMMITTED (982694f) 2026-07-17. Artifacts: skills/design (adapted from ultrapack udesign — autonomous, no wait-for-approval, records ### Decisions), skills/validate (single-agent review distilled from ultrapack ureview's reviewer criteria — confidence>=80, severity-tiered, never edits code), prompts/designer.md + prompts/validator.md (autonomy framing verified to prevent headless stalls), config.example.yaml wiring (skills_dir=skills, designer/validator roles, type:design/type:review task_types), README Roles section. COMPONENT-VERIFIED headless on pi 0.73.1 (opencode/claude-haiku-4-5): both skills load under --no-skills via --skill; designer produced docs/tasks/multiply-and-divide.md with full ## Design + IV/PC/AS/UK + TDD + ### Decisions, clean stop, no stall; validator wrote ## Review with a changes-requested verdict and did NOT edit code. config.example loads (TestLoadExample green). NOT verified: the literal live daemon+board flow (label->task_type->role->run) — infra-gated. Tracked as a follow-up depending on ah-k23. Design notes: udesign/ureview were ADAPTED not vendored verbatim — udesign is collaborative and references ultrapack-only skills (uplan/handsoff/references), and ureview is built around a dispatcher+@reviewer split that doesn't fit a single agent. The @-subagent references live only in the executor skills (uexecute/uplan/handsoff/ureview), so designer+validator needed minimal @-rewrite. | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-17T14:37:25Z | Eugene Blikh | bigbes@gmail.com | 2026-07-17T19:49:23Z | 2026-07-17T19:49:23Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Designer/validator roles delivered and component-verified (skills + prompts + config, committed 982694f); both roles produce the right artifacts headless on pi 0.73.1. Live daemon+board acceptance tracked as a follow-up gated on ah-k23 infra. | 0 | NULL | NULL | NULL | 0 | 2026-07-17T19:24:42Z | 0 | ||||||||||||||||||||
| ah-gxa | dffb086c7b7fb989cce7ee0c79a50575efa7059b98fd57b9c4d41e23de6555fb | internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth) | Why: agenthubd integrates Yonote (Outline-fork docs product, live at bigbes.yonote.ru) in three slices — claim-time artifact export, finalize-time publishing, later Q&A. All need one light client. What: internal/yonote in the internal/mem0 style (New(baseURL, token, opts), ctx-first, typed *APIError, stdlib-only, httptest tests, doc.go pinning contract+quirks), scoped to exactly the integration surface: AuthInfo, DocumentInfo (uuid|urlId|slug-urlId), ExportMarkdown (/api/v2/documents/{uuid}/markdown), Search/List, CreateDocument/UpdateDocument, comments create/list/resolve, pure ParseDocRefs URL helper. Runs under a BOT token (minting runbook in design). wave 2.5 — implement after feat/wave2-archive-links merges. | # internal/yonote — light API client (stdlib-only, mem0 style) Mirror internal/mem0 exactly in shape: `New(baseURL, token string, opts ...Option) *Client` (trailing "/" trimmed, default `http.Client{Timeout: 30s}`, `WithHTTPClient`), ctx-first methods, one `do(ctx, method, path, query, in, out)` round-tripper, typed `*APIError`, package doc.go pinning the contract + quirks, httptest unit tests. No third-party deps. Contract pinned to live bigbes.yonote.ru, x-app-version 1.47.1, verified 2026-07-18. Yonote = Outline fork; API is RPC-style `POST /api/<resource>.<verb>` (JSON body) PLUS a newer `/api/v2/*` REST namespace. Auth on every request: `Authorization: Bearer <token>` (tokens are JWTs; treat as opaque). Success envelope `{data, status, ok, [pagination, count|total, policies]}`. Error envelope `{ok:false, error:"<machine_code>", status:<int, SOMETIMES ABSENT>, message:"<human>"}` — key off the HTTP status, carry error/message. ## Endpoint table (methods to implement; L = live-verified, S = spec-only) | Method | HTTP + path | Request | Response essentials | |------------------------------------------|--------------------------------------------|----------------------------------------------------|---------------------| | AuthInfo(ctx) | POST /api/auth.info | {} | L data.user{id,name,email,isBot,isAdmin,isViewer}, data.team{name,url,subdomain} | | DocumentInfo(ctx, id) | POST /api/documents.info | {"id"} — uuid OR urlId OR slug-urlId (all L) | L data = Document | | ExportMarkdown(ctx, uuid) | GET /api/v2/documents/{uuid}/markdown | path uuid ONLY (urlId → 400 invalid_format, L) | L {"data":"<markdown string>"} — may be "" (see quirks) | | SearchDocuments(ctx, req{Query,Limit}) | POST /api/documents.search | {"query","limit"} | L data[] of {context string, ranking float64, document Document}; top-level count | | ListCollections(ctx, limit, offset) | POST /api/collections.list | {"limit","offset"} | L data[] Collection{ID,Name,URLID,URL,Permission,Sharing}; pagination{limit,offset,nextPath}; top-level count | | ListDocuments(ctx, req) | POST /api/documents.list | {"collectionId","parentDocumentId","limit","offset"} (all optional) | L data[] Document; pagination; top-level total | | CreateDocument(ctx, req) | POST /api/documents.create | {"title"!,"collectionId"!,"text","parentDocumentId","publish"} | L data = Document (text echoed VERBATIM markdown; publish:false ⇒ publishedAt null even with team autoPublishDocs) | | UpdateDocument(ctx, req) | POST /api/documents.update | {"id"!(uuid|urlId),"title","text","append","publish"} | S (v1 spec; same Document envelope) | | CreateComment(ctx, req) | POST /api/comments.create | {"entityType":"document","entityId"!,"text"!,"parentCommentId"} | L data.comment{id,entityId,threadId,text,quote,isResolved,createdById,createdAt,updatedAt} — NESTED under data.comment | | ListComments(ctx, req) | POST /api/comments.list | {"entityId"!,"isResolved"(*bool),"threadId","limit"≤100,"offset"} | L data.comments[] (nested; items also carry threadCommentsCount, attachments[]); pagination; total | | ResolveComment(ctx, id, resolved) | POST /api/comments.resolve | {"id"!,"isResolved"!} | S (v2-preview spec) data.comment | | ParseDocRefs(baseURL, text) []string | (pure, no HTTP) | — | ordered dedup'd doc-id tokens from free text | Deliberately OUT of the client (documented in doc.go): documents.export RPC (now an ASYNC fileOperation on live — see quirks), attachments.* (follow-up if ever needed: attachments.list {documentId} → items carry redirectUrl/url/contentType/size/name; attachments.redirect {id} → 302 signed URL — both spec'd, redirect flow live-verified via fileOperations), events.list + webhookSubscriptions.* (Q&A bead extends the client then), bots CRUD (one-time provisioning, curl runbook below), documents.delete. ## Types Document{ID, URLID, Title, URL (site-relative "/doc/<slug>-<urlId>"), CollectionID string, ParentDocumentID *string, Text string, Type string ("document"|"database"|"row"|"whiteboard"|"embed"), Revision int, ArchivedAt, DeletedAt, PublishedAt *string (keep RFC3339 strings, lossless), CreatedAt, UpdatedAt string, ChildrenCount int}. Collection{ID, URLID, Name, URL, Permission string, Sharing bool}. Comment{ID, EntityID string, ThreadID *string, Text string, Quote *string, IsResolved bool, CreatedByID, CreatedAt, UpdatedAt string}. AuthInfo{User{ID, Name, Email string, IsBot, IsAdmin, IsViewer bool}, Team{Name, URL, Subdomain string}}. ParseDocRefs(baseURL, text): regexp `https?://<escaped-host>/doc/([A-Za-z0-9._~-]+)` where host comes from url.Parse(baseURL). Token = full last path segment (slug-urlId form — documents.info accepts it whole, no urlId slicing needed); query/#fragment excluded by the charset; works on both raw markdown and TipTap HTML (`<a href="...">`); dedup preserving first occurrence. ## Errors `*APIError{StatusCode int, Method, Path, Code, Message, Snippet string}` parsed from the error envelope; `(e *APIError) NotFound() bool` (StatusCode==404). Body snippet capped 2KiB; success-body read capped 8MiB (mem0 constants). Observed error bodies (assert in tests): - 401 authentication_required, message "Unable to decode JWT token" (bad token) / "Authentication or shareId required" (no token) - 404 not_found, "Resource not found" (documents.info, unknown uuid AND unknown urlId) / "Document not found" (v2 markdown) - 400 invalid_format, "Invalid UUID (id)" — NOTE: this body has NO "status" key - 500 internal_server_error (markdown export of a whiteboard doc) ## Quirks for doc.go (all verified live unless noted) 1. Identity: doc URL is `https://<team>.yonote.ru/doc/<slug>-<urlId>`; documents.info resolves uuid | bare urlId | slug-urlId; ALL /api/v2/* paths take the uuid only. 2. Document.Text is NOT reliable markdown: after editor edits it is a PLAINTEXT projection (all markup stripped — verified on a real doc: text had no #/**/`` while /markdown had them). /v2/.../markdown is canonical BUT returned "" for an API-created never-edited draft whose .text still held the submitted markdown verbatim, and returns "" for database-type docs. Whether the draft case is "draft" or "no editor state yet" is UNVERIFIED — callers use the fallback chain (markdown → info.Text → unavailable) which covers both. 3. documents.export RPC diverges from Outline AND from the v1 docs: live it requires a uuid and returns an async fileOperation {state waiting→complete} (poll fileOperations.info, download via fileOperations.redirect → 302 signed Yandex-cloud URL). Not wrapped by this client. 4. type=whiteboard → markdown export 500s; type=database → "" (v2 search/accessible spec says types document|database|row|whiteboard|embed). 5. Pagination: {limit, offset, nextPath}; totals are INCONSISTENT — v1 RPC puts count/total at the top level, /api/v2/* puts total inside pagination. Don't share one envelope type. 6. No rate-limit headers at 1.47.1; no documented limits. No client-side retry (callers own it, mem0 precedent); surface 429 as *APIError if it ever appears. 7. Live (1.47.1) is newer than the published docs (1.38.1). The v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next/static/chunks/pages/ developers-*.js, two JSON.parse('...') literals: v2-preview then legacy v1); yonote.ru/openapi-3.json serves the OLD v1 spec. Re-extract from the chunk when in doubt. 8. Team feature flags seen in auth.info: mcpServer:true + mcpSettings (Yonote hosts a native MCP endpoint at /api/v2/mcp — the operator's claude.ai integration uses it; irrelevant to this client but explains the product's own tool surface). ## Bot-token provisioning runbook (one-time, operator; do BEFORE deploying the integration beads) The daemon must authenticate as a BOT, not as bigbes's personal token (attribution + blast radius). Bots are Yonote-native (not in Outline). Admin-only. As of 2026-07-18 GET /api/v2/bots returns [] — none exist yet. 1. POST /api/v2/bots {"name":"agent-hub","username":"agent-hub"} → data: bot user (grab id; user has isBot:true) 2. POST /api/v2/bots/<botId>/token {"name":"agenthubd"} → data{id,name,secret,...} — `secret` IS the API token, shown ONCE → store as YONOTE_TOKEN in /etc/agent-hub/env 3. Grant collections (bots see nothing by default per Bots tag doc "granted access to collections with read or write permissions"): POST /api/collections.add_user {"id":<collectionId>, "userId":<botId>,"permission":"read"|"read_write"} (v1-spec'd, NOT live-verified). 4. Verify with the minted token: POST /api/auth.info → user.isBot must be true; then documents.info on a granted doc, CreateDocument in the write collection. UNVERIFIED until then: auth.info under a bot token, bot default visibility, bot create rights. Rotation: GET /api/v2/bots/{id}/token (list, no secrets), DELETE /api/v2/bots/{id}/token/{tokenId}. Daemon behavior: on startup call AuthInfo, log identity, WARN when IsBot==false (personal token in prod must be visible, not fatal). ## Test plan (httptest, mem0-test style) Per method: happy path asserting HTTP method, exact path, Bearer header, JSON body; decode into typed result. Error mapping: 401/404 envelopes, 400 body WITHOUT "status" key, non-JSON body → Snippet only. Nested data.comment / data.comments decode. ExportMarkdown "" passthrough (no error). DocumentInfo with the three id forms (same handler, three paths asserted). ParseDocRefs table: bare URL in markdown, TipTap `<a href="https://host/doc/x-abc">`, uuid-in-URL, trailing punctuation/")"/query/fragment, cross-host rejected, http vs https, dedup order, cap-free (cap is bead-2 policy). | Live-verified 2026-07-18 against bigbes.yonote.ru x-app-version 1.47.1 (docs page documents 1.38.1 — live is newer). Evidence: (1) auth.info via Bearer OK — the operator token in use today is PERSONAL (user.isBot=false, isAdmin=true); no bots exist yet (GET /api/v2/bots → []); mint one before deploy (runbook in design). (2) documents.info resolved the same doc by uuid, bare urlId, slug-urlId. (3) /api/v2/documents/{uuid}/markdown returned faithful markdown for an editor-edited doc while documents.info .text for the SAME doc was plaintext with all markup stripped — .text is NOT markdown after editor edits. (4) One-probe-doc lifecycle (created→deleted, approved): documents.create with markdown text echoed text VERBATIM and honored publish:false (publishedAt null despite team autoPublishDocs:true); /markdown for that fresh API-created draft returned '' — hence the markdown→.text fallback chain; comments.create → {data:{comment}} nested, text stored verbatim, comments.list → {data:{comments:[…]}}; documents.delete {permanent:true} → {success:true}; info after delete → 404. (5) documents.export RPC live = ASYNC fileOperation (uuid-only, 400 invalid_format on urlId); fileOperations.info reached state complete and fileOperations.redirect 302'd to a signed Yandex-storage URL (Camera-export.md). SIDE EFFECT LEFT BEHIND: one accidental export fileOperation record (id 6b6e2056-9487-4f91-b10b-818d7c1a486c, doc 'Camera', 604B, complete) lingers in the workspace export history — harmless, operator may delete. (6) Error bodies: 401 authentication_required 'Unable to decode JWT token' / 'Authentication or shareId required'; 404 not_found; 400 invalid_format WITHOUT a status key; 500 internal_server_error on whiteboard markdown export; database-type docs export ''. (7) No rate-limit headers observed. (8) v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next chunk pages/developers-*.js, two JSON.parse single-quoted literals: v2-preview 1.38.1 with /v2/bots + /v2/documents, then legacy v1 0.1.0); yonote.ru/openapi-3.json serves the OLD v1 spec. (9) webhookSubscriptions.list responds live (empty) though undocumented in both specs; events.list live-verified. UNVERIFIED (implementer/provisioning): auth.info under a bot token; bot default visibility + collections.add_user grant; bot create rights; documents.update; comments.resolve; whether /markdown '' means draft-only or missing-editor-state (fallback chain covers both). | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-18T15:03:36Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T15:59:07Z | 2026-07-18T15:59:07Z | 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:56Z | 0 | |||||||||||||||||||||
| ah-i0v | f80dec59765bb08e0f95b6415363d43fc81e9a986e6ec059d9e8c2e017b50845 | stale package doc-comments: taskfiles.go, runner.go and httpapi.go describe a surface two stages old | Found by the agent refreshing SPEC §9/§11 — the same staleness it fixed in the spec, still present in the code that spec describes. 1. internal/runner/taskfiles.go:3-12 — the package header reproduces the OLD five-file .task/ layout and still says 'question.json reserved for Stage 4', in the very file that defines TasksFileName, SummaryFileName, PublishFileName, AnswerFileName and ToolAuthFileName. 2. internal/runner/runner.go:8-10 — the package doc says 'PROMPT.md and meta.json are written at Start'; prepareTaskDir also writes tool-auth.json and the artifacts/ tree, and clears five outbound files plus publish/. 3. internal/httpapi/httpapi.go:1-14 — the package doc lists only four routes and asserts 'The server trusts loopback (no auth in Stage 1)', never mentioning the three bearer-authenticated /api/tool/* routes that tools.go registers in the same package. That sentence is now actively misleading about the package's auth model. docs/SPEC.md §9 and §11 are correct as of 119b84d; these three comments are what a reader hits FIRST when they open the code, so they should match. | Each of the three package doc-comments matches what its package actually does, and the httpapi one states the split between the unauthenticated loopback routes and the bearer-authenticated tools routes. | in_progress | 4 | chore | NULL | NULL | 2026-08-04T23:52:28Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:54:12Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:12Z | 0 | |||||||||||||||||||||||
| ah-jzv | 0d78f613570ca2c75adf528c012e34dcb3619fcf996b32327a645896ef97baa4 | config: export a Configured() predicate per optional block; stop re-deriving presence in the wiring | Found while fixing ah-1cx.3. config's optional blocks each have an unexported present() predicate, so every consumer outside the package hand-rolls its own presence test against raw fields: cmd/agenthubd/main.go:180 (yonote client + boot identity probe), :192 (mem0 client + health probe), :292/:298 (notifier selection), plus internal/reconcile/{publish,qa,artifacts}.go reading cfg.Yonote.BaseURL directly. They agree with present() TODAY only because resolve trims those base URLs — which is exactly the coupling that produced ah-1cx.3, where a whitespace-only ntfy.url was absent to validation and present to wiring. The next field added without a matching trim reintroduces the same class of bug. Fix: export one predicate per optional block (e.g. func (y Yonote) Configured() bool wrapping the unexported present()) and have every consumer call it instead of restating the test. present() being unexported is precisely why the wiring hand-rolls it. | No consumer outside internal/config decides whether an optional block is configured by inspecting its raw fields; each calls the exported predicate. A grep for cfg.<Block>.<Field> != "" outside the package comes back empty. | closed | 3 | chore | NULL | NULL | 2026-08-04T23:44:00Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:00:42Z | 2026-08-05T00:00:42Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Done in baa8075. Each optional block's presence rule lived only in an unexported present(), so every consumer outside the package restated it against raw fields — cmd/agenthubd/main.go at the yonote client + identity probe, the mem0 client + health probe, and both arms of the notifier-selection switch. Those raw != "" tests agreed with present() ONLY because resolve happens to trim the fields they read; that coupling is what produced ah-1cx.3, where a whitespace-only ntfy.url was absent to validation and present to the wiring and killed a boot. Added an exported Configured() on AgentsView, Telegram, Ntfy, Yonote and Mem0 — each a one-line wrapper over present(), so there is exactly one definition of presence and validate() and the wiring can never diverge. AgentsView.Configured carries the full doc comment (Configured() is the only sanctioned presence test outside the package, plus the ah-1cx.3 boot failure that justifies the rule); the other four state the rule and point at it. All four cmd/agenthubd call sites migrated, comments rewritten to record why. Purely additive: present() stays, no signature changed, go build ./... passes for every package. Cover: table-driven TestConfiguredPredicates asserting Configured()==present() for absent, single-field-set and whitespace-only inputs per block (the whitespace rows are exactly where a raw != "" test would disagree), plus an end-to-end check through Load. FOLLOW-UP NOT DONE (internal/reconcile was owned by another agent at the time), split into its own bead: reconcile.go:2010 agentsViewLink is the one real drift risk left — its raw test silently ignores a set-but-blank machine and renders a link ending in '~pi:' rather than no link; reconcile.go:1996, publish.go:83/93/114, qa.go:83/203 and artifacts.go:77/89 are correct today because they are gated upstream by r.yonote != nil, which main.go now sets from Configured(). The Configured() doc comment also asserts a rule no test or lint enforces — the acceptance grep is verified by hand. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:54:12Z | 0 | ||||||||||||||||||||||
| ah-k23 | da0056f7dd6aa3670d94ec5f884c39b8591e1894a3ad250858f26540912ac504 | Infra: phoebe-lab wiring for agents-dev | Tracked in ~/data/home/phoebe-lab (deploy via labng). Runtime setup: Vikunja project+buckets+API token+webhook target; LiteLLM virtual key for agents (budget-capped); mem0 m0sk key; srht agent-hub user + repo ACLs; later: Proxmox VM (lab.yml remote entry, basic-vmagent host, VM-local singbox for TG egress — decided: VM-local, not LAN-published), agentsview pg-push firewall allowlist for the VM. | RUNTIME WIRING COMPLETE — full pipeline proven live incl. review-branch publish (2026-07-18). agenthubd on agent-1 runs board tasks end-to-end: Ready -> claim -> worktree -> pi(deepseek/LiteLLM) -> commit -> In Review with diff-stat AND a review link (agent branch pushed to srht). Everything on the bead's critical path is done: VM (agent-1), Vikunja project 2 + 9 buckets, LiteLLM budget-capped virtual key, pi models.json, unit PATH fix (ahub-run), srht push credential (agenthub SSH key, port 2222). Fixes made this session also flushed out and closed the vikunja Snapshot bug (ah-1nu). Deferred sub-items belong to other epics: mem0 m0sk key (Stage 3 ah-ydx), agentsview pg-push firewall allowlist (Stage 5 ah-oeq). VM-local singbox TG egress superseded by Prism/ntfy. Repo edits committed to phoebe-lab (4a2e9b1: unit PATH + CLAUDE.md); live-only changes (config, models.json, ssh key, PATH drop-in) applied on agent-1 and documented in agent-hub/CLAUDE.md. | in_progress | 3 | epic | Eugene Blikh | NULL | 2026-07-12T23:36:30Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T04:52:29Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | 2026-07-17T20:23:54Z | 0 | |||||||||||||||||||||||
| ah-nyl | 343ac6a94d3bd1074e58a6f7897af90cfc1a84f2e764dc2f17d9f96ba094b31f | Stage 1 MVP: poll-driven happy path | Vikunja task dragged to Ready -> claim -> worktree+branch -> pi in zellij -> In Review with diff summary (or Failed with diagnostics). Everything per docs/SPEC.md (authoritative). Done when: e2e harness green + a real task passes the loop against live Vikunja. | WORKFLOW (user directive): every implementer wave is followed by (a) hostile read-only reviewer agents and (b) empirical validator agents (real zellij/git/binaries, not stubs) BEFORE the next wave dispatches. Findings are triaged into fix beads. | closed | 1 | epic | NULL | NULL | 2026-07-12T23:34:03Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T04:02:45Z | 2026-07-13T04:02:45Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Stage 1 complete: all 15 child beads closed; e2e acceptance green (happy/failure/timeout); 3 review rounds + 2 empirical validations converged to SHIP | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||
| ah-nyl.1 | faee2c254f4120dc1b198544543cd6073a586bf48f0606929988685660049281 | foundation: go module, domain, ports, config | Create the Go foundation of agents-dev exactly per docs/SPEC.md (read it fully first; SS4-6, SS12-13 are normative for this task). Deliverables: 1. go.mod: module go.bigb.es/agents-dev, go 1.26. Add ALL stage-1 external deps now so siblings never touch go.mod: modernc.org/sqlite, gopkg.in/yaml.v3. Populate go.sum by building a throwaway smoke import (e.g. internal/smoke_test.go importing both, then delete the file but keep go.sum entries; or keep a tiny blank-import file under internal/deps/deps.go with build tag 'deps' — your choice, document it). 2. internal/domain: types + constants from SPEC SS5 (State, Outcome, Task, Repo, TaskRecord, Run, Event, RunSummary) and pure transition helpers, at minimum CanClaim(*TaskRecord) bool per SS5 semantics. Table tests for every helper. 3. internal/ports: interfaces exactly as SPEC SS6 (Board, Runner, Store, Notifier + BoardTask, StartSpec, RunInfo, RunStatus). Doc comments on every method stating error/nil semantics ((nil, nil) for absent, etc.). 4. internal/config: Load(path string) (*Config, error) implementing SPEC SS12: yaml.v3 with KnownFields(true), ${VAR} env expansion in string values (only for vars that exist; unknown var = validation error), "~" expansion in paths, duration parsing, validation that collects ALL problems into one error (repos non-empty, default_repo/default_role resolve, buckets map has all six canonical keys, listen is loopback host:port). Config struct mirrors config.example.yaml at repo root (keep the two in sync; fix the example if you find an inconsistency and note it in the commit message). Tests: golden-load of config.example.yaml with env set, plus failure cases. Rules: stdlib + the two deps only; log/slog if logging is needed (probably not here); no package-level state. Run: go build ./... && go vet ./... && go test ./... (allowed for THIS task only, since you own the whole tree). Commit everything as one or two commits, message style 'domain: ...', 'config: ...'. Do NOT push. Do NOT touch .beads/. Acceptance: go build/vet/test green on a clean checkout; interfaces compile exactly against the names/signatures in SPEC SS6 (parallel siblings will implement them verbatim); config.example.yaml loads. | closed | 0 | task | NULL | NULL | 2026-07-12T23:34:03Z | Eugene Blikh | bigbes@gmail.com | 2026-07-12T23:55:03Z | 2026-07-12T23:55:03Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Closed | 0 | NULL | NULL | NULL | 0 | 2026-07-12T23:38:10Z | 0 | |||||||||||||||||||||||
| ah-nyl.10 | 867e80be5ab1f94c6768afe0ee66011466dec5fca7e42479d958babdf51ea649 | fix: reconcile review findings 1-6 (FIX-FIRST verdict) | Hostile review of internal/reconcile @ 2acf7c4 returned FIX-FIRST with findings below. Fix ALL six (1-3 blocking, 4-5 behavior, 6 test infrastructure that would have caught 1). Line numbers refer to internal/reconcile/reconcile.go @ 2acf7c4. F1 MAJOR (:294-304,:311-330): a FINISHED latest-run row is fed to check(); runner.Status ignores attempt, so a live re-attempt gets killed on the old row's ancient StartedAt (timeout) and/or the new attempt's result is written onto the previous attempt's row. FIX: check() only when run.State == RunStateRunning; when rec.State==in_progress and latest run is finished, route to adoptOrFail and there create the missing run row as attempt = latestRun.Attempt+1 when adopting a live runtime (instead of skipping CreateRun). F2 MAJOR (:250-262): UpsertTask/CreateRun failure AFTER successful runner.Start leaves the claim retryable -> next tick re-claims same attempt: Start wipes the live attempt's exit.json/events and opens a SECOND pi pane on the same worktree (duplicate paid agents). Same divergence from a daemon crash between Start and persist. FIX: (a) on persist failure after Start, compensate with best-effort runner.Kill before returning (log both errors); (b) before Start in claim, probe runner.Status for the computed attempt — if there is evidence of a live/completed runtime for it, adopt instead of double-starting. F3 MAJOR (:411-412,:216-220): comment is posted BEFORE MoveToBucket in the adoptOrFail fail-branch and the claim render-failure path -> persistent move failure = a new comment every 20s forever; the claim_failed event is also appended even when the move failed. FIX: reorder both sites to move-then-comment (finalize already does this); gate the claim_failed event on move success (mirror adopt_failed). F4 MINOR (:334-347): finalize unconditionally rewrites an already-finished run row on retry (e.g. timeout kill recorded, then UpsertTask failed; next tick Status says crashed -> outcome falsified timeout->crashed, FinishedAt smeared). FIX: when run.State == RunStateFinished, skip UpdateRun and derive the target task state from the stored run.Outcome (success->in_review, else failed). F5 MINOR (:135-136): an in_progress record whose card sits in an UNMAPPED bucket is skipped entirely -> no check(), no timeout; the run burns tokens unobserved. DECISION (fix this way): run check() for any record with State==in_progress regardless of the card's bucket (including unmapped and ready-awaiting-heal); card moves still only happen per the §10 bucket rules — i.e. decouple runtime supervision from card position. F6 MINOR (test honesty, fakes_test.go:290-299): (a) fakeBoard.MoveToBucket must update the task's bucket so consecutive iterations see the moved card; (b) add double-iterate zero-delta assertions (run iterate twice with unchanged inputs after claim, finalize, adopt-fail: second pass produces ZERO new side effects) — this is the level-triggered core property; (c) key fakeRunner status by (taskID, attempt) so per-attempt misattribution (F1) is expressible; add regression tests for F1 (finished run + live re-attempt: no kill, no overwrite; adoption creates attempt+1 row), F2 (persist-failure -> Kill called; probe-adopt on retry), F3 (move fails -> no comment posted, no claim_failed event; comment appears after move succeeds), F4 (finalize retry preserves outcome), F5 (unmapped bucket + in_progress -> check still enforces timeout). Constraints: scope = internal/reconcile/ ONLY (the reviewer's suggested fixes deliberately keep runner untouched). SPEC §10 stays authoritative — where these fixes refine it (F1 routing, F3 ordering, F5 supervision decoupling), implement per THIS bead; the orchestrator will sync SPEC afterwards. Conventions: testify + culpa (SPEC §13). Build/test ONLY ./internal/reconcile/... (never ./...). Verify with -race. Commit 'reconcile: fix review findings (check routing, claim compensation, move-then-comment, finalize guard, unmapped supervision)' staging only internal/reconcile; hooks bypassed (git -c core.hooksPath=/tmp/nohooks commit); retry on index.lock; do NOT push; do NOT touch .beads/. | closed | 0 | bug | NULL | NULL | 2026-07-13T00:36:33Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T00:56:49Z | 2026-07-13T00:56:49Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed e863502: check routing via run.State, adopt creates attempt+1, claim probe-adopt + compensation kill, move-then-comment, finalize finished-row guard, bucket-decoupled supervision, fakes keyed by (task,attempt) + zero-delta assertions; 34 tests -race green | 0 | NULL | NULL | NULL | 0 | 2026-07-13T00:36:34Z | 0 | |||||||||||||||||||||||
| ah-nyl.11 | ccf963c6cdbbcf9043784dbdf1f98f71af8baca8cbd9a58cca23607dc1ff7f92 | fix: runner/ahub-run review findings 1-10 (FIX-FIRST verdict) | Hostile review of internal/runner + cmd/ahub-run @ HEAD (4348d2b) returned FIX-FIRST: findings 1-3 blocking, 4 strongly recommended, 5-9 hardening, 10 test gaps. Fix ALL. Line refs @ 4348d2b. F1 MAJOR (runner.go:201-225, commands.go:45-47): dir missing + branch exists + worktree still REGISTERED (human rm -rf'd it) -> `git worktree add` exits 128 "missing but already registered"; Start wedges forever. FIX: in ensureWorktree, when the worktree dir is missing, run `git -C <repo> worktree prune` before add (reproduced working on git 2.55). Add a regression test that registers a worktree, rm -rf's the dir WITHOUT prune, and asserts Start succeeds. F2 MAJOR (cmd/ahub-run/main.go): no signal handling — SIGTERM/SIGINT/SIGHUP to ahub-run kills the supervisor with NO exit.json; the child pi survives until its next stdout write (SIGPIPE), burning tokens; the zellij session survives so Status says Running for the full 30m timeout, then reports the wrong outcome. FIX: signal.Notify(SIGINT, SIGTERM, SIGHUP); forward the signal to the child (process group where sensible), wait for it, then STILL write exit.json (128+sig) and POST the report. Regression test: signal a running ahub-run, assert child死 + exit.json written with 128+sig. F3 MAJOR (runner.go:103-133, 158-163): Status ignores its attempt arg (`_ = attempt`) and never checks ef.Attempt -> a stale attempt-K supervisor's exit.json completes attempt K+1 with K's exit code; the truncated-then-repopulated events.jsonl attributes K's session id and costs to K+1. Kill swallows every kill-session failure at Debug, so a wedged zellij is indistinguishable from dead. FIX: (a) in Status, ef.Attempt != attempt => treat as no-exit.json and fall through to session check; (b) in Kill, after kill-session verify via list-sessions that the session is gone; if still alive, return an error (Warn+error). Regression tests for both. F4 MAJOR (runner.go:62-97): Start is not idempotent per attempt — after a claim-persist failure the reconciler re-claims the SAME attempt and Start re-prepares (truncating the live events.jsonl) and spawns a duplicate pane. FIX: at the top of Start, if .task/meta.json matches (task_id, attempt) AND exit.json is absent AND the zellij session is alive -> return the existing RunInfo (worktree/branch/session) without re-preparing or re-spawning. Regression test. F5 MINOR (runner.go:344-346): sessionAlive maps non-zero exit + EMPTY output to "no sessions" -> silent zellij failure finalizes a live run as crashed. FIX: only the recognized "no active session" text (or clean exit) means no-sessions; empty output + non-zero exit => return an observation error. Update the test at runner_test.go:396-403 that pins the old hazardous mapping. F6 MINOR (runner.go:406-413): runCmd merges stderr into the parsed stdout buffer -> git warnings corrupt gitResolveCommonDir (exclude entry lands in a bogus silently-created path) and Summary turns stderr into fake commit lines. FIX: separate stdout/stderr buffers; parse stdout only; include stderr in error messages. F7 MINOR (taskfiles.go:61-79): WriteExitFile renames without fsync -> post-power-loss empty-but-present exit.json makes ReadExitFile error forever. FIX: tmp.Sync() before Close/rename. F8 MINOR (internal/config/config.go:201-208 + commands.go:32-34): repo slug is not shape-validated; "/" or ".." in a slug makes worktreePath escape work_root. FIX in internal/config (you MAY touch config for exactly this): reject slugs not matching ^[A-Za-z0-9._-]+$ (and not "." / ".."), with a validation-collected error + test. This is the ONLY change allowed outside runner/ahub-run. F9 MINOR (events.go:49-51, 94-108): (a) a single line >10MB aborts the whole scan silently (message_end lines grow with conversation) -> switch to a reader that SKIPS an over-long line and continues; (b) cost summation semantics are under-pinned — fixtures have one assistant message; if pi's usage.cost.total is cumulative, += double-counts. Without re-running pi: add a two-assistant-message fixture built from the existing captured shape, document the per-message assumption in a comment referencing the fixture provenance, and add a TODO-marked guard test so the fixture is easy to re-pin from a real capture later. F10 test honesty: add the tests named above; also remove/rework TestKillDeadSessionIsNotAnError so it distinguishes "session already gone (ok)" from "kill failed but session alive (error)". Constraints: scope = internal/runner/, cmd/ahub-run/, plus the single F8 validation in internal/config/. Conventions: testify + culpa (SPEC §13). Build/vet/test ONLY your packages (./internal/runner/... ./cmd/ahub-run/... ./internal/config/...) with -race; never ./... . Another fixer is working in internal/reconcile concurrently — do not touch it. Commit 'runner: fix review findings (worktree prune, signals, per-attempt status, idempotent start, hardening)' (+ separate 'config: validate repo slug shape' commit) staging only your paths; hooks bypassed (git -c core.hooksPath=/tmp/nohooks commit); retry on index.lock; do NOT push; do NOT touch .beads/. | F11 (VALIDATOR blocker D1, empirically established on real zellij 0.44.3): sessions that lived past zellij's session-serialization tick (~65s, default config) and then die WITHOUT delete-session (pi/ahub-run crash killing the pane, zellij server death, reboot with cache) remain listed by 'list-sessions --short' as bare names indistinguishable from live sessions -> Status reports Running forever; OutcomeCrashed unreachable for real crashes. FIX: switch session liveness to 'zellij list-sessions --no-formatting' (plain text, dead sessions carry the '(EXITED - attach to resurrect)' suffix) and treat EXITED as NOT alive; parse defensively (name = first whitespace-separated token; EXITED detection by substring); update stub scripts + tests incl. an EXITED-listed case. Kill() keeps kill-then-delete (validator confirmed delete-session removes serialized dead sessions, exit 0). ALSO F12 (validator observation): zellij pane env = SESSION-CREATION-TIME server env, not run-client env — so bare 'ahub-run'/'pi' argv depend on the daemon's PATH at attach --create-background time. Harden: resolve ahub-run and pi to ABSOLUTE paths via exec.LookPath at Start (error clearly if not found) and use those in the pane argv; keeps working under systemd's minimal PATH later. | closed | 0 | bug | NULL | NULL | 2026-07-13T00:37:45Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T01:05:34Z | 2026-07-13T01:05:34Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed 7a489b9 (runner+ahub-run) + b978e0e (config slug): prune-before-add, signal handling w/ pgid, per-attempt status, idempotent start, EXITED-aware liveness via --no-formatting, absolute pane binaries, stream split, fsync, long-line skip; 5 negative controls confirmed | 0 | NULL | NULL | NULL | 0 | 2026-07-13T00:37:46Z | 0 | ||||||||||||||||||||||
| ah-nyl.12 | 7714724648b34e987bf24b58176889b5580a4ff3c05efff661cea5999723a656 | hardening: data-layer review fast-follows (vikunja pagination, cache refresh, config validation) | Data-layer review @ b86928a returned SHIP with fast-follow hardenings. Implement all. Line refs @ b86928a. H1 (from MAJOR-latent finding, internal/vikunja/board.go:28-73): Snapshot must NEVER silently return a partial board (reconcile treats missing tasks as vanished -> kills runs, cancels tasks). Fixes: (a) hitting maxSnapshotPages -> return an error, not partial data; (b) when the x-pagination-total-pages header is absent, do NOT treat a short page (<50) alone as end-of-data — continue until an EMPTY page (and stop on empty page in all cases); (c) if x-pagination-result-count (or total-pages) is present, cross-check the accumulated count and error on mismatch. Tests: header-absent short-page continuation, cap-hit error, mismatch error. H2 (board.go:28-35, 82-88): cache self-refresh on the read path — on a 404 from Snapshot (stale view id) or MoveToBucket's POST (stale bucket id after delete+recreate), refresh views/buckets once and retry once; second failure returns the error. Tests for both. H3 (internal/config/config.go:179-247): validate db and work_root non-empty (validation-collected errors + tests). NOTE: another fixer may have recently touched internal/config (slug validation, commit message 'config: validate repo slug shape') — pull the latest state of the file and integrate cleanly. H4 (config.go:114-124, 300-334): env-expanded plain scalars must stay strings — after substituting ${VAR} in a plain (unquoted) scalar, force the node's tag/style to !!str so a value like "true"/"123"/"null" cannot re-type and spuriously fail KnownFields decode. Test with an unquoted ${VAR} expanding to "true". H5 (internal/store/store.go:371-373): add a short code comment on the timestamp columns noting RFC3339Nano TEXT does not sort lexicographically by instant (variable-width fraction) — any future ORDER BY on time columns must ORDER BY id/seq or normalize width. Comment only, no behavior change. Constraints: scope = internal/vikunja/, internal/config/, internal/store/ (comment only). Conventions: testify + culpa. Build/vet/test -race ONLY those three packages; never ./... . Commits: 'vikunja: harden snapshot pagination and cache refresh' + 'config: require db/work_root, pin env-expanded scalars to !!str'; hooks bypassed; retry on index.lock; no push; no .beads/. | H6 (from store validator, empirically quantified): concurrent store.New on a NONEXISTENT db file -> loser fails with SQLITE_BUSY ~57% (busy_timeout does not cover first-creation/WAL-conversion during connection setup; error surfaces via PingContext, wrapped at store.go:77). Steady-state (existing file) is fully clean (93,867 hammered ops, 0 errors). FIX in internal/store: bounded retry (e.g. up to ~2s with small backoff) around the open/ping/migrate sequence in store.New when the error chain matches SQLITE_BUSY, so daemon + ahub status can race first boot safely. Add a two-process fresh-file race test if cheaply arrangeable in-package (two goroutines with separate Stores on one fresh path is enough to reproduce per the validator). Also note in the package doc that busy_timeout excludes creation. | closed | 1 | task | NULL | NULL | 2026-07-13T00:41:22Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T01:31:07Z | 2026-07-13T01:31:07Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed 8f1c381/4709405/878857f: pagination never-partial guarantees, cache refresh-on-404, db/work_root validation, !!str pinning, sort-order comment, first-boot BUSY retry + migrate TOCTOU serialization (BEGIN IMMEDIATE + in-tx recheck) | 0 | NULL | NULL | NULL | 0 | 2026-07-13T01:05:54Z | 0 | ||||||||||||||||||||||
| ah-nyl.13 | 7cbeacf33cd82863a1322764cd5c5d84db98099b33682f72598735f7aeccfc46 | fix: attempt-evidence composition (phantom adopt) + meta wedge + signal escalation | Fix-verification review of the fix commits found a CRITICAL composition bug plus follow-ons. Findings below; line refs @ HEAD (80b565b). Scope: internal/ports (ONE additive field), internal/runner, cmd/ahub-run, internal/reconcile. CORE RULE (fixes G1+G4): a live zellij session is evidence for attempt K ONLY when .task/meta.json's attempt == K (meta is written by Start(K)). The session task-<N> outlives attempts (keep-pane shell after normal exit; create-background default pane), so session-alive alone is NOT evidence. G1 CRITICAL (reconcile.go:240-252 + runner.go:151-178): re-attempt after a finished run with the session alive: probe Status(task, latest+1) ignores old exit.json (attempt mismatch) and sees the alive session -> Running -> phantom-adopts a run row for attempt K+1 that was never Started -> "timeout" kill ~30min later. Also defeats the interrupted-finalize guard (reconcile.go:486: !st.Running false due to keep-pane shell) so a successful attempt can be republished as "attempt K+1 timeout". FIX in runner.Status: in the session-alive branch read meta.json; meta.attempt == queried attempt -> Running as today; meta.attempt != queried attempt (or meta absent) -> this session is NOT runtime for the queried attempt: report Completed=false, Running=false is not representable... implement as: expose the meta attempt in the status and let Running mean "session alive AND meta matches". Concretely: add field `MetaAttempt int` (0 = unknown/absent) to ports.RunStatus with a doc comment; populate it whenever meta.json is readable; Running=true ONLY when session alive && MetaAttempt == queried attempt; when session alive && MetaAttempt != attempt -> Running=false, Completed=false (a new legitimate "no evidence for this attempt" state — update the ports doc comment for Status accordingly: exactly one of Running/Completed OR neither when the live session belongs to a different attempt). Reconcile: runtimeEvidence stays (Running || Completed-non-crashed) — the neither-state naturally means "no evidence", so claim proceeds with a real Start. Verify the crash-window adopt still works (meta matches -> Running -> adopt). G4 MAJOR (reconcile.go:478-484 + runner.go:151-158): DB-loss adoption probes attempt 1 while the live runtime is attempt K>1 -> with G1's fix alone this becomes "no evidence" -> wrongly fails the card while pi K runs unsupervised. FIX in reconcile.adoptOrFail: when the probed attempt yields no evidence but Status reports a live session with MetaAttempt M > 0 and M != probed attempt, re-probe/adopt attempt M (adopt the ACTUAL in-flight attempt: create run row at attempt M, StartedAt=now). Regression test: nil latest run + live runtime with meta{attempt:3} -> adopts attempt 3, no kill, no fail. G3 MAJOR (runner.go:255-263, taskfiles.go:111-128): torn/corrupt meta.json permanently wedges Start (existingRun hard-errors every tick). FIX: write meta.json atomically (same tmp+fsync+rename helper as exit.json) AND treat unparseable meta.json as absent (warn + decline reuse) in both existingRun and the G1 Status path. Regression test: garbage meta.json -> Start proceeds fresh (after prune/reuse logic), Status doesn't error. G5 MINOR (cmd/ahub-run/main.go:127-136): child ignoring SIGTERM/SIGHUP -> ahub-run waits forever, session killed under it, token burn. FIX: after forwarding the signal, bounded wait (10s) then SIGKILL the child process group; still write exit.json+report. Test with a TERM-ignoring child script. G6 MINOR (reconcile.go:294-298): persistent Start failure (e.g. pi not on PATH) -> error event appended EVERY tick, unbounded, card stuck in ready. FIX: dedup — skip appending when the task's most recent event has identical kind+payload (cheap: track last event per task in-memory in the Reconciler); AND after 5 consecutive start failures for the same (task, attempt), move the card to failed with a comment (move-then-comment) so the operator sees it. Tests for both. G7 MINOR (reconcile.go:486-496): dragging an already-finalized card back to in_progress re-runs finalize with moveCard=true -> duplicate result comment per drag. FIX: fire the interrupted-finalize completion branch with moveCard=true only when rec.State == in_progress; for terminal rec.State just align per the §10 terminal rules. Test: drag in_review card to in_progress with dead session -> no duplicate comment, converges. Constraints: scope exactly internal/ports (RunStatus field + doc), internal/runner, cmd/ahub-run, internal/reconcile. Do NOT touch internal/vikunja (a sibling fixer works there concurrently), internal/store, internal/config, docs/, .beads/, go.mod. Conventions testify+culpa. Build/vet/test -race ONLY ./internal/ports/... ./internal/runner/... ./cmd/ahub-run/... ./internal/reconcile/... ; never ./... . Update the reconcile fakes so an unscripted (task,attempt) Status returns the realistic three-state contract (the zero-value RunStatus masked G1 — make fakes fail loudly on unscripted queries instead). Commits: 'runner: attempt-evidence via meta.json (+atomic meta, signal escalation)' + 'reconcile: adopt actual in-flight attempt, start-failure backoff, refinalize guard'; hooks bypassed; retry on index.lock; no push. | closed | 0 | bug | NULL | NULL | 2026-07-13T01:51:43Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T02:26:12Z | 2026-07-13T02:26:12Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed a0c8858+24cb927: meta-aware attempt evidence (three-state Status w/ MetaAttempt), atomic meta + lenient corrupt handling, adopt actual in-flight attempt, SIGKILL escalation, start-failure dedup+escalation, refinalize bounce-back; fakes panic on unscripted queries; six traces pinned | 0 | NULL | NULL | NULL | 0 | 2026-07-13T01:51:44Z | 0 | |||||||||||||||||||||||
| ah-nyl.14 | 0bb6c4289149cfb59e17de6593561acac7c3bbda385532f6b1c8e02a79121ba9 | fix: vikunja result-count header semantics (verify against source) | Fix-verification review flagged H1's cross-check as likely wrong (CRITICAL-risk): board.go:107-131 compares the ACCUMULATED task total against the last-seen x-pagination-result-count header. If Vikunja's header means "items in the CURRENT response" (per-page) — which is the documented/likely semantics — any board >1 page makes snapshotOnce error on EVERY iteration -> reconcile aborts every pass -> no supervision, no timeouts, daemon effectively stalls at >50 tasks. Also the empty terminal page's headers are checked BEFORE the empty-page break (board.go:104-113), which under per-page semantics errors the headerless path too. STEP 1 — establish the truth from Vikunja 2.3 SOURCE (do not guess): fetch the pagination handler from the upstream repo (github.com/go-vikunja/vikunja, tag v2.3.0 or close; the header is set in pkg/web/handler/ — search for "x-pagination-result-count"). Record the exact semantics (total vs per-page) with a file/line citation in a code comment. STEP 2 — fix accordingly. If per-page (expected): cross-check len(page tasks) vs the header PER RESPONSE (mismatch -> error), keep x-pagination-total-pages as the page-count terminator, keep empty-page as universal terminator, keep cap-hit -> error; move the empty-page break BEFORE any header cross-checks. If genuinely total: keep the accumulated check but STILL move the empty-page break first and add the missing decisive fixtures. Either way add: multi-page fixture WITH result-count headers on every page; empty-terminal-page-with-headers fixture; headerless multi-page fixture (already exists — keep). Constraints: scope = internal/vikunja/ ONLY (a sibling fixer works in runner/reconcile/ports concurrently). Conventions testify+culpa. Build/vet/test -race ./internal/vikunja/... only. Commit 'vikunja: fix result-count semantics per upstream source (<citation>)'; hooks bypassed; retry on index.lock; no push; no .beads/. | closed | 0 | bug | NULL | NULL | 2026-07-13T01:51:44Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T02:00:03Z | 2026-07-13T02:00:03Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed 7802095: per-response result-count check (semantics proven from vikunja v2.3.0 pkg/web/handler/read_all.go with line citations), empty-page break before header checks, decisive multi-page fixtures | 0 | NULL | NULL | NULL | 0 | 2026-07-13T01:51:45Z | 0 | |||||||||||||||||||||||
| ah-nyl.15 | 26d73898c2195a2502a4cc9764f09b4e985bd97a0e976a09926b5c4133e47e19 | fix: adopt-guard livelock + neither-state timeout arm (final verification findings) | Final composed-semantics verification (@ 825238d) returned FIX-FIRST with two findings, both pre-analyzed with exact fixes. Scope: internal/reconcile/ ONLY. V1 HIGH (reconcile.go:524): adoptOrFail's finished-row branch guard `run.State == RunStateFinished && !st.Running` fires on ANY non-Running status including hard Completed for the NEXT attempt (exit.json{K+1} present but the K+1 row was never persisted — crash before CreateRun or persist failure whose compensating kill raced a fast-exiting pi). Result (empirically proven by the verifier): terminal record -> alignCardToRecord silently bounces ready->failed on every human re-drag, K+1's real result never persisted/commented, Start never issued, stale exit.json never cleaned — livelock against the human. FIX (verifier-tested in scratch against the full suite): change the guard to `!runtimeEvidence(st)` so hard Completed evidence falls through to the adopt branch (which persists the K+1 row from the evidence and finalizes with its true outcome). Add regression test: finished row K + probe returns hard Completed{attempt K+1, exit 0} -> adopts+finalizes K+1 (row created, in_review, one comment), then K+2 claimable; also the terminal-record variant (card in ready, terminal rec, completed evidence -> adopt path, NOT silent bounce). V2 MEDIUM (reconcile.go:409-421): check() switch has no arm for the neither-state (st.Running==false && st.Completed==false — live session, meta gone/mismatched; reachable when the worktree is rm -rf'd mid-run, MetaAttempt=0). Today: no finalize, no timeout, forever — run wedged in in_progress past any deadline (verifier proved 31min past a 30m timeout, zero kills). FIX: add a third arm — when neither-state persists and now > run.StartedAt + cfg.Timeout: runner.Kill + finalize with OutcomeTimeout (same path as the Running-timeout arm); before the deadline, log at warn (observation degraded) and do nothing. Regression test: running row, status neither (MetaAttempt 0), fake clock past deadline -> Kill called, finalized timeout, card failed; before deadline -> no action. V3 INFO (reconcile_test.go:36): statusCrashed fixture sets MetaAttempt 0, but the real runner reports the surviving meta's attempt on crash inference (0 only when the worktree is gone). Update the fixture/helpers so crashed statuses carry a realistic MetaAttempt (parameterize; keep a worktree-gone variant with 0) — hygiene so future MetaAttempt consumers are tested against real shapes. Constraints: internal/reconcile/ only; testify+culpa; go build/vet/test -race -count=1 ./internal/reconcile/... (never ./...); commit 'reconcile: adopt completed evidence, timeout the neither-state' staging only internal/reconcile; hooks bypassed (git -c core.hooksPath=/tmp/nohooks commit); no push; no .beads/. | closed | 0 | bug | NULL | NULL | 2026-07-13T02:43:36Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T03:01:04Z | 2026-07-13T03:01:04Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed 30292cb: adopt-guard via runtimeEvidence + same-tick finalize of hard completed evidence, neither-state timeout arm w/ shared timeoutKill, fixture hygiene; negative controls confirmed | 0 | NULL | NULL | NULL | 0 | 2026-07-13T02:43:37Z | 0 | |||||||||||||||||||||||
| ah-nyl.16 | 01a6ca2b6997fd15bcf53d2c2b93acfb1fced4d9dbb080e77d33e9711ddf4dd1 | fix: wiring review findings 1-7 (flag swallowing, agenthubd tests, polish) | Hostile review of the wiring commits (e116576/ae38c10/254c0f8) returned FIX-FIRST. Fix ALL seven. Line refs @ HEAD. W1 BLOCKER (cmd/agenthubd/main.go:70, cmd/ahub/main.go:105,200): stdlib flag.Parse stops at the first positional and nothing checks fs.NArg() -> `agenthubd serve --config ./missing.yaml` silently starts with ./agenthub.yaml defaults (exit 0, WRONG config); `ahub status 127.0.0.1:9188` silently queries the default :9100 (wrong daemon, exit 0). FIX: after each fs.Parse (three call sites), reject fs.NArg() > 0 with a usage error to stderr and exit 2. Tests for all three sites. W2 BLOCKER-adjacent (cmd/agenthubd): zero tests despite run() being injection-shaped. ADD minimum: bad flag -> exit 2; positional arg -> exit 2 (W1); missing/broken config -> exit 1; invalid --log-level -> exit 2; connection-refused preflight -> exit 1 AND stderr contains the operator-friendly preflight message. Use the injected args/stderr seams; httptest fake vikunja where needed (views endpoint refusing/absent). Keep each test <2s. W3 LOW (cmd/ahub/main.go:219-230 + internal/config/config.go:422-427): single-problem validate-config prints the path twice and skips the bullet format (culpa.Join(1) returns the bare error so the multi-unwrap loop never fires); also the existing two-problem test passes via Contains even if the multi-unwrap loop is deleted. FIX in ahub only (do not change config's wrap): fallback branch prints the problem without the duplicated prefix; pin the EXACT multi-line output format for the >=2 case and the exact single-line format for the ==1 case with require.Equal. W4 LOW (internal/httpapi/httpapi.go:116-119): wire http.Server.ErrorLog = slog.NewLogLogger(<handler>, slog.LevelError) so per-connection panics/header noise join the structured stream instead of log.Default() raw text. Test optional (constructor wiring assertion is enough). W5 INFO (cmd/agenthubd/main.go:184): the explicit stop() next to defer stop() is load-bearing — it unregisters signal handling so a second SIGTERM during the drain hard-kills (desired escape hatch). Add the pinning comment so a cleanup doesn't remove it. W6 INFO (internal/httpapi run-exit): trailing garbage after the JSON object is accepted (Decode reads one value). Add a dec.More() strictness check -> 400 on trailing content; adjust/add test. W7 INFO (cmd/agenthubd/main.go:117): SIGTERM during the preflight window logs the scary "startup preflight failed ... check vikunja.url" message and exits 1. Branch on errors.Is(err, context.Canceled) -> quiet "shutdown requested during startup" log, still exit 1 (or 0 — pick 1 for "did not reach ready", document in the message). Test if cheap via the run() seam. Constraints: scope = cmd/agenthubd/, cmd/ahub/, internal/httpapi/. A sibling agent is writing e2e/ concurrently — do NOT touch e2e/, internal/<anything else>, docs/, .beads/, go.mod. Conventions: testify, culpa, slog. Build/vet/test -race ONLY your three packages; never ./... . Commits: 'cmd: reject positional args, add agenthubd run() tests' + 'httpapi: strict run-exit decode, slog ErrorLog' (split as sensible); hooks bypassed (git -c core.hooksPath=/tmp/nohooks commit); retry on index.lock; no push. | closed | 0 | bug | NULL | NULL | 2026-07-13T03:34:21Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T03:47:12Z | 2026-07-13T03:47:12Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed c07e697+97133f9: NArg guards x3 w/ tests, agenthubd run() suite (9 tests), exact validate-config formats, slog ErrorLog, strict decode, stop() comment, calm preflight shutdown (found NotifyContext cause never unwraps to Canceled on go1.26) | 0 | NULL | NULL | NULL | 0 | 2026-07-13T03:34:22Z | 0 | |||||||||||||||||||||||
| ah-nyl.2 | 160899e1969f48c186279e62ca24ed7aa7f362fd1b56dc90ed2247c4cf660322 | store: SQLite implementation of ports.Store | Implement internal/store per docs/SPEC.md SS7 (read SPEC fully; SS5-7 normative). ports.Store on modernc.org/sqlite. Deliverables: - internal/store/store.go: New(path string) (*Store, error) — opens DB, applies PRAGMAs (WAL, busy_timeout=5000, foreign_keys=on), runs migrations; Close(). - Migrations: embedded schema.sql (embed package), applied under a PRAGMA user_version gate (hand-rolled, target version 1). DDL exactly per SPEC SS7. - All ports.Store methods with the documented semantics: GetTask/LatestRun return (nil, nil) when absent; CreateRun surfaces UNIQUE(task_id, attempt) violation as a distinguishable error (exported sentinel ErrDuplicateRun); UpsertTask insert-or-update by id; timestamps RFC3339 UTC; contexts honored (database/sql *Context variants everywhere). - Compile-time check: var _ ports.Store = (*Store)(nil). Tests (stdlib testing only, t.TempDir() databases): round-trip every method; absent-row nil,nil; duplicate run -> ErrDuplicateRun; upsert updates fields + updated_at; ListTasks ordering deterministic (by id); events append + monotonically increasing seq; migration idempotence (New twice on same file). Constraints: work ONLY under internal/store/. Do not modify go.mod/go.sum (deps are already there), other packages, or .beads/. Build/test ONLY your package: go build ./internal/store/... && go vet ./internal/store/... && go test ./internal/store/... . Commit with 'store: ...' staging only internal/store. Do NOT push. | closed | 1 | task | NULL | NULL | 2026-07-12T23:34:33Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T00:10:13Z | 2026-07-13T00:10:13Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed e3749f1, green | 0 | NULL | NULL | NULL | 0 | 2026-07-12T23:55:04Z | 0 | |||||||||||||||||||||||
| ah-nyl.3 | c5a4d9c93b65bce4f1b5dea36aa896d1dec99ba8a4a1c9579ace421cbb9f8d21 | vikunja: Board adapter over the REST API | Implement internal/vikunja per docs/SPEC.md SS8 (read SPEC fully; SS6, SS8 normative). ports.Board over the Vikunja 2.3.0 REST API. Deliverables: - New(cfg config.Vikunja, logger *slog.Logger) (*Client, error) storing an http.Client with a sane timeout (~15s). - Bucket resolution per SPEC SS8: locate the kanban view of the configured project, build title->bucketID and bucketID->canonical-name maps from the config buckets mapping; missing configured title = error listing found titles. Resolve lazily on first use and cache; provide a Refresh path when a lookup misses (board edited). - Snapshot(ctx): tasks of the project with canonical bucket names ("" for unmapped buckets), honoring pagination. - MoveToBucket(ctx, taskID, canonical), Comment(ctx, taskID, markdown), each per SPEC SS8 endpoints. - Error style: non-2xx -> error with method, path, status, and <=200 bytes of body. - Compile-time check: var _ ports.Board = (*Client)(nil). IMPORTANT — verify the real API contract before coding: the live OpenAPI JSON is at https://tasks.bigb.es/api/v1/docs.json (Swagger UI at /api/v1/docs) — fetch it (no auth needed for the spec itself) and confirm exact paths, request/response shapes, and pagination headers for: project views list, kanban view tasks, buckets list, bucket task move, comment create (Vikunja uses PUT-for-create), single task get. If the live spec is unreachable, use the upstream docs at https://vikunja.io/docs/ and pin your best understanding in code comments + fixtures. SPEC SS8's endpoint list is the expected shape, not gospel — trust the OpenAPI. Tests: httptest.Server fixtures (JSON canned from the OpenAPI shapes) covering snapshot incl. pagination + unmapped buckets, bucket resolution failure (helpful error), move, comment, non-2xx error rendering. No live-network tests. Constraints: work ONLY under internal/vikunja/. No go.mod changes, no other packages, no .beads/. Build/test ONLY your package (go build/vet/test ./internal/vikunja/...). Commit 'vikunja: ...' staging only internal/vikunja. Do NOT push. | closed | 1 | task | NULL | NULL | 2026-07-12T23:34:34Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T00:10:14Z | 2026-07-13T00:10:14Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed a1d6038, green; OpenAPI deviation from SPEC noted in package docs (flat paginated view tasks) | 0 | NULL | NULL | NULL | 0 | 2026-07-12T23:55:04Z | 0 | |||||||||||||||||||||||
| ah-nyl.4 | ff6bd742ed8006517b016443e69e35e99531123b2198633480125843c1ee2f40 | runner: pi+zellij implementation + ahub-run supervisor | Implement internal/runner + cmd/ahub-run per docs/SPEC.md SS9 (read SPEC fully; SS6, SS9 normative). This is the trickiest package — the SS9 contract (worktree layout, .task/ files, status precedence) is normative; follow it to the letter. Deliverables: - internal/runner: New(cfg *config.Config, logger *slog.Logger) *PiZellij implementing ports.Runner (compile-time check var _ ports.Runner = ...). - Start: create/reuse worktree + branch per SS9 (git -C <repo> worktree add ...; handle existing worktree dir and existing branch for retries), write .task/PROMPT.md and .task/meta.json, worktree-local ignore via the resolved git-dir info/exclude per SS9, ensure zellij session (zellij attach --create-background task-<id>), spawn the pane (zellij --session task-<id> run --cwd <worktree> -- ahub-run --task-id N --attempt K --report-url <url> -- pi --mode json -p @.task/PROMPT.md --model <model> --no-skills --no-extensions [pi_args...]). Return RunInfo. - Status: precedence per SS9 — exit.json => Completed (outcome success/error by code); else session alive in `zellij list-sessions --short` => Running with LastEvent=mtime(events.jsonl); else Completed with OutcomeCrashed, exit -1. Parse PiSession + CostUSD from events.jsonl leniently: scan lines as loose JSON maps; session id from the first object that has a plausible session identifier; cost accumulated from usage/cost fields when present. IMPORTANT: pi 0.70.2 is installed locally — empirically capture a real `pi --mode json -p 'say hi'` JSONL sample (any cheap/configured model, or ask for the shape via `pi --help` + a dry attempt; if no model is invocable offline, mark the parser 'best-effort, fixture-based' and derive fixtures from pi's documented event shape), commit the sample as a testdata fixture, and pin the parser to it. - Kill: zellij kill-session + best-effort delete-session. Summary: git log/diff per SS9. - ALL zellij/pi/git argv construction centralized in commands.go with unit tests asserting exact argv (SS9 requirement). - cmd/ahub-run: supervisor per SS9 contract — tee child stdout to .task/events.jsonl (stderr passthrough), atomic exit.json (tmp+rename), best-effort POST to --report-url (2s timeout, 1 retry), signal-death -> 128+sig, --keep-pane default true iff $ZELLIJ set (then print resume hint + exec $SHELL), false => exit with child code. Tests: stub `zellij`/`pi`/(where sensible `git` is real — use real git with a t.TempDir() repo for worktree tests) as executable scripts prepended to PATH; cover: worktree create+reuse, argv construction, status precedence matrix (exit.json / alive / gone), events parsing from fixture, ahub-run end-to-end via os/exec (tee, atomic exit.json, report POST to httptest, exit-code mapping). No sleeps >100ms; no real zellij sessions in tests. Constraints: work ONLY under internal/runner/ and cmd/ahub-run/. No go.mod changes, no other packages, no .beads/. Build/test ONLY yours: go build ./internal/runner/... ./cmd/ahub-run/... && go vet <same> && go test <same>. Commit 'runner: ...' staging only your paths. Do NOT push. | closed | 1 | task | NULL | NULL | 2026-07-12T23:35:16Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T00:20:36Z | 2026-07-13T00:20:36Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed 4348d2b, 37 tests race-clean, empirical pi JSONL fixtures + zellij probes; SPEC corrections: git-common-dir exclude path, attach --create-background not idempotent (exit 1 tolerated), retry clears stale exit.json | 0 | NULL | NULL | NULL | 0 | 2026-07-12T23:55:04Z | 0 | |||||||||||||||||||||||
| ah-nyl.5 | a229e6751c3b6c7e11fca843ebea6db9830e910a9d3b730b6df4481690de2551 | reconcile: the control loop | Implement internal/reconcile per docs/SPEC.md SS10 (read SPEC fully; SS5, SS6, SS10, SS12 normative). The reconciler imports ONLY internal/domain, internal/ports, internal/config (+ stdlib). Sibling packages (store/vikunja/runner) may not compile yet — you must not import or build them. Deliverables: - New(deps Deps) *Reconciler where Deps{Store ports.Store; Board ports.Board; Runner ports.Runner; Notifier ports.Notifier; Cfg *config.Config; Log *slog.Logger; Now func() time.Time}. - Run(ctx): loop — iterate every cfg.PollInterval, plus immediately when poked; Poke() (non-blocking, coalescing via 1-buffered channel); clean shutdown on ctx cancel. - iterate(ctx) implementing SPEC SS10 verbatim: the bucket switch (ready claim/heal; in_progress adopt-or-fail/check; terminal buckets kill+align), vanish handling for store tasks missing from snapshot, claim ordering (persist BEFORE moving the card), check() with finalize on Completed, timeout kill (Now() - StartedAt > cfg.Timeout -> Kill + OutcomeTimeout), comments per the SS10 templates (claim comment with attach hint; success comment with DiffStat+Commits; failure comment with outcome + fenced tail of events — obtain the tail via Runner.Summary? No: events tail is runner-internal; include what RunStatus/Summary give you: outcome, exit code, diff stat if any, and reference to the worktree path + zellij attach hint. Keep comment builders as small pure funcs with tests). - Prompt rendering per SPEC SS12: text/template over the role prompt file with {ID, Title, Description, Branch, RepoSlug}; render errors -> comment + move to failed (per SS12). - Per-task action errors: log, append event where sensible, continue with other tasks; Snapshot error aborts the iteration (SS10). - Every state-changing action appends a domain.Event via Store.AppendEvent. Tests (the heart of this task — table-driven, in-memory fakes for all four ports, fake clock): claim happy path (verify order: CreateRun+UpsertTask precede MoveToBucket; comment posted), exit-0 finalize -> in_review + summary comment, exit!=0 -> failed + diagnostic comment, timeout -> Kill + failed(timeout), human drag to cancelled mid-run -> Kill + killed + aligned, crash-between-persist-and-move heal (rec in_progress + bucket ready -> just MoveToBucket), adopt (bucket in_progress, no rec, runner reports running), adopt-fail (no runtime -> failed + comment), vanish (in store, not on board -> kill + cancelled), poke triggers immediate iteration, ctx cancel stops Run. Fakes record calls for assertion; no real time.Sleep beyond trivial. Constraints: work ONLY under internal/reconcile/. No go.mod changes, no .beads/. Build/test ONLY: go build ./internal/reconcile/... && go vet ./internal/reconcile/... && go test ./internal/reconcile/... . Commit 'reconcile: ...' staging only internal/reconcile. Do NOT push. | closed | 1 | task | NULL | NULL | 2026-07-12T23:35:17Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T00:15:01Z | 2026-07-13T00:15:01Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed 2acf7c4, 11/11 scenarios + extras, race-clean, testify+culpa applied; ambiguity rulings recorded in agent report (vanish idempotency, adopt-or-fail refinement, crash-window adopt) | 0 | NULL | NULL | NULL | 0 | 2026-07-12T23:55:05Z | 0 | |||||||||||||||||||||||
| ah-nyl.6 | 0f3627c5d82d75a475ee9648fba27c211ab6ca3b93bbb47bbb18b50ec3c92db4 | wiring: httpapi + agenthubd + ahub CLIs | Wire the daemon together per docs/SPEC.md SS3, SS11 (read SPEC fully). All sibling packages now exist — full-tree builds are allowed and expected in THIS task. Deliverables: - internal/httpapi: loopback server per SS11 — GET /healthz; POST /internal/v1/run-exit {task_id,attempt,exit_code} validated -> calls a RunExitHook (func injected by main; it pokes the reconciler); GET /api/v1/status -> JSON {tasks:[TaskRecord+latest Run], generated_at}. stdlib net/http + 1.22 mux patterns; graceful shutdown; tests via httptest. - cmd/agenthubd: flags --config (default ./agenthub.yaml) --log-level; load config, open store, construct vikunja client, runner, no-op notifier (slog), reconciler; start httpapi + reconciler.Run; SIGINT/SIGTERM -> graceful stop (context cancel, http shutdown, store close). slog JSON to stderr. - cmd/ahub: subcommands (stdlib flag, no cobra): `status` (GET /api/v1/status from --addr default 127.0.0.1:9100, human-readable table + --json raw), `validate-config` (load config, print OK or the collected errors, exit code accordingly), `version` (var set via -ldflags, default "dev"). - justfile: verify `just build` produces bin/agenthubd bin/ahub bin/ahub-run (adjust if needed). - Smoke check you must run and make pass: `go build ./... && go vet ./... && go test ./...` (whole tree), then `bin/agenthubd --config config.example.yaml` with a fake VIKUNJA_TOKEN env — it must start, log the bucket-resolution failure gracefully (retry next tick, not crash-loop-exit), and /healthz must answer. Note in the bead comment if SPEC/behavior forced any deviation. Constraints: you own cmd/agenthubd, cmd/ahub, internal/httpapi, plus minimal glue edits elsewhere ONLY if a sibling package has an integration bug you must fix to link (document any such fix in its own commit '<pkg>: fix ...'). No .beads/ changes. Commits: 'httpapi: ...', 'cmd: ...'. Do NOT push. | CONVENTION UPDATE (see SPEC §13, commit 5ce35c5+): tests use testify (require/assert); errors via auxilia culpa; agenthubd wires scribe handlers (TintHandler for ahub CLI, JSON or Multi for the daemon); steward MAY be used for daemon lifecycle wiring if it stays simple. testify + auxilia already in go.mod. REVIEW INPUT (data-layer review finding 4): agenthubd startup must PREFLIGHT the board — construct the vikunja client and resolve the configured bucket titles once at startup; a missing title / no-kanban-view error at that point is FATAL with a clear message (SPEC §8 'fatal config error'), while the same error later at runtime stays retryable inside the loop. Wire this into cmd/agenthubd. | closed | 1 | task | NULL | NULL | 2026-07-12T23:35:55Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T03:18:09Z | 2026-07-13T03:18:09Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed e116576+ae38c10+254c0f8: httpapi 3 endpoints, agenthubd wiring w/ fatal preflight + graceful shutdown, ahub CLI; scribe JSON/Tint handlers; steward skipped (justified); full happy-path smoke against fake vikunja done | 0 | NULL | NULL | NULL | 0 | 2026-07-13T03:01:57Z | 0 | ||||||||||||||||||||||
| ah-nyl.7 | 7c266e5f2bb15c0bb076a169f5f91846b9e15c8d4397026b55d441ea8638463d | e2e: harness with fake vikunja + stub pi | Build the end-to-end harness per docs/SPEC.md SS1/SS14 stage-1 acceptance: prove the full loop without live services. Build tag e2e, directory e2e/, run via `just e2e`. Scenario (single test, subtests per phase): temp dir with (a) a real git repo as the target repo (one commit on master), (b) fake Vikunja: httptest server implementing the subset internal/vikunja uses (views, kanban tasks, buckets, move, comment) over in-memory state you can mutate from the test; (c) stub `pi` script on PATH that reads .task/PROMPT.md, makes a commit in the cwd repo ('stub: change'), emits 2-3 plausible JSONL lines to stdout, exits 0; (d) real zellij is NOT used: stub `zellij` script that for `run` executes the wrapped command directly (background), for attach --create-background no-ops, for list-sessions prints active names from a state file — i.e. simulate sessions with files. (e) real ahub-run and agenthubd binaries built by the test (go build into t.TempDir()). Flow: start agenthubd with a generated config (short poll_interval ~200ms) -> put a task in fake-Vikunja Ready bucket -> wait (poll with deadline, no fixed sleeps) for: card moved to In Progress with claim comment; then card in In Review with a comment containing the diff stat; store db has task in_review + run finished/success (inspect via ahub status --json against the daemon). Negative subtest: stub pi exits 1 -> card lands in Failed with diagnostic comment. Timeout subtest optional (only if cheap with the fake clock — the daemon uses real time; skip if it needs sleeps >2s and note why). Constraints: everything under e2e/ (+ justfile tweak if needed). Full-tree build allowed. If you find integration bugs in other packages, fix them in separate commits '<pkg>: fix ...' with a test where feasible. No .beads/ changes. Commit 'e2e: ...'. Do NOT push. | CONVENTION UPDATE (see SPEC §13): tests use testify (require/assert); errors via culpa. Deps already in go.mod. | closed | 2 | task | NULL | NULL | 2026-07-12T23:35:56Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T03:40:07Z | 2026-07-13T03:40:07Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed 892cbda: hermetic full-loop harness (fake vikunja w/ per-response pagination + preflight support, argv-faithful zellij/pi stubs, real binaries); happy/failure/timeout paths green 4x no flakes, just e2e 3.3s; zero integration bugs found | 0 | NULL | NULL | NULL | 0 | 2026-07-13T03:18:19Z | 0 | ||||||||||||||||||||||
| ah-nyl.8 | 56c1d153e12c3a78d437729c157cf9955e07e9f7fdb9f846e61d0984e74e1941 | refactor: adopt go.bigb.es/auxilia (scribe/culpa/steward) where it pays | Post-MVP, per SPEC SS13: evaluate replacing slog wiring with scribe, error plumbing with culpa, cmd wiring with steward. Load the auxilia skill for API reference. Only adopt where it reduces code; keep diffs reviewable per package. Blocked until stage-1 e2e is green and stable. | closed | 3 | chore | NULL | NULL | 2026-07-12T23:35:57Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T00:09:34Z | 2026-07-13T00:09:34Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | superseded: auxilia + testify adopted as baseline conventions before wave 2 (user directive); refit of landed packages tracked in a dedicated bead | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-nyl.9 | b1c7f86f50a988844039d6deb7d3411e2391e037cc653193d484a51aec520898 | refit: testify + culpa in domain/config/store/vikunja | The repo conventions changed after these four packages landed (SPEC SS13 now): tests must use testify (github.com/stretchr/testify require/assert), and errors must be constructed/wrapped via go.bigb.es/auxilia/culpa (New/Errorf/Wrap/Wrapf; keep stdlib errors.New sentinels where callers use errors.Is). Both deps are already in go.mod. Scope — exactly these packages, which are DONE and committed: internal/domain, internal/config, internal/store, internal/vikunja. Do NOT touch internal/runner, internal/reconcile, cmd/ (siblings are working there right now), go.mod/go.sum, docs/, .beads/. Work: 1. Convert all *_test.go in the four packages to testify: require.* for fatal paths (setup, errors), assert.* for value checks where the test can meaningfully continue. Preserve every existing test case and its semantics — this is a mechanical style conversion, not a rewrite; keep table-test structures intact. 2. Convert error construction in non-test code of the four packages from fmt.Errorf to culpa equivalents (fmt.Errorf("...: %w", err) -> culpa.Wrapf/Wrap; fmt.Errorf without %w -> culpa.Errorf/New). Keep exported sentinels (ErrDuplicateRun, ErrRunNotFound) as-is so errors.Is keeps working; culpa-wrapped returns must still satisfy errors.Is against those sentinels where they did before (culpa supports errors.Is chains — verify with the existing duplicate-run test). Multi-error collection in config validation may stay errors.Join or move to culpa.Join — pick what keeps the error text readable and the tests passing with minimal churn. 3. No signature changes, no behavior changes, no coverage loss. Per-package verify: go build/vet/test for ./internal/domain/... ./internal/config/... ./internal/store/... ./internal/vikunja/... (these four only; NEVER ./...). Commit per package or as one commit: 'refit: testify + culpa in <pkgs>' staging only the four package dirs. Hooks bypassed (git -c core.hooksPath=/tmp/nohooks commit). Do NOT push. | closed | 1 | task | NULL | NULL | 2026-07-13T00:09:36Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T00:26:34Z | 2026-07-13T00:26:34Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | landed b86928a: testify+culpa across domain/config/store/vikunja, 30 top-level + 37 subtests preserved, errors.Is/As behavior verified | 0 | NULL | NULL | NULL | 0 | 2026-07-13T00:09:36Z | 0 | |||||||||||||||||||||||
| ah-oeq | 4c7fd7ca7b85255c43283cb38978e25fe359f7f32e03dd3033ee0860fe52a5bf | Stage 5: VM deploy, web terminal, virtual keys, spec editor, MCP facade | Per SPEC SS14.5: dedicated Proxmox VM (deploy via systemd, precedent remote/basic-vmagent in phoebe-lab); zellij web / ttyd behind Traefik (needs file provider for non-Docker backend); per-task LiteLLM virtual keys with max_budget + /spend attribution; spec-editor page; MCP facade over the agent API; multi-repo + pipeline roles. | open | 4 | epic | NULL | NULL | 2026-07-12T23:36:30Z | Eugene Blikh | bigbes@gmail.com | 2026-07-12T23:36:30Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||||
| ah-oeq.1 | 9a15614b6f1f39fdb588f3741ba183394c3e79f12d2cb9c787bea096ba534911 | Per-task repo selection via Task Spec | SPEC line 588 defers this to Stage 5: the daemon binds to a single repo and the Task Spec cannot choose one. Add per-task repo resolution (frontmatter key resolving against the configured repos list) so one board drives multiple repos. Filed under the Stage 5 epic since SPEC labels it Stage 5 even though the epic's original enumeration did not list it. Marker-sweep audit 2026-07-20. | open | 4 | feature | NULL | NULL | 2026-07-19T23:38:20Z | Eugene Blikh | bigbes@gmail.com | 2026-07-19T23:38:20Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||||
| ah-pdj | 4d1ae5d30224ee6444de2aa4446a10d46c298069d9008120afec7a6d3ccea866 | reconcile: migrate the remaining raw config-presence tests to Configured() | Follow-up to ah-jzv, which exported Configured() on every optional config block and migrated the cmd/agenthubd call sites. internal/reconcile was owned by another agent at the time and was deliberately left alone. ONE REAL DRIFT, not just style — internal/reconcile/reconcile.go:2010, agentsViewLink: 'if av.BaseURL == "" || piSessionID == ""'. A whitespace-only agentsview.base_url is rejected at load, but this raw test also silently ignores a set-but-blank 'machine', producing a deep link that ends in '~pi:' instead of no link at all. Replace with !av.Configured(). CORRECT TODAY, same shape, worth aligning: reconcile.go:1996 ('if r.cfg.Vikunja.WebURL == ""', task-link suppression) and the value reads at publish.go:83/93/114, qa.go:83/203, artifacts.go:77/89. Those are gated upstream by 'r.yonote != nil', which main.go now sets from Configured(), so they are consistent — the cheap hardening is qa.go:83 gaining '&& r.cfg.Yonote.Configured()' so the loop's own predicate matches config's rather than relying on the wiring having done it. | No consumer outside internal/config decides whether an optional block is configured by inspecting raw fields. agentsViewLink emits no link when 'machine' is unset, and a test pins that. ah-jzv's acceptance grep — cfg.<Block>.<Field> != "" outside internal/config — returns nothing. | open | 3 | chore | NULL | NULL | 2026-08-05T00:01:02Z | Eugene Blikh | bigbes@gmail.com | 2026-08-05T00:01:02Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-ptu | 656fcc1b3e50d2d0c581f01969cc197111ed13fffaf60d43d5c82a961ec5f638 | Yonote Q&A bot loop: poll doc comments, answer via board tasks (post-wave-2.5) | Why: the operator wants to ask questions as comments on Yonote docs (e.g. on agent-published reports) and have the bot answer in-thread. What: a polling loop (daemon has no public ingress — loopback-only listen — so webhooks are out for now) over events.list filtered to comments.create since a persisted cursor; qualifying comments spawn a question-answer board card whose description carries the doc URL (ah-2lh materialization then feeds the agent the doc as context); the answer is delivered via comments.create (parentCommentId threading) + optional comments.resolve under the bot identity. EXPLICITLY NOT wave 2.5: needs cursor persistence, a second poll loop, trigger-convention + mention-encoding verification, and a reply-channel decision. Depends on ah-gxa (comments methods; extend with ListEvents when picked up) and ah-25e (reply channel + bot write provisioning). | # Yonote Q&A bot loop (post-wave-2.5 — honest scope, NOT part of wave 2.5) Goal: the operator asks questions IN Yonote (comments on docs — e.g. on agent-published reports or on specs); the bot notices, an agent produces the answer, the bot replies in-thread and resolves the comment. ## Verified surface this builds on (2026-07-18, bigbes.yonote.ru 1.47.1) - comments.list {entityId, isResolved filter, threadId, limit ≤100, offset} → {data:{comments:[…]}, pagination, total} — LIVE-verified (nested envelope; items carry threadId, isResolved, threadCommentsCount, attachments). - comments.create {entityType:"document", entityId, text, parentCommentId} → {data:{comment}} — LIVE-verified end-to-end on a probe doc: text stored verbatim (plain string), threading via parentCommentId; a reply's threadId points at the root comment. - comments.resolve {id, isResolved} → data.comment — spec'd (v2-preview), plus the product MCP server exposes yonote_resolve_comment, so the surface is real. - events.list POST {name, actorId, documentId, collectionId, limit, offset, sort/direction} — LIVE-verified (returned {pagination, data:[{id, name, actorId, actor, documentId, collectionId, createdAt, data, modelId}]}); the `name` filter ("objects.verb", e.g. "comments.create") is spec'd — server-side event filtering makes cheap incremental polling possible. - webhookSubscriptions.list responds LIVE (empty list) though it is UNDOCUMENTED in both Yonote OpenAPI specs (Outline heritage; auth.info policies include create/listWebhookSubscription). BUT the daemon binds loopback-only (config validateLoopback) with no public ingress, so v1 transport = POLLING events.list; webhooks stay a documented future option if ingress appears. ## Sketch (design decisions OPEN — settle when picked up) - Poll loop: own ticker (~60s), separate from the reconcile tick; cursor persisted in the store (new kv row, e.g. yonote_cursor = last seen event id/createdAt). MUST verify then: events.list ordering + Sorting params for reliable incremental reads (direction/sort exist in the Pagination /Sorting schemas but were not exercised). - Trigger filter (pick one): (a) new unresolved comments on docs AUTHORED BY THE BOT (createdById == bot user id from AuthInfo) — simplest, covers "questions about agent output"; (b) explicit @mention of the bot — mention ENCODING in comment text is UNVERIFIED (probe stored plain text verbatim; the editor may encode mentions as structured nodes invisible to .text); (c) any unresolved comment in configured collections. - Self-trigger guard: ignore events with actorId == bot user id. - Answer path: reuse the whole existing pipeline — create a board card (existing CreateTask port, agent_tasks bucket conventions) of a question-answer task type whose description contains the doc URL (the materialization bead then auto-exports the doc as context for the agent!) plus the question thread text; the agent writes the answer; the daemon replies via comments.create with parentCommentId = thread root and optionally comments.resolve. Reply delivery needs either a small extension of the publish channel ({"reply_to_comment": …}) or a dedicated .task/answer.json — decide at design time. ## Why not wave 2.5 Needs cursor persistence, a second poll loop, trigger-convention and mention-encoding verification, and a reply file-channel decision — none of which artifact materialization or the publish lane depend on. Client extensions required when picked up: ListEvents (events.list) and possibly webhookSubscriptions.* — add to internal/yonote then, not now. Depends on: internal/yonote client bead (comments methods land there), publish-lane bead (reply channel shape + bot write provisioning). | Live evidence (2026-07-18): events.list POST works — {pagination, data:[{id, name, actorId, actor, documentId, collectionId, createdAt, data, modelId}]}, event names 'objects.verb' (observed documents.permanent_delete from the trash-purge cron); the name/actorId/documentId/collectionId request filters are v1-spec'd. comments.create/list verified end-to-end on the probe doc (threading via parentCommentId; replies carry threadId of the root; text stored verbatim). comments.resolve spec'd + present as yonote_resolve_comment in the product's own MCP server. webhookSubscriptions.list responds live (empty, UNDOCUMENTED in both OpenAPI specs — Outline heritage; auth.info policies include createWebhookSubscription) — future option only, blocked on public ingress. UNVERIFIED for pickup: events.list ordering/Sorting params for reliable incremental cursors; @mention encoding inside comment text (probe stored plain text; the editor may use structured nodes invisible in .text — trigger option (a) 'comments on bot-authored docs' avoids the question entirely). | closed | 3 | feature | Eugene Blikh | NULL | 2026-07-18T15:04:41Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T18:47:53Z | 2026-07-18T18:47:53Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Implemented: events.list poll loop w/ kv cursor + exactly-once spawn, threaded answer delivery via answer.json, config yonote.qa block, store v5, prompts/qa.md. Rollout/live-verify tracked in ah-eje. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T18:19:54Z | 0 | |||||||||||||||||||||
| ah-tc2 | 7d54ef700ae480aa00a8fdd268e4a073722f55c14bd0fdba7404db32ee366a57 | agenthubd review-branch push to srht 403 (agent lacks push creds) | Live run on agent-1: a successful task lands In Review but the review-branch publish fails: 'git push: fatal: unable to access https://git.srht.bigb.es/~bigbes/agent-demo/: 403'. The config repos[].review_remote is an https srht URL; the agenthub user has no push credential for it. Options: (a) use an SSH review_remote git@git.srht.bigb.es:~bigbes/agent-demo and add the agenthub user's SSH public key to srht with push ACL on agent-demo; (b) an https personal-access-token credential helper for the agenthub user. Non-blocking: the run succeeds and lands In Review regardless; only the review-branch push + review link is missing. | closed | 3 | task | NULL | NULL | 2026-07-17T21:29:25Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T04:51:38Z | 2026-07-18T04:51:38Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Fixed + verified live 2026-07-18. Gave the agenthub user an ed25519 SSH key (/var/lib/agenthub/.ssh/id_ed25519), registered its pubkey on bigbes' srht account via a direct meta GraphQL createSSHKey mutation (hut CLI failed on a fingerprint schema mismatch vs this self-hosted srht version; key id 2). srht git SSH is on PORT 2222 (not 22 — :22 is the host sshd). agenthub ~/.ssh/config maps git.srht.bigb.es -> Port 2222 + IdentityFile + accept-new. Switched the live config review_remote from https to git@git.srht.bigb.es:~bigbes/agent-demo. Verified: agenthub authenticates ('Hi bigbes!'), a manual branch push succeeds, and a fresh daemon run (attempt 2) landed In Review WITH the review link and no 403. SECURITY NOTE: the key is on bigbes' own account -> push access to all ~bigbes repos. Least-privilege alternative (a dedicated agent-hub srht user + per-repo ACL) is deferred; acceptable for the agent-demo sandbox. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-tqc | 0ec5f3f94c113689a7606ace2c0a5475ee68df56a367f991d8d4967233d03efe | Runner: an errored-final-turn pi run (exit 0, zero tokens) finalizes as success | Live incident 2026-07-19 (task 4, Q&A smoke): pi --mode json made exactly one model call, the litellm proxy 403'd it (key not allowed for model 'coder'), pi recorded stopReason:error with zero usage and EXITED 0 in ~0.87s. The daemon trusts the exit code: outcome=success, card advanced to in_review, Q&A delivery then correctly reported 'no answer.json' — a broken model config masquerades as a successful run. Fix direction: Status/finalize (or ahub-run) should inspect the tail of events.jsonl — a run whose final assistant turn has stopReason:error (or whose agent_end follows zero completed tool/text turns) should finalize as outcome=error regardless of exit code. Second finding to fold in: /api/v1/status shows cost_usd=0 for ALL runs including real multi-minute ones (tasks 1-2), so the usage.cost.total accumulation from events.jsonl appears broken on pi 0.73.x — re-verify the event shape and fix the cost parse. | FINDING 1 (errored-final-turn ⇒ false success) FIXED in 665e805. Root cause confirmed as filed: applyExit mapped exit.json's code straight to the §9 outcome, and pi exits 0 even when its only assistant turn was rejected by the provider (stopReason 'error', zero usage). Fix: ParseEvents now also records the LAST assistant message_end's stopReason/errorMessage plus an assistant-message count, exposed as EventStreamInfo.Errored(); applyExit takes the parsed stream and maps exit 0 + Errored() to domain.OutcomeError while still recording the true exit code on the run row. The stream is POSITIVE evidence only — an absent, unreadable, or assistant-turn-less events.jsonl leaves the exit code's verdict alone, so a genuine success can never be flipped by a missing file. Both Status completion branches were factored into (*PiZellij).finalizeFromExit so the ah-2ef re-read and the ah-wka grace-clear can no longer drift apart between them; it WARNs with the provider's rejection text whenever it overrules a zero exit. SPEC §9 'Status resolution' updated to match. Re-verified against a REAL pi 0.82.1 capture that the pinned message_end shape (message.usage.cost.total, message.stopReason, message.errorMessage) is unchanged since the 0.70.2 fixtures. FINDING 2 (cost_usd=0 on every live run) STILL OPEN. Ruled out: the parser. A real pi 0.82.1 run against the direct deepseek provider produces message.usage.cost.total exactly where ParseEvents reads it, and the summation is correct (see ah-1cx.4). Leading hypothesis: agent-1 runs pi through a CUSTOM 'litellm' provider (models are named litellm/<model>), and pi prices a response from its own per-provider model registry — a custom OpenAI-compatible provider has no pricing metadata, so every cost field comes back 0. That would make cost_usd=0 pi's behaviour, not our bug, and the fix would be to source cost from LiteLLM instead. NEEDS LIVE EVIDENCE from agent-1 (an events.jsonl from a real run: is message.usage.cost.total literally 0, or is the usage block shaped differently under the litellm provider?) — the ssh probe is blocked by the local permission classifier, so this needs an operator '!' handoff. | closed | 2 | bug | Eugene Blikh | NULL | 2026-07-19T05:38:11Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:46:00Z | 2026-08-04T23:46:00Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | BOTH findings resolved. Finding 1 (errored-final-turn ⇒ false success): fixed in 665e805, then COMPLETED in f11ef03 after an adversarial review disassembled the installed pi 0.82.x and found the first pass implemented only half of pi's actual contract. Errored() is now two-armed: (a) the final assistant turn's stopReason is one pi itself calls unusable — 'error' OR 'aborted', since pi's own predicate at print-mode.js:105 is 'error || aborted' and that block lives inside 'if (mode === "text")', which is precisely why json mode exits 0 on both; (b) SessionID != "" && AssistantMessages == 0, the arm the bead named but the first pass never implemented (AssistantMessages was being computed for exactly this and never consulted). Arm (b) closes an EMPIRICALLY REPRODUCED hole: print-mode.js:94 guards the whole run with 'if (initialMessage)', so a blank or whitespace-only rendered PROMPT.md — which prepareTaskDir writes verbatim, unchecked, with nothing in reconcile guarding it either — makes pi do nothing and exit 0 with a one-line stream, sending the card to In Review, publishing an empty review branch and firing the Q&A answer lane. The SessionID != "" gate is kept and heavily commented because it separates 'file present and parsed, zero turns' (evidence) from 'nothing to parse' (ignorance) — the conflation the original positive-evidence-only rationale made. finalizeFromExit's warn log gained a stream_verdict attribute so an operator can tell a provider rejection (pi's text in pi_error) from a did-nothing run. SPEC §9 and domain.Outcome's enum comment (c8083c2) both updated. Finding 2 (cost_usd=0 on every live run) was SPLIT OUT to its own bead — the parser is ruled out, and the leading hypothesis is that pi has no pricing metadata for the custom 'litellm' provider; it needs live evidence from agent-1. Review follow-ups also split out: the failure comment renders 'exit code 0' and drops the provider's rejection text; nothing rejects an empty prompt at the source. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:24:53Z | 0 | ||||||||||||||||||||||
| ah-tz0 | 4edd2663acbacfce6ea3669699809f3e06dfafddd847485f1f5f0ca6072a0285 | Surface agent closing summary / review verdict in the In Review comment | domain.RunSummary carries only DiffStat + Commits, so the comment shows diffstat+commits+link but not what was done. Designer and validator both END with a one-line summary/verdict; that line is lost (only in docs/tasks/slug.md). Capture the agent final summary (from exit.json or last event) into RunSummary and render it in successComment. Prereq for verdict-driven automation. | closed | 3 | feature | Eugene Blikh | NULL | 2026-07-18T06:30:21Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T06:46:05Z | 2026-07-18T06:46:05Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | RunSummary gains a Summary field read from .task/summary.md (runner.readSummaryFile, trim+2000-rune tail-truncate, missing=empty). successComment renders it as a blockquote below the header. designer/validator/coder prompts now write their closing summary/verdict there. Verified: unit tests green in isolation AND combined with ah-bkr on master; full-module build OK. Merged (rebased onto master). NOTE: follow-up needed — the blockquote markdown isn't yet handled by ah-bkr's HTML converter. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T06:38:47Z | 0 | |||||||||||||||||||||||
| ah-wd4 | 3ab58d5b62b82edabbd207aa06a161044e6c9bd3964169fe9e78b8c3732034dd | DECISION: move the task board from Vikunja to beads+Dolt (full swap vs hybrid mirror) | Three independent Opus researchers audited this on 2026-07-20 (board contract / beads capabilities / human workflow). Consensus: writing the adapter is the EASY part; the cost is infrastructure and the human write path. FEASIBILITY (good news): ports.Board is only 6 methods (Snapshot, MoveToBucket, Comment, Comments, CreateTask, SwapLabel), 22 call sites all in internal/reconcile, and everything else (attempts, runs, lineage, Q&A links) lives in the SQLite store. domain/ is Vikunja-free; bucket names are domain constants. Adapter est. a few hundred lines, minus the 238-line markdown->HTML converter which a plain-text board does not need. All 9 canonical buckets ARE expressible via beads custom statuses with active/wip/done/frozen categories, wired into the ready_issues view at SQL level. bd ready --claim is a real compare-and-swap (ErrAlreadyClaimed) — stronger than what we have today. Comments live in their own table, NOT mixed with the events audit log, so the ask-user answer detector is safe from machine-generated lines. BLOCKERS (the real cost): 1. NO Go library (all packages are internal/), no MCP, no HTTP daemon. The only interfaces are fork/exec of bd --json (~250ms warm) or raw MySQL to a dolt sql-server. 2. Embedded mode is single-process and BLOCKS UNBOUNDEDLY — measured bd count waiting 43s behind a 40s external DB hold, no timeout knob. Upstream design doc calls multi-process embedded 'unsupported'. Migrating to dolt sql-server mode is MANDATORY (backup + bd init --server + restore; different data dir; a new server process to supervise). 3. Human write path. Vikunja is the human INPUT surface, not just storage; the viewer is read-only. The killer interaction is ask-user: a card parked in Question (or a live agent polling /api/tool/answer against a 30m timeout) waits on a human comment. Today that is typed from any device in <=20s; under beads it is laptop-only bd comment behind a ~5min auto-push debounce (~17% of a run timeout per exchange). Lowering the interval does not fix the failure CLASS: a local write that reports success and is invisible to the daemon. 4. Every write is a Dolt commit — a comment per state change plus per-heartbeat progress = write amplification into a version-controlled DAG that auto-pushes. bd batch help names this; bd compact/gc/flatten are the cleanup treadmill. 5. int64 task IDs are load-bearing (branch task-<id>, zellij session, archive filename, tool-token binding, HTTP API, notification URL). Beads ids are strings (ah-1cx.1). Recommended fix: repo-wide int64->string (mechanical, compiler-verified, ~10 files) over a synthetic mapping table that can drift. 6. Snapshot must NEVER be partial: a card missing from a snapshot is treated as vanished and the daemon KILLS the live run and cancels the record. Any adapter must enumerate transactionally or prove completeness (the Vikunja adapter refuses a truncated bucket rather than dropping tasks). Watch bd list default limits. 7. Same-field concurrent updates are last-writer-wins with no optimistic locking (upstream open question #3); only --claim has CAS. Contradicts the SPEC principle that human intent wins and the daemon aligns. 8. No change notification, by product charter ('Beads does not need sub-second sync'). Poll the events table by created_at. NOTE: this is NOT a real blocker for us — the Vikunja webhook is explicitly only an acceleration of the 20s poll, and agent-completion latency rides the separate run-exit poke. WHAT IMPROVES: Task Spec escapes the rich-text editor (plainTextFromHTML exists ONLY to undo Vikunja HTML mangling of YAML frontmatter — becomes deletable); first-class deferred/--defer beats an unmapped Someday column; dependency-aware bd ready for free; agent and human share one tracker beside the code; full history/diff/branching. Attachments are a non-issue (zero code references). OPTIONS: A) HYBRID MIRROR (low risk): keep Vikunja as the board, add a one-way exporter into beads for reporting/milestones. No reconcile changes at all. Note the milestone viewer at dolt.srht.bigb.es/~bigbes/agents-dev/view/milestones already delivers most of this value today. B) FULL SWAP, gated on prerequisites: migrate beads to dolt sql-server mode; daemon speaks MySQL directly (or bd --json --sandbox with pushes on its own timer); map ready->status open (REQUIRED: the CAS claim hardcodes status='open'); use metadata JSON for daemon-private state; events.created_at as poll cursor; batch comment writes + scheduled compaction; int64->string ids; AND build a human write path — 3 POST endpoints on the existing httpapi (comment / status / label) reusing the proven bearer-token pattern, plus a small form in the viewer. Because the daemon runs on agent-1 next to the authoritative working set, writes through it have ZERO sync latency and take the laptop out of the write path entirely. RECOMMENDATION: do not swap while the viewer is read-only. Either stay on Vikunja, or commit to option B including the write path — the write path is what makes it viable, not the adapter. Implementation beads to be filed once this decision is made. | HANDOFF DESIGN — human write path for a beads-backed board. Written 2026-07-20 for a worker with no prior conversation context. Read this whole section before touching code. == WHY THIS EXISTS == Vikunja is not merely storage for agenthubd: it is the surface through which a HUMAN expresses intent, which the daemon then observes. SPEC.md:36 and :41-43 state the contract — the board is desired state owned by the human, the SQLite store plus runtime is actual state owned by the daemon, and "the daemon never fights a human drag: human intent wins". Replacing the board with beads+Dolt while the only web UI is READ-ONLY removes the human's write surface. That is the sole remaining blocker to the migration; everything else is tractable work (see the description). == THE COMPLETE HUMAN INTENT VOCABULARY (do not add verbs beyond these without re-deriving) == Every human gesture the daemon can observe reduces to four writes. Evidence is by interaction: 1. COMMENT — the only latency-critical write. Two cases: a. Card parked in Question: reconcile.go:1151 handleQuestion -> Comments() -> tools.go:263-278 detectAnswer. It finds the LAST comment containing marker "agent-hub:awaiting-answer" (reconcile.go:1105); if ANY comment follows it, that trailing comment IS the answer. b. Mid-run: tools.go:210-238 ToolAskUser parks the card while the agent stays LIVE polling GET /api/tool/answer (tools.go:245-252). The run is burning against cfg.Timeout (default 30m, enforced in check()). Delivery latency here is on the critical path of a running agent. CONSEQUENCE: any write path slower than ~1 min materially degrades (b). A 5-minute auto-push debounce consumes ~17% of a default run budget per exchange. 2. STATUS CHANGE — highest-frequency gesture; five human actions share this one operation: trigger work (drag to Ready -> reconcile.go:234-235 handleReady + domain.CanClaim); promote an agent-created task out of Triage (created by tools.go:344-350 into cfg.AgentTasks.TargetBucket, default triage; Triage is a PARKED bucket, reconcile.go:238-244 — never a claim source); cancel/kill (reconcile.go:254-256 handleTerminal:1525-1560 -> runner.Kill, outcome killed); route from In Review; park out of the way (unmapped bucket, reconcile.go:228-232). 3. CREATE TASK — title + description + initial status. The description carries the Task Spec YAML frontmatter (role/model/skills/timeout), parsed by internal/spec/frontmatter.go:36-58. 4. EDIT DESCRIPTION / LABELS — easy to under-rate. When the daemon REJECTS a Task Spec it bounces the card to Triage (reconcile.go:380) with a comment that literally instructs: "Fix the Task Spec in the description, then drag the card back to Ready" (comments.go:51-59). Without an edit path a rejected card is unrecoverable from any device that lacks the bd CLI. Labels are the same operation class: the type:<name> label selects the task-type preset (spec.go:194-217) and is read by verdict routing (routing.go:23-42). Exactly one type:* label is legal. NOT needed: assignees, priorities, due dates, attachments, ordering, reactions. The daemon reads none of them (grep -rni attachment internal/ returns zero hits). BoardTask carries only {ID, Title, Description, Bucket, Labels, UpdatedAt} and UpdatedAt has zero readers. == TWO DELIVERY SHAPES — evaluate SHAPE A FIRST, it may be nearly free == SHAPE A: one shared Dolt sql-server; no new code. Run dolt sql-server on agent-1 beside the daemon; point every bd client at it over the network. bd supports this explicitly: 'bd dolt set host <ip> [--update-config]', plus port/user/database, BEADS_DOLT_SERVER_MODE=1, bd init --server (see bd dolt --help; docs/DOLT.md in the beads source says server mode "connects to a running dolt sql-server for multi-client access ... enables concurrent agents"). With ONE database there is no push, no pull, no debounce, no divergence and no merge conflicts. Solves every desk interaction at ~zero engineering cost. DOES NOT solve: any device without bd + network access to the server (i.e. phone). Costs: a supervised sql-server process; network exposure of the DB port; migration from embedded to server mode is backup + 'bd init --server' + restore with a DIFFERENT data dir (.beads/dolt/ vs .beads/embeddeddolt/) — not a flag flip. SHAPE B: HTTP write endpoints on the daemon's existing httpapi. POST /api/v1/board/:id/comment {"text": "..."} -> interaction 1 (DO FIRST) POST /api/v1/board/:id/status {"status": "open"} -> interaction 2 (DO SECOND) POST /api/v1/board {"title","description","status"} -> interaction 3 PATCH /api/v1/board/:id {"description","labels"} -> interaction 4 Why the daemon and not the viewer: the daemon runs ON agent-1 next to the authoritative Dolt working set, so a write through it has ZERO sync latency — it mutates the DB the reconciler reads and pushes on the daemon's own schedule. This takes the laptop out of the write path, which is what eliminates the failure CLASS (a local write that reports success and is invisible to the daemon). Lowering the auto-push interval only narrows the window; it does not remove the class. MINIMUM VIABLE SLICE = comment + status. Those two cover the blocker and the highest-frequency gesture. Create/edit can lag because filing new work is a desk activity anyway. == NON-OBVIOUS COSTS OF SHAPE B (largest hidden cost; read before estimating) == - The daemon is LOOPBACK-ONLY today: config.example.yaml line 1, listen: "127.0.0.1:9100". A human-facing write API means binding off-loopback, which drags in TLS and a real auth story. - Auth machinery to REUSE, not reinvent: internal/reconcile/tools.go:60-137 mints per-task 256-bit bearer tokens with a constant-time compare; internal/httpapi/httpapi.go:311-353 does HMAC-SHA256 verification for the Vikunja webhook. What is genuinely NEW is an OPERATOR token with a different lifetime and scope than a per-run token. Do not reuse per-task tokens for humans. - TWO WRITE PATHS CAN DIVERGE: if the laptop keeps writing a LOCAL Dolt DB while the HTTP API writes agent-1's, the merge problem returns. Shape A avoids this by construction. If shipping B alone, point the laptop's bd at agent-1 as well, or consciously accept Dolt merges. - Viewer integration: wire the existing read-only viewer's issue rows to POST at these endpoints (https://dolt.srht.bigb.es/~bigbes/agents-dev/view/milestones). It already renders id/title/ priority/type/status and milestone progress; it is a sourcehut-style page with Log in/Register in the nav, so an auth context may already exist there. == INVARIANTS ANY IMPLEMENTATION MUST NOT BREAK == 1. NEVER return a partial board from Snapshot. A card missing from a snapshot is treated as VANISHED: reconcile.go:1659 handleVanished KILLS the live run and marks the record cancelled. The Vikunja adapter refuses a truncated bucket rather than dropping tasks (vikunja/board.go:95-99). See the open spike on SearchIssues/IssueFilter default limits — this is the gating unknown. 2. Do NOT let machine-generated audit lines into the COMMENT stream. detectAnswer takes the LAST comment unconditionally, so an injected "status changed to X" line would be consumed as the human's answer. Beads keeps comments in their own table separate from the events audit log, so this is currently safe — preserve that separation. 3. Move-then-comment, never comment-then-move (reconcile.go:376-379, :413-414, :1371-1373, :1494-1496). Only a successful move earns a comment, so a persistently failing move cannot spam one comment per tick. 4. Persist-before-move; never assume a write landed and never re-read to confirm. Every failed move converges on a later tick (heal branch reconcile.go:286-299, alignCardToRecord:1436). This tolerance is what makes a non-transactional board safe. 5. 'ready' MUST map to beads status 'open'. The atomic claim CAS hardcodes it: internal/storage/issueops/claim.go:47-58 UPDATE ... WHERE id=? AND status='open'. A custom 'ready:active' status would appear in bd ready but would NOT be claimable. == BEADS PUBLIC API (use it; do NOT import internal/ and do NOT shell out to the CLI) == Root package github.com/steveyegge/beads (MIT). Verified against the v1.1.0 source zip. Open(ctx, dbPath) / OpenFromConfig(ctx, beadsDir) -- the latter respects dolt_mode in metadata.json, so embedded-vs-server is CONFIGURATION not code. Storage interface maps ~1:1 to ports.Board: SearchIssues / GetReadyWork -> Snapshot (SEE SPIKE: default limit unverified) UpdateIssue(id, {"status": ...}) -> MoveToBucket AddIssueComment / GetIssueComments (typed, ordered) -> Comment / Comments CreateIssue -> CreateTask AddLabel + RemoveLabel -> SwapLabel RunInTransaction(ctx, commitMsg, fn) batches many writes into ONE Dolt commit with rollback on error or panic — use it to (a) kill write amplification and (b) make SwapLabel ATOMIC, which is strictly better than the current Vikunja adapter's documented non-atomic add-then-remove. GetAllEventsSince(ctx, since time.Time) is a typed change-feed cursor — no hand-rolled SQL. RemoteStore (via type assertion) exposes Push/Pull so the daemon controls push timing itself. Escape hatches if the public API ever falls short, in order of preference: direct SQL (bd's own docs recommend this for extensions); vendor the MIT-licensed code; a shim module declared under github.com/steveyegge/beads/<x> plus a replace directive (Go's internal rule is a path-prefix check on the IMPORTING package path, so this legally compiles). internal/ carries no compat guarantees. | UNBLOCKED by ah-wd4.1 (closed): the gating technical unknown is settled and it does NOT rule out a beads-backed Board adapter. beads v1.1.2's SearchIssues with a zero-value IssueFilter is an honest full enumeration — measured exact at 87, 700 and 2500 issues with no default page size — and it returns every status including closed and deferred, because the default hiding is CLI-side only. A safe Snapshot is buildable: count → list → count with a parity assertion and a REFUSAL on mismatch, mirroring internal/vikunja/board.go:95-99. See ah-wd4.1's close reason for the exact call, filter, assertion and the four traps (dead Offset, per-table limit fan-out, Statistics.TotalIssues excluding wisps, cross-table duplicate IDs). So the decision is now a PRODUCT decision, not a technical one. The remaining questions for the full-swap-vs-hybrid-mirror call: - Vikunja's kanban board is the human interface. beads has a Dolt web UI, but does dragging a card between buckets have an equivalent? The whole reconciler design rests on 'the board is the desired state owned by the human', and a human moving a card is the primary input. - The Vikunja adapter is delivered, live-proven and defends against truncation already. What does the swap BUY — one datastore instead of two, and beads-native task hierarchy? Weigh that against re-proving a live path that currently works. - A hybrid mirror means two sources of truth for the same card and a sync direction to define. That is usually worse than either pure option unless one side is strictly read-only. - Note the notifier/board split: cards carry comments (result summaries, failure diagnostics, Q&A answers, delegation reports). Does beads have a comment surface a human reads as naturally? | open | 3 | decision | NULL | NULL | 2026-07-20T00:26:37Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:51:12Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||
| ah-wd4.1 | 3903707241cf6bc246639a717d12ff22a014c91fc14f4d4363549230335222c4 | SPIKE (gating): does the beads Storage API enumerate ALL issues, or silently paginate? | GATING UNKNOWN for any beads-backed Board adapter — settle this before writing adapter code, and before ah-wd4 can be decided on technical grounds. PROBLEM: the beads public API (root package github.com/steveyegge/beads, MIT) has NO plain ListIssues. Methods returning issue lists are: SearchIssues(ctx, query, types.IssueFilter), GetReadyWork(ctx, types.WorkFilter), GetIssuesByIDs, GetIssuesByLabel, GetDependencies/GetDependents, ListWisps. A Snapshot implementation would have to use SearchIssues with an empty query plus a filter. WHY IT IS GATING: agenthubd treats a card missing from a Snapshot as VANISHED — internal/reconcile/reconcile.go:1659 handleVanished KILLS the live run and marks the record cancelled. A silently truncated enumeration therefore destroys running work rather than merely returning less data. The Vikunja adapter defends against exactly this by refusing a truncated bucket instead of dropping tasks (internal/vikunja/board.go:95-99). WHAT TO DETERMINE: 1. Does types.IssueFilter (and WorkFilter) have a Limit/Offset field, and what is the ZERO-VALUE behaviour — unlimited, or a silent default (e.g. 50/100)? 2. Does SearchIssues with an empty query return every issue regardless of status, including closed/deferred/frozen ones? (bd list hides done/frozen categories by default — check whether that filtering lives in the CLI or in the storage layer.) 3. Is there a transactional way to enumerate, or a count to cross-check against (GetStatistics returns types.Statistics — does it carry a total issue count usable as a completeness assertion)? 4. If no guarantee exists: can RunInTransaction wrap a count + list so the pair is consistent? HOW: source is at github.com/steveyegge/beads v1.1.0 (proxy.golang.org zip; module root has beads.go re-exporting internal via type aliases). Read internal/types for IssueFilter/WorkFilter and internal/storage/dolt for the SearchIssues SQL. Then PROVE it empirically with a throwaway Go program against a COPY of the DB (copy .beads/embeddeddolt elsewhere; do not write to the live one) — this repo has 84+ issues, so create a scratch DB with >200 to expose a default limit. Report the exact zero-value semantics and the recommended Snapshot implementation with its completeness guarantee. DELIVERABLE: a written answer to 1-4 plus the recommended Snapshot approach. Do NOT write adapter code. | SOURCE ANALYSIS DONE (beads v1.1.2, the version installed locally; clone checked out at tag 20e493e5). Empirical confirmation is running separately. Q1 — Limit zero-value. types.IssueFilter has a plain 'Limit int' (no Offset; keyset pagination instead, via AfterCreatedAt/AfterID over the (created_at DESC, id ASC) total order). The SQL builder gates the clause on 'filter.Limit > 0' — internal/storage/issueops/search.go:100-110 emits NO LIMIT at all for the zero value and takes the branch its own comment calls 'Pattern A: full 47-column scan (used for unlimited queries)'. Limit > 0 instead takes 'Pattern B', a cheap SELECT id + LIMIT then batch hydration. So the zero value is UNLIMITED, not a silent default page size. GetReadyWork likewise has an explicit unlimited branch for Limit <= 0 (issueops/ready_work.go:165) rather than a loop that would return nothing. Q2 — status visibility. The closed-hiding is a CLI behaviour, NOT a storage behaviour: cmd/bd/list_filter.go:157, cmd/bd/search.go:114, cmd/bd/query.go:136 and cmd/bd/gate*.go are what set ExcludeStatus. sqlbuild.BuildIssueFilterClauses (internal/storage/sqlbuild/filter.go:53-72) emits a status predicate ONLY when Status, Statuses or ExcludeStatus is explicitly populated. A zero-value IssueFilter therefore returns every status, closed and deferred included. Q3 — completeness cross-check, with a trap. types.Statistics (types.go:1181) carries TotalIssues, so a count exists. BUT SearchIssuesInTx (issueops/search.go:18-78) queries the 'issues' table AND merges the 'wisps' table unless filter.SkipWisps, preferring the wisp record on an ID collision — so a naive len(result)==TotalIssues assertion is only valid if TotalIssues counts the same union. Being verified empirically. Second trap in the same function: when Limit > 0 the limit is applied to EACH table separately before the merge, so a limited query can return MORE rows than Limit. Neither trap bites a Limit=0 snapshot, but both must be documented in whatever adapter gets written. Q4 — RunInTransaction exists on the public Storage surface (documented in beads.go:23; the root package re-exports the internal types by alias, e.g. IssueFilter = types.IssueFilter), so a count+list pair can be wrapped in one transaction. Being verified empirically. VERDICT SO FAR: the gating fear (silent pagination destroying live runs via handleVanished) does NOT appear to be real at the source level — SearchIssues(ctx, "", IssueFilter{}) is an honest full enumeration. Pending the >600-issue scale test that proves no default page size hides at a larger corpus than this repo's 86 issues. | closed | 2 | task | NULL | NULL | 2026-07-20T07:48:24Z | Eugene Blikh | bigbes@gmail.com | 2026-08-04T23:50:55Z | 2026-08-04T23:50:55Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | ANSWERED — the gating fear is NOT real. beads v1.1.2, verified by source reading AND by measurement against copies of the live DB plus scratch DBs of 700 and 2500 issues. Q1 Limit zero-value: UNLIMITED, no silent default. IssueFilter.Limit is a plain int and the SQL builder gates the clause on Limit > 0 (issueops/search.go:100-110, 'Pattern A: full scan (used for unlimited queries)'). Measured whole: 87/87 on the live copy, 700/700, 2500/2500. Explicit limits track exactly and saturate only at the true row count — 1,5,50,100,250,500,1000 all exact on the 2500-row DB, so there is no round-number cap hiding anywhere. IterIssues drains identically. WorkFilter{Limit:0} and {Limit:-1} both return the full 240 ready set, so GetReadyWork has a real unlimited branch too. NEW FINDING NOT IN THE BRIEF: IssueFilter.Offset is DEAD on this read path — grep finds zero uses in issueops/ and sqlbuild/, and empirically Offset:0/5/10 with Limit:5 all return page 0. A hand-rolled Limit+Offset pager against beads would loop on page 0 forever. Only the internal/storage/domain/db builder honours Offset. Q2 status visibility: an empty query returns EVERY status, unconditionally. Live copy: closed=65, in_progress=9, open=13. Scale DB: 140 each of blocked/closed/deferred/in_progress/open. The hiding is entirely CLI-side (cmd/bd/list_filter.go:150-159, which also excludes pinned and every custom status categorised done or frozen, and only when --status/--all/--ready/--pinned are all absent). Same DB: 'bd list --json' 560, 'bd list --all -n 0' 700, API zero-filter 740 — the last gap being wisps, which the CLI list path skips. Q3 completeness cross-check — the suspicion was RIGHT and it is not theoretical. Statistics.TotalIssues is 'SELECT COUNT(*) FROM issues' and nothing else (issueops/statistics.go:16-32); the wisps table is never touched. On a 700-issue + 40-wisp DB: SearchIssues=740, CountIssues=740, TotalIssues=700. So len(result)==stats.TotalIssues is WRONG and passes on agent-hub today only because our wisps table is empty — a latent bug the current data hides. The correct cross-check is Storage.CountIssues, explicitly built to mirror SearchIssues's wisps-merge semantics ('GH#4387 count/list parity contract'); parity verified across 15 filter shapes including label-driven DISTINCT paths. Two hazards measured: (i) with Limit > 0 the limit is applied PER TABLE then merged, so Limit:100 returns 140; (ii) a cross-table duplicate ID makes CountIssues exceed SearchIssues by exactly the number of dups (741 vs 740) — and the comment at count.go:52-58 claiming SearchIssuesInTx 'errors loudly' on that is STALE for v1.1.2: search.go:60-72 silently prefers the wisp record. Q4 RunInTransaction: NO, not through the public API. Two independent blockers, both tested. storage.Transaction exposes SearchIssues but no CountIssues, no GetStatistics, no Iter* — one enumeration primitive and nothing to check it against. And embedded Dolt holds a process-exclusive lock for the transaction's duration, so store-level calls from inside the callback fail with 'the database is locked by another dolt process'. A transaction does give an atomic snapshot (read-your-writes confirmed, clean rollback, zero extra Dolt commits), but the snapshot cannot be independently verified from within it. RECOMMENDED SNAPSHOT (see the bead's design field for the full form): build the filter in ONE place with Limit and Offset pinned to 0 and never caller-supplied; do count → list → count; assert (n==len(list) || n2==len(list)) and that IDs are unique; on failure REFUSE with an error rather than returning a short list, exactly as internal/vikunja/board.go:95-99 does — reconcile.go handleVanished kills the live run for any record missing from a snapshot, and ports.go already specifies that a Snapshot error makes the reconciler skip the whole iteration, so a refusal costs one poll interval while a short list destroys running work. Do NOT set SkipWisps by default (NoHistory beads live in wisps with ephemeral=0 and would become invisible ⇒ killed runs) and do NOT wrap the snapshot in RunInTransaction (no verifiability gained, and a process-exclusive lock on a poll loop starves every other store call). Perf is a non-issue at board scale: 126 ms to list 2500 rows, 59 ms for 740, 34 ms to count. Build note for whoever writes the adapter: the beads module needs CGO_ENABLED=1 AND ICU headers (go-icu-regex fails with 'unicode/regex.h' not found otherwise) — on this mac, CGO_CFLAGS/CXXFLAGS=-I/opt/homebrew/opt/icu4c@77/include and CGO_LDFLAGS=-L.../lib -licuuc -licui18n -licudata. | 0 | NULL | NULL | NULL | 0 | 2026-08-04T23:36:01Z | 0 | ||||||||||||||||||||||
| ah-wka | 61844a89efb3d2f3b72967bbcc6f9e305d2ee6cc40146aae71081887bb5527a5 | runner: Status crash-inference race misclassifies a finished run (e2e flake) | Found by the Stage 2 closing review (3-run e2e probe, 1 failure in 3): TestE2E (Stage 1) tasks 101/102 finalized as outcome=crashed / exit_code=-1 even though the daemon log shows the correct run-exit report received (exit_code 0/1). Root cause per static read: PiZellij.Status (internal/runner/runner.go, crash-inference fallback around lines 210-215) infers Completed/OutcomeCrashed whenever it observes neither exit.json for the attempt nor a live zellij session — a visibility window between ahub-run writing exit.json (atomic rename) and the session-liveness check observing the dying session. The e2e stub session dies immediately at child exit, making the window wide; real zellij keep-pane narrows but does not provably close it (kill-session paths, crashes). Pre-existing Stage 1 behavior, NOT introduced by the Stage 2 commits (all six Stage 2 flows were green in all three runs). Direction to evaluate: make the crash inference sticky-read-ordered — check exit.json AGAIN after observing the session dead (dead session THEN a fresh exit.json stat), or require N consecutive dead observations before inferring a crash, or have Status treat session-dead-without-exit.json as the neither-state (no evidence) for one grace tick instead of hard Completed/crashed. Any fix must keep the SPEC section 9 three-state contract and the section 10 adopt semantics intact. | The race window is closed or bounded (re-check ordering or grace tick); a regression test reproduces the old misclassification deterministically (stub with delayed exit.json visibility); 10 consecutive full e2e runs green | closed | 2 | bug | Eugene Blikh | NULL | 2026-07-13T08:37:59Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T09:26:45Z | 2026-07-13T09:26:45Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 2783d16 + 56f84bf (SPEC section 9 sync): crash inference now confirm-after-grace (re-stat + 5s window returning the neither state). Regression test falsified against old logic; 15/15 independent e2e runs green vs ~1-in-3 pre-fix; -race clean; reviewer merge-ready with the adopt corner independently confirmed unreachable | 0 | NULL | NULL | NULL | 0 | 2026-07-13T08:46:20Z | 0 | ||||||||||||||||||||||
| ah-xuc | 19aa9e472e32b714a672302d4a5b9431d5154fa20c3d0f98ef188f68d40ea1b7 | Stage 2: webhooks, Task Spec, full buckets, Telegram, srht push, watchdog | Per SPEC SS14.2: Vikunja webhook receiver (HMAC) as reconciler poke; Task Spec YAML frontmatter (role/model/skills/limits) + label type defaults + validation -> Triage bounce; buckets Triage/Blocked/Question; Telegram notifier (honor HTTPS_PROXY; TG blocked from lab network); push agent/task-N branch to git.srht.bigb.es for review + link in In Review comment; watchdog on stale events.jsonl; pi --skill materialization from /srv/skills. | Stage 2 design decisions (coordinator, 2026-07-13): 1. Webhook: POST /webhooks/vikunja on the SAME loopback mux; exposure to Vikunja is infra (ah-k23, tunnel/reverse-proxy). Hex HMAC-SHA256 of the raw body in X-Vikunja-Signature, constant-time compare; secret vikunja.webhook_secret; unset secret = route not registered. Any verified event = Poke(); payload untrusted beyond event_name logging. Poll loop remains the source of truth (Vikunja delivers webhooks once, no retries). 2. Buckets: triage/blocked/question are PARKED buckets — mapped and title-validated, never claim sources, never kill triggers, detached runtime supervision only. Triage is the bounce target for spec validation failures (not Failed — the human fixes and re-drags). Blocked/Question earn real semantics in Stage 4. domain.CanonicalBuckets() = States() + the three; buckets are a superset of states; config requires all nine keys. 3. Task Spec: YAML frontmatter (--- delimited) at the head of the description. Fields: role, model, skills, timeout. Merge precedence: config defaults < type:<name> label preset (config task_types) < frontmatter. Multiple type:* labels or an unknown type = validation error. Unknown frontmatter fields = warnings appended to the claim comment, never a bounce. Prompt renders over the frontmatter-stripped body. 4. Skills: names are safe slugs; resolved to <skills_dir>/<name>; SKILL.md must exist with a non-empty frontmatter description (pi refuses skills without one). pi argv keeps --no-skills --no-extensions and adds explicit --skill <abs> per skill — deterministic set. 5. Per-run timeout: effective value (spec/type override or config default) persisted on the run row (migration v2, runs.timeout_seconds, 0 = config default) so restarts enforce the right deadline. 6. Watchdog: stalled = Running and now - max(events.jsonl mtime, StartedAt) > stall_timeout (default 10m, 0 disables, else >= 1m). Kill + finalize OutcomeStalled/exit -1. Neither-state observation stays deadline-only. 7. Telegram: optional config block {token, chat_id, api_base}; default-transport proxy semantics honor HTTPS_PROXY; notify is best-effort after move+comment on finalize (success/failed/timeout/stalled) and spec bounce; token redacted from errors/logs. Unconfigured = existing slog no-op. 8. Publish: Runner.Publish(ctx, taskID) -> PublishInfo{RemoteURL, Branch, WebURL}; plain git push of agent/task-N to per-repo review_remote (never force); zero info + nil err = not configured; failure degrades to a push-failed line in the In Review comment; re-push on refinalize is idempotent. review_url template with {branch} builds the human link. 9. vikunja.web_url (default: url minus /api/v1) builds human task links for comments and notifications. 10. Scope cuts: NO worker pool / max_parallel in Stage 2 (research mentions it; epic and SPEC roadmap do not). No repo selection via spec (multi-repo is Stage 5). No new external deps. Infra prerequisites tracked in ah-k23: three new board columns (Triage/Blocked/Question), Vikunja webhook target+secret, srht repos + SSH key for the daemon user, TG bot token + proxy path. | closed | 3 | epic | NULL | NULL | 2026-07-12T23:36:28Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T08:38:18Z | 2026-07-13T08:38:18Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Stage 2 delivered: 21 commits b3ee57d..HEAD (webhook HMAC poke, Task Spec frontmatter + type-label presets + Triage bounce, parked buckets Triage/Blocked/Question, per-run timeout with store v2, stall watchdog, Telegram notifier via HTTPS_PROXY, review-branch publish to srht remotes with bounded push, e2e flows, SPEC synced to Stages 1-2). Every wave gated by hostile review + empirical validation; final epic-wide gate 11/11. Runtime prerequisites (board columns, webhook target, srht repos, TG bot) remain in ah-k23. | 0 | NULL | NULL | NULL | 0 | NULL | 0 | ||||||||||||||||||||||
| ah-xuc.1 | a152c1dd582b5477ff993f9f0c070eea86bf5d7994ae57afd24f3cbf8ac4394f | Stage 2 foundation: domain buckets/outcome, ports contracts, config surface | Every Stage 2 feature extends the shared contracts; land them first so later waves stay disjoint (SPEC section 13 isolation rule). No new external deps (crypto/hmac is stdlib, yaml.v3 already present) — go.mod stays untouched for the whole epic. domain: OutcomeStalled ("stalled"); bucket-name constants for triage/blocked/question plus CanonicalBuckets() (States() plus the three — canonical buckets are a superset of states from now on); Run.Timeout time.Duration (0 = use config default). ports: BoardTask.Labels []string (label titles); StartSpec.Skills []string (absolute skill dirs); Runner gains Publish(ctx, taskID) (PublishInfo, error) with PublishInfo{RemoteURL, Branch, WebURL} — zero-value info with nil error means publish not configured (skipped); error only on a real push failure. Document contracts in comments in the existing style. config: vikunja.webhook_secret (optional); vikunja.web_url (optional, default = url with trailing /api/v1 stripped) for human task links; buckets now require all nine canonical keys (validate over domain.CanonicalBuckets()); skills_dir (optional path, tilde-expanded); task_types map[label-name]{role, model, skills, timeout — all optional} with validation (role exists, skill names are safe slugs — reuse validSlug, timeout parses positive, any skills referenced require skills_dir set); telegram optional block {token, chat_id, api_base default https://api.telegram.org} — token and chat_id required when the block is present; stall_timeout duration (default 10m, explicit "0" disables, otherwise at least 1m); repos[*].review_remote (optional git URL) and repos[*].review_url (optional template, must contain {branch} when set). Update config.example.yaml with commented examples of every new key. | go test ./internal/domain/... ./internal/config/... green; go vet clean on touched packages; table tests for nine-bucket validation, task_types, telegram block, stall_timeout, review_url template; config.example.yaml stays parseable; commits follow the area convention; only own packages staged | Coordinator refinements: (a) do NOT add a method to the ports.Runner interface — define a SEPARATE port Publisher { Publish(ctx, taskID) (PublishInfo, error) } plus the PublishInfo struct; the runner implements it in ah-xuc.5; this keeps every package (cmd, reconcile fakes) compiling between waves. (b) The nine-bucket requirement invalidates config fixtures outside internal/config: update cmd/agenthubd test fixtures AND the e2e harness fixtures (harness config + fake vikunja board columns Triage/Blocked/Question) in THIS bead — wave 1 runs solo so touching them is safe; goal: go test ./... and go test -tags e2e ./e2e/... stay green at every wave boundary. (c) vikunja.web_url default: vikunja.url with a trailing /api/v1 stripped. (d) stall_timeout: absent = 10m default; explicit "0" = disabled; the current parseDuration rejects nonpositive values, so handle the explicit zero separately. (e) review_url set without review_remote is a config error; review_remote alone is fine. | closed | 2 | task | Eugene Blikh | NULL | 2026-07-13T05:14:51Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T05:44:24Z | 2026-07-13T05:44:24Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 4 commits landed (3c3e558..7b158c8); hostile review merge-ready with zero findings; empirical validation 8/9 (only pre-existing gofmt debt, pinned to ah-xuc.8) | 0 | NULL | NULL | NULL | 0 | 2026-07-13T05:19:13Z | 0 | |||||||||||||||||||||
| ah-xuc.10 | 93e494bccda4f59baacc19a0e3b326af7c363fd21532a445b37b51577c6c63e9 | cmd/agenthubd: wire webhook secret and Telegram notifier | Wiring only. agenthubd run(): construct internal/telegram when the config block is present, otherwise keep the existing slog no-op notifier; pass the notifier into reconcile.Deps; pass the webhook secret and a hook invoking Reconciler.Poke into httpapi.Deps. internal/deps updated if that is the wiring point. Startup board preflight: with nine required buckets the existing adapter title lookup already fails fatal with the found-titles list when the Triage/Blocked/Question columns are missing on the board — verify that failure stays readable at startup. ahub needs no changes (validate-config picks up the new keys through the config package). | go build ./cmd/... green; go test ./cmd/... green including a run() smoke asserting the webhook route responds when the secret is set and is absent when unset, and that the telegram notifier is selected when configured; go vet clean | Coordinator refinement: also wire the runner as ports.Publisher into reconcile.Deps. | closed | 2 | task | Eugene Blikh | NULL | 2026-07-13T05:16:18Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T07:53:56Z | 2026-07-13T07:53:56Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 95560cc; reviewer merge-ready (single wiring path, nil-safe, pointer-identity tested); validator all-pass incl. first live daemon runs — readable fatal preflight, telegram deferred past preflight with no I/O | 0 | NULL | NULL | NULL | 0 | 2026-07-13T07:35:21Z | 0 | |||||||||||||||||||||
| ah-xuc.11 | b6a041aecb730cbf5ad794b8bba2833ccb247eeb242441fa1b256c4a6130e930 | e2e: Stage 2 flows — webhook poke, Triage bounce, skills, publish, watchdog, Telegram | Extend the e2e harness (build tag e2e, fake vikunja plus stub pi/zellij, stub-honesty rules from commit b3ee57d). Fake vikunja gains labels on tasks and the nine-bucket board. - webhook poke: long poll_interval, POST a correctly signed payload to /webhooks/vikunja, assert the claim happens promptly (poke, not poll); a bad signature does nothing. - Triage bounce: a Ready task with broken frontmatter lands in Triage with the diagnostic comment and no record or run row. - skills: a task whose spec names skills (fixture SKILL.md dirs under a temp skills_dir) — the stub pi records argv; assert the --skill absolute paths and the retained --no-skills. - publish: repo review_remote points at a local bare repository; after a successful run the In Review comment carries the review link and the bare repo has the agent/task-N ref at the expected commit. - watchdog: stub pi hangs without touching events.jsonl; with a short stall_timeout the run is killed and the card lands in Failed with a stalled comment. - telegram: telegram.api_base points at a fake httptest Bot API; assert sendMessage calls for the in_review and stalled paths, and that the token appears only in the request path, never in daemon logs. | go test -tags e2e ./e2e/... green and hermetic (loopback only); each flow asserted through externally observable surfaces — board moves, comments, bare repo refs, fake TG requests — not daemon internals | closed | 2 | task | Eugene Blikh | NULL | 2026-07-13T05:16:24Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T08:38:17Z | 2026-07-13T08:38:17Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 8e29554; reviewer merge-ready with zero findings — stub honesty, hermeticity, timing discipline, full-argv equality all verified; 3-run flake probe: all six Stage 2 flows green in all runs (the one flake found is pre-existing Stage 1, filed as ah-wka) | 0 | NULL | NULL | NULL | 0 | 2026-07-13T07:53:57Z | 0 | ||||||||||||||||||||||
| ah-xuc.12 | 14d3638d45c9064a76941832983973f494233fccabe6a738101abdf7f06e6d89 | docs: sync SPEC with Stage 2 behavior | SPEC.md gains normative Stage 2 sections mirroring what landed, in the established style (invariants and failure modes, not narrative): domain deltas (stalled outcome, canonical buckets as a superset of states, Run.Timeout), ports deltas (Labels, Skills, Publish), store v2 migration, vikunja labels, runner skills plus publish (never force-push), reconciler claim spec resolution + Triage bounce + parked buckets + per-run timeout + watchdog + notification ordering, httpapi webhook endpoint (HMAC, disabled when unset), config reference with the full new example, security notes (webhook secret, TG token redaction, srht push scope), roadmap section 14 marks Stage 2 delivered. Cross-check config.example.yaml for drift. AGENTS.md and CLAUDE.md only if conventions changed (mirror both if so — independent files). | Each new SPEC section spot-checked against the shipped code; no contradiction with Stage 1 sections; the research-v3 supersede note stays accurate | closed | 2 | task | Eugene Blikh | NULL | 2026-07-13T05:16:35Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T08:38:17Z | 2026-07-13T08:38:17Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | e13b65c + b48849f (two wording nits); accuracy reviewer merge-ready — every behavioral claim verified against code; SPEC section-12 example validates through ahub validate-config; pre-existing RunStatus.MetaAttempt drift filed as ah-6u0 | 0 | NULL | NULL | NULL | 0 | 2026-07-13T07:53:57Z | 0 | ||||||||||||||||||||||
| ah-xuc.2 | fa1ad68d45c1cb503c73e2f25b2c07769c319f1d2adc6eefbb30f03d70297a4a | internal/spec: Task Spec frontmatter parse, type-label defaults, validation | New pure package internal/spec (imports domain, config, yaml only — no board/store/runner I/O). Custom fields do not exist in Vikunja, so the machine-readable part of a task lives as YAML frontmatter at the head of the description; labels give per-type defaults. Parse: frontmatter delimited by --- lines at the very start of the description (tolerate CRLF; no frontmatter = empty spec). Known fields: role, model, skills (list of names), timeout (duration string). Unknown fields are collected as warnings, never errors. Returns spec + body (description with frontmatter stripped) + warnings. Resolve(cfg, labels, description): merge precedence config defaults < type:<name> label preset (cfg.TaskTypes) < frontmatter. More than one type:* label is a validation error; a type:* label naming an unknown task type is a validation error. Validation (bounce-class) errors: broken YAML, unknown role, skill name not a safe slug, skills requested while skills_dir unset, missing <skills_dir>/<name>/SKILL.md, SKILL.md frontmatter with an empty description, timeout unparseable or nonpositive. Output: Resolved{RoleName, Model, SkillPaths (absolute), Timeout, Body, Warnings}. Filesystem checks go through a small injected func so most tests need no real skills tree; add one real-FS test with t.TempDir() fixtures. Validation errors must be a typed, human-readable list — the reconciler posts them verbatim in the Triage bounce comment — distinct from internal errors. | go test ./internal/spec/... green, go vet clean; table tests cover merge precedence for every field and each bounce class; frontmatter stripping keeps the body exact after the closing delimiter | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-13T05:15:08Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T06:22:47Z | 2026-07-13T06:22:47Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | d714741 + eb5a4be (review-gap pinning); hostile adversarial review merge-ready (yaml alias bombs bounded, path traversal blocked, error taxonomy sound) | 0 | NULL | NULL | NULL | 0 | 2026-07-13T05:44:24Z | 0 | ||||||||||||||||||||||
| ah-xuc.3 | 130d6553f11713918d8f32dca70661607918c8ae7bed880ea98a1b417dca5b30 | vikunja: fetch task labels into BoardTask.Labels | Stage 2 type defaults key off Vikunja labels and the adapter currently drops them. wireTask gains the labels array (verify the exact field shape against the live docs.json or the go-vikunja v2.3.0 source, as was done for result-count semantics in commit 7802095). Snapshot copies label titles into ports.BoardTask.Labels ([]string, empty-safe, order as returned). Extend the httptest fixtures with tasks carrying zero, one, and several labels, including one with a type: prefix. No behavior change for existing methods. | go test ./internal/vikunja/... green, go vet clean; fixtures cover labeled and label-less tasks | closed | 2 | task | Eugene Blikh | NULL | 2026-07-13T05:15:10Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T06:22:48Z | 2026-07-13T06:22:48Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 1e9fc7e; wire shape pinned against upstream go-vikunja v2.3.0; review clean | 0 | NULL | NULL | NULL | 0 | 2026-07-13T05:44:24Z | 0 | ||||||||||||||||||||||
| ah-xuc.4 | 33d26ffac04170d2d8e34c39c29e316a661775ceecd8aa8f20c5d11929a7589d | store: migration v2 — per-run timeout column | The effective timeout can differ per run once Task Spec overrides land, and a daemon restart must keep enforcing the right deadline, so it is persisted on the run row. Schema user_version 2: runs gains timeout_seconds INTEGER NOT NULL DEFAULT 0 (0 = config default at enforcement time). Fresh databases create straight at v2; existing v1 files get ALTER TABLE on open. Follow the existing hand-rolled migrator (PRAGMA user_version gate, BEGIN IMMEDIATE serialization, first-boot busy retry — keep every one of those properties). CreateRun/UpdateRun/LatestRun round-trip domain.Run.Timeout, stored as integer seconds. | go test ./internal/store/... green, go vet clean; tests: fresh create lands at user_version 2; a v1 database upgrades in place preserving rows; timeout round-trips; existing first-boot race tests keep passing | closed | 2 | task | Eugene Blikh | NULL | 2026-07-13T05:15:22Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T06:22:49Z | 2026-07-13T06:22:49Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | c04f7d7; step-list migrator preserves all race properties (verified under -race); review clean | 0 | NULL | NULL | NULL | 0 | 2026-07-13T05:44:25Z | 0 | ||||||||||||||||||||||
| ah-xuc.5 | b5fbb872b97c09d1baaee1d165892ef0ed8473656472b639900c43d5dbb8e0af | runner: explicit --skill arguments and Publish to the review remote | Two runner extensions, both behind existing seams. All argv construction stays in commands.go per SPEC section 9. Skills: piArgv gains the resolved skill dirs — one --skill <absolute-path> per entry, appended while keeping --no-skills and --no-extensions (pi 0.70.2 loads explicit --skill paths even under --no-skills, giving a deterministic per-run set). Start threads StartSpec.Skills through. Publish(ctx, taskID): resolve the task worktree, branch, and repo the same way Summary does today. When the repo has no review_remote, return a zero PublishInfo and nil error (publish not configured). Otherwise git -C <worktree> push <review_remote> <branch> with the branch pushed to the same name — plain push, never force (SPEC: agent branches are never force-pushed). On success fill PublishInfo{RemoteURL, Branch, WebURL} where WebURL is the repo review_url with {branch} substituted (empty when review_url unset). Push failures return a wrapped error carrying a stderr prefix. | go test ./internal/runner/... green, go vet clean; argv table tests for skills present/absent and push; PATH-shim git stub verifies push argv, success, and failure propagation; a Start test asserts --skill flags reach the pi argv | Coordinator refinement: implement ports.Publisher (separate port defined in ah-xuc.1) as a method on the existing runner type — the ports.Runner interface itself does not change. | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-13T05:15:26Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T06:22:49Z | 2026-07-13T06:22:49Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 28f75aa + eae0845; refspec refs/heads/X:refs/heads/X pinned, no-force verified, findWorktree semantics shared with Summary; review clean | 0 | NULL | NULL | NULL | 0 | 2026-07-13T05:44:25Z | 0 | |||||||||||||||||||||
| ah-xuc.6 | e8c8ca06f34dfebba7004095b3bec4be6a1dd7a6c04bce0af09d4edc831fed76 | telegram: ports.Notifier implementation (Bot API through HTTPS_PROXY) | New package internal/telegram implementing ports.Notifier. POST {api_base}/bot{token}/sendMessage with JSON {chat_id, text, disable_web_page_preview: true}. http.Client with a 10s timeout whose transport keeps ProxyFromEnvironment semantics so HTTPS_PROXY is honored (Telegram is blocked from the lab network; egress goes through the proxy) — do not build a bare Transport without the Proxy field. Non-2xx responses and ok:false bodies become errors carrying a short body prefix; the bot token must never appear in logs or error strings — redact the URL when wrapping errors. No retries: the reconciler treats Notify as best-effort. api_base comes from config telegram.api_base so tests and e2e can point it at httptest. | go test ./internal/telegram/... green, go vet clean; httptest covers success, HTTP error, ok:false, and token redaction in returned errors; a test asserts proxy resolution from the environment is active | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-13T05:15:38Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T06:22:50Z | 2026-07-13T06:22:50Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | e15fe4c; token-leak vectors traced to stdlib source and closed; proxy semantics verified empirically; review clean | 0 | NULL | NULL | NULL | 0 | 2026-07-13T05:44:25Z | 0 | ||||||||||||||||||||||
| ah-xuc.7 | 62a6806491ee4096a8206ec5b958064c69d772d7f8376188ca61bbfc8a354510 | httpapi: Vikunja webhook receiver with HMAC verification | POST /webhooks/vikunja as a reconciler poke. Design principle 1: a webhook only triggers an immediate iteration; polling stays the source of truth because Vikunja delivers webhooks once, without retries. Deps gain WebhookSecret string and a Webhook hook (non-blocking; cmd wires it to Reconciler.Poke). Empty secret = feature disabled: the route is not registered at all. Verification: X-Vikunja-Signature carries hex HMAC-SHA256 over the raw request body; compute over the exact bytes read and compare with hmac.Equal; missing or wrong signature = 401 with a terse body that echoes nothing back; cap the body at 256 KiB. After verification decode {event_name} best-effort for the log line only — the payload is otherwise untrusted and unused. Respond 200 fast, call the hook once per verified delivery. Exposing the loopback listener to Vikunja is infra (ah-k23), not this bead. | go test ./internal/httpapi/... green, go vet clean; tests: a valid computed signature pokes the hook and returns 200; tampered body, wrong secret, and missing header return 401 without invoking the hook; oversized body rejected; unset secret leaves the route absent and existing routes unaffected | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-13T05:15:43Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T06:22:50Z | 2026-07-13T06:22:50Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 61400ae; HMAC discipline verified (full-read-before-verify, uniform 401, constant-time compare); review clean | 0 | NULL | NULL | NULL | 0 | 2026-07-13T05:44:26Z | 0 | ||||||||||||||||||||||
| ah-xuc.8 | d48513a80bea7b4ebcfc74514ea6d2a7079500b44f1b1eebca7b445db6e316b7 | reconcile: spec-driven claim, Triage bounce, parked buckets, per-run timeout | Claim-path integration of Stage 2. Same-package constraint: this bead owns the internal/reconcile edits for the claim path; the finalize-path bead must not start until this one is committed. iterate(): canonical buckets triage/blocked/question become parked buckets — superviseRuntime only (detached supervision persists runtime truth; no claim, no kill, no card moves, no comments). They are never claim sources and never terminal targets. Blocked and Question get real semantics in Stage 4; Stage 2 only reserves and parks them. claim(): resolve the Task Spec via internal/spec from BoardTask.Labels plus Description. A validation failure bounces: MoveToBucket(triage) first, then one diagnostic comment listing the problems verbatim plus a hint to fix the spec and drag back to Ready, then event spec_rejected — move-then-comment discipline so a persistent move failure cannot spam comments; appendEvent dedup applies. No record is persisted and the card never goes to Failed for spec problems. Warnings (unknown fields) never block: append them to the claim comment. Success path: the spec role name resolves through cfg.Roles; model override applies; the prompt renders over the frontmatter-stripped Body; resolved SkillPaths go into StartSpec.Skills; the effective timeout (spec override or config default) goes into StartSpec.Timeout and is persisted as Run.Timeout. check(): deadline enforcement uses run.Timeout when positive, else cfg.Timeout — in both the running arm and the degraded neither-state arm. Extend fakes_test.go (fake runner captures Skills and Timeout; board tasks carry Labels) and cover: each bounce class end to end, merge precedence reaching StartSpec, parked buckets neither claim nor kill while detached supervision still finalizes a finished runtime, per-run timeout enforced at the right boundary with the fake clock — both shorter and longer than the config default. | go test ./internal/reconcile/... green, go vet clean; bounce tests assert move-before-comment ordering and zero Failed transitions; parked-bucket tests assert no board writes; timeout tests cover run.Timeout smaller and larger than the config default | Additional: pre-existing gofmt drift in internal/reconcile/reconcile_test.go (comment alignment around lines 983-984 and 1345, present since before Stage 2) — run gofmt -w on the files you touch and fold the fix into this bead's commit. | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-13T05:16:00Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T06:57:17Z | 2026-07-13T06:57:17Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 9823250 + 06_pin commit; review merge-ready (all SPEC section-10 invariants traced clean); validator 10/10 incl. e2e byte-identical for spec-less tasks; adopt-over-bounce coverage gap closed | 0 | NULL | NULL | NULL | 0 | 2026-07-13T06:23:38Z | 0 | |||||||||||||||||||||
| ah-xuc.9 | 5c6568a5b55170ea70ba05fb8c0af96dcfaf33b977424934bdb124f8c46e5c10 | reconcile: publish review branch, watchdog on stale events, Telegram notifications | Finalize-path integration of Stage 2. Starts only after the claim-path bead is committed (same package). Publish: in finalize, on the success path with moveCard set, call runner.Publish before posting the In Review comment. Zero PublishInfo = not configured, plain comment. Success adds a review link line (WebURL, falling back to RemoteURL plus branch) and appends event published. Failure degrades: the comment still posts with a push-failed line carrying the error, event publish_failed (deduped). A refinalize repeats the push — pushing an up-to-date branch is idempotent and fine. Watchdog: in the check() running arm, when cfg.StallTimeout > 0 and now minus max(st.LastEvent, run.StartedAt) exceeds StallTimeout, the run is stalled: kill and finalize with OutcomeStalled, exit -1 (share the timeoutKill shape). The neither-state arm stays deadline-only — no events are observable there. failureComment renders the stalled outcome distinctly (agent went silent, not merely slow). Notifications: Notify best-effort — log on error, never gate the flow, guard the nil notifier — after a successful move plus comment: finalize with moveCard (success and failure including timeout and stalled) and the spec bounce. Text: outcome emoji, task id and title, outcome word, cost when known, and the human task link built from vikunja web_url. Tests with fakes: the In Review comment contains the review link; publish failure still posts the comment; the stall kill fires just past the boundary and not before; a run with events flowing but past run.Timeout still dies by deadline; notify recorded after move and comment in that order; nil notifier safe; a notify failure does not fail finalize. | go test ./internal/reconcile/... green, go vet clean; ordering asserted move then comment then notify; stalled and timeout outcomes distinguishable in comments, events, and notifications | Coordinator refinement: publishing arrives via a new optional reconcile Deps field of type ports.Publisher (nil-guarded like Notifier); finalize publishes only when the field is non-nil. cmd wires the runner into it in ah-xuc.10. | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-13T05:16:07Z | Eugene Blikh | bigbes@gmail.com | 2026-07-13T07:35:20Z | 2026-07-13T07:35:20Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | d5d1efc + 64a7216 (review fix: bounded publish ctx via publish_timeout knob, zero-delta recorder covers publish/notify); reviewer merge-ready after fix, validator 10/10 with all 24 subtests by name | 0 | NULL | NULL | NULL | 0 | 2026-07-13T06:57:43Z | 0 | |||||||||||||||||||||
| ah-ydx | 8735805091c14831debfbdb26c8f324f6832e8b80ebcc313e05201a7bd3c85f8 | Stage 3: mem0 memory lifecycle + Context Pack | Per SPEC SS14.3: mem0 REST client (X-API-Key, scoping user_id=proj:<slug>/global, run_id=task:<id> — agent_id filter is buggy upstream); load cascade on claim -> .task/CONTEXT.md section; save on Done; post-run summarizer role; Context Pack builder (task+thread+parent-chain+memory+git, ~40k char budget, deterministic order, truncate-from-tail). | Audit note (2026-07-20): the SPEC 14.3 'post-run summarizer role' was never implemented as a role — the shipped design substitutes the agent-authored .task/summary.md + mem0 infer=true LLM extraction at Done-time (memory.go). All four ah-ydx children are closed. DECISION NEEDED: if the substitution is accepted, update the epic body + SPEC 14.3 wording and close this epic; otherwise the summarizer role is the one remaining piece. | open | 3 | epic | NULL | NULL | 2026-07-12T23:36:28Z | Eugene Blikh | bigbes@gmail.com | 2026-07-19T23:38:22Z | NULL | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | 0 | NULL | NULL | NULL | 0 | NULL | 0 | |||||||||||||||||||||||
| ah-ydx.1 | 942024e29b162a28d01e0bddbd3bf862f1ff8bf498c45bd03199c5a0dc916489 | internal/mem0: REST client for live mem0.bigb.es | Typed Go client for the self-hosted Mem0 server (LIVE at mem0.bigb.es, phoebe-lab/mem0 stack): X-API-Key auth, add/search/get-all/delete memories. Encode scoping conventions as helpers: user_id='proj:<slug>' | 'global', run_id='task:<id>'; do NOT rely on agent_id filtering (buggy upstream per SPEC §14.3). Stdlib-only, httptest unit tests; live verification (incl. API-key provisioning via dashboard/register) is a follow-up ops step. | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-18T13:23:56Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T13:41:11Z | 2026-07-18T13:41:11Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | internal/mem0 client merged to master (55b9b43); routes confirmed against mem0 v2.0.11 source; live verification against mem0.bigb.es tracked in ah-ydx.3 | 0 | NULL | NULL | NULL | 0 | 2026-07-18T13:25:07Z | 0 | |||||||||||||||||||||||
| ah-ydx.2 | f548f321fdf44dbfaaa3c420a175ff8b3bea76ab1cf76d097509d87eeb461072 | internal/ctxpack: Context Pack builder | Deterministic prompt-context assembler per SPEC §14.3: ordered sections (task, comment thread, parent chain, memory, git log/diffstat) under a ~40k char budget, per-section truncate-from-tail with stable ordering so identical inputs render identical packs. Pure package + table tests first; wiring into runner PROMPT.md rendering is a separate integration step after wave 1 merges. | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-18T13:23:56Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T13:41:11Z | 2026-07-18T13:41:11Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | internal/ctxpack merged to master (342410e); format contract pinned by golden tests; runner integration tracked in ah-ydx.3 | 0 | NULL | NULL | NULL | 0 | 2026-07-18T13:25:08Z | 0 | |||||||||||||||||||||||
| ah-ydx.3 | 16681b27f4960105b6ca27beabc1e8219b7b6edab3c59c243830102a1c17bd7a | Memory lifecycle: load-on-claim CONTEXT section, save-on-Done | Wire mem0 into the task loop per SPEC §14.3: on claim, search mem0 (proj scope + global) and render a memory section into the prompt context via ctxpack; on Done/Cancelled terminal scan (shares the ah-07g hook), save run summary/verdict facts back to mem0 (run_id='task:<id>'). Depends on the mem0 client, ctxpack, and the ah-07g terminal-state scan. | MEM0 SERVER HEALTHY (2026-07-18, after user-authorized override delete + restart): /configure now reflects env-of-truth (deepseek/deepseek-v4-flash + nvidia/nemotron-embed-1b-v2). FULL live smoke green: ADD infer=false 200/event=ADD; SEARCH 200 with score (embeddings ok); ADD infer=true 200 (LLM extraction ok); LIST/PURGE/EMPTY clean; bogus key 401. Server-side contract fully verified — lifecycle implementation unblocked once ah-07g lands the terminal-state hook. OPERATIONAL RULE: mem0 config is ENV-ONLY; never POST /configure (GET redacts secrets, round-trip corrupts stored creds — happened + fixed today, backup at phoebe:/root/mem0-settings-backup.sql); if a stored override reappears (dashboard onboarding creates one), delete settings row key=config_overrides + restart. | closed | 2 | feature | Eugene Blikh | NULL | 2026-07-18T13:23:57Z | Eugene Blikh | bigbes@gmail.com | 2026-07-18T16:22:33Z | 2026-07-18T16:22:33Z | NULL | 0 | NULL | NULL | NULL | 0 | 0 | 0 | �{} | Merged b761cbe: recall-on-claim (## Relevant memories via ctxpack, proj+global scopes, 15s budget, feature-off byte-identical) + save-on-Done (save→archive→prune ordering, infer=true, once-only via worktree signal, Cancelled excluded). Rollout: install MEM0_API_KEY in /etc/agent-hub/env, live-smoke recall block + one memory_saved event. | 0 | NULL | NULL | NULL | 0 | 2026-07-18T15:59:39Z | 0 |