Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,51 @@

All notable changes to loopctl are documented here.

## [Unreleased] — 2026-04-17 — Import merge + agent ergonomics (PR #105)

### Added

- `POST /api/v1/projects/:project_id/stories` — create a single story by
epic number (agent-friendly alternative to the UUID-based
`POST /epics/:epic_id/stories`). Role: `:orchestrator`.
- `POST /api/v1/stories/:id/backfill` — mark a story as verified when the
work was completed outside loopctl. Records provenance in
`metadata.backfill` plus an `action: "backfilled"` audit entry and a
`story.backfilled` webhook. Refused for any story with dispatch lineage
(non-pending `agent_status`, `assigned_agent_id`,
`implementer_dispatch_id`, or `verifier_dispatch_id` set) — this is the
structural guard that makes backfill safe regardless of role. Role:
`:orchestrator`.
- `story.backfilled` added to the webhook event allowlist.

### Fixed

- `POST /api/v1/projects/:id/import?merge=true` no longer returns
`epics[0].tenant_id: has already been taken for this project` when
clients serialize epic numbers as strings. Epic numbers are normalized
to integers (and story numbers to strings) before validation and DB
lookups.
- Fallback changeset rendering translates Epic/Story unique-number
violations into `"Epic 72 already exists in this project. Use
merge=true..."` regardless of which controller surfaced the error.

### Changed

- Data-op roles: create/update for epics, stories, and dependencies
lowered from `:user` to `:orchestrator`. DELETE stays at `:user` per
the destructive-op rule. CLAUDE.md Security section clarified.
- `/loopctl:orchestrate` skill carves out "data operations" (imports,
creates, backfills, dispatches, reads) as operations the orchestrator
can perform directly without dispatching a sub-agent. Sub-agents are
only required for editing application code.

### Security

- `unique_constraint` error translation now scopes to the `_number_`
index specifically, so future unique constraints (external_id, slug,
etc.) on Epic/Story schemas won't be mis-reported as "X already
exists."

## [1.0.0] — 2026-04-12 — Chain of Custody v2

27 stories across 7 phases implementing a six-layer trust model for
Expand Down
22 changes: 22 additions & 0 deletions docs/articles/chain-of-custody.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,28 @@ bypassed, the next catches the violation.
- **Honest work is the path of least resistance**: The happy path (do real
work, report it, get verified) is easier than any bypass attempt.

## Pre-loopctl work: the `backfill` exception

Onboarding a project with work completed before loopctl exists is a legitimate
need that would otherwise require faking the full lifecycle. `POST
/stories/:id/backfill` (MCP: `backfill_story`) handles this case explicitly:
it marks a story verified, records the reason, PR number, and evidence URL
in `metadata.backfill`, writes an audit entry with `action: "backfilled"`
and `new_state.source: "pre_loopctl"`, and fires a `story.backfilled`
webhook.

The structural guard is that backfill is refused for any story with
dispatch lineage — non-pending `agent_status`, `assigned_agent_id`,
`implementer_dispatch_id`, or `verifier_dispatch_id` set. That refusal
is what prevents an orchestrator from chaining `force_unclaim → backfill`
to launder dispatched work as pre-loopctl. If a story went through
loopctl's dispatch flow, it must go through the normal report/review/verify
path — no shortcuts.

This carves out the honest onboarding case (pre-existing work with
provenance) while keeping the L4 structural role separation intact for
anything that entered loopctl's lifecycle.

## Related articles

- [Agent Bootstrap](/wiki/agent-bootstrap) — getting started from zero
Expand Down
69 changes: 68 additions & 1 deletion docs/orchestration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,28 @@ curl -X POST http://localhost:4000/api/v1/projects/:id/import \

Use this pattern when you know the status of work at import time.

**Adding stories incrementally.** To add stories to an epic that already
exists, pass `merge: true` to `import_stories` or use `create_story` for a
single story:

```
mcp__loopctl__import_stories({
project_id: "<uuid>",
merge: true,
payload: { epics: [{ number: 1, title: "Foundation", stories: [...] }] }
})

mcp__loopctl__create_story({
project_id: "<uuid>",
epic_number: 1,
story: { number: "1.7", title: "New story added later" }
})
```

Without `merge: true`, a duplicate epic number returns 409. The merge path
is also type-tolerant — epic numbers can be sent as integers or numeric
strings; they normalize to integers before the DB lookup.

### Pattern 2: Bulk mark-complete after import

Import stories normally (they start as `pending`), then bulk-complete them in one call:
Expand All @@ -221,7 +243,52 @@ curl -X POST http://localhost:4000/api/v1/stories/bulk/mark-complete \

Use this pattern when you need to import first and then batch-verify after reviewing what exists.

### Pattern 3: Epic-wide verification
### Pattern 3: Per-story backfill with provenance

When onboarding a project where you need to mark individual stories as verified
*with a paper trail* (PR number, evidence URL, reason), use
`POST /stories/:id/backfill`. This is the preferred pattern when the work was
done outside loopctl and you want the audit log to show why:

```bash
curl -X POST https://loopctl.com/api/v1/stories/:id/backfill \
-H "Authorization: Bearer $LOOPCTL_ORCH_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "completed before loopctl onboarding",
"evidence_url": "https://github.com/acme/app/pull/232",
"pr_number": 232
}'
```

Or via MCP:

```
mcp__loopctl__backfill_story({
story_id: "<uuid>",
reason: "completed before loopctl onboarding",
evidence_url: "https://github.com/acme/app/pull/232",
pr_number: 232
})
```

**Structural guard.** Backfill is refused for any story that has loopctl
dispatch lineage — non-pending `agent_status`, `assigned_agent_id`,
`implementer_dispatch_id`, or `verifier_dispatch_id` set. That prevents
using backfill as a chain-of-custody shortcut to "verify" dispatched work
without review. Use Pattern 1 or 2 for bulk onboarding; use Pattern 3 for
surgical per-story backfill with provenance.

**Idempotent retry.** Retrying a backfill with the same payload returns 200
(same story). Retrying with different `reason`/`evidence_url`/`pr_number`
returns 422 — investigate before overwriting.

Sets `agent_status=:reported_done`, `verified_status=:verified`, records the
provenance in `metadata.backfill`, writes an audit entry with
`action: "backfilled"` and `new_state.source: "pre_loopctl"`, and emits a
`story.backfilled` webhook.

### Pattern 4: Epic-wide verification

After implementation agents have reported done on all stories in an epic, the orchestrator can
verify the entire epic in a single call instead of verifying each story individually:
Expand Down
49 changes: 47 additions & 2 deletions lib/loopctl_web/controllers/page_html/docs.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,7 @@
<h2 class="font-display text-xl font-semibold text-slate-100">MCP Tools</h2>
<div class="mt-4 space-y-4 text-sm text-slate-400">
<p>
The loopctl MCP server provides 41 typed tools for Claude Code agents.
The loopctl MCP server provides 50 typed tools for Claude Code agents.
Install via
<span class="font-mono text-slate-300">npm install loopctl-mcp-server</span>
and configure in <span class="font-mono text-slate-300">.mcp.json</span>.
Expand Down Expand Up @@ -975,12 +975,57 @@
</div>
</div>

<h3 class="mt-8 text-sm font-semibold text-accent-400 uppercase tracking-wider text-xs">
Work Breakdown Tools
</h3>
<div class="mt-4 space-y-3">
<div class="bg-slate-800 border border-slate-700 rounded-md p-3">
<p class="font-mono text-xs text-slate-300">
<span class="text-accent-400">import_stories</span>
</p>
<p class="mt-1.5 text-sm text-slate-400">
Bulk-import epics and stories into a project. Pass
<span class="font-mono text-slate-300">merge: true</span>
to add to epics that already exist (without it, duplicates return 409).
For large payloads, <span class="font-mono text-slate-300">payload_path</span>
accepts an absolute file path instead of the inline object.
Epic numbers are type-tolerant -- integers and numeric strings both work.
</p>
</div>
<div class="bg-slate-800 border border-slate-700 rounded-md p-3">
<p class="font-mono text-xs text-slate-300">
<span class="text-accent-400">create_story</span>
</p>
<p class="mt-1.5 text-sm text-slate-400">
Create a single story in an existing epic. Accepts either
<span class="font-mono text-slate-300">epic_id</span>
(UUID) or (<span class="font-mono text-slate-300">project_id</span> + <span class="font-mono text-slate-300">epic_number</span>) --
the latter is friendlier when you know the epic number but not the UUID.
</p>
</div>
<div class="bg-slate-800 border border-slate-700 rounded-md p-3">
<p class="font-mono text-xs text-slate-300">
<span class="text-accent-400">backfill_story</span>
</p>
<p class="mt-1.5 text-sm text-slate-400">
Mark a story as verified when the work was completed outside loopctl
(pre-onboarding, external delivery). Requires <span class="font-mono text-slate-300">reason</span>;
accepts <span class="font-mono text-slate-300">evidence_url</span>
and <span class="font-mono text-slate-300">pr_number</span>.
Refused for any story with dispatch lineage -- you cannot use backfill
as a chain-of-custody shortcut. Emits a
<span class="font-mono text-slate-300">story.backfilled</span>
webhook and records provenance in <span class="font-mono text-slate-300">metadata.backfill</span>.
</p>
</div>
</div>

<h3 class="mt-8 text-sm font-semibold text-accent-400 uppercase tracking-wider text-xs">
Other Notable Tools
</h3>
<div class="mt-4 space-y-4 text-sm text-slate-400">
<p>
The full set of 42 tools covers projects, stories, epics, verification,
The full set of 50 tools covers projects, stories, epics, verification,
artifacts, orchestrator state, webhooks, skills, token usage, analytics,
and knowledge. See the
<a
Expand Down
8 changes: 5 additions & 3 deletions lib/loopctl_web/controllers/page_html/home.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@
MCP Integration
</dt>
<dd class="mt-1.5 text-slate-400">
42 typed tools for AI coding agents. No curl needed — agents interact through the MCP server.
50 typed tools for AI coding agents. No curl needed — agents interact through the MCP server.
</dd>
</div>

Expand Down Expand Up @@ -788,7 +788,9 @@
<p class="mt-1.5 text-xs text-slate-500">
Then import stories via
<span class="text-slate-400 font-mono">POST /projects/:id/import</span>
with your epic/story JSON.
with your epic/story JSON. Add
<span class="text-slate-400 font-mono">?merge=true</span>
to append to an epic that already exists.
</p>
</div>
</div>
Expand All @@ -809,7 +811,7 @@
Install via
<span class="text-slate-400 font-mono">npm install loopctl-mcp-server</span>
or run with <span class="text-slate-400 font-mono">npx loopctl-mcp-server</span>.
42 typed tools for AI coding agents.
50 typed tools for AI coding agents.
</p>
</div>
</div>
Expand Down
34 changes: 34 additions & 0 deletions mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ All notable changes to `loopctl-mcp-server` are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## 2.1.0 — 2026-04-17 (Agent ergonomics)

### Added

- `import_stories` now accepts `merge: true` to append stories to epics that
already exist (previously duplicates returned 409 with no way forward).
- `import_stories` now accepts `payload_path` (absolute JSON file path) so
large imports can bypass inline tool-call size limits. When both
`payload` and `payload_path` are passed, inline wins.
- `create_story` — create a single story inside an existing epic. Accepts
either `epic_id` (UUID) or (`project_id` + `epic_number`). No more
wrapping a single story in a bulk import payload.
- `backfill_story` — mark a story as verified when the work was completed
outside loopctl. Records provenance (`reason`, `evidence_url`,
`pr_number`) in `metadata.backfill` plus an audit entry and a
`story.backfilled` webhook. Refused for any story with dispatch
lineage (non-pending `agent_status`, `assigned_agent_id`,
`implementer_dispatch_id`, or `verifier_dispatch_id` set) — cannot be
used as a chain-of-custody shortcut.

### Changed

- `import_stories` is type-tolerant on epic numbers. Integer and numeric
string both normalize to integers before DB lookup, fixing the
`epics[0].tenant_id: has already been taken for this project` error
when clients serialized epic numbers as strings.
- `resolvePayload` validates `payload_path` before reading: requires an
absolute path, refuses `/proc`, `/dev`, `/sys` prefixes, rejects
non-regular files, enforces a 5 MiB size cap.
- Domain error translation for Epic/Story unique-number violations —
duplicate imports and direct creates now return
`"Epic 72 already exists in this project. Use merge=true..."` instead
of the raw Ecto constraint message.

## 2.0.0 — 2026-04-12 (Chain of Custody v2)

### Breaking
Expand Down
Loading
Loading