From da224a845cac536d059db0800524fbcf5a162ed1 Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski Date: Wed, 22 Jul 2026 12:02:05 +0200 Subject: [PATCH 01/14] feat: agent-driven task decomposition via the aeos skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK_BREAKDOWN previously stopped at AWAITING_DECOMPOSITION and required a human to retype the breakdown into `ticket create --parent` calls. The architect now creates the child tickets itself. Approach: rather than parsing tasks out of the artifact, ship an `aeos` skill that teaches an agentic executor to call the CLI. One canonical copy lives at .aeos/skills/aeos/, relative-symlinked into each executor's skills dir (.claude/skills, .augment/skills) by `project init`, so a single definition serves whichever backend runs — the executor abstraction stays intact. Falls back to a copy where symlinks are unavailable. TASK_BREAKDOWN becomes agentic; the architect writes tasks.md and calls `aeos ticket create "" --parent <epic>` per task. Three supporting changes make that safe: - `ticket create` is idempotent by (parent, title): a matching child short-circuits to the existing ticket. The review loop may retry the breakdown, and a retry must not duplicate tasks. - New column-spec flag `requiresRepoDiff` (default true) decouples "needs agentic/tool execution" from "must leave a repo diff". TASK_BREAKDOWN sets it false — creating tickets and writing a gitignored artifact is not a repo edit, so the IMPLEMENTATION diff guarantee must not fail it. - The orchestrator needs no decomposition step: children created during the run are picked up by its existing drive-all-children logic on the next tick. AWAITING_DECOMPOSITION remains the fallback when none appear (AEOS_EXECUTOR=stub can't shell out, so offline runs still halt there). Verified end to end: a fresh `project init` links the skill into both executor dirs, and creating the same task twice under an epic short-circuits to the existing ticket. 620 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- CLAUDE.md | 2 + package.json | 1 + skills/aeos/SKILL.md | 65 +++++++++++++ .../ticket-create.use-case.test.ts | 87 ++++++++++++++++++ src/application/ticket-create.use-case.ts | 19 +++- src/application/ticket-run.use-case.test.ts | 27 ++++++ src/application/ticket-run.use-case.ts | 5 +- src/cli/commands/project-init.command.ts | 2 +- src/cli/commands/ticket-create.command.ts | 11 ++- src/domain/model/column-spec.ts | 9 ++ .../ports/driving/ticket-create.port.ts | 2 + .../filesystem/fs-project.repository.ts | 4 + .../filesystem/skill-source.test.ts | 68 ++++++++++++++ src/infrastructure/filesystem/skill-source.ts | 91 +++++++++++++++++++ src/infrastructure/spec-loader/schemas.ts | 1 + templates/agents/architect-agent.yaml | 21 ++++- templates/column-specs/task-breakdown.yaml | 7 +- 17 files changed, 413 insertions(+), 9 deletions(-) create mode 100644 skills/aeos/SKILL.md create mode 100644 src/infrastructure/filesystem/skill-source.test.ts create mode 100644 src/infrastructure/filesystem/skill-source.ts diff --git a/CLAUDE.md b/CLAUDE.md index b742b48..d34ca61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,8 @@ 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`), so one definition serves whichever backend runs. This is how `TASK_BREAKDOWN` decomposition works: that column is **agentic**, and the architect calls `aeos ticket create --parent <epic>` to create the child tasks itself rather than AEOS parsing them out of an artifact. `ticket create` is idempotent by (parent, title) so a review-loop retry can't duplicate tasks, and `TASK_BREAKDOWN` sets `requiresRepoDiff: false` because creating tickets leaves no repo diff (the agentic diff check otherwise fails it). 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). + ### 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..e0fb382 --- /dev/null +++ b/skills/aeos/SKILL.md @@ -0,0 +1,65 @@ +--- +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` artifact at your given output path — the human-readable + breakdown, in the format your agent spec defines. +2. One child ticket per task, created by calling the CLI. + +Create each task as a child of the epic you are decomposing: + +```sh +aeos ticket create "Add password hashing" --parent <EPIC_ID> +aeos ticket create "Add session middleware" --parent <EPIC_ID> +``` + +`<EPIC_ID>` is the ID of the ticket in your context — the `# Ticket: <ID>` +heading in the ticket document (e.g. `AEOS-1`). Use that exact ID. + +Rules: + +- **One `ticket create` call per task in your breakdown.** The titles must match + the task titles in `tasks.md` so the two stay in correspondence. +- **Creating a task is idempotent.** If a child with the same title already + exists under the epic, the command leaves it as is and prints `= ... already + exists`. This means re-running after a rejected review will not duplicate + tasks — always create the full set, do not try to detect what already exists. +- **Do not create tasks under a task.** Nesting is one level deep; `--parent` + must always be an epic. +- Keep titles short and imperative — they become ticket titles. + +After writing `tasks.md` and creating the child tickets, your column output is +complete. The reviewer evaluates `tasks.md`; the orchestrator picks up the +children you created and drives each through its own build pipeline. + +## Other useful commands + +```sh +aeos ticket list # tickets in this project; tasks nest under their epic +aeos ticket show <ID> # kind, parent/children, column, state, artifacts +aeos ticket create "<title>" # a new epic (no --parent) +``` + +Do **not** run `aeos ticket run`, `aeos ticket approve`, or `aeos orchestrator` +from inside a column — those drive the pipeline and would re-enter it. Your job +is to produce this column's output; advancing is the operator's or +orchestrator's concern. diff --git a/src/application/ticket-create.use-case.test.ts b/src/application/ticket-create.use-case.test.ts index 4737194..fac4da4 100644 --- a/src/application/ticket-create.use-case.test.ts +++ b/src/application/ticket-create.use-case.test.ts @@ -76,6 +76,7 @@ describe('TicketCreateUseCase', () => { // No parent supplied, so this is a top-level epic. kind: 'EPIC', parentId: null, + alreadyExisted: false, }); }); @@ -190,4 +191,90 @@ 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(); + }); + }); }); diff --git a/src/application/ticket-create.use-case.ts b/src/application/ticket-create.use-case.ts index 7185ae7..021d655 100644 --- a/src/application/ticket-create.use-case.ts +++ b/src/application/ticket-create.use-case.ts @@ -34,6 +34,23 @@ export class TicketCreateUseCase implements TicketCreatePort { `Parent ${parentId} is a ${parent.kind}; tasks may only hang off an EPIC (one level of nesting).`, ); } + + // Idempotent by (parent, title): decomposition creates tasks during an + // agentic run, which the review loop may retry. Re-running must not + // duplicate a task the previous attempt already created, so a matching + // child short-circuits to the existing ticket rather than a new one. + const existing = this.ticketRepo + .findChildren(projectId, parentId) + .find((child) => child.title.trim().toLowerCase() === title.trim().toLowerCase()); + if (existing) { + return { + ticketId: existing.id, + title: existing.title, + kind: existing.kind, + parentId: existing.parentId, + alreadyExisted: true, + }; + } } // 1. Atomically allocate ID + insert in a single transaction (prevents race conditions) @@ -78,6 +95,6 @@ export class TicketCreateUseCase implements TicketCreatePort { } // 6. Return result - return { ticketId, title, kind, parentId: parentId ?? null }; + return { ticketId, title, kind, parentId: parentId ?? null, alreadyExisted: false }; } } diff --git a/src/application/ticket-run.use-case.test.ts b/src/application/ticket-run.use-case.test.ts index 3c96025..b48bb3b 100644 --- a/src/application/ticket-run.use-case.test.ts +++ b/src/application/ticket-run.use-case.test.ts @@ -568,6 +568,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..841fdd3 100644 --- a/src/application/ticket-run.use-case.ts +++ b/src/application/ticket-run.use-case.ts @@ -576,7 +576,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'; 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/ticket-create.command.ts b/src/cli/commands/ticket-create.command.ts index 457cf24..7800993 100644 --- a/src/cli/commands/ticket-create.command.ts +++ b/src/cli/commands/ticket-create.command.ts @@ -44,8 +44,15 @@ export function registerTicketCreateCommand( }); const lineage = result.parentId ? ` (task of ${result.parentId})` : ''; - // eslint-disable-next-line no-console - console.log(`✓ Created ${result.kind} ${result.ticketId}: "${result.title}"${lineage}`); + 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 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/ports/driving/ticket-create.port.ts b/src/domain/ports/driving/ticket-create.port.ts index 42580fb..18a14a9 100644 --- a/src/domain/ports/driving/ticket-create.port.ts +++ b/src/domain/ports/driving/ticket-create.port.ts @@ -20,6 +20,8 @@ export interface TicketCreateResult { title: string; kind: TicketKind; parentId: string | null; + /** True when a child with this title already existed under the parent. */ + alreadyExisted: boolean; } export interface TicketCreatePort { 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/skill-source.test.ts b/src/infrastructure/filesystem/skill-source.test.ts new file mode 100644 index 0000000..a460c17 --- /dev/null +++ b/src/infrastructure/filesystem/skill-source.test.ts @@ -0,0 +1,68 @@ +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('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..71e55a7 --- /dev/null +++ b/src/infrastructure/filesystem/skill-source.ts @@ -0,0 +1,91 @@ +// 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'; + +/** + * Where each agentic executor discovers skills, relative to the project root. + * + * Claude Code reads `.claude/skills`, Augment/auggie reads `.augment/skills`. + * ollama-cli is artifact-only (no tools, no skills) and is intentionally + * absent. Extend this list 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)); + return path.resolve(here, '..', '..', '..', SKILLS_SUBDIR, SKILL_NAME); +} + +function copyDir(from: string, to: string): void { + fs.mkdirSync(to, { recursive: true }); + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const src = path.join(from, entry.name); + const dst = path.join(to, entry.name); + if (entry.isDirectory()) copyDir(src, dst); + else if (entry.isFile()) fs.copyFileSync(src, dst); + } +} + +/** + * 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 { + copyDir(canonical, target); + } + 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)) { + copyDir(packagedSkillDir(), canonical); + 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/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..78fb79a 100644 --- a/templates/agents/architect-agent.yaml +++ b/templates/agents/architect-agent.yaml @@ -32,9 +32,24 @@ 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 write the breakdown AND create the child + tickets by calling the aeos CLI. Two deliverables: + + 1. Write the `tasks.md` artifact at your output path (format below). + 2. Create one child ticket per task under the epic you are decomposing: + + ```sh + aeos ticket create "<task title>" --parent <EPIC_ID> + ``` + + `<EPIC_ID>` is the ID in your context's `# Ticket: <ID>` heading. Make one + call per task; the titles must match the tasks in `tasks.md`. Creating a + task is idempotent — if a matching child already exists it is left as is — + so 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. 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 From c2325adc5ec74620645a6fb2387cfc347ef4b0ec Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 12:53:09 +0200 Subject: [PATCH 02/14] fix: address code-review findings on decomposition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the review of the decomposition PR. 1. Portability (skill-source.ts): the finding was that opencode-cli gets no skill link and would silently stall. In fact the decomposition instruction is carried by the architect's taskInstruction (the prompt), which reaches every executor — the skill is only reinforcement. Corrected the comment to document the portable carrier so this isn't misread again. 2. Idempotency keyed on title, but the retry it guards against is exactly when titles drift: the review loop re-runs the agent, which may rephrase a task's wording, and title-only dedup would then duplicate it. Added a stable decomposition key — `aeos ticket create --key T-NNN`, persisted as task_key (migration v4, idempotent), matched under a parent in preference to the title. The architect and skill now pass the T-NNN label. Falls back to title when no key is given. 3. Tickets are created mid-run before review, so an abandoned breakdown leaves orphan BACKLOG children that block the epic's DONE-join. The key fix stops retries from accumulating (same keys reuse); a genuinely removed task is cleared by the operator via `ticket move <id> DONE`, which the join's blocker list points at. Auto-deleting user tickets on a breakdown change is deliberately avoided. Documented. 4. skill-source had no packaging guard, unlike the sibling template-source: a missing skills/ dir would throw a cryptic ENOENT mid-scaffold. Added an explicit packaging-fault error. Verified end to end: a task re-created with a rephrased title but the same --key short-circuits to the existing ticket; migration v4 applies cleanly to a v3 database and is idempotent. 624 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- CLAUDE.md | 4 +- skills/aeos/SKILL.md | 21 +++--- .../ticket-create.use-case.test.ts | 73 +++++++++++++++++++ src/application/ticket-create.use-case.ts | 30 +++++--- src/cli/commands/ticket-create.command.ts | 7 +- src/domain/model/ticket.ts | 8 ++ .../ports/driving/ticket-create.port.ts | 10 ++- .../filesystem/skill-source.test.ts | 11 +++ src/infrastructure/filesystem/skill-source.ts | 25 +++++-- src/infrastructure/persistence/database.ts | 13 ++++ .../persistence/sqlite-ticket.repository.ts | 9 ++- templates/agents/architect-agent.yaml | 14 ++-- 12 files changed, 189 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d34ca61..94a3bdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,9 @@ 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`), so one definition serves whichever backend runs. This is how `TASK_BREAKDOWN` decomposition works: that column is **agentic**, and the architect calls `aeos ticket create --parent <epic>` to create the child tasks itself rather than AEOS parsing them out of an artifact. `ticket create` is idempotent by (parent, title) so a review-loop retry can't duplicate tasks, and `TASK_BREAKDOWN` sets `requiresRepoDiff: false` because creating tickets leaves no repo diff (the agentic diff check otherwise fails it). 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). +`skills/aeos/SKILL.md` (also shipped via `files`) teaches an agentic executor to drive the `aeos` CLI. `project init` copies it to `.aeos/skills/aeos/` and relative-symlinks it into each executor's skills dir (`.claude/skills`, `.augment/skills` — see `skill-source.ts`). The **portable** carrier of the decomposition instruction is the architect's `taskInstruction` (in the prompt, so every executor gets it); the skill is reinforcement for skill-aware backends. This is how `TASK_BREAKDOWN` decomposition works: that column is **agentic**, and the architect calls `aeos ticket create --parent <epic> --key T-NNN` to create the child tasks itself rather than AEOS parsing them out of an artifact. `requiresRepoDiff: false` on that column stops the agentic diff check (which enforces the IMPLEMENTATION guarantee) from failing a run that creates tickets rather than editing the repo. + +`ticket create` is idempotent under a parent: it keys on `--key` (the task's stable `T-NNN` label) when given, else the title. 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). The orchestrator needs no special decomposition step: children created during the run are picked up by its existing "drive all children" logic on the next tick; `AWAITING_DECOMPOSITION` remains only as the fallback when none appear (e.g. under `AEOS_EXECUTOR=stub`, which can't shell out). Tickets are created mid-run, before the breakdown is reviewed — the key keeps retries from accumulating, but a breakdown that is _abandoned_ after removing a task leaves an orphan child in `BACKLOG`; the epic's DONE-join lists it, and the operator clears it with `aeos ticket move <id> DONE`. Auto-deleting is deliberately avoided. ### State diff --git a/skills/aeos/SKILL.md b/skills/aeos/SKILL.md index e0fb382..0119218 100644 --- a/skills/aeos/SKILL.md +++ b/skills/aeos/SKILL.md @@ -25,11 +25,12 @@ When you run the `TASK_BREAKDOWN` column you produce two things: breakdown, in the format your agent spec defines. 2. One child ticket per task, created by calling the CLI. -Create each task as a child of the epic you are decomposing: +Create each task as a child of the epic you are decomposing, passing its +`T-NNN` key from `tasks.md` with `--key`: ```sh -aeos ticket create "Add password hashing" --parent <EPIC_ID> -aeos ticket create "Add session middleware" --parent <EPIC_ID> +aeos ticket create "Add password hashing" --parent <EPIC_ID> --key T-001 +aeos ticket create "Add session middleware" --parent <EPIC_ID> --key T-002 ``` `<EPIC_ID>` is the ID of the ticket in your context — the `# Ticket: <ID>` @@ -37,12 +38,14 @@ heading in the ticket document (e.g. `AEOS-1`). Use that exact ID. Rules: -- **One `ticket create` call per task in your breakdown.** The titles must match - the task titles in `tasks.md` so the two stay in correspondence. -- **Creating a task is idempotent.** If a child with the same title already - exists under the epic, the command leaves it as is and prints `= ... already - exists`. This means re-running after a rejected review will not duplicate - tasks — always create the full set, do not try to detect what already exists. +- **One `ticket create` call per task in your breakdown**, each with its + `--key T-NNN` matching the task's label in `tasks.md`. +- **Creating a task is idempotent, keyed on `--key`.** If a child with the same + key already exists under the epic, the command leaves it as is and prints + `= ... already exists`. Keying on the label rather than the title means a + retry that rephrases a task's wording 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. diff --git a/src/application/ticket-create.use-case.test.ts b/src/application/ticket-create.use-case.test.ts index fac4da4..34cb087 100644 --- a/src/application/ticket-create.use-case.test.ts +++ b/src/application/ticket-create.use-case.test.ts @@ -76,6 +76,7 @@ describe('TicketCreateUseCase', () => { // No parent supplied, so this is a top-level epic. kind: 'EPIC', parentId: null, + taskKey: null, alreadyExisted: false, }); }); @@ -276,5 +277,77 @@ describe('TicketCreateUseCase', () => { 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('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 021d655..fbc775f 100644 --- a/src/application/ticket-create.use-case.ts +++ b/src/application/ticket-create.use-case.ts @@ -19,7 +19,7 @@ export class TicketCreateUseCase implements TicketCreatePort { ) {} execute(input: TicketCreateInput): TicketCreateResult { - const { title, projectId, projectKey, projectPath, parentId } = input; + const { title, projectId, projectKey, projectPath, parentId, taskKey } = input; // A parent makes this a task; without one it is an epic. const kind = parentId ? TicketKind.TASK : TicketKind.EPIC; @@ -35,19 +35,23 @@ export class TicketCreateUseCase implements TicketCreatePort { ); } - // Idempotent by (parent, title): decomposition creates tasks during an - // agentic run, which the review loop may retry. Re-running must not - // duplicate a task the previous attempt already created, so a matching - // child short-circuits to the existing ticket rather than a new one. - const existing = this.ticketRepo - .findChildren(projectId, parentId) - .find((child) => child.title.trim().toLowerCase() === title.trim().toLowerCase()); + // Idempotent: decomposition creates tasks during an agentic run, which the + // review loop may retry. A matching child short-circuits rather than + // duplicating. When a stable key is given, match on it — a retry may + // rephrase the title, so keying on title alone would miss and duplicate. + // Without a key, fall back to title. + const children = this.ticketRepo.findChildren(projectId, parentId); + const key = taskKey?.trim(); + const existing = key + ? children.find((child) => child.taskKey?.trim().toLowerCase() === key.toLowerCase()) + : children.find((child) => child.title.trim().toLowerCase() === title.trim().toLowerCase()); if (existing) { return { ticketId: existing.id, title: existing.title, kind: existing.kind, parentId: existing.parentId, + taskKey: existing.taskKey ?? null, alreadyExisted: true, }; } @@ -63,6 +67,7 @@ export class TicketCreateUseCase implements TicketCreatePort { title, kind, parentId: parentId ?? null, + taskKey: taskKey?.trim() || null, column: 'BACKLOG' as const, subState: null, createdAt: now, @@ -95,6 +100,13 @@ export class TicketCreateUseCase implements TicketCreatePort { } // 6. Return result - return { ticketId, title, kind, parentId: parentId ?? null, alreadyExisted: false }; + return { + ticketId, + title, + kind, + parentId: parentId ?? null, + taskKey: taskKey?.trim() || null, + alreadyExisted: false, + }; } } diff --git a/src/cli/commands/ticket-create.command.ts b/src/cli/commands/ticket-create.command.ts index 7800993..18fe608 100644 --- a/src/cli/commands/ticket-create.command.ts +++ b/src/cli/commands/ticket-create.command.ts @@ -21,7 +21,11 @@ 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 }) => { + .option( + '--key <taskKey>', + 'Stable decomposition key (e.g. T-001); idempotency matches on it instead of the title', + ) + .action((title: string, options: { parent?: string; key?: string }) => { try { const cwd = process.cwd(); const projectPath = projectRepo.findRoot(cwd); @@ -41,6 +45,7 @@ export function registerTicketCreateCommand( projectKey: project.key, projectPath, parentId: options.parent, + taskKey: options.key, }); const lineage = result.parentId ? ` (task of ${result.parentId})` : ''; diff --git a/src/domain/model/ticket.ts b/src/domain/model/ticket.ts index 5286e69..1be1cb1 100644 --- a/src/domain/model/ticket.ts +++ b/src/domain/model/ticket.ts @@ -15,6 +15,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 */ diff --git a/src/domain/ports/driving/ticket-create.port.ts b/src/domain/ports/driving/ticket-create.port.ts index 18a14a9..452fa2d 100644 --- a/src/domain/ports/driving/ticket-create.port.ts +++ b/src/domain/ports/driving/ticket-create.port.ts @@ -13,6 +13,13 @@ 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; } export interface TicketCreateResult { @@ -20,7 +27,8 @@ export interface TicketCreateResult { title: string; kind: TicketKind; parentId: string | null; - /** True when a child with this title already existed under the parent. */ + taskKey: string | null; + /** True when a matching child (by key, else title) already existed under the parent. */ alreadyExisted: boolean; } diff --git a/src/infrastructure/filesystem/skill-source.test.ts b/src/infrastructure/filesystem/skill-source.test.ts index a460c17..82d2dd6 100644 --- a/src/infrastructure/filesystem/skill-source.test.ts +++ b/src/infrastructure/filesystem/skill-source.test.ts @@ -56,6 +56,17 @@ describe('scaffoldSkill', () => { 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'); diff --git a/src/infrastructure/filesystem/skill-source.ts b/src/infrastructure/filesystem/skill-source.ts index 71e55a7..d321811 100644 --- a/src/infrastructure/filesystem/skill-source.ts +++ b/src/infrastructure/filesystem/skill-source.ts @@ -16,18 +16,31 @@ const SKILLS_SUBDIR = 'skills'; const SKILL_NAME = 'aeos'; /** - * Where each agentic executor discovers skills, relative to the project root. - * - * Claude Code reads `.claude/skills`, Augment/auggie reads `.augment/skills`. - * ollama-cli is artifact-only (no tools, no skills) and is intentionally - * absent. Extend this list as other backends gain a skills mechanism. + * 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)); - return path.resolve(here, '..', '..', '..', SKILLS_SUBDIR, SKILL_NAME); + 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; } function copyDir(from: string, to: string): void { diff --git a/src/infrastructure/persistence/database.ts b/src/infrastructure/persistence/database.ts index 4bebf3f..6509661 100644 --- a/src/infrastructure/persistence/database.ts +++ b/src/infrastructure/persistence/database.ts @@ -125,6 +125,19 @@ 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)`, + ); + }, + }, // ── Future migrations go here ────────────────────────────────── ]; diff --git a/src/infrastructure/persistence/sqlite-ticket.repository.ts b/src/infrastructure/persistence/sqlite-ticket.repository.ts index e46c0d7..b316b65 100644 --- a/src/infrastructure/persistence/sqlite-ticket.repository.ts +++ b/src/infrastructure/persistence/sqlite-ticket.repository.ts @@ -7,7 +7,7 @@ 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`; interface TicketRow { id: string; @@ -15,6 +15,7 @@ interface TicketRow { title: string; kind: TicketKind; parent_id: string | null; + task_key: string | null; column: Column; sub_state: string | null; created_at: string; @@ -32,6 +33,7 @@ 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, @@ -52,8 +54,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 +63,7 @@ export class SqliteTicketRepository implements TicketRepository { ticket.title, ticket.kind, ticket.parentId, + ticket.taskKey ?? null, ticket.column, ticket.subState, ticket.createdAt, diff --git a/templates/agents/architect-agent.yaml b/templates/agents/architect-agent.yaml index 78fb79a..d39404a 100644 --- a/templates/agents/architect-agent.yaml +++ b/templates/agents/architect-agent.yaml @@ -36,17 +36,19 @@ taskInstruction: | tickets by calling the aeos CLI. Two deliverables: 1. Write the `tasks.md` artifact at your output path (format below). - 2. Create one child ticket per task under the epic you are decomposing: + 2. Create one child ticket per task under the epic you are decomposing, + passing the task's `T-NNN` key so retries stay idempotent: ```sh - aeos ticket create "<task title>" --parent <EPIC_ID> + aeos ticket create "<task title>" --parent <EPIC_ID> --key T-001 ``` `<EPIC_ID>` is the ID in your context's `# Ticket: <ID>` heading. Make one - call per task; the titles must match the tasks in `tasks.md`. Creating a - task is idempotent — if a matching child already exists it is left as is — - so always create the full set; do not try to detect what already exists. - See the `aeos` skill for details. + call per task; the `--key` must be the task's `T-NNN` label from `tasks.md`. + Idempotency keys on `--key`, not the title — so if you revise a task's + wording on a later attempt, keep its key the same and it updates in place + instead of duplicating. 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. From c0bd10ff5c9c80f3d5102a7be6e3b6b079ab9ab6 Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 13:03:20 +0200 Subject: [PATCH 03/14] fix: second-pass review findings on --key idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from reviewing the --key fix itself. 1. The prompt claimed a keyed retry "updates in place," but the code returns the existing ticket unchanged — a reworded title was silently discarded. Aligned the architect prompt and skill with the actual behaviour: a key match leaves the task unchanged, so keep titles stable across attempts too. 2. A reused key silently drops a task (the second create matches the first by key). The example hardcoded `--key T-001`, inviting copy-paste. Now shows two distinct keys and states plainly that each task needs its own key. 3. A keyed lookup ignored keyless children, so switching a task from unkeyed to keyed across attempts duplicated it. Added a keyless-title bridge: when a key finds no match, a keyless child with the same title (a pre-key attempt) is matched instead. It only matches keyless children, so it can't collide with a differently-keyed task. 4. `taskKey` was persisted even without a parent, contradicting the port's "ignored without a parent". Now gated: an epic never carries a key. Verified end to end: an unkeyed create then a keyed retry of the same title does not duplicate; an epic created with a stray --key stores no key. 627 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- CLAUDE.md | 2 +- skills/aeos/SKILL.md | 16 +++-- .../ticket-create.use-case.test.ts | 68 +++++++++++++++++++ src/application/ticket-create.use-case.ts | 31 ++++++--- templates/agents/architect-agent.yaml | 15 ++-- 5 files changed, 109 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 94a3bdd..6e5486e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ Changing pipeline behaviour usually means editing YAML, not TypeScript. Loaders `skills/aeos/SKILL.md` (also shipped via `files`) teaches an agentic executor to drive the `aeos` CLI. `project init` copies it to `.aeos/skills/aeos/` and relative-symlinks it into each executor's skills dir (`.claude/skills`, `.augment/skills` — see `skill-source.ts`). The **portable** carrier of the decomposition instruction is the architect's `taskInstruction` (in the prompt, so every executor gets it); the skill is reinforcement for skill-aware backends. This is how `TASK_BREAKDOWN` decomposition works: that column is **agentic**, and the architect calls `aeos ticket create --parent <epic> --key T-NNN` to create the child tasks itself rather than AEOS parsing them out of an artifact. `requiresRepoDiff: false` on that column stops the agentic diff check (which enforces the IMPLEMENTATION guarantee) from failing a run that creates tickets rather than editing the repo. -`ticket create` is idempotent under a parent: it keys on `--key` (the task's stable `T-NNN` label) when given, else the title. 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). The orchestrator needs no special decomposition step: children created during the run are picked up by its existing "drive all children" logic on the next tick; `AWAITING_DECOMPOSITION` remains only as the fallback when none appear (e.g. under `AEOS_EXECUTOR=stub`, which can't shell out). Tickets are created mid-run, before the breakdown is reviewed — the key keeps retries from accumulating, but a breakdown that is _abandoned_ after removing a task leaves an orphan child in `BACKLOG`; the epic's DONE-join lists it, and the operator clears it with `aeos ticket move <id> DONE`. Auto-deleting is deliberately avoided. +`ticket create` is idempotent under a parent: it keys on `--key` (the task's stable `T-NNN` label) when given, else the title (and, as a bridge, a keyless prior child with a matching title). The key matters because a review-loop retry re-runs the _agent_, which may rephrase a task's title — title-only dedup would then duplicate it; the key survives the rephrase (`task_key`, migration v4). A key match leaves the existing task **unchanged** (a reworded title is not applied), and each task must have a **distinct** key — a reused key collapses two tasks into one. Both constraints are stated in the architect prompt and skill. The orchestrator needs no special decomposition step: children created during the run are picked up by its existing "drive all children" logic on the next tick; `AWAITING_DECOMPOSITION` remains only as the fallback when none appear (e.g. under `AEOS_EXECUTOR=stub`, which can't shell out). Tickets are created mid-run, before the breakdown is reviewed — the key keeps retries from accumulating, but a breakdown that is _abandoned_ after removing a task leaves an orphan child in `BACKLOG`; the epic's DONE-join lists it, and the operator clears it with `aeos ticket move <id> DONE`. Auto-deleting is deliberately avoided. ### State diff --git a/skills/aeos/SKILL.md b/skills/aeos/SKILL.md index 0119218..69d9454 100644 --- a/skills/aeos/SKILL.md +++ b/skills/aeos/SKILL.md @@ -38,14 +38,16 @@ 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 - `--key T-NNN` matching the task's label in `tasks.md`. +- **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`. 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 as is and prints - `= ... already exists`. Keying on the label rather than the title means a - retry that rephrases a task's wording 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. + 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. diff --git a/src/application/ticket-create.use-case.test.ts b/src/application/ticket-create.use-case.test.ts index 34cb087..8c29bf7 100644 --- a/src/application/ticket-create.use-case.test.ts +++ b/src/application/ticket-create.use-case.test.ts @@ -326,6 +326,74 @@ describe('TicketCreateUseCase', () => { 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([ diff --git a/src/application/ticket-create.use-case.ts b/src/application/ticket-create.use-case.ts index fbc775f..af738e1 100644 --- a/src/application/ticket-create.use-case.ts +++ b/src/application/ticket-create.use-case.ts @@ -24,6 +24,12 @@ export class TicketCreateUseCase implements TicketCreatePort { // 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) { @@ -37,14 +43,21 @@ export class TicketCreateUseCase implements TicketCreatePort { // Idempotent: decomposition creates tasks during an agentic run, which the // review loop may retry. A matching child short-circuits rather than - // duplicating. When a stable key is given, match on it — a retry may - // rephrase the title, so keying on title alone would miss and duplicate. - // Without a key, fall back to title. + // duplicating. const children = this.ticketRepo.findChildren(projectId, parentId); - const key = taskKey?.trim(); - const existing = key - ? children.find((child) => child.taskKey?.trim().toLowerCase() === key.toLowerCase()) - : children.find((child) => child.title.trim().toLowerCase() === title.trim().toLowerCase()); + 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, @@ -67,7 +80,7 @@ export class TicketCreateUseCase implements TicketCreatePort { title, kind, parentId: parentId ?? null, - taskKey: taskKey?.trim() || null, + taskKey: resolvedTaskKey, column: 'BACKLOG' as const, subState: null, createdAt: now, @@ -105,7 +118,7 @@ export class TicketCreateUseCase implements TicketCreatePort { title, kind, parentId: parentId ?? null, - taskKey: taskKey?.trim() || null, + taskKey: resolvedTaskKey, alreadyExisted: false, }; } diff --git a/templates/agents/architect-agent.yaml b/templates/agents/architect-agent.yaml index d39404a..96e54a4 100644 --- a/templates/agents/architect-agent.yaml +++ b/templates/agents/architect-agent.yaml @@ -40,15 +40,18 @@ taskInstruction: | passing the task's `T-NNN` key so retries stay idempotent: ```sh - aeos ticket create "<task title>" --parent <EPIC_ID> --key T-001 + aeos ticket create "First task title" --parent <EPIC_ID> --key T-001 + aeos ticket create "Second task title" --parent <EPIC_ID> --key T-002 ``` `<EPIC_ID>` is the ID in your context's `# Ticket: <ID>` heading. Make one - call per task; the `--key` must be the task's `T-NNN` label from `tasks.md`. - Idempotency keys on `--key`, not the title — so if you revise a task's - wording on a later attempt, keep its key the same and it updates in place - instead of duplicating. Always create the full set; do not try to detect - what already exists. See the `aeos` skill for details. + 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 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. From 4eaa432922e68bf07ff6286cc6ef3017a85269aa Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 13:16:14 +0200 Subject: [PATCH 04/14] =?UTF-8?q?fix:=20high-effort=20review=20=E2=80=94?= =?UTF-8?q?=20agentic=20artifact=20framing=20+=20cpSync=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of five review findings applied; three skipped with reason. Applied: - TASK_BREAKDOWN is agentic, which grants the agent Write/Edit tools, and the prompt told it to "write the tasks.md artifact at your output path". But AEOS captures the agent's stdout as the artifact and never passes an output path — a file the agent writes is ignored. If the agent wrote a file and printed only a summary, tasks.md would be a thin stub (failing minWordCount, or misreviewed). Reworded the architect prompt and skill: produce the breakdown as response text; a written file is ignored, only printed output is captured. - skill-source: replaced the hand-rolled recursive copyDir with fs.cpSync (recursive), available on the required Node 22. Skipped: - Keyless-bridge match doesn't backfill the key onto the matched child, so a keyless-first task that is later rephrased under a key can still duplicate. Fixing needs a new repo mutator and the template always keys from the start — rare mixed-mode edge, left as a defensive-only bridge. - The idempotency read is outside createAtomic's transaction, so concurrent same-key creates could duplicate. Enforcing needs a UNIQUE constraint that would throw on the race; the agent creates tasks sequentially. Out of scope. - linkOrCopy skips a dangling executor symlink rather than repairing it. Repairing changes the intentional skip-existing behaviour for a rare case. 627 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- skills/aeos/SKILL.md | 5 +++-- src/infrastructure/filesystem/skill-source.ts | 14 ++------------ templates/agents/architect-agent.yaml | 7 +++++-- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/skills/aeos/SKILL.md b/skills/aeos/SKILL.md index 69d9454..f795bce 100644 --- a/skills/aeos/SKILL.md +++ b/skills/aeos/SKILL.md @@ -21,8 +21,9 @@ directory. Every command below is safe to run from within a column execution. When you run the `TASK_BREAKDOWN` column you produce two things: -1. The `tasks.md` artifact at your given output path — the human-readable - breakdown, in the format your agent spec defines. +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 diff --git a/src/infrastructure/filesystem/skill-source.ts b/src/infrastructure/filesystem/skill-source.ts index d321811..ff93c1b 100644 --- a/src/infrastructure/filesystem/skill-source.ts +++ b/src/infrastructure/filesystem/skill-source.ts @@ -43,16 +43,6 @@ function packagedSkillDir(): string { return dir; } -function copyDir(from: string, to: string): void { - fs.mkdirSync(to, { recursive: true }); - for (const entry of fs.readdirSync(from, { withFileTypes: true })) { - const src = path.join(from, entry.name); - const dst = path.join(to, entry.name); - if (entry.isDirectory()) copyDir(src, dst); - else if (entry.isFile()) fs.copyFileSync(src, dst); - } -} - /** * 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 @@ -66,7 +56,7 @@ function linkOrCopy(canonical: string, target: string): boolean { try { fs.symlinkSync(relative, target, 'dir'); } catch { - copyDir(canonical, target); + fs.cpSync(canonical, target, { recursive: true }); } return true; } @@ -89,7 +79,7 @@ export function scaffoldSkill(projectRoot: string): string[] { const canonical = path.join(projectRoot, AEOS_DIR, SKILLS_SUBDIR, SKILL_NAME); if (!fs.existsSync(canonical)) { - copyDir(packagedSkillDir(), canonical); + fs.cpSync(packagedSkillDir(), canonical, { recursive: true }); created.push(path.join(AEOS_DIR, SKILLS_SUBDIR, SKILL_NAME)); } diff --git a/templates/agents/architect-agent.yaml b/templates/agents/architect-agent.yaml index 96e54a4..ee8f520 100644 --- a/templates/agents/architect-agent.yaml +++ b/templates/agents/architect-agent.yaml @@ -32,10 +32,13 @@ taskInstruction: | ### When running in TASK_BREAKDOWN - This column is agentic: you both write the breakdown AND create the child + This column is agentic: you both produce the breakdown AND create the child tickets by calling the aeos CLI. Two deliverables: - 1. Write the `tasks.md` artifact at your output path (format below). + 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: From 0b237637298fba6e4278f35d13896dcea5f07a0f Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 13:26:57 +0200 Subject: [PATCH 05/14] feat(orchestrator): render the live ticket-run view during a run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `orchestrator run` printed only plain step lines; a standalone `ticket run` shows a live split-pane view. Now the orchestrator forwards each inner ticket run's event stream to that same display, so an orchestrated epic renders the same interface per ticket. - OrchestratorObserver gains an optional ticketRunObserver; the use case forwards every inner ticket-run event to it (alongside the lock heartbeat). - The command reuses createTicketRunDisplay: started lazily on the first event (so an immediate halt prints its summary rather than flashing a blank screen), torn down before the end-of-run summary. In a TTY the summary replays the step lines the live pane suppressed; without a TTY, steps print inline as before. - ticket-run-display gained the two cases it was missing — ticket-run.escalated and run.attempt.started — and now resets its per-run panels on ticket-run.started so a display reused across an epic's tickets reflects the current ticket (raw log kept for scroll-back). This also fixes escalation not showing in a standalone `ticket run`. 628 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/application/orchestrator.use-case.test.ts | 26 ++++++++++ src/application/orchestrator.use-case.ts | 16 ++++-- src/cli/commands/orchestrator.command.ts | 52 +++++++++++++++---- src/cli/ui/ticket-run-display.ts | 24 ++++++++- src/domain/ports/driving/orchestrator.port.ts | 6 +++ 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/src/application/orchestrator.use-case.test.ts b/src/application/orchestrator.use-case.test.ts index 4528167..6854ff1 100644 --- a/src/application/orchestrator.use-case.test.ts +++ b/src/application/orchestrator.use-case.test.ts @@ -333,6 +333,32 @@ 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('unexpected failure', () => { diff --git a/src/application/orchestrator.use-case.ts b/src/application/orchestrator.use-case.ts index fd7a0ff..828b15f 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, @@ -140,8 +141,12 @@ export class OrchestratorUseCase implements OrchestratorPort { ); } - const outcome = await this.perform(action, projectId, projectPath, () => - this.heartbeat(projectId, epicId), + const outcome = await this.perform( + action, + projectId, + projectPath, + () => this.heartbeat(projectId, epicId), + observer?.ticketRunObserver, ); const recorded: OrchestratorStep = { action: action.kind, @@ -219,6 +224,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 +244,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) { diff --git a/src/cli/commands/orchestrator.command.ts b/src/cli/commands/orchestrator.command.ts index 68cb87f..4676fb0 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,6 +38,13 @@ 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; + try { const result = await getOrchestrator().run( project.id, @@ -44,27 +52,51 @@ export function registerOrchestratorCommand( 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.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'), + 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.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; } } catch (err) { + 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/ui/ticket-run-display.ts b/src/cli/ui/ticket-run-display.ts index fc46931..73a76d6 100644 --- a/src/cli/ui/ticket-run-display.ts +++ b/src/cli/ui/ticket-run-display.ts @@ -109,12 +109,29 @@ 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.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} | 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, { @@ -194,6 +211,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(); diff --git a/src/domain/ports/driving/orchestrator.port.ts b/src/domain/ports/driving/orchestrator.port.ts index dc7a5ea..e9b7dae 100644 --- a/src/domain/ports/driving/orchestrator.port.ts +++ b/src/domain/ports/driving/orchestrator.port.ts @@ -2,6 +2,7 @@ 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'; @@ -27,6 +28,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 { From fcd4540f9a34df6bf4c1929abc961ba06c6d42de Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 13:32:15 +0200 Subject: [PATCH 06/14] feat(orchestrator): graceful Ctrl+C mid-run The orchestrator had no interrupt path, so Ctrl+C mid-run killed the process ungracefully (and, before the terminal-restore fix, left the alt-screen active). It now stops like `ticket run` does. - OrchestratorPort gains interrupt(): sets a flag and forwards to the in-flight ticket run's interrupt() (which kills its executor child). No-op when idle. - The run loop checks the flag before scheduling the next action and again right after each one, so it stops as soon as the interrupted ticket unwinds rather than scheduling more work. New HaltReason.INTERRUPTED; the epic is left IDLE (not RUNNING), so it is not locked out and re-running resumes. - The flag is reset at the start of each run, so a stray interrupt with nothing running does not poison the next run. - The command captures the orchestrator instance and wires SIGINT -> display.requestInterrupt() + orchestrator.interrupt(), removing the handler in both success and error paths. Exit code 130 on interrupt (conventional). Verified end to end: SIGINT mid-run halts with INTERRUPTED, exit 130, epic left IDLE with a resume message. 631 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/application/orchestrator.use-case.test.ts | 56 +++++++++++++++++++ src/application/orchestrator.use-case.ts | 41 ++++++++++++++ src/cli/commands/orchestrator.command.ts | 15 ++++- src/domain/ports/driving/orchestrator.port.ts | 6 ++ src/domain/services/orchestrator-policy.ts | 2 + 5 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/application/orchestrator.use-case.test.ts b/src/application/orchestrator.use-case.test.ts index 6854ff1..6488396 100644 --- a/src/application/orchestrator.use-case.test.ts +++ b/src/application/orchestrator.use-case.test.ts @@ -361,6 +361,62 @@ describe('OrchestratorUseCase', () => { }); }); + 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', () => { it('clears RUNNING so the epic is not locked out, and rethrows', async () => { (ticketRun.execute as ReturnType<typeof vi.fn>).mockRejectedValue( diff --git a/src/application/orchestrator.use-case.ts b/src/application/orchestrator.use-case.ts index 828b15f..8f92595 100644 --- a/src/application/orchestrator.use-case.ts +++ b/src/application/orchestrator.use-case.ts @@ -49,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, @@ -77,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 @@ -111,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); @@ -156,6 +166,14 @@ export class OrchestratorUseCase implements OrchestratorPort { 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; @@ -185,6 +203,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( @@ -340,6 +365,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/cli/commands/orchestrator.command.ts b/src/cli/commands/orchestrator.command.ts index 4676fb0..74fe87d 100644 --- a/src/cli/commands/orchestrator.command.ts +++ b/src/cli/commands/orchestrator.command.ts @@ -45,8 +45,16 @@ export function registerOrchestratorCommand( 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, @@ -73,6 +81,7 @@ export function registerOrchestratorCommand( }, ); + process.removeListener('SIGINT', interruptHandler); if (displayStarted) display.stop(); const summary: string[] = []; @@ -93,9 +102,11 @@ export function registerOrchestratorCommand( 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)}`); diff --git a/src/domain/ports/driving/orchestrator.port.ts b/src/domain/ports/driving/orchestrator.port.ts index e9b7dae..aaee2d3 100644 --- a/src/domain/ports/driving/orchestrator.port.ts +++ b/src/domain/ports/driving/orchestrator.port.ts @@ -44,6 +44,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/services/orchestrator-policy.ts b/src/domain/services/orchestrator-policy.ts index d42dd5a..bb71eab 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. From 0f58c3638cd8974e34f4efffc6db422f6d678a9d Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 13:35:35 +0200 Subject: [PATCH 07/14] fix(orchestrator): actionable halt for a stuck WORKING/IN_REVIEW ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ticket left WORKING or IN_REVIEW (a run killed before it could transition — a crash or a pre-graceful interrupt) fell through to the generic halt: "…which the orchestrator has no action for," which tells the operator nothing. The orchestrator still can't tell a live run from an orphan, so it correctly declines to touch the ticket — but the recovery is concrete. It now names it: "reset it with `aeos ticket ready <id>` and re-run." Applies to both an epic stuck in its own column and a child task. 633 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/domain/services/orchestrator-policy.test.ts | 14 ++++++++++++++ src/domain/services/orchestrator-policy.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/domain/services/orchestrator-policy.test.ts b/src/domain/services/orchestrator-policy.test.ts index d6514ca..c835ea8 100644 --- a/src/domain/services/orchestrator-policy.test.ts +++ b/src/domain/services/orchestrator-policy.test.ts @@ -101,6 +101,20 @@ describe('decideNextAction', () => { expect(action.reason).toBe(expected); }); + 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 bb71eab..5b9f006 100644 --- a/src/domain/services/orchestrator-policy.ts +++ b/src/domain/services/orchestrator-policy.ts @@ -109,6 +109,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; } From 653fd6efb6f6e8b8c5bb76d57e0ed5d41ec797e3 Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 15:35:03 +0200 Subject: [PATCH 08/14] feat(escalation): persist the escalation reason and surface it in the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline computed a precise escalation reason (ITERATIONS_EXHAUSTED, NOT_CONVERGING, UNPARSEABLE_VERDICT, PREFLIGHT_BLOCKERS) and a human-readable message, then threw both away at end-of-run — they lived only in the live run event. An operator asking "why did this stall?" later had to reverse-engineer it from the review artifact on disk, and `orchestrator status` could only say "needs a human", which is exactly what escalation.ts promises it won't. Now the escalation is persisted on the ticket (migration v5: escalation_reason /message/artifact) and surfaced: - `aeos ticket show` → a Reason: line + the artifact to read first - `aeos orchestrator status` / halt messages → the reason inline The reason is cleared automatically the moment the ticket leaves ESCALATED/ BLOCKED — the clear lives in updateSubState, the single chokepoint for sub-state changes, so every path (run start, `ticket ready`, advance) drops a stale reason with no call-site churn. Tests: repo round-trip + retain/clear invariant, policy message includes the reason. 639 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/application/orchestrator.use-case.test.ts | 1 + .../ticket-answer.use-case.test.ts | 1 + .../ticket-approve.use-case.test.ts | 1 + .../ticket-create.use-case.test.ts | 1 + .../ticket-dod-approve.use-case.test.ts | 1 + src/application/ticket-list.use-case.test.ts | 1 + src/application/ticket-move.use-case.test.ts | 1 + src/application/ticket-ready.use-case.test.ts | 1 + src/application/ticket-run.use-case.test.ts | 1 + src/application/ticket-run.use-case.ts | 9 ++ src/application/ticket-show.use-case.test.ts | 1 + .../ticket-sign-off.use-case.test.ts | 1 + src/cli/commands/ticket-show.command.ts | 12 +++ src/domain/model/ticket.ts | 20 ++++ .../ports/driven/ticket-repository.port.ts | 13 ++- .../services/orchestrator-policy.test.ts | 20 ++++ src/domain/services/orchestrator-policy.ts | 23 +++- src/domain/services/state-machine.test.ts | 9 ++ src/infrastructure/persistence/database.ts | 19 ++++ .../sqlite-ticket.repository.test.ts | 101 ++++++++++++++++++ .../persistence/sqlite-ticket.repository.ts | 52 ++++++++- 21 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 src/infrastructure/persistence/sqlite-ticket.repository.test.ts diff --git a/src/application/orchestrator.use-case.test.ts b/src/application/orchestrator.use-case.test.ts index 6488396..5f281fd 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(), 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 8c29bf7..7bc2a01 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(), }; } 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 b48bb3b..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(), }; } diff --git a/src/application/ticket-run.use-case.ts b/src/application/ticket-run.use-case.ts index 841fdd3..2b412ff 100644 --- a/src/application/ticket-run.use-case.ts +++ b/src/application/ticket-run.use-case.ts @@ -953,6 +953,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', 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/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/domain/model/ticket.ts b/src/domain/model/ticket.ts index 1be1cb1..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; @@ -31,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/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/services/orchestrator-policy.test.ts b/src/domain/services/orchestrator-policy.test.ts index c835ea8..177bb69 100644 --- a/src/domain/services/orchestrator-policy.test.ts +++ b/src/domain/services/orchestrator-policy.test.ts @@ -101,6 +101,26 @@ 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) => { diff --git a/src/domain/services/orchestrator-policy.ts b/src/domain/services/orchestrator-policy.ts index 5b9f006..a04ed8d 100644 --- a/src/domain/services/orchestrator-policy.ts +++ b/src/domain/services/orchestrator-policy.ts @@ -54,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; @@ -82,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, + ), }; } @@ -163,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, + ), }; } @@ -187,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/persistence/database.ts b/src/infrastructure/persistence/database.ts index 6509661..9682780 100644 --- a/src/infrastructure/persistence/database.ts +++ b/src/infrastructure/persistence/database.ts @@ -138,6 +138,25 @@ CREATE TABLE IF NOT EXISTS orchestrator_state ( ); }, }, + { + 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 b316b65..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, task_key, "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; @@ -20,6 +22,9 @@ interface TicketRow { 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 { @@ -38,6 +43,13 @@ export class SqliteTicketRepository implements TicketRepository { 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, }; } @@ -135,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, + ); } } From db8e0c5764ed8e318815db2b2a2f23164cdfb7b2 Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 16:11:52 +0200 Subject: [PATCH 09/14] fix(architect): stop the TECH_SPEC agent from attempting file writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TECH_SPEC runs in artifact mode: the agent's stdout IS the artifact (the adapter writes it), and `claude --print` grants no file-write permission. But the architect's TECH_SPEC instruction said "Produce a detailed technical specification as a single Markdown file" — so the agent reached for the Write tool, had it denied in headless mode, narrated "file write permissions aren't being granted. Let me output directly instead", and dumped a *partial summary* to stdout. That truncated, error-prefixed text became the artifact and the reviewer rejected it (2 blockers: leaked-tool-error, spec-content-not-reproduced). Mirror the capture contract already stated for TASK_BREAKDOWN: print the full spec as the response, never use Write/Edit, and don't hedge into a summary. TASK_BREAKDOWN (agentic, does need Bash to create tickets) is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- templates/agents/architect-agent.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/agents/architect-agent.yaml b/templates/agents/architect-agent.yaml index ee8f520..2e0ef66 100644 --- a/templates/agents/architect-agent.yaml +++ b/templates/agents/architect-agent.yaml @@ -78,7 +78,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. From e21ab81db10a7d9f720367020a3c58751fb137c7 Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 16:22:51 +0200 Subject: [PATCH 10/14] feat(ui): show the working agent in the live run display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a run — standalone or orchestrator-driven — the display named the stage ("Worker") and the executor/model, but not *which agent* was working: architect, pm, and reviewer all showed up as "Worker". You couldn't tell what the pipeline was actually doing at a glance. Thread the agent spec name through the stage.started event (worker, reviewer, preflight) and render it: - live meta line + summary pane → "Agent: architect-agent" - plain/log mode → "[stage:worker] started | … | agent=architect-agent" The column (pipeline stage) was already in the header; this fills the other half of "where are we and who's working". 640 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/application/ticket-run.use-case.ts | 4 ++ src/cli/ui/ticket-run-display.test.ts | 59 ++++++++++++++++++++++++++ src/cli/ui/ticket-run-display.ts | 10 ++++- src/domain/model/ticket-run-event.ts | 2 + 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/cli/ui/ticket-run-display.test.ts diff --git a/src/application/ticket-run.use-case.ts b/src/application/ticket-run.use-case.ts index 2b412ff..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, @@ -658,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', @@ -779,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', @@ -1084,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/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 73a76d6..2f5a224 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 = '—'; @@ -119,6 +120,7 @@ abstract class BaseTicketRunDisplay implements TicketRunDisplay, TicketRunObserv this.activeStage = null; this.subState = null; this.finalStatus = 'running'; + this.agent = '—'; this.executor = event.payload.executor; this.model = event.payload.model ?? '—'; this.appendLogLine( @@ -138,10 +140,14 @@ abstract class BaseTicketRunDisplay implements TicketRunDisplay, TicketRunObserv 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, { @@ -379,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}`, @@ -422,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/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'; From 0bdb93a3e4acc333b1badc41a0d0eda7c48adbb5 Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 16:39:16 +0200 Subject: [PATCH 11/14] fix(architect): stop hallucinated "aeos requires approval" refusals in TASK_BREAKDOWN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK_BREAKDOWN is agentic and the executor grants the Bash tool, so `aeos ticket create` runs without any approval step — verified by reproducing the exact `claude -p … --permission-mode acceptEdits --allowedTools Bash,…` invocation, which created a ticket cleanly. Yet the architect (opus) sometimes narrates "aeos ticket create requires approval and was not granted in this non-interactive session… re-approve aeos and I'll create the tickets" and creates nothing — a fabricated permission block, the same excuse-narration failure mode as the TECH_SPEC "file write permissions aren't being granted" one. Tell the agent explicitly that the CLI is pre-authorized, to run it directly, never claim it needs approval, and that a breakdown with no child tickets is a failed run. Same discipline guard already added for TECH_SPEC. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- templates/agents/architect-agent.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/templates/agents/architect-agent.yaml b/templates/agents/architect-agent.yaml index 2e0ef66..afb019c 100644 --- a/templates/agents/architect-agent.yaml +++ b/templates/agents/architect-agent.yaml @@ -33,7 +33,17 @@ taskInstruction: | ### When running in TASK_BREAKDOWN This column is agentic: you both produce the breakdown AND create the child - tickets by calling the aeos CLI. Two deliverables: + 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 From 4fc15ebad371f4903f84e62085dfaaa543cd812a Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 16:50:08 +0200 Subject: [PATCH 12/14] feat(project): add `aeos project sync` to reconcile a project with current templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project scaffolded by an older `project init` keeps its original column specs, agents, and rubrics forever — template fixes never reach it, and the drift only surfaces when a ticket reaches the affected column (e.g. TASK_BREAKDOWN still on executorMode: artifact, so the architect had no Bash tool to create child tickets). There was no way to see or close that gap short of hand-diffing files. `aeos project sync`: - default: dry run — lists missing / drifted / up-to-date files - --diff: line diff (LCS, context-collapsed) for each drifted file - --apply: writes missing files (always safe) - --force: also overwrites drifted files, backing each up to <file>.bak first No three-way merge: AEOS doesn't store the template version a file came from, so it can't distinguish a local edit from a template change within one file. Sync is honest about that — it never silently overwrites a local edit; --force keeps a .bak so a change like `advanceMode: auto` is recoverable. Hexagonal: TemplateCatalog (driven) → ProjectSyncUseCase → ProjectSyncPort → command; FsTemplateCatalog reuses readTemplates(). 648 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/application/project-sync.use-case.test.ts | 108 ++++++++++++++++++ src/application/project-sync.use-case.ts | 67 +++++++++++ src/application/services/line-diff.test.ts | 45 ++++++++ src/application/services/line-diff.ts | 80 +++++++++++++ src/cli/commands/project-sync.command.ts | 99 ++++++++++++++++ src/cli/container.ts | 5 + src/cli/index.ts | 3 + .../ports/driven/template-catalog.port.ts | 28 +++++ src/domain/ports/driving/project-sync.port.ts | 35 ++++++ .../filesystem/fs-template-catalog.ts | 44 +++++++ 10 files changed, 514 insertions(+) create mode 100644 src/application/project-sync.use-case.test.ts create mode 100644 src/application/project-sync.use-case.ts create mode 100644 src/application/services/line-diff.test.ts create mode 100644 src/application/services/line-diff.ts create mode 100644 src/cli/commands/project-sync.command.ts create mode 100644 src/domain/ports/driven/template-catalog.port.ts create mode 100644 src/domain/ports/driving/project-sync.port.ts create mode 100644 src/infrastructure/filesystem/fs-template-catalog.ts 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/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/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/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/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/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; + } +} From 527af74a8c24a3249198bb242eb9ba21296ac36c Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 16:59:29 +0200 Subject: [PATCH 13/14] fix(decomposition): child tickets carry parent lineage and full task content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in agentic decomposition made child tickets nearly useless to the engineer that later implements them: 1. No lineage in the ticket document. The AEOS:METADATA block recorded only Column and Sub-state — a task's own document never named its parent epic or kind. Add Kind, and Parent/Task key when present. 2. Only a title, no content. `aeos ticket create` accepted just a title, so the architect's `create "<title>" --parent … --key …` dropped every task's Description, Acceptance criteria, Touches, and Out-of-scope from tasks.md. The child then ran IMPLEMENTATION from a bare title. Add `--body`/`--body-file` to `ticket create`; the body becomes the ticket's Description verbatim (placeholders remain for hand-created tickets). The architect prompt and the aeos skill now write each task's block to a temp file and pass `--body-file`, so the full breakdown reaches the child. Verified end-to-end: a created task shows Kind/Parent/Task-key metadata and the task body in its Description. 653 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- skills/aeos/SKILL.md | 26 ++++- .../services/ticket-document.test.ts | 64 +++++++++++ src/application/services/ticket-document.ts | 32 ++++-- .../ticket-create.use-case.test.ts | 10 ++ src/application/ticket-create.use-case.ts | 4 +- src/cli/commands/ticket-create.command.ts | 102 ++++++++++++------ .../ports/driving/ticket-create.port.ts | 7 ++ templates/agents/architect-agent.yaml | 45 ++++++-- 8 files changed, 233 insertions(+), 57 deletions(-) create mode 100644 src/application/services/ticket-document.test.ts diff --git a/skills/aeos/SKILL.md b/skills/aeos/SKILL.md index f795bce..e0ab1a0 100644 --- a/skills/aeos/SKILL.md +++ b/skills/aeos/SKILL.md @@ -27,21 +27,37 @@ When you run the `TASK_BREAKDOWN` column you produce two things: 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`: +`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 -aeos ticket create "Add password hashing" --parent <EPIC_ID> --key T-001 -aeos ticket create "Add session middleware" --parent <EPIC_ID> --key T-002 +cat > /tmp/<EPIC_ID>-T-001.md <<'BODY' +**Depends on:** none +**Touches:** lib/auth/hash.ts + +**Description** +... + +**Acceptance criteria** +- [ ] ... +BODY +aeos ticket create "Add password hashing" --parent <EPIC_ID> --key T-001 --body-file /tmp/<EPIC_ID>-T-001.md ``` +Small bodies may be passed inline with `--body "<markdown>"`, but prefer +`--body-file` for multi-line content — it avoids shell-quoting mistakes. + `<EPIC_ID>` is the ID of the ticket in your context — the `# Ticket: <ID>` heading in the ticket document (e.g. `AEOS-1`). Use that exact ID. Rules: - **One `ticket create` call per task in your breakdown**, each with its own - **distinct** `--key T-NNN` matching the task's label in `tasks.md`. Reusing a - key across two tasks silently drops the second — they are treated as one task. + **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 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-create.use-case.test.ts b/src/application/ticket-create.use-case.test.ts index 7bc2a01..df24008 100644 --- a/src/application/ticket-create.use-case.test.ts +++ b/src/application/ticket-create.use-case.test.ts @@ -116,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( diff --git a/src/application/ticket-create.use-case.ts b/src/application/ticket-create.use-case.ts index af738e1..f33e1ec 100644 --- a/src/application/ticket-create.use-case.ts +++ b/src/application/ticket-create.use-case.ts @@ -19,7 +19,7 @@ export class TicketCreateUseCase implements TicketCreatePort { ) {} execute(input: TicketCreateInput): TicketCreateResult { - const { title, projectId, projectKey, projectPath, parentId, taskKey } = 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; @@ -91,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`; diff --git a/src/cli/commands/ticket-create.command.ts b/src/cli/commands/ticket-create.command.ts index 18fe608..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'; @@ -25,44 +26,77 @@ export function registerTicketCreateCommand( '--key <taskKey>', 'Stable decomposition key (e.g. T-001); idempotency matches on it instead of the title', ) - .action((title: string, options: { parent?: string; key?: string }) => { - try { - const cwd = process.cwd(); - const projectPath = projectRepo.findRoot(cwd); + .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) { - // eslint-disable-next-line no-console - console.error('Error: No AEOS project found. Run "aeos project init" first.'); - process.exitCode = 1; - return; - } + if (!projectPath) { + // eslint-disable-next-line no-console + console.error('Error: No AEOS project found. Run "aeos project init" first.'); + process.exitCode = 1; + return; + } - const project = projectRepo.read(projectPath); + 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; + } - const result = getTicketCreateUseCase().execute({ - title, - projectId: project.id, - projectKey: project.key, - projectPath, - parentId: options.parent, - taskKey: options.key, - }); + 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 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 { + 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.log(`✓ Created ${result.kind} ${result.ticketId}: "${result.title}"${lineage}`); + console.error(`Error: ${message}`); + process.exitCode = 1; } - } 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/domain/ports/driving/ticket-create.port.ts b/src/domain/ports/driving/ticket-create.port.ts index 452fa2d..316b213 100644 --- a/src/domain/ports/driving/ticket-create.port.ts +++ b/src/domain/ports/driving/ticket-create.port.ts @@ -20,6 +20,13 @@ export interface TicketCreateInput { * 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 { diff --git a/templates/agents/architect-agent.yaml b/templates/agents/architect-agent.yaml index afb019c..87342fb 100644 --- a/templates/agents/architect-agent.yaml +++ b/templates/agents/architect-agent.yaml @@ -50,21 +50,46 @@ taskInstruction: | 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: + 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 - aeos ticket create "First task title" --parent <EPIC_ID> --key T-001 - aeos ticket create "Second task title" --parent <EPIC_ID> --key T-002 + # 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 - 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 is not applied. Always create the full set; do not try to - detect what already exists. See the `aeos` skill for details. + 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. From 5625ff54958a3f29dac19ba26b40d2ffdfeea5d1 Mon Sep 17 00:00:00 2001 From: Krzysztof Jackowski <kjackowski@clari.com> Date: Wed, 22 Jul 2026 17:29:51 +0200 Subject: [PATCH 14/14] feat(orchestrator): show task + column in progress output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During an orchestrator run it was hard to tell which task was being driven and at what pipeline stage. The live pane's header already showed ticket + column, but the orchestrator's own step lines and the plain-mode/scrollback output did not. - OrchestratorStep now carries the column it acted on (captured before the action, so an 'advance' reports the stage it drove, not the destination). - Progress lines render it: `STAN-2 [IMPLEMENTATION]: run succeeded …` — both the inline non-TTY prints and the end-of-run replay. - The run-started log line now includes the column too, so plain mode and live scrollback show `[run] started | STAN-2 | IMPLEMENTATION | executor=…`. 653 tests, typecheck, lint, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/application/orchestrator.use-case.test.ts | 2 ++ src/application/orchestrator.use-case.ts | 6 ++++++ src/cli/commands/orchestrator.command.ts | 4 ++-- src/cli/ui/ticket-run-display.ts | 2 +- src/domain/ports/driving/orchestrator.port.ts | 2 ++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/application/orchestrator.use-case.test.ts b/src/application/orchestrator.use-case.test.ts index 5f281fd..4327157 100644 --- a/src/application/orchestrator.use-case.test.ts +++ b/src/application/orchestrator.use-case.test.ts @@ -208,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); }); diff --git a/src/application/orchestrator.use-case.ts b/src/application/orchestrator.use-case.ts index 8f92595..4dae54c 100644 --- a/src/application/orchestrator.use-case.ts +++ b/src/application/orchestrator.use-case.ts @@ -151,6 +151,11 @@ export class OrchestratorUseCase implements OrchestratorPort { ); } + // 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, @@ -161,6 +166,7 @@ export class OrchestratorUseCase implements OrchestratorPort { const recorded: OrchestratorStep = { action: action.kind, ticketId: action.ticketId, + column: actingColumn, outcome, }; steps.push(recorded); diff --git a/src/cli/commands/orchestrator.command.ts b/src/cli/commands/orchestrator.command.ts index 74fe87d..5292d83 100644 --- a/src/cli/commands/orchestrator.command.ts +++ b/src/cli/commands/orchestrator.command.ts @@ -75,7 +75,7 @@ export function registerOrchestratorCommand( // 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.outcome}`); + console.log(` ${step.ticketId} [${step.column}]: ${step.outcome}`); } }, }, @@ -89,7 +89,7 @@ export function registerOrchestratorCommand( // 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.outcome}`); + summary.push(` ${step.ticketId} [${step.column}]: ${step.outcome}`); } } summary.push( diff --git a/src/cli/ui/ticket-run-display.ts b/src/cli/ui/ticket-run-display.ts index 2f5a224..009cbee 100644 --- a/src/cli/ui/ticket-run-display.ts +++ b/src/cli/ui/ticket-run-display.ts @@ -124,7 +124,7 @@ abstract class BaseTicketRunDisplay implements TicketRunDisplay, TicketRunObserv this.executor = event.payload.executor; this.model = event.payload.model ?? '—'; this.appendLogLine( - `[run] started | ${event.ticketId} | 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': diff --git a/src/domain/ports/driving/orchestrator.port.ts b/src/domain/ports/driving/orchestrator.port.ts index aaee2d3..92f64d4 100644 --- a/src/domain/ports/driving/orchestrator.port.ts +++ b/src/domain/ports/driving/orchestrator.port.ts @@ -7,6 +7,8 @@ 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; }