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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ Changing pipeline behaviour usually means editing YAML, not TypeScript. Loaders

`templates/` at the package root holds the starter copies of `column-specs/`, `agents/`, and `rubrics/` that `aeos project init` scaffolds into a new project (`infrastructure/filesystem/template-source.ts`). They are plain YAML/Markdown rather than embedded strings, and ship via the `files` field in `package.json`. **Adding a column means adding its template spec too** — otherwise a fresh project fails on its first `ticket run` when a ticket reaches that column. `template-source.test.ts` guards this by scaffolding a temp project and asserting every column spec loads and its agents and rubrics resolve.

`skills/aeos/SKILL.md` (also shipped via `files`) teaches an agentic executor to drive the `aeos` CLI. `project init` copies it to `.aeos/skills/aeos/` and relative-symlinks it into each executor's skills dir (`.claude/skills`, `.augment/skills` — see `skill-source.ts`). The **portable** carrier of the decomposition instruction is the architect's `taskInstruction` (in the prompt, so every executor gets it); the skill is reinforcement for skill-aware backends. This is how `TASK_BREAKDOWN` decomposition works: that column is **agentic**, and the architect calls `aeos ticket create --parent <epic> --key T-NNN` to create the child tasks itself rather than AEOS parsing them out of an artifact. `requiresRepoDiff: false` on that column stops the agentic diff check (which enforces the IMPLEMENTATION guarantee) from failing a run that creates tickets rather than editing the repo.

`ticket create` is idempotent under a parent: it keys on `--key` (the task's stable `T-NNN` label) when given, else the title (and, as a bridge, a keyless prior child with a matching title). The key matters because a review-loop retry re-runs the _agent_, which may rephrase a task's title — title-only dedup would then duplicate it; the key survives the rephrase (`task_key`, migration v4). A key match leaves the existing task **unchanged** (a reworded title is not applied), and each task must have a **distinct** key — a reused key collapses two tasks into one. Both constraints are stated in the architect prompt and skill. The orchestrator needs no special decomposition step: children created during the run are picked up by its existing "drive all children" logic on the next tick; `AWAITING_DECOMPOSITION` remains only as the fallback when none appear (e.g. under `AEOS_EXECUTOR=stub`, which can't shell out). Tickets are created mid-run, before the breakdown is reviewed — the key keeps retries from accumulating, but a breakdown that is _abandoned_ after removing a task leaves an orphan child in `BACKLOG`; the epic's DONE-join lists it, and the operator clears it with `aeos ticket move <id> DONE`. Auto-deleting is deliberately avoided.

### State

- **SQLite at `~/.aeos/state.db`** — the source of truth for tickets, transitions, and costs. All projects share one DB, isolated by `project_id`. Path overridable via `AEOS_HOME`.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"files": [
"dist",
"templates",
"skills",
"README.md",
"LICENSE"
],
Expand Down
87 changes: 87 additions & 0 deletions skills/aeos/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
name: aeos
description: >
Drive the AEOS ticket pipeline from the command line. Use this when working
inside an AEOS project (a directory containing .aeos/project.json) to create
tickets, decompose an epic into child tasks, run a pipeline column, or inspect
ticket state. Triggers whenever a task involves aeos tickets, epics, tasks, or
breaking work down into tasks.
---

# Driving AEOS from the CLI

AEOS runs tickets through an agent pipeline. A ticket is an **epic** or a
**task**. You are usually invoked *by* AEOS as the worker for a column, with the
current ticket in your context and the project root as your working directory.

The `aeos` binary is on PATH and operates on the project in the current working
directory. Every command below is safe to run from within a column execution.

## Decomposing an epic (TASK_BREAKDOWN)

When you run the `TASK_BREAKDOWN` column you produce two things:

1. The `tasks.md` breakdown as your response text, in the format your agent
spec defines. Your printed output is captured as the artifact — print the
breakdown; do not write it to a file (a file you write is ignored).
2. One child ticket per task, created by calling the CLI.

Create each task as a child of the epic you are decomposing, passing its
`T-NNN` key from `tasks.md` with `--key` **and its full content with
`--body-file`**. The engineer that implements a task sees only its ticket, so a
title alone strands it — the ticket must carry the task's Description,
Acceptance criteria, Depends on, Touches, and Out of scope:

```sh
cat > /tmp/<EPIC_ID>-T-001.md <<'BODY'
**Depends on:** none
**Touches:** lib/auth/hash.ts

**Description**
...

**Acceptance criteria**
- [ ] ...
BODY
aeos ticket create "Add password hashing" --parent <EPIC_ID> --key T-001 --body-file /tmp/<EPIC_ID>-T-001.md
```

Small bodies may be passed inline with `--body "<markdown>"`, but prefer
`--body-file` for multi-line content — it avoids shell-quoting mistakes.

`<EPIC_ID>` is the ID of the ticket in your context — the `# Ticket: <ID>`
heading in the ticket document (e.g. `AEOS-1`). Use that exact ID.

Rules:

- **One `ticket create` call per task in your breakdown**, each with its own
**distinct** `--key T-NNN` matching the task's label in `tasks.md`, and its
own `--body-file` carrying that task's full block. Reusing a key across two
tasks silently drops the second — they are treated as one task.
- **Creating a task is idempotent, keyed on `--key`.** If a child with the same
key already exists under the epic, the command leaves it **unchanged** and
prints `= ... already exists` — a reworded title is not applied, so keep each
task's title stable across attempts too. Keying on the label rather than the
title means a retry still matches, so re-running after a rejected review never
duplicates tasks. Always create the full set with stable keys; do not try to
detect what already exists.
- **Do not create tasks under a task.** Nesting is one level deep; `--parent`
must always be an epic.
- Keep titles short and imperative — they become ticket titles.

After writing `tasks.md` and creating the child tickets, your column output is
complete. The reviewer evaluates `tasks.md`; the orchestrator picks up the
children you created and drives each through its own build pipeline.

## Other useful commands

```sh
aeos ticket list # tickets in this project; tasks nest under their epic
aeos ticket show <ID> # kind, parent/children, column, state, artifacts
aeos ticket create "<title>" # a new epic (no --parent)
```

Do **not** run `aeos ticket run`, `aeos ticket approve`, or `aeos orchestrator`
from inside a column — those drive the pipeline and would re-enter it. Your job
is to produce this column's output; advancing is the operator's or
orchestrator's concern.
85 changes: 85 additions & 0 deletions src/application/orchestrator.use-case.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('OrchestratorUseCase', () => {
findChildren: vi.fn().mockReturnValue([]),
updateColumn: vi.fn(),
updateSubState: vi.fn(),
setEscalation: vi.fn(),
};
costRepo = {
record: vi.fn(),
Expand Down Expand Up @@ -207,6 +208,8 @@ describe('OrchestratorUseCase', () => {
expect(result.haltReason).toBe(HaltReason.NEEDS_HUMAN);
expect(result.steps).toHaveLength(1);
expect(result.steps[0].outcome).toContain('escalated');
// The step records the column it drove, so the progress log can show it.
expect(result.steps[0].column).toBe(Column.PRODUCT_SCOPING);
// One run attempted, then the escalated sub-state stops the loop.
expect(ticketRun.execute).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -333,6 +336,88 @@ describe('OrchestratorUseCase', () => {
expect(emit).toBeDefined();
expect(stateRepo.touch).toHaveBeenCalled();
});

it('forwards ticket-run events to the caller ticketRunObserver for the live view', async () => {
(ticketRun.execute as ReturnType<typeof vi.fn>).mockImplementation(
async (_p, _pp, _t, _o, observer) => {
observer?.onEvent?.({ type: 'stage.started', ticketId: EPIC_ID });
return {
status: 'escalated',
ticketId: EPIC_ID,
reason: 'ITERATIONS_EXHAUSTED',
message: 'x',
attempts: 1,
};
},
);
const seen: unknown[] = [];

await useCase.run(
PROJECT_ID,
PROJECT_PATH,
EPIC_ID,
{ maxSteps: 1 },
{ ticketRunObserver: { onEvent: (event) => seen.push(event) } },
);

expect(seen).toContainEqual({ type: 'stage.started', ticketId: EPIC_ID });
});
});

describe('interrupt', () => {
it('halts with INTERRUPTED and stops scheduling when interrupted mid-run', async () => {
let subState: SubState = SubState.READY;
(ticketRepo.findById as ReturnType<typeof vi.fn>).mockImplementation(() =>
epic({ subState }),
);
(ticketRun.execute as ReturnType<typeof vi.fn>).mockImplementation(async () => {
// Operator hits Ctrl+C while this ticket run is in flight.
useCase.interrupt();
subState = SubState.INTERRUPTED;
return {
status: 'failed',
ticketId: EPIC_ID,
error: 'Execution interrupted by operator',
};
});

const result = await useCase.run(PROJECT_ID, PROJECT_PATH, EPIC_ID, { maxSteps: 10 });

expect(result.haltReason).toBe(HaltReason.INTERRUPTED);
// Exactly one action ran; the loop did not schedule more.
expect(result.steps).toHaveLength(1);
expect(ticketRun.execute).toHaveBeenCalledTimes(1);
// The in-flight run was told to stop.
expect(ticketRun.interrupt).toHaveBeenCalled();
});

it('leaves the epic IDLE after an interrupt, not RUNNING', async () => {
(ticketRun.execute as ReturnType<typeof vi.fn>).mockImplementation(async () => {
useCase.interrupt();
return { status: 'failed', ticketId: EPIC_ID, error: 'Execution interrupted by operator' };
});

await useCase.run(PROJECT_ID, PROJECT_PATH, EPIC_ID);

const lastWrite = (stateRepo.upsert as ReturnType<typeof vi.fn>).mock.calls
.map((call) => call[0])
.at(-1);
expect(lastWrite).toMatchObject({ status: OrchestratorStatus.IDLE });
});

it('a stale interrupt flag does not carry into the next run', async () => {
useCase.interrupt(); // interrupt fired with nothing running
let ran = false;
(ticketRun.execute as ReturnType<typeof vi.fn>).mockImplementation(async () => {
ran = true;
return { status: 'escalated', ticketId: EPIC_ID, reason: 'X', message: 'x', attempts: 1 };
});

await useCase.run(PROJECT_ID, PROJECT_PATH, EPIC_ID, { maxSteps: 1 });

// run() resets the flag, so this fresh run proceeds normally.
expect(ran).toBe(true);
});
});

describe('unexpected failure', () => {
Expand Down
63 changes: 60 additions & 3 deletions src/application/orchestrator.use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { ConfigStore } from '../domain/ports/driven/config-store.port.js';
import type { ColumnSpecLoader } from '../domain/ports/driven/column-spec-loader.port.js';
import type { OrchestratorStateRepository } from '../domain/ports/driven/orchestrator-state-repository.port.js';
import type { TicketRunPort } from '../domain/ports/driving/ticket-run.port.js';
import type { TicketRunObserver } from '../domain/model/ticket-run-event.js';
import type { TicketApprovePort } from '../domain/ports/driving/ticket-approve.port.js';
import type {
OrchestratorPort,
Expand Down Expand Up @@ -48,6 +49,7 @@ const HEARTBEAT_INTERVAL_MS = 30 * 1000;

export class OrchestratorUseCase implements OrchestratorPort {
private lastHeartbeatMs = 0;
private interruptRequested = false;

constructor(
private readonly ticketRepo: TicketRepository,
Expand Down Expand Up @@ -76,6 +78,8 @@ export class OrchestratorUseCase implements OrchestratorPort {
);
}

this.interruptRequested = false;

const existing = this.stateRepo.find(projectId, epicId);

// Both early returns deliberately leave stored state alone: a paused epic
Expand Down Expand Up @@ -110,6 +114,13 @@ export class OrchestratorUseCase implements OrchestratorPort {

try {
for (let step = 0; step < maxSteps; step += 1) {
// An interrupt that arrived between actions stops before scheduling the
// next one.
if (this.interruptRequested) {
settled = true;
return this.interruptedHalt(projectId, epicId, steps);
}

// Re-read every tick: the previous action changed the world, and a
// stale view is how a scheduler double-runs a ticket.
const current = this.ticketRepo.findById(projectId, epicId);
Expand Down Expand Up @@ -140,17 +151,35 @@ export class OrchestratorUseCase implements OrchestratorPort {
);
}

const outcome = await this.perform(action, projectId, projectPath, () =>
this.heartbeat(projectId, epicId),
// Capture the column before performing — an 'advance' moves the ticket
// on, so reading it afterwards would report the destination, not the
// stage this step drove.
const actingColumn = this.ticketRepo.findById(projectId, action.ticketId)?.column ?? '—';

const outcome = await this.perform(
action,
projectId,
projectPath,
() => this.heartbeat(projectId, epicId),
observer?.ticketRunObserver,
);
const recorded: OrchestratorStep = {
action: action.kind,
ticketId: action.ticketId,
column: actingColumn,
outcome,
};
steps.push(recorded);
observer?.onStep?.(recorded);
this.heartbeat(projectId, epicId, true);

// The action just finished — if the operator interrupted during it, the
// in-flight ticket was already stopped; halt now rather than scheduling
// more work.
if (this.interruptRequested) {
settled = true;
return this.interruptedHalt(projectId, epicId, steps);
}
}

settled = true;
Expand Down Expand Up @@ -180,6 +209,13 @@ export class OrchestratorUseCase implements OrchestratorPort {
}
}

interrupt(): void {
// Stop scheduling, and kill whatever ticket run is in flight. interrupt() on
// the ticket-run use case is a no-op when nothing is running.
this.interruptRequested = true;
void this.ticketRun.interrupt();
}

pause(projectId: string, epicId: string): OrchestratorState {
const existing = this.stateRepo.find(projectId, epicId);
return this.writeState(
Expand Down Expand Up @@ -219,6 +255,7 @@ export class OrchestratorUseCase implements OrchestratorPort {
projectId: string,
projectPath: string,
onProgress: () => void,
ticketRunObserver: TicketRunObserver | undefined,
): Promise<string> {
if (action.kind === 'advance') {
const result = this.ticketApprove.execute(projectId, projectPath, action.ticketId);
Expand All @@ -238,7 +275,11 @@ export class OrchestratorUseCase implements OrchestratorPort {
action.ticketId,
undefined,
{
onEvent: () => onProgress(),
// Heartbeat the lock, then forward to the caller's live view.
onEvent: (event) => {
onProgress();
ticketRunObserver?.onEvent?.(event);
},
},
);
switch (result.status) {
Expand Down Expand Up @@ -330,6 +371,22 @@ export class OrchestratorUseCase implements OrchestratorPort {
return state;
}

private interruptedHalt(
projectId: string,
epicId: string,
steps: OrchestratorStep[],
): OrchestratorRunResult {
return this.haltWith(
projectId,
epicId,
HaltReason.INTERRUPTED,
`Interrupted by operator after ${steps.length} action(s). The in-flight ticket was stopped and left INTERRUPTED; re-run the epic to resume.`,
steps,
this.spendFor(projectId, epicId),
OrchestratorStatus.IDLE,
);
}

private haltWith(
projectId: string,
epicId: string,
Expand Down
Loading
Loading