Repository navigation
feat: the task-board and delivery ports (ADR-006, ADR-007) - #2
Merged
Merged
Conversation
Core gains a provider-agnostic task board, symmetric to the existing AgentRuntimeAdapter contract: - the six-state delivery model with an EXPLICIT transition table; an illegal transition throws instead of being silently corrected; - the versioned machine-readable run record (schema 1) that a resumed run reads back from the board, with human-readable text kept as display only; - the TaskBoardProvider port with capability negotiation (states, comments, editable comments, machine-readable record, author-trust filter, atomic claim, delivery) and a closed error taxonomy (auth/transport/precondition/not_found/unsupported); - one shared contract suite every adapter must pass, runnable with zero credentials and zero network; - the injected HTTP seam every network-backed adapter uses (curl by default), so tests never touch a network and an unconfigured adapter fails closed. State ownership is explicit: the board owns the delivery state, takumi owns the execution evidence. See docs/adr/006-task-board-provider.md.
…ages FakeBoardProvider is the reference implementation: deterministic, in-memory, zero credentials, and it runs the shared contract suite offline — which is what makes 'the abstraction holds' a test result instead of a claim. The workspace registers boards/* as a glob so adding a board is one new directory rather than an edit to every workspace file.
GitHub Issues as the task board: labels are the columns, comments carry the versioned run record, and every read of that record is filtered by the comment's author_association so a stranger's copy of the block never becomes the run. Two limits are declared rather than hidden: atomicClaim=false (labels cannot be updated conditionally, so a claim is read-then-write plus a re-read that reports a lost race), and the same-account race that no API can close is pinned by a test that names it.
GitLab Issues and notes as the task board, over the same contract. The PRIVATE-TOKEN transport is curl-based like every other adapter, the notes API makes the progress comment editable in place, and the trust question is answered honestly: GitLab exposes no per-note association, so a trust boundary exists only when the caller configures an author allowlist.
Jira issues as the task board. Two board-specific decisions: the column is the workflow STATUS (each state maps to a status name and moves through a real transition resolved at call time, never a guessed id), and the run record lives in an ISSUE PROPERTY because Jira cannot hide machine-readable text in a comment. Jira has no pull request, no pipeline and no merge, so delivery is all false — a caller that needs those is told so instead of finding out in production.
A Notion database as the task board, and the heterogeneous case that keeps the abstraction honest: the column is a select/status property, comments are append-only, there are no pull requests and no author roles. Each of those is declared in capabilities and gated with a hard error, so a caller asking for an editable progress comment is told to pick another board. The run record stays the same versioned grammar, stored in a rich-text property.
'takumi board [--provider fake|github|gitlab|jira|notion] [--json]' renders the board grouped by delivery state and prints what the provider can actually do, because a silent capability gap is how a team ends up believing a Notion board can merge pull requests. Read-only by construction: the board owns the delivery state, so this command never claims, transitions or comments. Providers are imported lazily, an unknown option is rejected instead of ignored, and a requested state the provider does not declare is an error.
ADR-006 states the two ports (board vs delivery), the state-ownership rule, the capability differences measured across GitHub/GitLab/Jira/Notion, the adapter order (fake -> GitHub -> Notion as the heterogeneous proof -> GitLab/Jira -> one MCP/REST adapter for the long tail) and the alternatives rejected. The READMEs gained the board diagram, the boards/ entry, the feature rows and the honest limitations (M1 is the work source, not delivery).
The curl transport passed every header, including the API token, as command arguments — which any local user can read with `ps` on a shared machine. The whole request now travels on stdin via `curl -K -`, with the values escaped for curl's config grammar so a header or body can never break out of its quotes and inject another option. Only non-secret flags stay in argv. Two tests pin it down with a stub curl that records BOTH its argv and its stdin: one proves the token and URL are absent from argv while the config carries them, the other proves an embedded quote/newline stays inside its own value instead of becoming a real option.
ADR-006 says an adapter is discovered like every other extension, but four of the five board packages had no manifest.yaml, so `takumi extension list` could not see them (only GitLab's existed). This adds the standard manifests — name, kind, entry, package, provider factory/transport, the auth env var, the endpoints actually used and the declared capabilities — and a cli test that proves the discovery path end to end: a board directory with a manifest is found with kind=board, and a project config that predates the board registry simply scans none instead of failing.
…DR-007) The board layer answered "where does work come from"; this answers "how does a committed change reach a host", as a second port with its own capabilities (canPushBranch / canOpenPullRequest / canRunChecks / canMerge) and its own contract suite. Three rules are encoded in the port rather than trusted to the agent: - the agent's boundary is the COMMIT: deliver() refuses a dirty worktree and never stages or commits anything; - the push is PLAIN, and deliver() reports the mode it used so a caller can prove it was not forced; - only the REVIEWED head may merge: merge() requires the head it was asked to merge and fails when the remote moved. Also in this commit: the shared error taxonomy extracted to provider-error.ts (BoardError and DeliveryError now extend one ProviderError, so a caller branches on the same kinds whichever provider failed), the async GitRunner seam (a test records the argv, which is what makes "never force-pushed" a fact), and the run marker grammar that board and delivery now share.
…ivery kind FakeDeliveryProvider is an in-memory git+host simulator: zero credentials, zero network, and it RECORDS what it did (pushes, force pushes, pull requests, merges) so the contract suite can assert effects instead of trusting a return value. It is the reference implementation the GitHub and GitLab adapters are measured against. The delivery kind joins the extension registry (manifest `kind: delivery`, `deliveries/*` workspace glob, `deliveries` registry key scaffolded by `takumi init`), so a delivery adapter is discovered like every other extension.
Local git through the injected GitRunner, the GitHub REST API through the shared curl seam: verify the commit boundary, absorb an advanced base with a plain merge (abort on conflict), push plainly, reuse or open EXACTLY ONE pull request, and merge with `sha=<expectedHeadSha>` so GitHub itself refuses a moved head. The pull request body carries the item cross-reference (`Fixes #n` when the id is numeric, always a readable `Item: <id>`) plus the machine-readable run marker. Checks map GitHub's own vocabulary onto success/failure/pending/neutral/unknown, and a pending check is never coerced to success. Without a token or a git runner the adapter fails closed instead of touching the operator's repository.
One command with in-memory providers (no credentials, no repository) that drives the whole loop and then makes each guard fire on purpose: a double claim is refused, a stale-head merge is refused, a dirty worktree delivery is refused, and a board with no delivery capability reports [none]. It exits non-zero if any guard fails to fire, so it works as a smoke test of the two ports.
ADR-007 states why delivery is a separate port (a Jira board can pair with a GitHub delivery; a Notion database has no pull request at all), the three rules the port enforces, the one-PR-per-delivery rule, the two seams that make it testable, and the alternatives rejected — including the common one: letting the agent itself push and open the pull request. Both READMEs gained the delivery diagram, the deliveries/ entry, the feature rows, the runnable demo, and honest limitations: every provider is offline-tested only, claiming is not atomic anywhere, and a conflicting base merge is handed to the review session rather than resolved by rewriting history.
The board and delivery ports were primitives with nothing sequencing them. This adds the loop that makes them a delivery: claim -> agent(commit) -> deliver -> checks -> review -> (fix -> deliver)* -> merge It owns the ORDER, the state transitions and the fail-fast classification; it owns neither the work (the injected `agent` hook) nor the judgement (the `review` hook), which is what lets the whole thing be tested with the in-memory providers and no model. It never writes code, never commits, never force-pushes, and merges only the head the review actually covered — re-reading the host first, so a push that lands during the review sends the item back for re-review instead of landing unreviewed. Building it exposed a real gap in the six-state table: `pr_open -> blocked` did not exist, so a delivery that cannot be merged (rounds exhausted, host refuses a conflicting head, base branch reconfigured) had nowhere to go and would have sat in pr_open forever. The edge is added, documented in ADR-006 and pinned by a test. Classification follows the shared taxonomy: a `transport` failure is retriable on the next tick; everything else blocks the item for a human. A failing CI check is treated as work (the agent gets the remaining rounds) and only an exhausted budget is a decision.
Redmine is the work source most of the enterprises here actually run, and it is nothing like the others: statuses are numeric ids behind a per-site workflow, comments are journals, and there is nowhere out of band to hide a run record. That last point is the interesting one. Redmine has no hidden comment and no issue properties, so the versioned state block lives in an issue CUSTOM FIELD — and `machineReadableState` is therefore true ONLY when the caller configured such a field. Without one, reading or writing the record fails closed as `unsupported` instead of pretending the state was stored. Every other capability follows the same rule: journals expose no role, so the trust boundary exists only when the caller names the authors it trusts; claiming is read-then-write plus a re-read verification (Redmine cannot write conditionally), so `atomicClaim` stays false. `listWork` walks EVERY page Redmine reports rather than silently truncating at the first hundred: the API hands back `total_count`, so stopping early would quietly drop work.
Local git for the branch, the GitLab merge-request API for the merge request, the pipelines and the merge — the same three rules as GitHub, enforced the GitLab way: the base is absorbed with a plain merge (aborted on conflict), the push is plain and nothing else, and `merge()` re-reads the MR, requires its `sha` to be the reviewed head, and sends that `sha` to GitLab so GitLab ITSELF refuses a head that moved between the read and the merge. Pipeline states map onto the shared vocabulary with pending kept pending, and a 405/406/409 answer is a `precondition` rather than a success. The git seam and the run marker come from core: two adapters must not drift into two subtly different contracts for the same operation.
Found by running the adapter against a real HTTP Redmine double: the board showed "0 item(s)" while the mock held two issues, because `listWork` swallowed any issue whose status the statusMap did not cover. That is the worst failure mode a board can have — an empty board is indistinguishable from a board with no work, so an unattended runner would idle forever while its queue is full. The distinction the code now makes: a status that IS mapped but not requested is the filter working as asked (no error, just absence), while a status that maps to NO delivery state is a configuration gap and is reported once, naming the status names, an example issue, the fix (`statusMap`), and the statuses the instance actually has. `toWorkItemOrNull` — the helper that made the silent skip possible — is gone. The test that pinned the old behaviour is rewritten to pin the new one, and the READMEs and ADR-006 now state the rule: an unmappable item is reported, never dropped.
Redmine's states are numeric ids behind a per-site workflow, so `takumi board --provider redmine` needs to be told which status name means which delivery state: `--status-map "ready=New,pr_open=In Progress"`. `--state-field` names the text custom field holding the run record; without it the view honestly reports stateRecord=false instead of pretending the state can be stored. An invalid entry is rejected at parse time (an unknown delivery state would be ignored by the adapter, and a silently ignored mapping is a board that lies about its columns), and the help text documents both flags plus the REDMINE_API_KEY environment variable.
A board item whose state an adapter cannot map is a configuration gap, not a filter outcome: it is reported with the fix, never skipped. Written down in ADR-006 and in both READMEs' limitations, next to the Redmine statusMap and state-field requirements it explains.
Five adapters each carried their own copy of the marker string — the exact drift the core module exists to prevent. They now all call core's `renderRunMarker`, and because that function VALIDATES the id (eight lowercase hex), the rule is enforced everywhere at once instead of being a convention each adapter could miss. The validation surfaced a real hole: the old local copies wrote a marker for any string, which the reader (which matches eight hex characters) would never find — an unattributable comment. It also turned up an unclassified failure: the raw `Error` thrown for a bad id broke the "every provider failure has a kind" rule. So a malformed id now fails as a classified `precondition`, and each board surfaces it through this port's own error family (`BoardError`) instead of leaking a bare Error. GitLab's board tests used pseudo-ids (`run-1`, `trusted-run`); the fixtures now use canonical ids, and the test says why.
Two of the five defects fixed so far were found while building a feature, so they sit inside a feat/refactor commit whose label does not say "bug" and cannot be found with git log --grep '^fix'. This ledger is the record: what broke, why, the commit that fixed it, and the test that fails if it returns — plus the four classes worth remembering (silent drop, missing edge, unattributable artefact, swallowed failure). Every entry was verified against the real commit diff and the real test file before being written down, and the rule for future work is recorded at the top: a found defect gets its own fix(scope) commit, never folded into the feature commit that happened to be open.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The work source and the delivery target become two ports.
Full suite at this head: 324 tests, 0 failing.