diff --git a/CLAUDE.md b/CLAUDE.md index b742b48..6e5486e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --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 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`. diff --git a/package.json b/package.json index 31dbea7..96912c8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "files": [ "dist", "templates", + "skills", "README.md", "LICENSE" ], diff --git a/skills/aeos/SKILL.md b/skills/aeos/SKILL.md new file mode 100644 index 0000000..e0ab1a0 --- /dev/null +++ b/skills/aeos/SKILL.md @@ -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/-T-001.md <<'BODY' +**Depends on:** none +**Touches:** lib/auth/hash.ts + +**Description** +... + +**Acceptance criteria** +- [ ] ... +BODY +aeos ticket create "Add password hashing" --parent --key T-001 --body-file /tmp/-T-001.md +``` + +Small bodies may be passed inline with `--body ""`, but prefer +`--body-file` for multi-line content — it avoids shell-quoting mistakes. + +`` is the ID of the ticket in your context — the `# Ticket: ` +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 # kind, parent/children, column, state, artifacts +aeos ticket create "" # 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. diff --git a/src/application/orchestrator.use-case.test.ts b/src/application/orchestrator.use-case.test.ts index 4528167..4327157 100644 --- a/src/application/orchestrator.use-case.test.ts +++ b/src/application/orchestrator.use-case.test.ts @@ -55,6 +55,7 @@ describe('OrchestratorUseCase', () => { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; costRepo = { record: vi.fn(), @@ -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); }); @@ -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', () => { diff --git a/src/application/orchestrator.use-case.ts b/src/application/orchestrator.use-case.ts index fd7a0ff..4dae54c 100644 --- a/src/application/orchestrator.use-case.ts +++ b/src/application/orchestrator.use-case.ts @@ -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, @@ -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, @@ -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 @@ -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); @@ -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; @@ -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( @@ -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); @@ -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) { @@ -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, diff --git a/src/application/project-sync.use-case.test.ts b/src/application/project-sync.use-case.test.ts new file mode 100644 index 0000000..a4d71bf --- /dev/null +++ b/src/application/project-sync.use-case.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { ProjectSyncUseCase } from './project-sync.use-case.js'; +import type { + TemplateCatalog, + TemplateEntry, +} from '../domain/ports/driven/template-catalog.port.js'; + +class FakeCatalog implements TemplateCatalog { + writes: Array<{ relativePath: string; content: string }> = []; + backups: string[] = []; + + constructor( + private readonly templates: TemplateEntry[], + private readonly project: Map<string, string>, + ) {} + + list(): TemplateEntry[] { + return this.templates; + } + readProjectCopy(_projectPath: string, relativePath: string): string | null { + return this.project.get(relativePath) ?? null; + } + write(_projectPath: string, relativePath: string, content: string): void { + this.writes.push({ relativePath, content }); + this.project.set(relativePath, content); + } + backup(_projectPath: string, relativePath: string): string { + this.backups.push(relativePath); + return `${relativePath}.bak`; + } +} + +const templates: TemplateEntry[] = [ + { relativePath: 'agents/pm.yaml', content: 'pm v2' }, + { relativePath: 'column-specs/task-breakdown.yaml', content: 'mode: agentic' }, + { relativePath: 'column-specs/qa.yaml', content: 'qa v1' }, +]; + +let project: Map<string, string>; + +beforeEach(() => { + project = new Map([ + ['agents/pm.yaml', 'pm v2'], // unchanged + ['column-specs/task-breakdown.yaml', 'mode: artifact'], // drifted + // qa.yaml absent → missing + ]); +}); + +describe('ProjectSyncUseCase', () => { + it('classifies unchanged, drifted, and missing files', () => { + const catalog = new FakeCatalog(templates, project); + const result = new ProjectSyncUseCase(catalog).execute({ + projectPath: '/p', + apply: false, + force: false, + }); + + const byPath = Object.fromEntries(result.files.map((f) => [f.relativePath, f.status])); + expect(byPath['agents/pm.yaml']).toBe('unchanged'); + expect(byPath['column-specs/task-breakdown.yaml']).toBe('drifted'); + expect(byPath['column-specs/qa.yaml']).toBe('missing'); + }); + + it('writes nothing on a dry run', () => { + const catalog = new FakeCatalog(templates, project); + new ProjectSyncUseCase(catalog).execute({ projectPath: '/p', apply: false, force: false }); + expect(catalog.writes).toEqual([]); + expect(catalog.backups).toEqual([]); + }); + + it('adds missing files under --apply but leaves drift untouched', () => { + const catalog = new FakeCatalog(templates, project); + const result = new ProjectSyncUseCase(catalog).execute({ + projectPath: '/p', + apply: true, + force: false, + }); + + expect(catalog.writes).toEqual([{ relativePath: 'column-specs/qa.yaml', content: 'qa v1' }]); + expect(catalog.backups).toEqual([]); + expect(result.files.find((f) => f.relativePath === 'column-specs/qa.yaml')?.action).toBe( + 'added', + ); + expect( + result.files.find((f) => f.relativePath === 'column-specs/task-breakdown.yaml')?.action, + ).toBe('none'); + }); + + it('overwrites drifted files under --force, backing them up first', () => { + const catalog = new FakeCatalog(templates, project); + const result = new ProjectSyncUseCase(catalog).execute({ + projectPath: '/p', + apply: false, + force: true, + }); + + // Both the missing add and the drifted overwrite happen; the drifted one is backed up. + expect(catalog.backups).toEqual(['column-specs/task-breakdown.yaml']); + const drifted = result.files.find((f) => f.relativePath === 'column-specs/task-breakdown.yaml'); + expect(drifted?.action).toBe('updated'); + expect(drifted?.backupPath).toBe('column-specs/task-breakdown.yaml.bak'); + // Missing file still added (force implies apply). + expect(result.files.find((f) => f.relativePath === 'column-specs/qa.yaml')?.action).toBe( + 'added', + ); + }); +}); diff --git a/src/application/project-sync.use-case.ts b/src/application/project-sync.use-case.ts new file mode 100644 index 0000000..f596d26 --- /dev/null +++ b/src/application/project-sync.use-case.ts @@ -0,0 +1,67 @@ +// Use case — ProjectSync: reconcile a project's scaffolded templates with the +// current package templates. +// +// A project scaffolded by an older `project init` keeps its original column +// specs, agents, and rubrics forever — template improvements never reach it, +// and the drift only surfaces when a ticket reaches the affected column. This +// use case makes the drift visible (and, on request, applies it). +// +// There is deliberately no three-way merge: AEOS does not store the template +// version a file was scaffolded from, so it cannot tell a local edit from a +// template change within the same file. Instead sync reports drift, and only +// overwrites a drifted file under `--force`, backing up the prior content so a +// local edit (e.g. a changed `advanceMode`) is recoverable. + +import type { TemplateCatalog } from '../domain/ports/driven/template-catalog.port.js'; +import type { + ProjectSyncInput, + ProjectSyncPort, + ProjectSyncResult, + SyncFileOutcome, + SyncStatus, +} from '../domain/ports/driving/project-sync.port.js'; + +export class ProjectSyncUseCase implements ProjectSyncPort { + constructor(private readonly catalog: TemplateCatalog) {} + + execute(input: ProjectSyncInput): ProjectSyncResult { + const { projectPath, apply, force } = input; + const files: SyncFileOutcome[] = []; + + for (const template of this.catalog.list()) { + const projectContent = this.catalog.readProjectCopy(projectPath, template.relativePath); + const status: SyncStatus = + projectContent === null + ? 'missing' + : projectContent === template.content + ? 'unchanged' + : 'drifted'; + + let action: SyncFileOutcome['action'] = 'none'; + let backupPath: string | undefined; + + // Missing files are always safe to add — restoring them can only fix a + // project. Drifted files are only touched under --force because the + // overwrite discards whatever the user changed. + if (status === 'missing' && (apply || force)) { + this.catalog.write(projectPath, template.relativePath, template.content); + action = 'added'; + } else if (status === 'drifted' && force) { + backupPath = this.catalog.backup(projectPath, template.relativePath); + this.catalog.write(projectPath, template.relativePath, template.content); + action = 'updated'; + } + + files.push({ + relativePath: template.relativePath, + status, + action, + backupPath, + templateContent: template.content, + projectContent, + }); + } + + return { files }; + } +} diff --git a/src/application/services/line-diff.test.ts b/src/application/services/line-diff.test.ts new file mode 100644 index 0000000..069e45f --- /dev/null +++ b/src/application/services/line-diff.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; + +import { lineDiff, formatLineDiff } from './line-diff.js'; + +describe('lineDiff', () => { + it('marks unchanged lines with a space', () => { + const diff = lineDiff('a\nb\nc', 'a\nb\nc'); + expect(diff.every((d) => d.tag === ' ')).toBe(true); + expect(diff.map((d) => d.text)).toEqual(['a', 'b', 'c']); + }); + + it('marks a changed line as a removal followed by an addition', () => { + const diff = lineDiff('a\nb\nc', 'a\nB\nc'); + expect(diff).toEqual([ + { tag: ' ', text: 'a' }, + { tag: '-', text: 'b' }, + { tag: '+', text: 'B' }, + { tag: ' ', text: 'c' }, + ]); + }); + + it('handles pure insertions', () => { + const diff = lineDiff('a\nc', 'a\nb\nc'); + expect(diff).toEqual([ + { tag: ' ', text: 'a' }, + { tag: '+', text: 'b' }, + { tag: ' ', text: 'c' }, + ]); + }); +}); + +describe('formatLineDiff', () => { + it('elides long unchanged runs but keeps context around changes', () => { + const before = ['1', '2', '3', '4', '5', '6', '7', '8', 'old', '10'].join('\n'); + const after = ['1', '2', '3', '4', '5', '6', '7', '8', 'new', '10'].join('\n'); + + const out = formatLineDiff(before, after, 1); + + expect(out).toContain('- old'); + expect(out).toContain('+ new'); + expect(out).toContain('⋯'); // early unchanged lines collapsed + expect(out).toContain(' 8'); // one line of context retained + expect(out).not.toContain(' 2'); // far-away unchanged line dropped + }); +}); diff --git a/src/application/services/line-diff.ts b/src/application/services/line-diff.ts new file mode 100644 index 0000000..e5f1169 --- /dev/null +++ b/src/application/services/line-diff.ts @@ -0,0 +1,80 @@ +// Application service — a small, pure line-level diff for `aeos project sync --diff`. +// +// Spec/agent files are short (tens to low-hundreds of lines), so a plain +// O(n·m) LCS is more than fast enough and keeps this dependency-free and +// trivially testable. Output is a compact unified-style listing: unchanged +// lines prefixed with a space, removals with '-', additions with '+'. + +export interface DiffLine { + readonly tag: ' ' | '-' | '+'; + readonly text: string; +} + +/** Longest-common-subsequence line diff of `before` → `after`. */ +export function lineDiff(before: string, after: string): DiffLine[] { + const a = before.split('\n'); + const b = after.split('\n'); + const n = a.length; + const m = b.length; + + // lcs[i][j] = length of LCS of a[i:] and b[j:]. + const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.push({ tag: ' ', text: a[i] }); + i++; + j++; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + out.push({ tag: '-', text: a[i] }); + i++; + } else { + out.push({ tag: '+', text: b[j] }); + j++; + } + } + while (i < n) out.push({ tag: '-', text: a[i++] }); + while (j < m) out.push({ tag: '+', text: b[j++] }); + + return out; +} + +/** + * Renders a diff as text, collapsing long runs of unchanged lines to keep the + * output focused on what actually changed. `context` unchanged lines are kept + * around each change; longer runs are elided with a `⋯` marker. + */ +export function formatLineDiff(before: string, after: string, context = 2): string { + const diff = lineDiff(before, after); + const keep = new Array<boolean>(diff.length).fill(false); + + for (let k = 0; k < diff.length; k++) { + if (diff[k].tag !== ' ') { + for (let c = Math.max(0, k - context); c <= Math.min(diff.length - 1, k + context); c++) { + keep[c] = true; + } + } + } + + const lines: string[] = []; + let elided = false; + for (let k = 0; k < diff.length; k++) { + if (keep[k]) { + lines.push(`${diff[k].tag} ${diff[k].text}`); + elided = false; + } else if (!elided) { + lines.push(' ⋯'); + elided = true; + } + } + + return lines.join('\n'); +} diff --git a/src/application/services/ticket-document.test.ts b/src/application/services/ticket-document.test.ts new file mode 100644 index 0000000..a8eec42 --- /dev/null +++ b/src/application/services/ticket-document.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; + +import { buildInitialTicketDocument } from './ticket-document.js'; +import { TicketKind } from '../../domain/model/ticket-kind.js'; +import type { Ticket } from '../../domain/model/ticket.js'; + +function ticket(overrides: Partial<Ticket> = {}): Ticket { + const now = new Date().toISOString(); + return { + id: 'AEOS-2', + projectId: 'p', + title: 'Add password hashing', + kind: TicketKind.TASK, + parentId: 'AEOS-1', + taskKey: 'T-001', + column: 'BACKLOG', + subState: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +describe('buildInitialTicketDocument — lineage metadata', () => { + it('records kind, parent, and task key for a decomposed task', () => { + const doc = buildInitialTicketDocument(ticket()); + expect(doc).toContain('- Kind: TASK'); + expect(doc).toContain('- Parent: AEOS-1'); + expect(doc).toContain('- Task key: T-001'); + }); + + it('omits parent and task-key lines for an epic', () => { + const doc = buildInitialTicketDocument( + ticket({ kind: TicketKind.EPIC, parentId: null, taskKey: null }), + ); + expect(doc).toContain('- Kind: EPIC'); + expect(doc).not.toContain('- Parent:'); + expect(doc).not.toContain('- Task key:'); + }); +}); + +describe('buildInitialTicketDocument — body', () => { + it('uses the provided body as the Description, verbatim', () => { + const body = [ + '**Depends on:** none', + '**Touches:** lib/auth/hash.ts', + '', + '**Acceptance criteria**', + '- [ ] bcrypt with cost 12', + ].join('\n'); + + const doc = buildInitialTicketDocument(ticket(), body); + + expect(doc).toContain('bcrypt with cost 12'); + expect(doc).toContain('**Touches:** lib/auth/hash.ts'); + // The placeholder must be gone — the engineer sees the real spec. + expect(doc).not.toContain('Fill in the ticket description here'); + }); + + it('falls back to placeholders when no body is given', () => { + const doc = buildInitialTicketDocument(ticket()); + expect(doc).toContain('Fill in the ticket description here'); + }); +}); diff --git a/src/application/services/ticket-document.ts b/src/application/services/ticket-document.ts index 4a8f321..53059de 100644 --- a/src/application/services/ticket-document.ts +++ b/src/application/services/ticket-document.ts @@ -8,13 +8,23 @@ const METADATA_START = '<!-- AEOS:METADATA START -->'; const METADATA_END = '<!-- AEOS:METADATA END -->'; function renderMetadataBlock(ticket: Ticket): string { - return [ + const lines = [ METADATA_START, '## AEOS Metadata', + `- Kind: ${ticket.kind}`, `- Column: ${ticket.column}`, `- Sub-state: ${ticket.subState ?? 'NONE'}`, - METADATA_END, - ].join('\n'); + ]; + // Lineage — a task is meaningless without the epic it decomposes; surface the + // parent (and its stable key) so the document is self-describing. + if (ticket.parentId) { + lines.push(`- Parent: ${ticket.parentId}`); + } + if (ticket.taskKey) { + lines.push(`- Task key: ${ticket.taskKey}`); + } + lines.push(METADATA_END); + return lines.join('\n'); } function upsertMetadataBlock(content: string, ticket: Ticket): string { @@ -39,7 +49,17 @@ export function ticketDocumentPath(projectPath: string, ticketId: string): strin return path.join(projectPath, '.aeos', 'tickets', ticketId, `${ticketId}-ticket.md`); } -export function buildInitialTicketDocument(ticket: Ticket): string { +export function buildInitialTicketDocument(ticket: Ticket, body?: string): string { + // A decomposed task arrives with its full breakdown (description, acceptance + // criteria, touches, out-of-scope) in `body`; use it verbatim as the + // Description so the engineer implements from the real spec, not a bare title. + // Without a body (a hand-created ticket), fall back to editable placeholders. + const trimmedBody = body?.trim(); + const description = trimmedBody ? trimmedBody : '<!-- Fill in the ticket description here -->'; + const definitionOfDone = trimmedBody + ? '<!-- Acceptance criteria are covered in the Description above (from the task breakdown). -->' + : '<!-- Define acceptance criteria — evaluated at DoD Gate -->'; + return [ `# Ticket: ${ticket.id}`, '', @@ -49,10 +69,10 @@ export function buildInitialTicketDocument(ticket: Ticket): string { ticket.title, '', '## Description', - '<!-- Fill in the ticket description here -->', + description, '', '## Definition of Done', - '<!-- Define acceptance criteria — evaluated at DoD Gate -->', + definitionOfDone, '', '## Notes', '<!-- Additional context, links, constraints -->', diff --git a/src/application/ticket-answer.use-case.test.ts b/src/application/ticket-answer.use-case.test.ts index a299024..320719e 100644 --- a/src/application/ticket-answer.use-case.test.ts +++ b/src/application/ticket-answer.use-case.test.ts @@ -20,6 +20,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } diff --git a/src/application/ticket-approve.use-case.test.ts b/src/application/ticket-approve.use-case.test.ts index 1472a66..deeb0dd 100644 --- a/src/application/ticket-approve.use-case.test.ts +++ b/src/application/ticket-approve.use-case.test.ts @@ -19,6 +19,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } diff --git a/src/application/ticket-create.use-case.test.ts b/src/application/ticket-create.use-case.test.ts index 4737194..df24008 100644 --- a/src/application/ticket-create.use-case.test.ts +++ b/src/application/ticket-create.use-case.test.ts @@ -24,6 +24,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } @@ -76,6 +77,8 @@ describe('TicketCreateUseCase', () => { // No parent supplied, so this is a top-level epic. kind: 'EPIC', parentId: null, + taskKey: null, + alreadyExisted: false, }); }); @@ -113,6 +116,16 @@ describe('TicketCreateUseCase', () => { expect(content).toContain('## Notes'); }); + it('writes the provided body into the ticket document', () => { + useCase.execute({ ...defaultInput, body: '**Acceptance criteria**\n- [ ] rate cap enforced' }); + + const content = (artifactStore.writeArtifact as ReturnType<typeof vi.fn>).mock + .calls[0][3] as string; + + expect(content).toContain('rate cap enforced'); + expect(content).not.toContain('Fill in the ticket description here'); + }); + it('should build ticket with column BACKLOG and null sub_state via createAtomic', () => { let capturedTicket: Ticket | null = null; (ticketRepo.createAtomic as ReturnType<typeof vi.fn>).mockImplementation( @@ -190,4 +203,230 @@ describe('TicketCreateUseCase', () => { expect(artifactStore.writeArtifact).toHaveBeenCalledTimes(2); expect(ticketRepo.createAtomic).toHaveBeenCalledTimes(2); }); + + describe('child tasks', () => { + const epic = { + id: 'AEOS-1', + projectId: 'startup-a', + title: 'Epic', + kind: 'EPIC' as const, + parentId: null, + column: 'TASK_BREAKDOWN' as const, + subState: 'WORKING' as const, + createdAt: 'x', + updatedAt: 'y', + }; + + it('creates a task under an epic', () => { + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue(epic); + + const result = useCase.execute({ + ...defaultInput, + title: 'Hash passwords', + parentId: 'AEOS-1', + }); + + expect(result).toMatchObject({ + kind: 'TASK', + parentId: 'AEOS-1', + alreadyExisted: false, + }); + }); + + it('rejects a task whose parent is another task (one level of nesting)', () => { + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue({ + ...epic, + id: 'AEOS-2', + kind: 'TASK', + parentId: 'AEOS-1', + }); + + expect(() => + useCase.execute({ ...defaultInput, title: 'Nested', parentId: 'AEOS-2' }), + ).toThrow('tasks may only hang off an EPIC'); + }); + + it('is idempotent — a matching child short-circuits without creating a duplicate', () => { + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue(epic); + (ticketRepo.findChildren as ReturnType<typeof vi.fn>).mockReturnValue([ + { + ...epic, + id: 'AEOS-2', + kind: 'TASK', + parentId: 'AEOS-1', + title: 'Hash passwords', + column: 'IMPLEMENTATION', + }, + ]); + + const result = useCase.execute({ + // Whitespace/case differences must still match — a retry rephrases nothing. + ...defaultInput, + title: ' hash passwords ', + parentId: 'AEOS-1', + }); + + expect(result).toMatchObject({ ticketId: 'AEOS-2', alreadyExisted: true }); + // The whole point: no new row, no new artifact, no commit. + expect(ticketRepo.createAtomic).not.toHaveBeenCalled(); + expect(artifactStore.writeArtifact).not.toHaveBeenCalled(); + expect(gitGateway.commit).not.toHaveBeenCalled(); + }); + + it('creates a genuinely new task even when the epic has other children', () => { + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue(epic); + (ticketRepo.findChildren as ReturnType<typeof vi.fn>).mockReturnValue([ + { ...epic, id: 'AEOS-2', kind: 'TASK', parentId: 'AEOS-1', title: 'Hash passwords' }, + ]); + + const result = useCase.execute({ + ...defaultInput, + title: 'Add session middleware', + parentId: 'AEOS-1', + }); + + expect(result.alreadyExisted).toBe(false); + expect(ticketRepo.createAtomic).toHaveBeenCalledOnce(); + }); + + it('matches on the task key, surviving a title rephrase on retry', () => { + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue(epic); + (ticketRepo.findChildren as ReturnType<typeof vi.fn>).mockReturnValue([ + { + ...epic, + id: 'AEOS-2', + kind: 'TASK', + parentId: 'AEOS-1', + title: 'Add password hashing', + taskKey: 'T-001', + column: 'IMPLEMENTATION', + }, + ]); + + // A retry rephrases the title but keeps the key — must still match, which + // title-based dedup would have missed and duplicated. + const result = useCase.execute({ + ...defaultInput, + title: 'Add password hashing to the login flow', + parentId: 'AEOS-1', + taskKey: 'T-001', + }); + + expect(result).toMatchObject({ ticketId: 'AEOS-2', alreadyExisted: true }); + expect(ticketRepo.createAtomic).not.toHaveBeenCalled(); + }); + + it('persists the task key on a newly created task', () => { + let built: import('../domain/model/ticket.js').Ticket | undefined; + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue(epic); + (ticketRepo.createAtomic as ReturnType<typeof vi.fn>).mockImplementation( + (_projectId: string, build: (n: number) => import('../domain/model/ticket.js').Ticket) => { + built = build(2); + return built; + }, + ); + + const result = useCase.execute({ + ...defaultInput, + title: 'Hash passwords', + parentId: 'AEOS-1', + taskKey: 'T-001', + }); + + expect(built?.taskKey).toBe('T-001'); + expect(result.taskKey).toBe('T-001'); + }); + + it('bridges an unkeyed prior child: a keyed retry matches it by title, no duplicate', () => { + // A pre-key attempt created the task title-only; the retry now supplies a + // key. Without the keyless-title bridge, the keyed lookup would miss the + // keyless child and duplicate. + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue(epic); + (ticketRepo.findChildren as ReturnType<typeof vi.fn>).mockReturnValue([ + { + ...epic, + id: 'AEOS-2', + kind: 'TASK', + parentId: 'AEOS-1', + title: 'Hash passwords', + taskKey: null, + }, + ]); + + const result = useCase.execute({ + ...defaultInput, + title: 'Hash passwords', + parentId: 'AEOS-1', + taskKey: 'T-001', + }); + + expect(result).toMatchObject({ ticketId: 'AEOS-2', alreadyExisted: true }); + expect(ticketRepo.createAtomic).not.toHaveBeenCalled(); + }); + + it('the keyless bridge only matches keyless children, not a differently-keyed task', () => { + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue(epic); + (ticketRepo.findChildren as ReturnType<typeof vi.fn>).mockReturnValue([ + // Same title, but already owns a different key — a distinct task. + { + ...epic, + id: 'AEOS-2', + kind: 'TASK', + parentId: 'AEOS-1', + title: 'Setup', + taskKey: 'T-001', + }, + ]); + + const result = useCase.execute({ + ...defaultInput, + title: 'Setup', + parentId: 'AEOS-1', + taskKey: 'T-009', + }); + + expect(result.alreadyExisted).toBe(false); + expect(ticketRepo.createAtomic).toHaveBeenCalledOnce(); + }); + + it('does not store a stray key on an epic (no parent)', () => { + let built: import('../domain/model/ticket.js').Ticket | undefined; + (ticketRepo.createAtomic as ReturnType<typeof vi.fn>).mockImplementation( + (_projectId: string, build: (n: number) => import('../domain/model/ticket.js').Ticket) => { + built = build(1); + return built; + }, + ); + + // --key with no --parent: the key is meaningless for an epic and must not persist. + const result = useCase.execute({ ...defaultInput, taskKey: 'T-001' }); + + expect(built?.taskKey).toBeNull(); + expect(result.taskKey).toBeNull(); + }); + + it('a different key is a different task even with an identical title', () => { + (ticketRepo.findById as ReturnType<typeof vi.fn>).mockReturnValue(epic); + (ticketRepo.findChildren as ReturnType<typeof vi.fn>).mockReturnValue([ + { + ...epic, + id: 'AEOS-2', + kind: 'TASK', + parentId: 'AEOS-1', + title: 'Add endpoint', + taskKey: 'T-001', + }, + ]); + + const result = useCase.execute({ + ...defaultInput, + title: 'Add endpoint', + parentId: 'AEOS-1', + taskKey: 'T-002', + }); + + expect(result.alreadyExisted).toBe(false); + expect(ticketRepo.createAtomic).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/src/application/ticket-create.use-case.ts b/src/application/ticket-create.use-case.ts index 7185ae7..f33e1ec 100644 --- a/src/application/ticket-create.use-case.ts +++ b/src/application/ticket-create.use-case.ts @@ -19,11 +19,17 @@ export class TicketCreateUseCase implements TicketCreatePort { ) {} execute(input: TicketCreateInput): TicketCreateResult { - const { title, projectId, projectKey, projectPath, parentId } = input; + const { title, projectId, projectKey, projectPath, parentId, taskKey, body } = input; // A parent makes this a task; without one it is an epic. const kind = parentId ? TicketKind.TASK : TicketKind.EPIC; + // A key only identifies a task under a parent; an epic never carries one, + // matching the port contract ("ignored without a parent"). + const resolvedTaskKey = parentId ? taskKey?.trim() || null : null; + const titleMatches = (child: { title: string }): boolean => + child.title.trim().toLowerCase() === title.trim().toLowerCase(); + if (parentId) { const parent = this.ticketRepo.findById(projectId, parentId); if (!parent) { @@ -34,6 +40,34 @@ export class TicketCreateUseCase implements TicketCreatePort { `Parent ${parentId} is a ${parent.kind}; tasks may only hang off an EPIC (one level of nesting).`, ); } + + // Idempotent: decomposition creates tasks during an agentic run, which the + // review loop may retry. A matching child short-circuits rather than + // duplicating. + const children = this.ticketRepo.findChildren(projectId, parentId); + let existing: (typeof children)[number] | undefined; + if (resolvedTaskKey) { + const wanted = resolvedTaskKey.toLowerCase(); + // Match on the key — a retry may rephrase the title, so keying on title + // alone would miss and duplicate. Secondarily match a KEYLESS child with + // the same title: that is the same task from a pre-key attempt, and + // pairing it here avoids duplicating when key usage started mid-stream. + existing = + children.find((child) => child.taskKey?.trim().toLowerCase() === wanted) ?? + children.find((child) => !child.taskKey && titleMatches(child)); + } else { + existing = children.find(titleMatches); + } + if (existing) { + return { + ticketId: existing.id, + title: existing.title, + kind: existing.kind, + parentId: existing.parentId, + taskKey: existing.taskKey ?? null, + alreadyExisted: true, + }; + } } // 1. Atomically allocate ID + insert in a single transaction (prevents race conditions) @@ -46,6 +80,7 @@ export class TicketCreateUseCase implements TicketCreatePort { title, kind, parentId: parentId ?? null, + taskKey: resolvedTaskKey, column: 'BACKLOG' as const, subState: null, createdAt: now, @@ -56,7 +91,7 @@ export class TicketCreateUseCase implements TicketCreatePort { const ticketId = ticket.id; // 2. Build ticket markdown content - const content = buildInitialTicketDocument(ticket); + const content = buildInitialTicketDocument(ticket, body); // 4. Write artifact file; compensate on failure const filename = `${ticketId}-ticket.md`; @@ -78,6 +113,13 @@ export class TicketCreateUseCase implements TicketCreatePort { } // 6. Return result - return { ticketId, title, kind, parentId: parentId ?? null }; + return { + ticketId, + title, + kind, + parentId: parentId ?? null, + taskKey: resolvedTaskKey, + alreadyExisted: false, + }; } } diff --git a/src/application/ticket-dod-approve.use-case.test.ts b/src/application/ticket-dod-approve.use-case.test.ts index 57ec154..e29dddf 100644 --- a/src/application/ticket-dod-approve.use-case.test.ts +++ b/src/application/ticket-dod-approve.use-case.test.ts @@ -20,6 +20,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } diff --git a/src/application/ticket-list.use-case.test.ts b/src/application/ticket-list.use-case.test.ts index 7112fad..7791c88 100644 --- a/src/application/ticket-list.use-case.test.ts +++ b/src/application/ticket-list.use-case.test.ts @@ -14,6 +14,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } diff --git a/src/application/ticket-move.use-case.test.ts b/src/application/ticket-move.use-case.test.ts index c634f2e..ce2001d 100644 --- a/src/application/ticket-move.use-case.test.ts +++ b/src/application/ticket-move.use-case.test.ts @@ -19,6 +19,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } diff --git a/src/application/ticket-ready.use-case.test.ts b/src/application/ticket-ready.use-case.test.ts index 4a73248..a89097c 100644 --- a/src/application/ticket-ready.use-case.test.ts +++ b/src/application/ticket-ready.use-case.test.ts @@ -19,6 +19,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } function createMockArtifactStore(): ArtifactStore { diff --git a/src/application/ticket-run.use-case.test.ts b/src/application/ticket-run.use-case.test.ts index 3c96025..2946c9f 100644 --- a/src/application/ticket-run.use-case.test.ts +++ b/src/application/ticket-run.use-case.test.ts @@ -34,6 +34,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } @@ -568,6 +569,33 @@ describe('TicketRunUseCase', () => { ); }); + it('skips the repo-diff check for an agentic column that opts out', async () => { + // TASK_BREAKDOWN is agentic (it calls the CLI to create tasks) but its work + // is not a repo edit, so requiresRepoDiff:false must let a no-diff run pass. + (columnSpecLoader.load as ReturnType<typeof vi.fn>).mockReturnValue( + defaultColumnSpec({ executorMode: 'agentic', requiresRepoDiff: false }), + ); + (gitGateway.diff as ReturnType<typeof vi.fn>).mockReturnValue(''); + + const result = await useCase.execute(PROJECT_ID, PROJECT_PATH, TICKET_ID); + + expect(result.status).toBe('success'); + }); + + it('still enforces the repo-diff check for a default agentic column', async () => { + // Undefined requiresRepoDiff must behave as true — IMPLEMENTATION's guarantee. + (columnSpecLoader.load as ReturnType<typeof vi.fn>).mockReturnValue( + defaultColumnSpec({ executorMode: 'agentic' }), + ); + (gitGateway.diff as ReturnType<typeof vi.fn>).mockReturnValue(''); + + const result = await useCase.execute(PROJECT_ID, PROJECT_PATH, TICKET_ID); + + expect(result.status).toBe('failed'); + if (result.status !== 'failed') return; + expect(result.error).toContain('no repository changes'); + }); + it('should reuse the first assembled context for the worker prompt', async () => { const firstContext = { ...defaultContext(), settledDecisions: '# AEOS Decisions' }; const secondContext = { diff --git a/src/application/ticket-run.use-case.ts b/src/application/ticket-run.use-case.ts index 1de9a0e..35971b3 100644 --- a/src/application/ticket-run.use-case.ts +++ b/src/application/ticket-run.use-case.ts @@ -501,6 +501,7 @@ export class TicketRunUseCase implements TicketRunPort { `Running worker executor${attemptLabel}`, { role: 'worker', + agent: ctx.workerAgentSpec.name, executor: ctx.workerExecutorType, model: ctx.workerModel, mode: workerMode, @@ -576,7 +577,10 @@ export class TicketRunUseCase implements TicketRunPort { const content = executorResult.content ?? ''; this.emitStageEvent(emitter, 'stage.started', 'validation', 'Validating worker output'); - if (workerMode === 'agentic') { + // An agentic run must change the repo — unless the column opts out, because + // its work is not a repo edit (TASK_BREAKDOWN creates tickets). Undefined + // means true, so IMPLEMENTATION keeps the guarantee without stating it. + if (workerMode === 'agentic' && columnSpec.requiresRepoDiff !== false) { const repoDiff = this.gitGateway.diff(projectPath).trim(); if (repoDiff.length === 0) { const error = 'Agentic implementation produced no repository changes'; @@ -655,6 +659,7 @@ export class TicketRunUseCase implements TicketRunPort { this.emitStageEvent(emitter, 'stage.started', 'reviewer', 'Running reviewer executor', { role: 'reviewer', + agent: ctx.reviewerAgentSpec.name, executor: ctx.reviewerExecutorType, model: ctx.reviewerModel, mode: 'artifact', @@ -776,6 +781,7 @@ export class TicketRunUseCase implements TicketRunPort { this.emitStageEvent(emitter, 'stage.started', 'preflight', 'Running preflight checks', { role: 'preflight', + agent: ctx.workerAgentSpec.name, executor: ctx.workerExecutorType, model: ctx.workerModel, mode: 'artifact', @@ -950,6 +956,15 @@ export class TicketRunUseCase implements TicketRunPort { }); } + // Persist the reason so `aeos ticket show` and the orchestrator can explain + // the stall after the run ends — the event below is live-only. Written after + // the sub-state transition, which retains escalation for ESCALATED/BLOCKED. + this.ticketRepo.setEscalation(ctx.projectId, ctx.ticketId, { + reason: escalation.reason, + message: escalation.message, + artifactPath: escalation.artifactPath ?? null, + }); + ctx.emitter.emit({ type: 'ticket-run.escalated', phase: 'complete', @@ -1072,6 +1087,7 @@ export class TicketRunUseCase implements TicketRunPort { message: string, metadata?: { role?: 'preflight' | 'worker' | 'reviewer'; + agent?: string; executor?: string; model?: string; mode?: 'artifact' | 'agentic'; diff --git a/src/application/ticket-show.use-case.test.ts b/src/application/ticket-show.use-case.test.ts index ac0872e..c68704d 100644 --- a/src/application/ticket-show.use-case.test.ts +++ b/src/application/ticket-show.use-case.test.ts @@ -16,6 +16,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } diff --git a/src/application/ticket-sign-off.use-case.test.ts b/src/application/ticket-sign-off.use-case.test.ts index 98dd7ec..9a705f5 100644 --- a/src/application/ticket-sign-off.use-case.test.ts +++ b/src/application/ticket-sign-off.use-case.test.ts @@ -19,6 +19,7 @@ function createMockTicketRepo(): TicketRepository { findChildren: vi.fn().mockReturnValue([]), updateColumn: vi.fn(), updateSubState: vi.fn(), + setEscalation: vi.fn(), }; } function createMockArtifactStore(): ArtifactStore { diff --git a/src/cli/commands/orchestrator.command.ts b/src/cli/commands/orchestrator.command.ts index 68cb87f..5292d83 100644 --- a/src/cli/commands/orchestrator.command.ts +++ b/src/cli/commands/orchestrator.command.ts @@ -3,6 +3,7 @@ import type { Command } from 'commander'; import type { OrchestratorPort } from '../../domain/ports/driving/orchestrator.port.js'; import type { ProjectRepository } from '../../domain/ports/driven/project-repository.port.js'; +import { createTicketRunDisplay } from '../ui/ticket-run-display.js'; /** Halt reasons that mean "finished cleanly", as opposed to "stopped early". */ const CLEAN_HALTS = new Set(['COMPLETE', 'HUMAN_GATE', 'AWAITING_APPROVAL']); @@ -37,34 +38,76 @@ export function registerOrchestratorCommand( const project = resolveProject(); if (!project) return; + // Reuse the ticket-run live view: each ticket the orchestrator drives + // renders the same pane as a standalone `aeos ticket run`. Started lazily + // on the first ticket-run event, so an immediate halt (no runs) prints + // its summary normally rather than flashing a blank screen. + const display = createTicketRunDisplay(process.stdout); + let displayStarted = false; + + // Capture the instance so SIGINT interrupts the same run it started. + const orchestrator = getOrchestrator(); + const interruptHandler = () => { + display.requestInterrupt(); + orchestrator.interrupt(); + }; + process.once('SIGINT', interruptHandler); + try { - const result = await getOrchestrator().run( + const result = await orchestrator.run( project.id, project.path, epicId, { budgetUsd: options.budget, maxSteps: options.maxSteps }, { + ticketRunObserver: { + onEvent(event) { + if (!displayStarted) { + displayStarted = true; + display.start(); + } + display.observer.onEvent?.(event); + }, + }, onStep(step) { - // eslint-disable-next-line no-console - console.log(` ${step.ticketId}: ${step.outcome}`); + // In the live pane, interleaving plain lines would corrupt the + // alt-screen; the per-ticket view and the end-of-run summary carry + // it instead. Without a TTY, print each step as it happens. + if (!display.live) { + // eslint-disable-next-line no-console + console.log(` ${step.ticketId} [${step.column}]: ${step.outcome}`); + } }, }, ); - // eslint-disable-next-line no-console - console.log( - [ - '', - `${CLEAN_HALTS.has(result.haltReason) ? '✓' : '⏸'} ${result.epicId} — ${result.haltReason}`, - ` ${result.message}`, - ` ${result.steps.length} action(s), $${result.spentUsd.toFixed(2)} spent`, - ].join('\n'), + process.removeListener('SIGINT', interruptHandler); + if (displayStarted) display.stop(); + + const summary: string[] = []; + // The live pane suppressed inline step lines — replay them after the + // screen is torn down so the run is legible in scroll-back. + if (display.live) { + for (const step of result.steps) { + summary.push(` ${step.ticketId} [${step.column}]: ${step.outcome}`); + } + } + summary.push( + '', + `${CLEAN_HALTS.has(result.haltReason) ? '✓' : '⏸'} ${result.epicId} — ${result.haltReason}`, + ` ${result.message}`, + ` ${result.steps.length} action(s), $${result.spentUsd.toFixed(2)} spent`, ); + // eslint-disable-next-line no-console + console.log(summary.join('\n')); if (!CLEAN_HALTS.has(result.haltReason)) { - process.exitCode = 1; + // 130 is the conventional "terminated by Ctrl+C" code. + process.exitCode = result.haltReason === 'INTERRUPTED' ? 130 : 1; } } catch (err) { + process.removeListener('SIGINT', interruptHandler); + if (displayStarted) display.stop(); // eslint-disable-next-line no-console console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); process.exitCode = 1; diff --git a/src/cli/commands/project-init.command.ts b/src/cli/commands/project-init.command.ts index 009a319..557032f 100644 --- a/src/cli/commands/project-init.command.ts +++ b/src/cli/commands/project-init.command.ts @@ -43,7 +43,7 @@ export function registerProjectInitCommand( const scaffoldNote = result.scaffolded.length > 0 - ? `\n Scaffolded ${result.scaffolded.length} file(s) into .aeos/ — column specs, agents, and rubrics.` + ? `\n Scaffolded ${result.scaffolded.length} file(s) into .aeos/ — column specs, agents, rubrics, and the aeos skill.` : '\n Everything was already in place; nothing scaffolded.'; // eslint-disable-next-line no-console diff --git a/src/cli/commands/project-sync.command.ts b/src/cli/commands/project-sync.command.ts new file mode 100644 index 0000000..f0521d9 --- /dev/null +++ b/src/cli/commands/project-sync.command.ts @@ -0,0 +1,99 @@ +// CLI command — aeos project sync + +import type { Command } from 'commander'; +import type { ProjectSyncPort } from '../../domain/ports/driving/project-sync.port.js'; +import type { ProjectRepository } from '../../domain/ports/driven/project-repository.port.js'; +import type { SyncFileOutcome } from '../../domain/ports/driving/project-sync.port.js'; +import { formatLineDiff } from '../../application/services/line-diff.js'; + +/* eslint-disable no-console */ + +export function registerProjectSyncCommand( + program: Command, + getProjectSync: () => ProjectSyncPort, + projectRepo: ProjectRepository, +): void { + const projectCmd = + program.commands.find((c) => c.name() === 'project') ?? + program.command('project').description('Project management commands'); + + projectCmd + .command('sync') + .description( + "Reconcile this project's column specs, agents, and rubrics with the current templates", + ) + .option('--apply', 'Write files that are missing from the project') + .option( + '--force', + 'Also overwrite drifted files (each backed up to <file>.bak). Implies --apply', + ) + .option('--diff', 'Show a line diff for each drifted file') + .action((opts: { apply?: boolean; force?: boolean; diff?: boolean }) => { + const projectPath = projectRepo.findRoot(process.cwd()); + if (!projectPath) { + console.error('Error: No AEOS project found. Run "aeos project init" first.'); + process.exitCode = 1; + return; + } + + const apply = Boolean(opts.apply); + const force = Boolean(opts.force); + const result = getProjectSync().execute({ projectPath, apply, force }); + + const missing = result.files.filter((f) => f.status === 'missing'); + const drifted = result.files.filter((f) => f.status === 'drifted'); + const unchanged = result.files.filter((f) => f.status === 'unchanged'); + + console.log('Comparing .aeos/ against the current templates...\n'); + + for (const file of result.files) { + if (file.status === 'unchanged') continue; + console.log(` ${label(file)} ${file.relativePath}`); + if (opts.diff && file.status === 'drifted' && file.projectContent !== null) { + const diff = formatLineDiff(file.projectContent, file.templateContent); + console.log(indent(diff)); + } + } + + console.log( + `\nSummary: ${unchanged.length} up-to-date, ${drifted.length} drifted, ${missing.length} missing.`, + ); + + // Guidance depends on what was left unaddressed. + const addedNow = result.files.some((f) => f.action !== 'none'); + if (!apply && !force && (missing.length > 0 || drifted.length > 0)) { + const hints: string[] = []; + if (missing.length > 0) hints.push('`aeos project sync --apply` to add missing files'); + if (drifted.length > 0) + hints.push('`aeos project sync --force` to overwrite drifted files (backed up first)'); + console.log(`\nDry run — nothing changed. Run ${hints.join(', or ')}.`); + console.log('Use --diff to inspect drift before overwriting.'); + } else if (addedNow) { + console.log(''); + for (const f of result.files) { + if (f.action === 'added') console.log(` + added ${f.relativePath}`); + if (f.action === 'updated') + console.log(` ~ updated ${f.relativePath} (backup: ${f.backupPath})`); + } + if (drifted.length > 0 && !force) { + console.log( + `\n${drifted.length} drifted file(s) left untouched — run with --force to overwrite them.`, + ); + } + } + }); +} + +function label(file: SyncFileOutcome): string { + if (file.action === 'added') return 'added '; + if (file.action === 'updated') return 'updated'; + if (file.status === 'missing') return 'missing'; + return 'drift '; +} + +function indent(text: string): string { + return text + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); +} diff --git a/src/cli/commands/ticket-create.command.ts b/src/cli/commands/ticket-create.command.ts index 457cf24..751381e 100644 --- a/src/cli/commands/ticket-create.command.ts +++ b/src/cli/commands/ticket-create.command.ts @@ -1,5 +1,6 @@ // CLI command — aeos ticket create +import * as fs from 'node:fs'; import type { Command } from 'commander'; import type { TicketCreatePort } from '../../domain/ports/driving/ticket-create.port.js'; import type { ProjectRepository } from '../../domain/ports/driven/project-repository.port.js'; @@ -21,36 +22,81 @@ export function registerTicketCreateCommand( '--parent <epicId>', 'Create this ticket as a task under the given epic, skipping scoping and spec', ) - .action((title: string, options: { parent?: string }) => { - try { - const cwd = process.cwd(); - const projectPath = projectRepo.findRoot(cwd); + .option( + '--key <taskKey>', + 'Stable decomposition key (e.g. T-001); idempotency matches on it instead of the title', + ) + .option( + '--body <markdown>', + 'Markdown description for the ticket (e.g. a task breakdown block). Prefer --body-file for multi-line content', + ) + .option( + '--body-file <path>', + 'Read the ticket description from a file (robust for multi-line markdown)', + ) + .action( + ( + title: string, + options: { parent?: string; key?: string; body?: string; bodyFile?: string }, + ) => { + try { + const cwd = process.cwd(); + const projectPath = projectRepo.findRoot(cwd); - if (!projectPath) { + if (!projectPath) { + // eslint-disable-next-line no-console + console.error('Error: No AEOS project found. Run "aeos project init" first.'); + process.exitCode = 1; + return; + } + + if (options.body !== undefined && options.bodyFile !== undefined) { + // eslint-disable-next-line no-console + console.error('Error: pass either --body or --body-file, not both.'); + process.exitCode = 1; + return; + } + + let body = options.body; + if (options.bodyFile !== undefined) { + try { + body = fs.readFileSync(options.bodyFile, 'utf-8'); + } catch { + // eslint-disable-next-line no-console + console.error(`Error: could not read --body-file "${options.bodyFile}".`); + process.exitCode = 1; + return; + } + } + + const project = projectRepo.read(projectPath); + + const result = getTicketCreateUseCase().execute({ + title, + projectId: project.id, + projectKey: project.key, + projectPath, + parentId: options.parent, + taskKey: options.key, + body, + }); + + const lineage = result.parentId ? ` (task of ${result.parentId})` : ''; + if (result.alreadyExisted) { + // eslint-disable-next-line no-console + console.log( + `= ${result.kind} ${result.ticketId} already exists: "${result.title}"${lineage} — left as is`, + ); + } else { + // eslint-disable-next-line no-console + console.log(`✓ Created ${result.kind} ${result.ticketId}: "${result.title}"${lineage}`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console - console.error('Error: No AEOS project found. Run "aeos project init" first.'); + console.error(`Error: ${message}`); process.exitCode = 1; - return; } - - const project = projectRepo.read(projectPath); - - const result = getTicketCreateUseCase().execute({ - title, - projectId: project.id, - projectKey: project.key, - projectPath, - parentId: options.parent, - }); - - const lineage = result.parentId ? ` (task of ${result.parentId})` : ''; - // eslint-disable-next-line no-console - console.log(`✓ Created ${result.kind} ${result.ticketId}: "${result.title}"${lineage}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - // eslint-disable-next-line no-console - console.error(`Error: ${message}`); - process.exitCode = 1; - } - }); + }, + ); } diff --git a/src/cli/commands/ticket-show.command.ts b/src/cli/commands/ticket-show.command.ts index f9cf5af..8267d13 100644 --- a/src/cli/commands/ticket-show.command.ts +++ b/src/cli/commands/ticket-show.command.ts @@ -76,6 +76,18 @@ export function registerTicketShowCommand( // eslint-disable-next-line no-console console.log(`State: ${subState}`); + // The whole point of ESCALATED/BLOCKED is "a human must look" — so tell the + // human why, right here, instead of making them open the review artifact. + const escalation = result.ticket.escalation; + if (escalation) { + // eslint-disable-next-line no-console + console.log(`Reason: ${escalation.reason} — ${escalation.message}`); + if (escalation.artifactPath) { + // eslint-disable-next-line no-console + console.log(` See: ${escalation.artifactPath}`); + } + } + if (result.children.length > 0) { const done = result.children.filter((child) => child.column === 'DONE').length; // eslint-disable-next-line no-console diff --git a/src/cli/container.ts b/src/cli/container.ts index 032b3a4..dc3e757 100644 --- a/src/cli/container.ts +++ b/src/cli/container.ts @@ -2,6 +2,7 @@ import type { InstallPort } from '../domain/ports/driving/install.port.js'; import type { ProjectInitPort } from '../domain/ports/driving/project-init.port.js'; +import type { ProjectSyncPort } from '../domain/ports/driving/project-sync.port.js'; import type { TicketCreatePort } from '../domain/ports/driving/ticket-create.port.js'; import type { TicketListPort } from '../domain/ports/driving/ticket-list.port.js'; import type { TicketShowPort } from '../domain/ports/driving/ticket-show.port.js'; @@ -26,6 +27,8 @@ import { SqliteTicketRepository } from '../infrastructure/persistence/sqlite-tic import { getDb } from '../infrastructure/persistence/database.js'; import { InstallUseCase } from '../application/install.use-case.js'; import { ProjectInitUseCase } from '../application/project-init.use-case.js'; +import { ProjectSyncUseCase } from '../application/project-sync.use-case.js'; +import { FsTemplateCatalog } from '../infrastructure/filesystem/fs-template-catalog.js'; import { TicketCreateUseCase } from '../application/ticket-create.use-case.js'; import { TicketListUseCase } from '../application/ticket-list.use-case.js'; import { TicketShowUseCase } from '../application/ticket-show.use-case.js'; @@ -57,6 +60,7 @@ import type { AgentSpec } from '../domain/model/agent-spec.js'; export interface Container { install: InstallPort; projectInit: ProjectInitPort; + projectSync: ProjectSyncPort; ticketCreate: TicketCreatePort; ticketList: TicketListPort; ticketShow: TicketShowPort; @@ -135,6 +139,7 @@ export function createContainer(): Container { return { install: new InstallUseCase(configStore), projectInit: new ProjectInitUseCase(projectRepo, configStore, gitGateway), + projectSync: new ProjectSyncUseCase(new FsTemplateCatalog()), get ticketCreate() { return new TicketCreateUseCase(getTicketRepo(), artifactStore, gitGateway); }, diff --git a/src/cli/index.ts b/src/cli/index.ts index 7e528a0..8479823 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ import { createContainer } from './container.js'; import { runInkApp } from './ui/run-ink-app.js'; import { registerInstallCommand } from './commands/install.command.js'; import { registerProjectInitCommand } from './commands/project-init.command.js'; +import { registerProjectSyncCommand } from './commands/project-sync.command.js'; import { registerTicketCreateCommand } from './commands/ticket-create.command.js'; import { registerTicketListCommand } from './commands/ticket-list.command.js'; import { registerTicketShowCommand } from './commands/ticket-show.command.js'; @@ -24,6 +25,7 @@ export { createContainer } from './container.js'; export type { Container } from './container.js'; export { registerInstallCommand } from './commands/install.command.js'; export { registerProjectInitCommand } from './commands/project-init.command.js'; +export { registerProjectSyncCommand } from './commands/project-sync.command.js'; export { registerTicketCreateCommand } from './commands/ticket-create.command.js'; export { registerTicketListCommand } from './commands/ticket-list.command.js'; export { registerTicketShowCommand } from './commands/ticket-show.command.js'; @@ -69,6 +71,7 @@ export function buildProgram(): Command { const container = createContainer(); registerInstallCommand(program, container.install); registerProjectInitCommand(program, container.projectInit); + registerProjectSyncCommand(program, () => container.projectSync, container.projectRepo); // Ticket commands access the DB — resolve lazily inside the action callback, // not at program build time. This allows `aeos install` and `aeos project init` diff --git a/src/cli/ui/ticket-run-display.test.ts b/src/cli/ui/ticket-run-display.test.ts new file mode 100644 index 0000000..02280f8 --- /dev/null +++ b/src/cli/ui/ticket-run-display.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; + +import { createTicketRunDisplay } from './ticket-run-display.js'; +import type { TicketRunEvent } from '../../domain/model/ticket-run-event.js'; + +/** A non-TTY sink so createTicketRunDisplay picks the plain (log-streaming) mode. */ +function fakeStdout(): { stream: NodeJS.WriteStream; output: () => string } { + let buffer = ''; + const stream = { + isTTY: false, + write: (chunk: string) => { + buffer += chunk; + return true; + }, + } as unknown as NodeJS.WriteStream; + return { stream, output: () => buffer }; +} + +function event( + partial: Partial<TicketRunEvent> & Pick<TicketRunEvent, 'type' | 'payload'>, +): TicketRunEvent { + return { + runId: 'r1', + projectId: 'p', + ticketId: 'STAN-1', + column: 'TECH_SPEC', + phase: 'worker', + at: new Date().toISOString(), + sequence: 1, + ...partial, + } as TicketRunEvent; +} + +describe('ticket-run-display — agent visibility', () => { + it('names the working agent in the stage log line', () => { + const { stream, output } = fakeStdout(); + const display = createTicketRunDisplay(stream); + display.start(); + + display.observer.onEvent?.( + event({ + type: 'stage.started', + phase: 'worker', + payload: { + stage: 'worker', + message: 'Running worker executor', + role: 'worker', + agent: 'architect-agent', + executor: 'claude-cli', + model: 'claude-opus-4-8', + mode: 'artifact', + }, + }), + ); + + // The operator can tell *which* agent is working, not just "worker". + expect(output()).toContain('agent=architect-agent'); + }); +}); diff --git a/src/cli/ui/ticket-run-display.ts b/src/cli/ui/ticket-run-display.ts index fc46931..009cbee 100644 --- a/src/cli/ui/ticket-run-display.ts +++ b/src/cli/ui/ticket-run-display.ts @@ -75,6 +75,7 @@ abstract class BaseTicketRunDisplay implements TicketRunDisplay, TicketRunObserv protected ticketId = '—'; protected column = '—'; + protected agent = '—'; protected executor = '—'; protected model = '—'; protected mode = '—'; @@ -109,22 +110,44 @@ abstract class BaseTicketRunDisplay implements TicketRunDisplay, TicketRunObserv switch (event.type) { case 'ticket-run.started': + // Reset the per-run panels so a display reused across an orchestrator's + // tickets reflects the current ticket, not the previous one. The raw log + // is intentionally kept so the whole epic's output scrolls back. + for (const phase of PHASE_ORDER) { + this.stageStates.set(phase, { status: 'pending' }); + } + this.partialBuffers.clear(); + this.activeStage = null; + this.subState = null; + this.finalStatus = 'running'; + this.agent = '—'; this.executor = event.payload.executor; this.model = event.payload.model ?? '—'; this.appendLogLine( - `[run] started | executor=${this.executor}${event.payload.model ? ` | model=${event.payload.model}` : ''}`, + `[run] started | ${event.ticketId} | ${event.column} | executor=${this.executor}${event.payload.model ? ` | model=${event.payload.model}` : ''}`, ); break; + case 'run.attempt.started': + if (event.payload.attempt > 1) { + this.appendLogLine( + `[run] revision attempt ${event.payload.attempt} of ${event.payload.maxAttempts}`, + ); + } + break; case 'stage.started': this.activeStage = event.payload.stage; this.stageStates.set(event.payload.stage, { status: 'active', message: event.payload.message, }); + this.agent = event.payload.agent ?? this.agent; this.executor = event.payload.executor ?? this.executor; this.model = event.payload.model ?? this.model; this.mode = event.payload.mode ?? this.mode; - this.appendLogLine(`[stage:${event.payload.stage}] started | ${event.payload.message}`); + this.appendLogLine( + `[stage:${event.payload.stage}] started | ${event.payload.message}` + + (event.payload.agent ? ` | agent=${event.payload.agent}` : ''), + ); break; case 'stage.completed': this.stageStates.set(event.payload.stage, { @@ -194,6 +217,11 @@ abstract class BaseTicketRunDisplay implements TicketRunDisplay, TicketRunObserv `[run] interrupted${event.payload.stage ? ` at ${event.payload.stage}` : ''} | ${event.payload.message}`, ); break; + case 'ticket-run.escalated': + this.finalStatus = 'escalated'; + this.flushAllBuffers(); + this.appendLogLine(`[run] escalated | ${event.payload.reason} | ${event.payload.message}`); + break; } this.afterStateChange(); @@ -357,6 +385,7 @@ class LiveTicketRunDisplay extends BaseTicketRunDisplay { private buildMetaLine(): string { return [ `Stage: ${this.activeStage ? PHASE_LABELS[this.activeStage] : this.finalStatus}`, + `Agent: ${this.agent}`, `Executor: ${this.executor}`, `Model: ${this.model}`, `Mode: ${this.mode}`, @@ -400,6 +429,7 @@ class LiveTicketRunDisplay extends BaseTicketRunDisplay { const metadata = [ 'Stages', `Current: ${this.activeStage ? PHASE_LABELS[this.activeStage] : this.finalStatus}`, + `Agent: ${this.agent}`, `Executor: ${this.executor}`, `Model: ${this.model}`, `Mode: ${this.mode}`, diff --git a/src/domain/model/column-spec.ts b/src/domain/model/column-spec.ts index 01a01a0..5ebb0ad 100644 --- a/src/domain/model/column-spec.ts +++ b/src/domain/model/column-spec.ts @@ -3,6 +3,15 @@ export interface ColumnSpec { readonly column: string; readonly executorMode?: 'artifact' | 'agentic'; + /** + * Whether an agentic run must leave a git diff to pass validation. + * + * True is the IMPLEMENTATION guarantee — agentic code work that changes + * nothing is a failure. False is for agentic columns whose work is not a + * repo edit (TASK_BREAKDOWN creates tickets and writes to gitignored .aeos/). + * Undefined defaults to true, so IMPLEMENTATION keeps its guarantee unstated. + */ + readonly requiresRepoDiff?: boolean; readonly workerAgentFile: string; readonly reviewerAgentFile: string; readonly outputArtifact: string; diff --git a/src/domain/model/ticket-run-event.ts b/src/domain/model/ticket-run-event.ts index 421925c..a6eefc5 100644 --- a/src/domain/model/ticket-run-event.ts +++ b/src/domain/model/ticket-run-event.ts @@ -64,6 +64,8 @@ export type TicketRunLifecycleEvent = stage: TicketRunPhase; message: string; role?: 'preflight' | 'worker' | 'reviewer'; + /** The agent spec driving this stage, e.g. "architect-agent". */ + agent?: string; executor?: string; model?: string; mode?: 'artifact' | 'agentic'; diff --git a/src/domain/model/ticket.ts b/src/domain/model/ticket.ts index 5286e69..7413cf1 100644 --- a/src/domain/model/ticket.ts +++ b/src/domain/model/ticket.ts @@ -1,9 +1,24 @@ // Aggregate — Ticket (id, kind, parent, title, column, subState, timestamps) import type { Column } from './column.js'; +import type { EscalationReason } from './escalation.js'; import type { SubStateOrNull } from './sub-state.js'; import type { TicketKind } from './ticket-kind.js'; +/** + * The most recent escalation recorded against a ticket, or absent when it has + * never escalated (or has since moved on). Persisted so `aeos ticket show` and + * the orchestrator can answer "why did this stall?" after the run has ended — + * the live run event is gone by then. + */ +export interface TicketEscalation { + reason: EscalationReason; + /** Operator-facing explanation. */ + message: string; + /** Artifact the operator should read first (review, questions, …). */ + artifactPath?: string | null; +} + export interface Ticket { /** Ticket identifier, e.g. "AEOS-1" */ id: string; @@ -15,6 +30,14 @@ export interface Ticket { kind: TicketKind; /** Parent epic ID for a TASK; null for an EPIC */ parentId: string | null; + /** + * Stable decomposition key for a TASK (e.g. "T-001"), or null. + * + * Set when a task is created via `--key` during decomposition. It is the + * idempotency identity a review-loop retry keys on, so re-creating a task + * survives the architect rephrasing its title between attempts. + */ + taskKey?: string | null; /** Pipeline column */ column: Column; /** Sub-state within the column — null for BACKLOG tickets */ @@ -23,4 +46,9 @@ export interface Ticket { createdAt: string; /** ISO-8601 last-updated timestamp */ updatedAt: string; + /** + * The last escalation, when the ticket is (or recently was) ESCALATED/BLOCKED. + * Cleared automatically once the ticket returns to any other sub-state. + */ + escalation?: TicketEscalation | null; } diff --git a/src/domain/ports/driven/template-catalog.port.ts b/src/domain/ports/driven/template-catalog.port.ts new file mode 100644 index 0000000..76f1a1e --- /dev/null +++ b/src/domain/ports/driven/template-catalog.port.ts @@ -0,0 +1,28 @@ +// Driven port — TemplateCatalog: reads shipped templates and the project's copies. +// +// Backs `aeos project sync`, which reconciles a project's scaffolded +// `.aeos/{column-specs,agents,rubrics}` against the current package templates. +// Kept as a port so the sync use case can be tested against a fake catalog +// without touching the filesystem. + +export interface TemplateEntry { + /** Path relative to `.aeos/`, e.g. `column-specs/task-breakdown.yaml`. */ + readonly relativePath: string; + /** The shipped template content. */ + readonly content: string; +} + +export interface TemplateCatalog { + /** Every shipped template file (column specs, agents, rubrics). */ + list(): TemplateEntry[]; + /** The project's copy of a template file, or null when it does not exist. */ + readProjectCopy(projectPath: string, relativePath: string): string | null; + /** Writes content to the project's copy, creating parent directories. */ + write(projectPath: string, relativePath: string, content: string): void; + /** + * Renames the project's existing copy to a sibling backup so a subsequent + * write does not lose local edits. Returns the backup's path relative to + * `.aeos/`. Only called when a copy exists. + */ + backup(projectPath: string, relativePath: string): string; +} diff --git a/src/domain/ports/driven/ticket-repository.port.ts b/src/domain/ports/driven/ticket-repository.port.ts index d1f7702..59cc7d4 100644 --- a/src/domain/ports/driven/ticket-repository.port.ts +++ b/src/domain/ports/driven/ticket-repository.port.ts @@ -2,7 +2,7 @@ import type { Column } from '../../model/column.js'; import type { SubStateOrNull } from '../../model/sub-state.js'; -import type { Ticket } from '../../model/ticket.js'; +import type { Ticket, TicketEscalation } from '../../model/ticket.js'; export interface TicketRepository { /** Returns the next auto-incrementing ticket number for the given project */ @@ -24,6 +24,15 @@ export interface TicketRepository { findChildren(projectId: string, parentTicketId: string): Ticket[]; /** Updates a ticket's column and updated_at timestamp */ updateColumn(projectId: string, ticketId: string, column: Column): void; - /** Updates a ticket's sub-state and updated_at timestamp */ + /** + * Updates a ticket's sub-state and updated_at timestamp. Clears any recorded + * escalation when the new sub-state is neither ESCALATED nor BLOCKED — a + * ticket that has moved on is no longer "stalled for this reason". + */ updateSubState(projectId: string, ticketId: string, subState: SubStateOrNull): void; + /** + * Records (or clears, when passed null) the ticket's latest escalation. + * Called right after the sub-state is set to ESCALATED/BLOCKED. + */ + setEscalation(projectId: string, ticketId: string, escalation: TicketEscalation | null): void; } diff --git a/src/domain/ports/driving/orchestrator.port.ts b/src/domain/ports/driving/orchestrator.port.ts index dc7a5ea..92f64d4 100644 --- a/src/domain/ports/driving/orchestrator.port.ts +++ b/src/domain/ports/driving/orchestrator.port.ts @@ -2,10 +2,13 @@ import type { HaltReason } from '../../services/orchestrator-policy.js'; import type { OrchestratorState } from '../../model/orchestrator-state.js'; +import type { TicketRunObserver } from '../../model/ticket-run-event.js'; export interface OrchestratorStep { readonly action: 'run' | 'advance'; readonly ticketId: string; + /** The column the action acted on — which pipeline stage this step drove. */ + readonly column: string; /** One-line outcome, suitable for a progress log. */ readonly outcome: string; } @@ -27,6 +30,11 @@ export interface OrchestratorRunOptions { export interface OrchestratorObserver { onStep?(step: OrchestratorStep): void; + /** + * Forwarded the event stream of each ticket run the orchestrator drives, so a + * caller can render the same live view as a standalone `ticket run`. + */ + ticketRunObserver?: TicketRunObserver; } export interface OrchestratorPort { @@ -38,6 +46,12 @@ export interface OrchestratorPort { observer?: OrchestratorObserver, ): Promise<OrchestratorRunResult>; + /** + * Stop the active run gracefully: halt the in-flight ticket and stop + * scheduling once it unwinds. No-op if nothing is running. + */ + interrupt(): void; + pause(projectId: string, epicId: string): OrchestratorState; resume(projectId: string, epicId: string): OrchestratorState; status(projectId: string, epicId?: string): OrchestratorState[]; diff --git a/src/domain/ports/driving/project-sync.port.ts b/src/domain/ports/driving/project-sync.port.ts new file mode 100644 index 0000000..9a13f45 --- /dev/null +++ b/src/domain/ports/driving/project-sync.port.ts @@ -0,0 +1,35 @@ +// Driving port — ProjectSync use case interface + +/** How a project's copy compares to the current template. */ +export type SyncStatus = 'missing' | 'drifted' | 'unchanged'; + +/** What sync did about it on this run. */ +export type SyncAction = 'added' | 'updated' | 'none'; + +export interface SyncFileOutcome { + readonly relativePath: string; + readonly status: SyncStatus; + readonly action: SyncAction; + /** Set when a drifted file was overwritten and its prior content backed up. */ + readonly backupPath?: string; + /** Current template content — carried for `--diff` rendering. */ + readonly templateContent: string; + /** The project's copy, or null when missing — carried for `--diff`. */ + readonly projectContent: string | null; +} + +export interface ProjectSyncInput { + readonly projectPath: string; + /** Write files that are missing from the project. */ + readonly apply: boolean; + /** Also overwrite drifted files (each backed up first). Implies `apply`. */ + readonly force: boolean; +} + +export interface ProjectSyncResult { + readonly files: SyncFileOutcome[]; +} + +export interface ProjectSyncPort { + execute(input: ProjectSyncInput): ProjectSyncResult; +} diff --git a/src/domain/ports/driving/ticket-create.port.ts b/src/domain/ports/driving/ticket-create.port.ts index 42580fb..316b213 100644 --- a/src/domain/ports/driving/ticket-create.port.ts +++ b/src/domain/ports/driving/ticket-create.port.ts @@ -13,6 +13,20 @@ export interface TicketCreateInput { * breakdown, so it skips scoping and goes straight to the build pipeline. */ parentId?: string; + /** + * Stable decomposition key (e.g. "T-001"). When present, idempotency keys on + * (parent, taskKey) instead of the title, so a review-loop retry that + * rephrases a task's title still matches the existing ticket. Ignored without + * a parent. + */ + taskKey?: string; + /** + * Markdown body for the ticket's Description — the task's full breakdown + * (description, acceptance criteria, touches, out-of-scope) when a decomposed + * task is created. Omitted for a hand-created ticket, which gets editable + * placeholders instead. + */ + body?: string; } export interface TicketCreateResult { @@ -20,6 +34,9 @@ export interface TicketCreateResult { title: string; kind: TicketKind; parentId: string | null; + taskKey: string | null; + /** True when a matching child (by key, else title) already existed under the parent. */ + alreadyExisted: boolean; } export interface TicketCreatePort { diff --git a/src/domain/services/orchestrator-policy.test.ts b/src/domain/services/orchestrator-policy.test.ts index d6514ca..177bb69 100644 --- a/src/domain/services/orchestrator-policy.test.ts +++ b/src/domain/services/orchestrator-policy.test.ts @@ -101,6 +101,40 @@ describe('decideNextAction', () => { expect(action.reason).toBe(expected); }); + it('surfaces the recorded escalation reason in the halt message', () => { + const action = decide({ + epic: epic({ + subState: SubState.ESCALATED, + escalation: { + reason: 'ITERATIONS_EXHAUSTED', + message: 'Reviewer never cleared its blockers.', + artifactPath: 'AEOS-1-tech-spec-review.md', + }, + }), + }); + + expect(action.kind).toBe('halt'); + if (action.kind !== 'halt') return; + // The operator learns *why* without opening the artifact. + expect(action.message).toContain('ITERATIONS_EXHAUSTED'); + expect(action.message).toContain('Reviewer never cleared its blockers.'); + expect(action.message).toContain('AEOS-1-tech-spec-review.md'); + }); + + it.each([SubState.WORKING, SubState.IN_REVIEW])( + 'halts with an actionable message for a stuck %s epic', + (subState) => { + const action = decide({ epic: epic({ column: Column.TECH_SPEC, subState }) }); + + expect(action.kind).toBe('halt'); + if (action.kind !== 'halt') return; + expect(action.reason).toBe(HaltReason.NEEDS_HUMAN); + // The recovery is named, not the generic "no action for" fallback. + expect(action.message).toContain('aeos ticket ready AEOS-1'); + expect(action.message).not.toContain('no action for'); + }, + ); + it('halts at DOD_GATE — final sign-off is human-only', () => { const action = decide({ epic: epic({ column: Column.DOD_GATE, subState: SubState.READY }), diff --git a/src/domain/services/orchestrator-policy.ts b/src/domain/services/orchestrator-policy.ts index d42dd5a..a04ed8d 100644 --- a/src/domain/services/orchestrator-policy.ts +++ b/src/domain/services/orchestrator-policy.ts @@ -26,6 +26,8 @@ export const HaltReason = { BUDGET_EXCEEDED: 'BUDGET_EXCEEDED', /** A column is set to manual advance, so the operator must approve. */ AWAITING_APPROVAL: 'AWAITING_APPROVAL', + /** The operator interrupted the run (Ctrl+C); the in-flight ticket was stopped. */ + INTERRUPTED: 'INTERRUPTED', /** * The epic is decomposed but has no child tasks yet. * Creating them from `tasks.md` is still a human step. @@ -52,6 +54,14 @@ export interface OrchestratorPolicyInput { readonly autoAdvance: (column: Column) => boolean; } +/** Appends the recorded escalation reason to a halt message, when present. */ +function withEscalationDetail(base: string, ticket: Ticket): string { + const esc = ticket.escalation; + if (!esc) return base; + const artifact = esc.artifactPath ? ` See: ${esc.artifactPath}` : ''; + return `${base}\n Reason: ${esc.reason} — ${esc.message}${artifact}`; +} + /** Sub-states that mean "a human must look at this before work continues". */ function needsHuman(ticket: Ticket): HaltReason | null { if (ticket.subState === SubState.ESCALATED) return HaltReason.NEEDS_HUMAN; @@ -80,7 +90,10 @@ function decideForTicket( return { kind: 'halt', reason: halt, - message: `${ticket.id} is ${ticket.subState} in ${ticket.column} and needs a human before work continues.`, + message: withEscalationDetail( + `${ticket.id} is ${ticket.subState} in ${ticket.column} and needs a human before work continues.`, + ticket, + ), }; } @@ -107,6 +120,18 @@ function decideForTicket( }; } + // WORKING / IN_REVIEW mean a run is mid-flight — or was killed before it could + // transition (a hard crash or pre-graceful interrupt). The orchestrator can't + // tell a live run apart from an orphan, so it stops; but the recovery is + // concrete, so name it rather than falling through to the generic message. + if (ticket.subState === SubState.WORKING || ticket.subState === SubState.IN_REVIEW) { + return { + kind: 'halt', + reason: HaltReason.NEEDS_HUMAN, + message: `${ticket.id} is ${ticket.subState} in ${ticket.column} — a run is in progress, or one was interrupted before it finished. If no run is active, reset it with \`aeos ticket ready ${ticket.id}\` and re-run.`, + }; + } + return null; } @@ -149,7 +174,10 @@ export function decideNextAction(input: OrchestratorPolicyInput): OrchestratorAc return { kind: 'halt', reason: epicHalt, - message: `Epic ${epic.id} is ${epic.subState} in ${epic.column} and needs a human before work continues.`, + message: withEscalationDetail( + `Epic ${epic.id} is ${epic.subState} in ${epic.column} and needs a human before work continues.`, + epic, + ), }; } @@ -173,7 +201,10 @@ export function decideNextAction(input: OrchestratorPolicyInput): OrchestratorAc return { kind: 'halt', reason: childHalt, - message: `Task ${child.id} is ${child.subState} in ${child.column} and needs a human before the epic continues.`, + message: withEscalationDetail( + `Task ${child.id} is ${child.subState} in ${child.column} and needs a human before the epic continues.`, + child, + ), }; } } diff --git a/src/domain/services/state-machine.test.ts b/src/domain/services/state-machine.test.ts index 38e1200..b007718 100644 --- a/src/domain/services/state-machine.test.ts +++ b/src/domain/services/state-machine.test.ts @@ -80,6 +80,15 @@ class StubTicketRepository implements TicketRepository { } } + setEscalation(projectId: string, ticketId: string, escalation: Ticket['escalation']): void { + const ticket = this.findById(projectId, ticketId); + if (ticket) { + ticket.escalation = escalation ?? null; + ticket.updatedAt = new Date().toISOString(); + this.store.set(this.key(projectId, ticketId), ticket); + } + } + createAtomic(projectId: string, buildTicket: (nextNum: number) => Ticket): Ticket { const nextNum = this.nextId(projectId); const ticket = buildTicket(nextNum); diff --git a/src/infrastructure/filesystem/fs-project.repository.ts b/src/infrastructure/filesystem/fs-project.repository.ts index 539849a..df1d581 100644 --- a/src/infrastructure/filesystem/fs-project.repository.ts +++ b/src/infrastructure/filesystem/fs-project.repository.ts @@ -12,6 +12,7 @@ import { } from '../../shared/errors.js'; import { CONSTRAINTS_PLACEHOLDER } from './defaults/constraints-placeholder.js'; import { readTemplates } from './template-source.js'; +import { scaffoldSkill } from './skill-source.js'; const AEOS_DIR = '.aeos'; const PROJECT_JSON = 'project.json'; @@ -118,6 +119,9 @@ export class FsProjectRepository implements ProjectRepository { fs.writeFileSync(target, template.content, 'utf-8'); created.push(template.relativePath); } + + // The aeos skill lets agentic executors drive the CLI (task decomposition). + created.push(...scaffoldSkill(projectPath)); return created; } diff --git a/src/infrastructure/filesystem/fs-template-catalog.ts b/src/infrastructure/filesystem/fs-template-catalog.ts new file mode 100644 index 0000000..44f203d --- /dev/null +++ b/src/infrastructure/filesystem/fs-template-catalog.ts @@ -0,0 +1,44 @@ +// Adapter — filesystem implementation of TemplateCatalog. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { + TemplateCatalog, + TemplateEntry, +} from '../../domain/ports/driven/template-catalog.port.js'; +import { readTemplates } from './template-source.js'; + +const AEOS_DIR = '.aeos'; + +export class FsTemplateCatalog implements TemplateCatalog { + list(): TemplateEntry[] { + return readTemplates().map((t) => ({ relativePath: t.relativePath, content: t.content })); + } + + readProjectCopy(projectPath: string, relativePath: string): string | null { + const target = path.join(projectPath, AEOS_DIR, relativePath); + try { + return fs.readFileSync(target, 'utf-8'); + } catch { + return null; + } + } + + write(projectPath: string, relativePath: string, content: string): void { + const target = path.join(projectPath, AEOS_DIR, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, 'utf-8'); + } + + backup(projectPath: string, relativePath: string): string { + const target = path.join(projectPath, AEOS_DIR, relativePath); + const backupRelative = `${relativePath}.bak`; + const backupTarget = path.join(projectPath, AEOS_DIR, backupRelative); + // rename (not copy) so the subsequent write starts from a clean slate; an + // earlier .bak is overwritten — the newest local version is what matters. + fs.rmSync(backupTarget, { force: true }); + fs.renameSync(target, backupTarget); + return backupRelative; + } +} diff --git a/src/infrastructure/filesystem/skill-source.test.ts b/src/infrastructure/filesystem/skill-source.test.ts new file mode 100644 index 0000000..82d2dd6 --- /dev/null +++ b/src/infrastructure/filesystem/skill-source.test.ts @@ -0,0 +1,79 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { scaffoldSkill } from './skill-source.js'; + +describe('scaffoldSkill', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aeos-skill-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('writes a canonical skill and links it into each executor skills dir', () => { + const created = scaffoldSkill(tmpDir); + + const canonical = path.join(tmpDir, '.aeos', 'skills', 'aeos', 'SKILL.md'); + expect(fs.existsSync(canonical)).toBe(true); + expect(fs.readFileSync(canonical, 'utf-8')).toContain('aeos ticket create'); + + // Each executor's skills dir resolves to the same SKILL.md. + for (const dir of ['.claude/skills', '.augment/skills']) { + const linked = path.join(tmpDir, dir, 'aeos', 'SKILL.md'); + expect(fs.existsSync(linked), `${dir} not linked`).toBe(true); + expect(fs.readFileSync(linked, 'utf-8')).toContain('aeos ticket create'); + } + + expect(created).toContain('.aeos/skills/aeos'); + expect(created).toContain('.claude/skills/aeos'); + expect(created).toContain('.augment/skills/aeos'); + }); + + it('resolves the executor link to the canonical copy, not a separate file', () => { + scaffoldSkill(tmpDir); + + const canonical = path.join(tmpDir, '.aeos', 'skills', 'aeos', 'SKILL.md'); + // Editing the canonical copy is reflected through the link — one source of + // truth, which is the point of linking rather than copying. + fs.appendFileSync(canonical, '\nEDITED\n'); + + const linked = path.join(tmpDir, '.claude', 'skills', 'aeos', 'SKILL.md'); + if (fs.lstatSync(path.join(tmpDir, '.claude', 'skills', 'aeos')).isSymbolicLink()) { + expect(fs.readFileSync(linked, 'utf-8')).toContain('EDITED'); + } + }); + + it('is idempotent — a second call creates nothing', () => { + scaffoldSkill(tmpDir); + const second = scaffoldSkill(tmpDir); + + expect(second).toEqual([]); + }); + + it('creates the executor links as relative symlinks where supported', () => { + scaffoldSkill(tmpDir); + + const link = path.join(tmpDir, '.claude', 'skills', 'aeos'); + const stat = fs.lstatSync(link); + if (stat.isSymbolicLink()) { + // Relative, so the project stays portable if moved. + expect(fs.readlinkSync(link)).toBe(path.join('..', '..', '.aeos', 'skills', 'aeos')); + } + }); + + it('does not overwrite a canonical skill the user has edited', () => { + scaffoldSkill(tmpDir); + const canonical = path.join(tmpDir, '.aeos', 'skills', 'aeos', 'SKILL.md'); + fs.writeFileSync(canonical, 'my own skill\n', 'utf-8'); + + scaffoldSkill(tmpDir); + + expect(fs.readFileSync(canonical, 'utf-8')).toBe('my own skill\n'); + }); +}); diff --git a/src/infrastructure/filesystem/skill-source.ts b/src/infrastructure/filesystem/skill-source.ts new file mode 100644 index 0000000..ff93c1b --- /dev/null +++ b/src/infrastructure/filesystem/skill-source.ts @@ -0,0 +1,94 @@ +// Infrastructure — locates the shipped `aeos` agent skill and links it into +// each executor's skill directory. +// +// The skill teaches an agent to drive the aeos CLI (creating child tasks during +// decomposition, chiefly). One canonical copy lives in the project under +// .aeos/skills/, and each agentic executor's own skills directory links to it, +// so a single definition serves whichever backend runs — the executor +// abstraction stays intact. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const AEOS_DIR = '.aeos'; +const SKILLS_SUBDIR = 'skills'; +const SKILL_NAME = 'aeos'; + +/** + * Executor skill directories the skill is linked into, relative to the project + * root. This is *reinforcement*, not the load-bearing path: the decomposition + * instruction lives in the architect agent's taskInstruction, which reaches + * every executor through the prompt (see templates/agents/architect-agent.yaml). + * So a backend absent from this list — opencode-cli, or ollama-cli, which is + * artifact-only anyway — still gets the instruction and is not silently broken; + * it just lacks the skill's extra reference. Claude Code reads `.claude/skills`, + * Augment/auggie reads `.augment/skills`. Extend as other backends gain a + * skills mechanism. + */ +const EXECUTOR_SKILL_DIRS = ['.claude/skills', '.augment/skills'] as const; + +/** Package root is three levels up from this module, in both src/ and dist/. */ +function packagedSkillDir(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + const dir = path.resolve(here, '..', '..', '..', SKILLS_SUBDIR, SKILL_NAME); + // Fail loudly on a packaging fault, as template-source does — otherwise the + // copy below throws a cryptic ENOENT after templates are already written, + // leaving a half-scaffolded project. + if (!fs.existsSync(dir)) { + throw new Error( + `aeos skill not found at ${dir}. This is an AEOS packaging fault — reinstall, or run \`npm run build\` from source.`, + ); + } + return dir; +} + +/** + * Links a target directory to the canonical skill via a relative symlink, + * falling back to a copy where symlinks are unavailable (e.g. Windows without + * privilege). Skips a target that already points somewhere. + */ +function linkOrCopy(canonical: string, target: string): boolean { + if (fs.existsSync(target) || isSymlink(target)) return false; + + fs.mkdirSync(path.dirname(target), { recursive: true }); + const relative = path.relative(path.dirname(target), canonical); + try { + fs.symlinkSync(relative, target, 'dir'); + } catch { + fs.cpSync(canonical, target, { recursive: true }); + } + return true; +} + +function isSymlink(p: string): boolean { + try { + return fs.lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +} + +/** + * Scaffolds the aeos skill into a project and links it into each executor's + * skills directory. Idempotent: never overwrites an existing canonical copy or + * an existing executor link. Returns the paths it created, relative to root. + */ +export function scaffoldSkill(projectRoot: string): string[] { + const created: string[] = []; + const canonical = path.join(projectRoot, AEOS_DIR, SKILLS_SUBDIR, SKILL_NAME); + + if (!fs.existsSync(canonical)) { + fs.cpSync(packagedSkillDir(), canonical, { recursive: true }); + created.push(path.join(AEOS_DIR, SKILLS_SUBDIR, SKILL_NAME)); + } + + for (const dir of EXECUTOR_SKILL_DIRS) { + const target = path.join(projectRoot, dir, SKILL_NAME); + if (linkOrCopy(canonical, target)) { + created.push(path.join(dir, SKILL_NAME)); + } + } + + return created; +} diff --git a/src/infrastructure/persistence/database.ts b/src/infrastructure/persistence/database.ts index 4bebf3f..9682780 100644 --- a/src/infrastructure/persistence/database.ts +++ b/src/infrastructure/persistence/database.ts @@ -125,6 +125,38 @@ CREATE TABLE IF NOT EXISTS orchestrator_state ( `); }, }, + { + version: 4, + description: 'Add task_key for stable decomposition idempotency', + apply: (db) => { + if (!hasColumn(db, 'tickets', 'task_key')) { + db.exec(`ALTER TABLE tickets ADD COLUMN task_key TEXT DEFAULT NULL`); + } + db.exec( + `CREATE INDEX IF NOT EXISTS idx_tickets_task_key + ON tickets(project_id, parent_id, task_key)`, + ); + }, + }, + { + version: 5, + description: 'Persist escalation reason/message/artifact on tickets', + apply: (db) => { + // Why the reason lives on the ticket rather than only in the run event: + // the run emits the escalation once, live, then ends. An operator asking + // "why did this stall?" days later needs it queryable. These columns are + // cleared when the ticket leaves ESCALATED/BLOCKED (see updateSubState). + if (!hasColumn(db, 'tickets', 'escalation_reason')) { + db.exec(`ALTER TABLE tickets ADD COLUMN escalation_reason TEXT DEFAULT NULL`); + } + if (!hasColumn(db, 'tickets', 'escalation_message')) { + db.exec(`ALTER TABLE tickets ADD COLUMN escalation_message TEXT DEFAULT NULL`); + } + if (!hasColumn(db, 'tickets', 'escalation_artifact')) { + db.exec(`ALTER TABLE tickets ADD COLUMN escalation_artifact TEXT DEFAULT NULL`); + } + }, + }, // ── Future migrations go here ────────────────────────────────── ]; diff --git a/src/infrastructure/persistence/sqlite-ticket.repository.test.ts b/src/infrastructure/persistence/sqlite-ticket.repository.test.ts new file mode 100644 index 0000000..1fc26da --- /dev/null +++ b/src/infrastructure/persistence/sqlite-ticket.repository.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import type BetterSqlite3 from 'better-sqlite3'; + +import { initSchema, resetDb } from './database.js'; +import { SqliteTicketRepository } from './sqlite-ticket.repository.js'; +import { Column } from '../../domain/model/column.js'; +import { SubState } from '../../domain/model/sub-state.js'; +import { TicketKind } from '../../domain/model/ticket-kind.js'; +import { EscalationReason } from '../../domain/model/escalation.js'; +import type { Ticket } from '../../domain/model/ticket.js'; + +let db: BetterSqlite3.Database; +let repo: SqliteTicketRepository; + +function ticket(overrides: Partial<Ticket> = {}): Ticket { + const now = new Date().toISOString(); + return { + id: 'AEOS-1', + projectId: 'p', + title: 'Epic', + kind: TicketKind.EPIC, + parentId: null, + column: Column.TECH_SPEC, + subState: SubState.WORKING, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +beforeEach(() => { + // initSchema short-circuits on a module-level flag; reset it so each fresh + // in-memory db actually gets migrated. + resetDb(); + db = new Database(':memory:'); + initSchema(db); + repo = new SqliteTicketRepository(db); +}); + +afterEach(() => { + db.close(); + resetDb(); +}); + +describe('SqliteTicketRepository — escalation', () => { + it('round-trips a recorded escalation', () => { + repo.save(ticket({ subState: SubState.ESCALATED })); + repo.setEscalation('p', 'AEOS-1', { + reason: EscalationReason.ITERATIONS_EXHAUSTED, + message: 'Reviewer never cleared its blockers.', + artifactPath: 'AEOS-1-tech-spec-review.md', + }); + + const found = repo.findById('p', 'AEOS-1'); + expect(found?.escalation).toEqual({ + reason: EscalationReason.ITERATIONS_EXHAUSTED, + message: 'Reviewer never cleared its blockers.', + artifactPath: 'AEOS-1-tech-spec-review.md', + }); + }); + + it('has no escalation on a freshly saved ticket', () => { + repo.save(ticket()); + expect(repo.findById('p', 'AEOS-1')?.escalation).toBeNull(); + }); + + it('retains the escalation while the ticket stays ESCALATED', () => { + repo.save(ticket({ subState: SubState.ESCALATED })); + repo.setEscalation('p', 'AEOS-1', { + reason: EscalationReason.NOT_CONVERGING, + message: 'Same blockers each attempt.', + }); + + // Re-affirming ESCALATED (or moving to BLOCKED) must not wipe the reason. + repo.updateSubState('p', 'AEOS-1', SubState.ESCALATED); + expect(repo.findById('p', 'AEOS-1')?.escalation?.reason).toBe(EscalationReason.NOT_CONVERGING); + }); + + it('clears the escalation when the ticket returns to READY', () => { + repo.save(ticket({ subState: SubState.ESCALATED })); + repo.setEscalation('p', 'AEOS-1', { + reason: EscalationReason.NOT_CONVERGING, + message: 'Same blockers each attempt.', + }); + + repo.updateSubState('p', 'AEOS-1', SubState.READY); + expect(repo.findById('p', 'AEOS-1')?.escalation).toBeNull(); + }); + + it('clears the escalation via setEscalation(null)', () => { + repo.save(ticket({ subState: SubState.ESCALATED })); + repo.setEscalation('p', 'AEOS-1', { + reason: EscalationReason.UNPARSEABLE_VERDICT, + message: 'No verdict trailer.', + }); + + repo.setEscalation('p', 'AEOS-1', null); + expect(repo.findById('p', 'AEOS-1')?.escalation).toBeNull(); + }); +}); diff --git a/src/infrastructure/persistence/sqlite-ticket.repository.ts b/src/infrastructure/persistence/sqlite-ticket.repository.ts index e46c0d7..8490d78 100644 --- a/src/infrastructure/persistence/sqlite-ticket.repository.ts +++ b/src/infrastructure/persistence/sqlite-ticket.repository.ts @@ -2,12 +2,14 @@ import type BetterSqlite3 from 'better-sqlite3'; import type { Column } from '../../domain/model/column.js'; -import type { Ticket } from '../../domain/model/ticket.js'; +import type { Ticket, TicketEscalation } from '../../domain/model/ticket.js'; +import type { EscalationReason } from '../../domain/model/escalation.js'; +import { SubState } from '../../domain/model/sub-state.js'; import type { SubStateOrNull } from '../../domain/model/sub-state.js'; import type { TicketKind } from '../../domain/model/ticket-kind.js'; import type { TicketRepository } from '../../domain/ports/driven/ticket-repository.port.js'; -const TICKET_COLUMNS = `id, project_id, title, kind, parent_id, "column", sub_state, created_at, updated_at`; +const TICKET_COLUMNS = `id, project_id, title, kind, parent_id, task_key, "column", sub_state, created_at, updated_at, escalation_reason, escalation_message, escalation_artifact`; interface TicketRow { id: string; @@ -15,10 +17,14 @@ interface TicketRow { title: string; kind: TicketKind; parent_id: string | null; + task_key: string | null; column: Column; sub_state: string | null; created_at: string; updated_at: string; + escalation_reason: string | null; + escalation_message: string | null; + escalation_artifact: string | null; } export class SqliteTicketRepository implements TicketRepository { @@ -32,10 +38,18 @@ export class SqliteTicketRepository implements TicketRepository { // Rows written before the hierarchy migration are epics by definition. kind: row.kind ?? 'EPIC', parentId: row.parent_id ?? null, + taskKey: row.task_key ?? null, column: row.column, subState: (row.sub_state as SubStateOrNull) ?? null, createdAt: row.created_at, updatedAt: row.updated_at, + escalation: row.escalation_reason + ? { + reason: row.escalation_reason as EscalationReason, + message: row.escalation_message ?? '', + artifactPath: row.escalation_artifact, + } + : null, }; } @@ -52,8 +66,8 @@ export class SqliteTicketRepository implements TicketRepository { save(ticket: Ticket): void { this.db .prepare( - `INSERT INTO tickets (id, project_id, title, kind, parent_id, "column", sub_state, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO tickets (id, project_id, title, kind, parent_id, task_key, "column", sub_state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( ticket.id, @@ -61,6 +75,7 @@ export class SqliteTicketRepository implements TicketRepository { ticket.title, ticket.kind, ticket.parentId, + ticket.taskKey ?? null, ticket.column, ticket.subState, ticket.createdAt, @@ -132,8 +147,40 @@ export class SqliteTicketRepository implements TicketRepository { } updateSubState(projectId: string, ticketId: string, subState: SubStateOrNull): void { + // A ticket that has moved off ESCALATED/BLOCKED is no longer stalled for the + // recorded reason, so drop it in the same write. Keeping it would let + // `ticket show` report a stale "why" against a READY/WORKING ticket. + const retainEscalation = subState === SubState.ESCALATED || subState === SubState.BLOCKED; + if (retainEscalation) { + this.db + .prepare(`UPDATE tickets SET sub_state = ?, updated_at = ? WHERE project_id = ? AND id = ?`) + .run(subState, new Date().toISOString(), projectId, ticketId); + } else { + this.db + .prepare( + `UPDATE tickets + SET sub_state = ?, updated_at = ?, + escalation_reason = NULL, escalation_message = NULL, escalation_artifact = NULL + WHERE project_id = ? AND id = ?`, + ) + .run(subState, new Date().toISOString(), projectId, ticketId); + } + } + + setEscalation(projectId: string, ticketId: string, escalation: TicketEscalation | null): void { this.db - .prepare(`UPDATE tickets SET sub_state = ?, updated_at = ? WHERE project_id = ? AND id = ?`) - .run(subState, new Date().toISOString(), projectId, ticketId); + .prepare( + `UPDATE tickets + SET escalation_reason = ?, escalation_message = ?, escalation_artifact = ?, updated_at = ? + WHERE project_id = ? AND id = ?`, + ) + .run( + escalation?.reason ?? null, + escalation?.message ?? null, + escalation?.artifactPath ?? null, + new Date().toISOString(), + projectId, + ticketId, + ); } } diff --git a/src/infrastructure/spec-loader/schemas.ts b/src/infrastructure/spec-loader/schemas.ts index b0e5d1c..d2e3bc3 100644 --- a/src/infrastructure/spec-loader/schemas.ts +++ b/src/infrastructure/spec-loader/schemas.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; export const ColumnSpecSchema = z.object({ column: z.string().min(1), executorMode: z.enum(['artifact', 'agentic']).optional(), + requiresRepoDiff: z.boolean().optional(), workerAgentFile: z.string().min(1), reviewerAgentFile: z.string().min(1), outputArtifact: z.string().min(1), diff --git a/templates/agents/architect-agent.yaml b/templates/agents/architect-agent.yaml index 95ebeca..87342fb 100644 --- a/templates/agents/architect-agent.yaml +++ b/templates/agents/architect-agent.yaml @@ -32,9 +32,67 @@ taskInstruction: | ### When running in TASK_BREAKDOWN - Decompose the tech spec into atomic tasks as a single Markdown file. Each task - becomes a child ticket that runs implementation → code review → QA on its own, - so the decomposition is a contract, not a sketch. + This column is agentic: you both produce the breakdown AND create the child + tickets by calling the aeos CLI. + + The `aeos` CLI is pre-authorized in this session — the Bash tool is granted and + `aeos ticket create` runs without any approval step. Run it directly. Do NOT + ask for permission, do NOT say a command "requires approval" or "was not + granted", do NOT wait to be re-approved, and do NOT narrate tool-permission + limitations in your output. If you find yourself about to write "re-approve + aeos and I'll create the tickets", stop and just run the commands now. Creating + the tickets is not optional — a breakdown with no child tickets is a failed run. + + Two deliverables: + + 1. Produce the `tasks.md` breakdown as your response text, in the format + below. Your printed output IS the artifact AEOS records and the reviewer + evaluates — do not write it to a file with a tool; a file you write is + ignored, only what you print is captured. + 2. Create one child ticket per task under the epic you are decomposing, + passing the task's `T-NNN` key so retries stay idempotent **and the task's + full content via `--body-file`**. A title alone is not enough: the engineer + that implements the task sees only this ticket, so it must carry the task's + Description, Acceptance criteria, Depends on, Touches, and Out of scope — + not just its title. + + For each task, first write its body block to a temp file, then create the + ticket pointing at that file: + + ```sh + # Write the task's full markdown block (everything under the "### T-001" + # heading — Depends on, Touches, Description, Acceptance criteria, Out of + # scope) to a temp file: + cat > /tmp/<EPIC_ID>-T-001.md <<'BODY' + **Depends on:** none + **Touches:** path/to/file.ts + + **Description** + ... + + **Acceptance criteria** + - [ ] ... + + **Out of scope** + ... + BODY + aeos ticket create "First task title" --parent <EPIC_ID> --key T-001 --body-file /tmp/<EPIC_ID>-T-001.md + ``` + + (Small bodies may instead be passed inline with `--body "<markdown>"`, but + prefer `--body-file` — it avoids shell-quoting mistakes on multi-line text.) + + `<EPIC_ID>` is the ID in your context's `# Ticket: <ID>` heading. Make one + create call per task, and give **each task its own distinct `--key`** — the + task's `T-NNN` label from `tasks.md`. Reusing a key across two tasks silently + drops the second (it is treated as the same task). Idempotency keys on + `--key`, not the title: on a later attempt keep each task's key stable, and + keep its title stable too — a matching key leaves the existing ticket as is, + so a reworded title (or body) is not applied. Always create the full set; do + not try to detect what already exists. See the `aeos` skill for details. + + Each task becomes a child ticket that runs implementation → code review → QA on + its own, so the decomposition is a contract, not a sketch. A task is atomic when: 1. One engineer can complete it in a single focused sitting. @@ -55,7 +113,15 @@ taskInstruction: | ### When running in TECH_SPEC - Produce a detailed technical specification as a single Markdown file. The tech spec translates the architectural decisions recorded in this spec into an actionable implementation plan. It must: + Produce the technical specification **as your response text**, in the format + below. Your printed output IS the artifact AEOS records and the reviewer + evaluates — do NOT use Write, Edit, or any file-writing tool. A file you write + is ignored (and in this column the tool call is denied outright); only what you + print is captured. Print the complete spec in one response — do not summarise + it, promise it separately, or narrate tool limitations. If you find yourself + writing "let me output this directly instead", just output the full spec. + + The tech spec translates the architectural decisions recorded in this spec into an actionable implementation plan. It must: 1. Define the component architecture with clear module boundaries and dependency direction. 2. Specify API contracts (endpoints, request/response schemas, error codes) where applicable. 3. Define data models, storage schemas, and migration strategy where applicable. diff --git a/templates/column-specs/task-breakdown.yaml b/templates/column-specs/task-breakdown.yaml index d669f6a..4db49b7 100644 --- a/templates/column-specs/task-breakdown.yaml +++ b/templates/column-specs/task-breakdown.yaml @@ -1,5 +1,10 @@ column: TASK_BREAKDOWN -executorMode: artifact +# Agentic so the architect can call `aeos ticket create --parent` to decompose +# the epic into child tasks. requiresRepoDiff is false because that work writes +# tickets and a gitignored artifact, not a repo change — the IMPLEMENTATION +# diff guarantee does not apply here. +executorMode: agentic +requiresRepoDiff: false workerAgentFile: agents/architect-agent.yaml reviewerAgentFile: agents/reviewer-agent.yaml outputArtifact: tasks.md