Skip to content

feat: the task-board and delivery ports (ADR-006, ADR-007) - #2

Merged
sscodeai merged 24 commits into
devfrom
feat/board-and-delivery-ports
Sep 30, 2026
Merged

sscodeai merged 24 commits into
devfrom
feat/board-and-delivery-ports

Conversation

@sscodeai

Copy link
Copy Markdown
Owner

The work source and the delivery target become two ports.

Full suite at this head: 324 tests, 0 failing.

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.
@sscodeai
sscodeai merged commit eb5f31c into dev Sep 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant