Files
maestro/docs/superpowers/plans/2026-06-22-maestro-refactor-phase1.md
T
wangjia 1fcd811c12 docs: maestro 后端重构 Phase1 设计文档(schedule_status × work_type)
8 章设计文档 HTML(架构决策/状态设计/DB Schema/记忆注入/Agent 规格/
运维护栏/安全沙箱/前端 API 契约),全图深色内联 SVG,附 docs/index.html 索引。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:52:53 +08:00

40 KiB
Raw Blame History

Maestro Refactor Phase 1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Upgrade Task model (taskType/ownedFiles/expectedOutput), CAS claim scheduling, CPM-based scoring with agingBonus + couplingPenalty, fable-5 model tiers, planner decompose JSON extension, and pipeline hardgates (checks分项/diff体量).

Architecture: Bottom-up: schema + types first (Task 1), then store correctness (Task 2), then scheduling intelligence (Task 3), model upgrades (Task 4), planner output enrichment (Task 5), pipeline hardgates (Task 6). Each task produces independently testable changes. Later tasks reference types from earlier ones.

Tech Stack: TypeScript 5, better-sqlite3, Node.js ≥22, tsx (dev runner), node --test via npm test

Global Constraints

  • Run all tests: npm test
  • Run single test file: tsx --test test/<file>.test.ts
  • Type-check (no build): npm run typecheck
  • Build: npm run build
  • Tests use real in-memory SQLite (new Store(':memory:')) — no SQLite mocking
  • Test imports: import { test } from 'node:test'; import assert from 'node:assert/strict';
  • Store imported as: import { Store } from '../src/store/index.js'
  • Do NOT push to remote or deploy
  • fable-5 model ID: 'claude-fable-5'

Pre-flight: What's already done

The following were already implemented — do NOT re-implement:

  • verdict → hardgate in /Users/wangjia/code/maestro/src/daemon/ingest.ts (lines 149165)
  • Parallel Promise.all reviews in /Users/wangjia/code/maestro/src/executor/pipeline.ts (line 154)
  • syncMain before execution in /Users/wangjia/code/maestro/src/executor/pipeline.ts (lines 121127)
  • conflict pipeline in /Users/wangjia/code/maestro/src/executor/pipeline.ts (lines 211273)
  • Planner maxTurns by complexity (90/60/40) in /Users/wangjia/code/maestro/src/executor/runner.ts (line 168)
  • Planner depth injection in buildPlannerPrompt decompose branch (line 133)
  • Planner git read-only tools (log/diff/show) in runPlanner (line 171)
  • Decompose deps sequence→taskId mapping in /Users/wangjia/code/maestro/src/daemon/ingest.ts (lines 103116)
  • checks + auto_approve_plan/auto_approve_exec columns in projects schema

Task 1: Schema Migration + Task Model Types

Files:

  • Modify: /Users/wangjia/code/maestro/src/store/schema.sql
  • Modify: /Users/wangjia/code/maestro/src/store/db.ts
  • Modify: /Users/wangjia/code/maestro/src/model/types.ts
  • Modify: /Users/wangjia/code/maestro/src/store/mappers.ts
  • Test: /Users/wangjia/code/maestro/test/store.test.ts

Interfaces:

  • Produces: TaskType = 'feature' | 'bugfix' | 'refactor' | 'chore' | 'docs'

  • Produces: TaskScope = 'file' | 'module' | 'service' | 'cross-service'

  • Produces: Task.taskType, Task.scope, Task.ownedFiles: string[], Task.expectedOutput: string | null, Task.parentVersionId: string | null, Task.version: number

  • Consumed by: Tasks 2, 3, 5

  • Step 1: Write failing test for new Task fields

Add this test to /Users/wangjia/code/maestro/test/store.test.ts:

test('Task 新字段默认值', () => {
  const store = new Store(':memory:');
  const p = store.createProject({ name: 'p', repoPath: '/tmp/t1-' + Math.random(), autonomy: 'manual' });
  const t = store.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' });
  assert.strictEqual(t.taskType, null);
  assert.strictEqual(t.scope, null);
  assert.deepStrictEqual(t.ownedFiles, []);
  assert.strictEqual(t.expectedOutput, null);
  assert.strictEqual(t.parentVersionId, null);
  assert.strictEqual(t.version, 1);
});

test('patchTask 更新 ownedFiles 与 taskType', () => {
  const store = new Store(':memory:');
  const p = store.createProject({ name: 'p', repoPath: '/tmp/t1b-' + Math.random(), autonomy: 'manual' });
  const t = store.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' });
  const updated = store.patchTask(t.id, {
    taskType: 'feature',
    scope: 'module',
    ownedFiles: ['src/foo.ts', 'src/bar.ts'],
    expectedOutput: 'all tests pass',
  });
  assert.strictEqual(updated.taskType, 'feature');
  assert.strictEqual(updated.scope, 'module');
  assert.deepStrictEqual(updated.ownedFiles, ['src/foo.ts', 'src/bar.ts']);
  assert.strictEqual(updated.expectedOutput, 'all tests pass');
});
  • Step 2: Run test to verify it fails
tsx --test test/store.test.ts 2>&1 | grep -E "fail|error|FAIL|Error" | head -5

Expected: fails with t.taskType is not a property or similar.

  • Step 3: Update schema.sql — add new columns to tasks CREATE TABLE

In /Users/wangjia/code/maestro/src/store/schema.sql, find the CREATE TABLE IF NOT EXISTS tasks block and add 6 new columns before the closing ):

  task_type       TEXT,                               -- feature|bugfix|refactor|chore|docs
  scope           TEXT,                               -- file|module|service|cross-service
  owned_files     TEXT NOT NULL DEFAULT '[]',         -- JSON string[] 声明的文件所有权
  expected_output TEXT,                               -- "done" 的可验证描述(exec_review 验收用)
  parent_version_id TEXT,                             -- 版本链:reject 后新建版指向前一版
  version         INTEGER NOT NULL DEFAULT 1          -- 任务版本号(每次 reject 递增)

Also add to runs table before closing ):

  trace_id        TEXT                                -- Trace ID(跨进程可观测性)
  • Step 4: Update db.ts — add ensureColumn migrations

In /Users/wangjia/code/maestro/src/store/db.ts, after line 34 (ensureColumn(db, 'runs', 'last_seq', ...)), add:

  ensureColumn(db, 'tasks', 'task_type', 'task_type TEXT');
  ensureColumn(db, 'tasks', 'scope', 'scope TEXT');
  ensureColumn(db, 'tasks', 'owned_files', "owned_files TEXT NOT NULL DEFAULT '[]'");
  ensureColumn(db, 'tasks', 'expected_output', 'expected_output TEXT');
  ensureColumn(db, 'tasks', 'parent_version_id', 'parent_version_id TEXT');
  ensureColumn(db, 'tasks', 'version', 'version INTEGER NOT NULL DEFAULT 1');
  ensureColumn(db, 'tasks', 'claimed_at', 'claimed_at TEXT');               // CAS claimTask 2 用)
  ensureColumn(db, 'runs', 'trace_id', 'trace_id TEXT');
  • Step 5: Update types.ts — add TaskType, TaskScope and Task fields

In /Users/wangjia/code/maestro/src/model/types.ts, add after the imports block:

export type TaskType = 'feature' | 'bugfix' | 'refactor' | 'chore' | 'docs';
export type TaskScope = 'file' | 'module' | 'service' | 'cross-service';

In the Task interface, add after lastRunError:

  taskType: TaskType | null;
  scope: TaskScope | null;
  ownedFiles: string[];
  expectedOutput: string | null;
  parentVersionId: string | null;
  version: number;
  • Step 6: Update mappers.ts — TaskRow + rowToTask

In /Users/wangjia/code/maestro/src/store/mappers.ts, add to TaskRow interface (after last_run_error):

  task_type: string | null;
  scope: string | null;
  owned_files: string;
  expected_output: string | null;
  parent_version_id: string | null;
  version: number;
  claimed_at: string | null;  // CAS claimTask 2 用)

Add to RunRow interface (after last_seq):

  trace_id: string | null;

Update rowToTask function body (add before closing }):

    taskType: (r.task_type as TaskType | null) ?? null,
    scope: (r.scope as TaskScope | null) ?? null,
    ownedFiles: r.owned_files ? (JSON.parse(r.owned_files) as string[]) : [],
    expectedOutput: r.expected_output ?? null,
    parentVersionId: r.parent_version_id ?? null,
    version: r.version ?? 1,

Also add import at top of mappers.ts: import type { TaskType, TaskScope } from '../model/types.js';

  • Step 7: Update store.ts — patchTask to handle new fields

In /Users/wangjia/code/maestro/src/store/store.ts, find the patchTask method. Update its accepted fields to include the new ones. Find the PatchTaskInput type (or wherever patchTask's input type is declared) and add:

taskType?: TaskType | null;
scope?: TaskScope | null;
ownedFiles?: string[];
expectedOutput?: string | null;

In the patchTask implementation, map these new fields into the SQL UPDATE. The method needs to build the SET clause dynamically — add handling for each new field that serializes ownedFiles as JSON:

if ('taskType' in patch) sets.push(`task_type = '${patch.taskType ?? null}'`) // use parameterized query
if ('ownedFiles' in patch) sets.push(/* JSON.stringify(patch.ownedFiles) */)

Note: Look at how patchTask currently handles existing optional fields (title, priority, deps, etc.) and follow the same pattern for parameterized queries. Do NOT use string interpolation for values.

  • Step 8: Run tests
npm test 2>&1 | tail -20

Expected: the two new store tests pass; all existing tests still pass.

  • Step 9: Typecheck
npm run typecheck

Expected: no errors.

  • Step 10: Commit
git add src/store/schema.sql src/store/db.ts src/model/types.ts src/store/mappers.ts src/store/store.ts test/store.test.ts
git commit -m "feat(model): add taskType/scope/ownedFiles/expectedOutput/version fields + schema migration"

Task 2: CAS Claim + Execution Lock

Files:

  • Modify: /Users/wangjia/code/maestro/src/store/store.ts
  • Modify: /Users/wangjia/code/maestro/src/daemon/orchestrator.ts
  • Test: /Users/wangjia/code/maestro/test/orchestrator.test.ts

Interfaces:

  • Consumes: claimed_at column from Task 1

  • Produces: store.claimTask(taskId, toStatus) — CAS claim that sets claimed_at; → ready transition clears claimed_at = NULL

  • Produces: execution lock — any task with a started run rejects mutations on title/spec/plan/deps/complexity

  • Step 1: Write failing tests

Add to /Users/wangjia/code/maestro/test/orchestrator.test.ts:

test('CAS claim — 同一任务不能被双重领取', (t) => {
  const store = new Store(':memory:');
  const p = store.createProject({ name: 'cas', repoPath: '/tmp/cas-' + Math.random(), autonomy: 'auto-easy' });
  const task = store.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
  store.setOperations(task.id, 'op');
  store.transition(task.id, 'queued', { by: 'test' });
  store.transition(task.id, 'executing', { by: 'test' });

  // 第一次 claim 成功(CAS
  const ok1 = store.casClaimTask(task.id, 'run-001');
  assert.ok(ok1, '第一次 claim 应成功');

  // 第二次 claim 同一任务失败
  const ok2 = store.casClaimTask(task.id, 'run-002');
  assert.ok(!ok2, '双重 claim 应失败');
});

test('transition → ready 清空 claimed_at', (t) => {
  const store = new Store(':memory:');
  const p = store.createProject({ name: 'cas2', repoPath: '/tmp/cas2-' + Math.random(), autonomy: 'auto-easy' });
  const task = store.createTask({ projectId: p.id, title: 'y', complexity: 'easy' });
  store.setOperations(task.id, 'op');
  store.transition(task.id, 'queued', { by: 'test' });
  store.transition(task.id, 'executing', { by: 'test' });
  store.casClaimTask(task.id, 'run-x');

  // reject → ready 应清空 claimed_at,使下次 CAS 可成功
  store.transition(task.id, 'exec_review', { by: 'test' });
  store.decide(task.id, 'reject', 'human', '需要修改');
  const ok = store.casClaimTask(task.id, 'run-y');
  assert.ok(ok, 'ready 后应可再次 claim');
});
  • Step 2: Run tests to verify they fail
tsx --test test/orchestrator.test.ts 2>&1 | grep -E "fail|Error|casClaimTask" | head -5

Expected: store.casClaimTask is not a function

  • Step 3: Add casClaimTask to store.ts

In /Users/wangjia/code/maestro/src/store/store.ts, add this new method to the Store class (near the claimTick-adjacent logic):

/**
 * CAS 领取:仅当 claimed_at IS NULL 时才 SET claimed_at=now(),防止双重领取。
 * 返回 true=领取成功,false=已被其他线程/进程抢先。
 */
casClaimTask(taskId: string, runId: string): boolean {
  const result = this.db
    .prepare(`UPDATE tasks SET claimed_at = ?, updated_at = ? WHERE id = ? AND claimed_at IS NULL`)
    .run(now(), now(), taskId);
  return result.changes === 1;
}
  • Step 4: Clear claimed_at when transitioning to ready

In /Users/wangjia/code/maestro/src/store/store.ts, find the transition method (around line 899 or UPDATE tasks SET status = ?, updated_at = ?). When the target status is 'ready', also clear claimed_at:

// In transition() — when to === 'ready', clear claimed_at
if (to === 'ready') {
  this.db.prepare(`UPDATE tasks SET status = ?, claimed_at = NULL, updated_at = ? WHERE id = ?`).run(to, now(), taskId);
} else {
  this.db.prepare(`UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`).run(to, now(), taskId);
}

Also clear in decide() when transitioning back to ready (exec reject), analyzing (plan reject), speccing (spec reject) — all these pass through transition() already, so the check above covers them.

Also ensure requeueTask() (lines 967979) clears claimed_at:

// After updating retry_baseline in requeueTask:
this.db.prepare(`UPDATE tasks SET claimed_at = NULL, updated_at = ? WHERE id = ?`).run(now(), taskId);
  • Step 5: Use casClaimTask in orchestrator.claimOne

In /Users/wangjia/code/maestro/src/daemon/orchestrator.ts, in claimOne() (around line 168), after store.transition(task.id, 'executing', ...) succeeds, add a CAS check:

// After both transitions (queued → executing), verify CAS claim
if (!store.casClaimTask(task.id, run.id)) {
  // Another process already claimed this task — abort and clean up
  log.error(`任务 ${task.id} CAS claim 失败(并发冲突),放弃本次领取`);
  return;
}

Place this after store.startRun() and before d.writeJobSpec().

  • Step 6: Run tests
npm test 2>&1 | tail -20

Expected: new CAS tests pass; all existing tests still pass.

  • Step 7: Typecheck
npm run typecheck
  • Step 8: Add execution lock to mutation methods in store.ts

在途任务(有 status=started 的 run)应拒绝修改 title/spec/plan/complexity/deps。在 store.ts 中新增内部校验辅助方法:

/** 若任务有在途 runstatus=started),抛出 StoreError(执行期锁)。 */
private assertNoActiveRun(taskId: string): void {
  const row = this.db.prepare(
    `SELECT COUNT(*) AS n FROM runs WHERE task_id = ? AND status = 'started'`,
  ).get(taskId) as { n: number };
  if (row.n > 0) throw new StoreError('任务执行中,禁止修改(等 run 结束后再操作)');
}

在以下方法开头加 this.assertNoActiveRun(taskId) 调用:

  • setSpec(taskId, ...)
  • setPlan(taskId, ...)
  • setOperations(taskId, ...)
  • patchTask(taskId, patch) — 仅当 patch 含 title/complexity/deps/priority 时

patchField 私有方法里统一加也可以(因为 setSpec/setPlan/setOperations 都走它)。

  • Step 9: Write test for execution lock
test('执行期间禁止修改 spec(执行期锁)', () => {
  const store = new Store(':memory:');
  const p = store.createProject({ name: 'lock', repoPath: '/tmp/lock-' + Math.random(), autonomy: 'manual' });
  const t = store.createTask({ projectId: p.id, title: 'x', complexity: 'medium' });
  store.transition(t.id, 'speccing', { by: 'test' });
  const run = store.startRun(t.id, 'planner');

  // 有在途 run 时修改 spec 应抛出
  assert.throws(() => store.setSpec(t.id, 'new spec'), /执行中/);

  // run 结束后可以修改
  store.finishRun(run.id, 'failed', { error: 'test' });
  assert.doesNotThrow(() => store.setSpec(t.id, 'new spec'));
});
  • Step 10: Commit
git add src/store/store.ts src/daemon/orchestrator.ts test/orchestrator.test.ts
git commit -m "feat(store): CAS claimed_at 防双重 claim + transition→ready 清零 + 执行期锁"

Task 3: Scheduling Upgrade (rankU + agingBonus + couplingPenalty)

Files:

  • Modify: /Users/wangjia/code/maestro/src/model/scoring.ts
  • Modify: /Users/wangjia/code/maestro/src/daemon/orchestrator.ts
  • Test: /Users/wangjia/code/maestro/test/orchestrator.test.ts

Interfaces:

  • Consumes: Task.ownedFiles from Task 1; Task.createdAt

  • Produces: rankU(taskId, allByIdMap, dependentsMap, cache): number — recursive CPM unlock value

  • Produces: agingBonus(task, nowMs): number

  • Produces: couplingPenalty(task, inFlight): number

  • Produces: new rankByScore(candidates, all, inFlight, nowMs) signature (adds inFlight and nowMs)

  • Consumed by: orchestrator.claimable()

  • Step 1: Write failing tests

Add to /Users/wangjia/code/maestro/test/orchestrator.test.ts:

import { rankByScore, agingBonus, couplingPenalty } from '../src/model/scoring.js';

test('agingBonus — 新建任务无加成', () => {
  const task = { createdAt: new Date().toISOString(), priority: 1 } as Task;
  assert.strictEqual(agingBonus(task, Date.now()), 0);
});

test('agingBonus — 等待 48h 达到 baseScore 上限', () => {
  const fortyEightHoursAgo = new Date(Date.now() - 48 * 3600 * 1000).toISOString();
  const task = { createdAt: fortyEightHoursAgo, priority: 1 } as Task;
  // P1 baseScore = 2, bonus after 48h = min(2, 48/48) = min(2,1) = 1
  assert.ok(agingBonus(task, Date.now()) >= 0.99);
  assert.ok(agingBonus(task, Date.now()) <= 1.01);
});

test('couplingPenalty — 无重叠文件时为 0', () => {
  const candidate = { ownedFiles: ['src/a.ts'] } as Task;
  const inFlight = [{ ownedFiles: ['src/b.ts'] } as Task];
  assert.strictEqual(couplingPenalty(candidate, inFlight), 0);
});

test('couplingPenalty — 每个重叠文件 -0.5', () => {
  const candidate = { ownedFiles: ['src/a.ts', 'src/b.ts', 'src/c.ts'] } as Task;
  const inFlight = [{ ownedFiles: ['src/a.ts', 'src/b.ts'] } as Task];
  assert.strictEqual(couplingPenalty(candidate, inFlight), 1.0);
});

test('rankByScore — agingBonus 提升得分', () => {
  const old = { id: 'old', priority: 2, createdAt: new Date(Date.now() - 96 * 3600 * 1000).toISOString(),
    ownedFiles: [], deps: [], status: 'ready' } as unknown as Task;
  const fresh = { id: 'fresh', priority: 2, createdAt: new Date().toISOString(),
    ownedFiles: [], deps: [], status: 'ready' } as unknown as Task;
  const ranked = rankByScore([old, fresh], [old, fresh], [], Date.now());
  assert.strictEqual(ranked[0].task.id, 'old', '等待更久的应排前');
});
  • Step 2: Run tests to verify they fail
tsx --test test/orchestrator.test.ts 2>&1 | grep -E "agingBonus|couplingPenalty" | head -5

Expected: agingBonus is not exported

  • Step 3: Update scoring.ts

Replace /Users/wangjia/code/maestro/src/model/scoring.ts content:

import type { Task } from './types.js';

/** P0=3 / P1=2 / P2=1 */
export function baseScore(priority: number): number {
  return Math.max(1, 3 - priority);
}

/** 反向索引:taskId → 直接依赖它的任务列表 */
export function buildDependentsIndex(tasks: Task[]): Map<string, Task[]> {
  const idx = new Map<string, Task[]>();
  for (const t of tasks) {
    for (const d of t.deps) {
      const list = idx.get(d) ?? [];
      list.push(t);
      idx.set(d, list);
    }
  }
  return idx;
}

/**
 * CPM 后向传播:递归累计"解锁价值"。
 * cache 防止 DAG 中重复计算(deps 无环保证终止)。
 */
export function rankU(
  taskId: string,
  byId: Map<string, Task>,
  dependents: Map<string, Task[]>,
  cache: Map<string, number>,
): number {
  if (cache.has(taskId)) return cache.get(taskId)!;
  const task = byId.get(taskId);
  if (!task) { cache.set(taskId, 0); return 0; }
  const base = baseScore(task.priority);
  const unlockValue = (dependents.get(taskId) ?? [])
    .reduce((sum, dep) => sum + rankU(dep.id, byId, dependents, cache), 0);
  const result = base + unlockValue;
  cache.set(taskId, result);
  return result;
}

/**
 * 老化加成:防止低优先级任务因文件耦合被长期回避。
 * waitHours / 48 线性增长,上限 = baseScore(priority)(即 P0 最多 +3 点,P2 最多 +1 点)。
 */
export function agingBonus(task: Task, nowMs: number): number {
  const base = baseScore(task.priority);
  const waitHours = (nowMs - Date.parse(task.createdAt)) / 3_600_000;
  return Math.min(base, waitHours / 48);
}

/**
 * 耦合惩罚:ownedFiles 与在途任务有交集时,每个重叠文件 -0.5。
 * 不硬 block(防饥饿),agingBonus 会最终覆盖惩罚。
 */
export function couplingPenalty(task: Task, inFlight: Task[]): number {
  const files = new Set(task.ownedFiles ?? []);
  if (files.size === 0) return 0;
  let overlaps = 0;
  for (const t of inFlight) {
    for (const f of (t.ownedFiles ?? [])) {
      if (files.has(f)) overlaps++;
    }
  }
  return overlaps * 0.5;
}

/**
 * 综合调度得分(在 claim 时现算,不落库):
 *   rankU(自身 + 递归解锁效应)
 * + chainInertia(已完成依赖的 baseScore 之和,链条惯性)
 * + agingBonus(等待越久加分越多)
 * - couplingPenalty(文件重叠减分,防并发冲突)
 */
export function scoreTask(
  t: Task,
  byId: Map<string, Task>,
  dependents: Map<string, Task[]>,
  inFlight: Task[],
  nowMs: number,
  rankUCache: Map<string, number>,
): number {
  const chainInertia = t.deps.reduce((s, d) => {
    const dep = byId.get(d);
    return dep?.status === 'done' ? s + baseScore(dep.priority) : s;
  }, 0);
  return rankU(t.id, byId, dependents, rankUCache)
    + chainInertia
    + agingBonus(t, nowMs)
    - couplingPenalty(t, inFlight);
}

/**
 * 候选任务按综合得分降序排(同分则创建时间早优先)。
 * inFlight: 当前在途任务列表(用于耦合惩罚)。
 * nowMs: 当前时间(便于测试注入可控时钟)。
 */
export function rankByScore(
  candidates: Task[],
  all: Task[],
  inFlight: Task[],
  nowMs: number = Date.now(),
): Array<{ task: Task; score: number }> {
  const byId = new Map(all.map((t) => [t.id, t]));
  const dependents = buildDependentsIndex(all);
  const cache = new Map<string, number>();
  return candidates
    .map((task) => ({ task, score: scoreTask(task, byId, dependents, inFlight, nowMs, cache) }))
    .sort((a, b) => b.score - a.score || a.task.createdAt.localeCompare(b.task.createdAt));
}
  • Step 4: Update orchestrator.ts to pass inFlight to rankByScore

In /Users/wangjia/code/maestro/src/daemon/orchestrator.ts, in the claimable() function (around line 159), change:

return rankByScore(candidates, tasks);

to:

const inflightList = tasks.filter((t) => inflight.has(t.id));
return rankByScore(candidates, tasks, inflightList, d.nowMs());
  • Step 5: Run tests
npm test 2>&1 | tail -20

Expected: new scoring tests pass; all existing tests pass.

  • Step 6: Typecheck
npm run typecheck
  • Step 7: Commit
git add src/model/scoring.ts src/daemon/orchestrator.ts test/orchestrator.test.ts
git commit -m "feat(scoring): CPM rankU + agingBonus + couplingPenalty 调度算法升级"

Task 4: Model Tier Upgrade (fable-5)

Files:

  • Modify: /Users/wangjia/code/maestro/src/executor/models.ts
  • Test: /Users/wangjia/code/maestro/test/models.test.ts

Interfaces:

  • Produces: MODEL_FALLBACK_CHAIN = ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6']

  • Produces: planner hard → 'claude-fable-5'; reviewer all → 'claude-fable-5'; conflict all → 'claude-fable-5'

  • Step 1: Write failing tests

Add to /Users/wangjia/code/maestro/test/models.test.ts:

import { pickModel, MODEL_FALLBACK_CHAIN, pickFallbackModel } from '../src/executor/models.js';

test('planner hard 用 fable-5', () => {
  const task = { complexity: 'hard' } as Task;
  const project = { model: null } as Project;
  assert.strictEqual(pickModel(task, project, 'planner'), 'claude-fable-5');
});

test('reviewer 不受 project.model 影响,固定 fable-5', () => {
  const task = { complexity: 'easy' } as Task;
  const project = { model: 'claude-sonnet-4-6' } as Project;
  assert.strictEqual(pickModel(task, project, 'reviewer'), 'claude-fable-5');
});

test('conflict 固定 fable-5', () => {
  const task = { complexity: 'easy' } as Task;
  const project = { model: null } as Project;
  assert.strictEqual(pickModel(task, project, 'conflict'), 'claude-fable-5');
});

test('MODEL_FALLBACK_CHAIN 首档为 fable-5', () => {
  assert.strictEqual(MODEL_FALLBACK_CHAIN[0], 'claude-fable-5');
});

test('pickFallbackModel — fable-5 回退到 opus-4-8', () => {
  assert.strictEqual(pickFallbackModel('claude-fable-5'), 'claude-opus-4-8');
});
  • Step 2: Run tests to verify they fail
tsx --test test/models.test.ts 2>&1 | grep -E "fable|fail" | head -5

Expected: assertions fail (current hard=opus, reviewer=opus).

  • Step 3: Update models.ts

In /Users/wangjia/code/maestro/src/executor/models.ts:

  1. Change MODEL_FALLBACK_CHAIN:
export const MODEL_FALLBACK_CHAIN = ['claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6'] as const;
  1. Change MODEL_TABLE.planner.hard:
hard: ['MAESTRO_MODEL_PLAN_HARD', 'claude-fable-5'],
  1. Change all three MODEL_TABLE.reviewer entries to fable-5:
reviewer: {
  easy: ['MAESTRO_MODEL_REVIEW_EASY', 'claude-fable-5'],
  medium: ['MAESTRO_MODEL_REVIEW_MEDIUM', 'claude-fable-5'],
  hard: ['MAESTRO_MODEL_REVIEW_HARD', 'claude-fable-5'],
},
  1. Change MODEL_TABLE.conflict and the pickModel conflict early return:
// In MODEL_TABLE.conflict (占位,实际由 pickModel 直接返回 fable-5)
conflict: {
  easy: ['', 'claude-fable-5'],
  medium: ['', 'claude-fable-5'],
  hard: ['', 'claude-fable-5'],
},
  1. Update pickModel conflict return:
if (role === 'conflict') return 'claude-fable-5';
  • Step 4: Run tests
npm test 2>&1 | tail -20

Expected: new model tests pass; all existing tests pass. Note: existing tests that assert 'claude-opus-4-8' for reviewer/conflict/planner-hard will need to be updated to 'claude-fable-5'.

  • Step 5: Typecheck
npm run typecheck
  • Step 6: Commit
git add src/executor/models.ts test/models.test.ts
git commit -m "feat(models): fable-5 为 planner-hard/reviewer/conflict 最强档;回退链首档改 fable-5"

Task 5: Planner Decompose JSON Extension (ownedFiles + expectedOutput)

Files:

  • Modify: /Users/wangjia/code/maestro/src/executor/runner.ts
  • Modify: /Users/wangjia/code/maestro/src/daemon/ingest.ts
  • Test: /Users/wangjia/code/maestro/test/ingest.test.ts

Interfaces:

  • Consumes: Task.ownedFiles, Task.expectedOutput from Task 1

  • Produces: buildPlannerPrompt decompose branch requires ownedFiles and expectedOutput in subtask JSON

  • Produces: ingest.ts decompose-result handler maps subtask ownedFiles/expectedOutput via store.patchTask

  • Step 1: Write failing test

Add to /Users/wangjia/code/maestro/test/ingest.test.ts:

test('decompose-result — ownedFiles/expectedOutput 落入子任务', () => {
  withTmpDataDir(() => {
    const store = new Store(':memory:');
    const p = store.createProject({
      name: 'p', repoPath: '/tmp/dr-' + Math.random(), autonomy: 'auto-approved',
    });
    const parent = store.createTask({ projectId: p.id, title: 'big feat', complexity: 'hard' });
    store.transition(parent.id, 'analyzing', { by: 'test' });
    const run = store.startRun(parent.id, 'planner', { worktree: p.repoPath });

    const dir = runDir(run.id);
    mkdirSync(dir, { recursive: true });
    appendOutbox(run.id, {
      type: 'decompose-result',
      plan: 'big feature',
      subtasks: [
        {
          title: '类型定义',
          complexity: 'easy' as const,
          priority: 0,
          deps: [],
          ownedFiles: ['src/model/types.ts'],
          expectedOutput: 'typecheck 通过',
        },
        {
          title: '实现层',
          complexity: 'medium' as const,
          priority: 1,
          deps: [0],
          ownedFiles: ['src/store/taskRepo.ts'],
          expectedOutput: '单测通过',
        },
      ],
      transcriptRef: null,
      sessionId: null,
    });
    ingestRun(store, noopLog, run.id);

    const children = store.childrenOf(parent.id);
    assert.strictEqual(children.length, 2);
    assert.deepStrictEqual(children[0].ownedFiles, ['src/model/types.ts']);
    assert.strictEqual(children[0].expectedOutput, 'typecheck 通过');
    assert.deepStrictEqual(children[1].ownedFiles, ['src/store/taskRepo.ts']);
    assert.strictEqual(children[1].expectedOutput, '单测通过');
  });
});

Note: you'll need to import runDir from protocol.ts and mkdirSync from fs. Check existing ingest.test.ts imports to see what's already imported.

  • Step 2: Run test to verify it fails
tsx --test test/ingest.test.ts 2>&1 | grep -E "ownedFiles|expectedOutput|fail" | head -5

Expected: assertion fails (ownedFiles is [], expectedOutput is null).

  • Step 3: Update buildPlannerPrompt in runner.ts

In /Users/wangjia/code/maestro/src/executor/runner.ts, find buildPlannerPrompt (line 114), in the decompose branch, update the JSON example in the prompt:

Change this line (around line 150):

'{"plan":"一句话分析与拆解理由","subtasks":[{"title":"子任务标题","complexity":"easy","priority":1,"deps":[]}]}',

To:

'{"plan":"一句话分析与拆解理由","subtasks":[{"title":"子任务标题","complexity":"easy","priority":1,"deps":[],"ownedFiles":["src/example.ts"],"expectedOutput":"相关测试通过"}]}',

Also update the subtasks description bullet points (around line 138139) to add:

'- ownedFiles:该子任务主要修改的文件路径列表(JSON string[],无则填 []',
'- expectedOutput:一句话描述该子任务"完成"的可验证标准(例:"typecheck 通过"',

Also update the table header line to add columns:

'| # | 子任务标题 | 复杂度 | 优先级 | 依赖序号 | 主要文件 | 验收标准 |',
'|---|---|---|---|---|---|---|',
  • Step 4: Update ingest.ts decompose-result handler

In /Users/wangjia/code/maestro/src/daemon/ingest.ts, in the decompose-result case (around line 83), update the subtask creation loop. After the store.patchTask(createdIds[i], { deps: depIds }) call for deps, add ownedFiles/expectedOutput mapping:

// After the deps mapping loop (around line 116), add:
for (let i = 0; i < rec.subtasks.length; i++) {
  const sub = rec.subtasks[i] as {
    ownedFiles?: string[];
    expectedOutput?: string;
    deps?: number[];
  };
  if (!createdIds[i]) continue;
  const patch: Record<string, unknown> = {};
  if (Array.isArray(sub.ownedFiles) && sub.ownedFiles.length > 0) {
    patch.ownedFiles = sub.ownedFiles;
  }
  if (typeof sub.expectedOutput === 'string' && sub.expectedOutput.trim()) {
    patch.expectedOutput = sub.expectedOutput.trim();
  }
  if (Object.keys(patch).length > 0) {
    try {
      store.patchTask(createdIds[i], patch);
    } catch (e) {
      log.error(`decompose: 更新子任务「${rec.subtasks[i].title}」元数据失败:${(e as Error).message}`);
    }
  }
}

Important: Place this loop AFTER the deps mapping loop (not inside it). Both loops iterate the same rec.subtasks array independently.

  • Step 5: Update OutboxPayload type in protocol.ts

In /Users/wangjia/code/maestro/src/executor/protocol.ts, find the decompose-result payload type. The subtasks array currently has { title: string; complexity: Complexity }. Extend it:

// In the decompose-result payload subtasks item type:
subtasks: Array<{
  title: string;
  complexity: Complexity;
  priority?: number;
  deps?: number[];
  ownedFiles?: string[];        // 新增
  expectedOutput?: string;      // 新增
}>;
  • Step 6: Run tests
npm test 2>&1 | tail -20

Expected: new ingest test passes; all existing tests pass.

  • Step 7: Typecheck
npm run typecheck
  • Step 8: Commit
git add src/executor/runner.ts src/executor/protocol.ts src/daemon/ingest.ts test/ingest.test.ts
git commit -m "feat(planner): decompose JSON 扩展 ownedFiles/expectedOutput,落入子任务"

Task 6: Checks分项闸 + Diff体量闸

Files:

  • Modify: /Users/wangjia/code/maestro/src/executor/pipeline.ts
  • Modify: /Users/wangjia/code/maestro/src/store/schema.sql
  • Modify: /Users/wangjia/code/maestro/src/store/db.ts
  • Modify: /Users/wangjia/code/maestro/src/model/types.ts
  • Modify: /Users/wangjia/code/maestro/src/store/mappers.ts
  • Test: /Users/wangjia/code/maestro/test/pipeline.test.ts

Interfaces:

  • Consumes: project.checks: string | null (existing field, JSON {lint:"...",typecheck:"..."})

  • Produces: new Project.diffMaxFiles: number (default 100)

  • Produces: runPipeline emits failed if any check has non-zero exit OR if changed file count > diffMaxFiles

  • Step 1: Write failing tests

Add to /Users/wangjia/code/maestro/test/pipeline.test.ts:

test('checks分项闸 — lint 失败阻止进入 exec_review', async () => {
  const job = makeJob({ complexity: 'easy' }); // use existing test helper
  job.project.checks = JSON.stringify({ lint: 'exit 1' }); // always-fail lint

  const emitted: OutboxPayload[] = [];
  const mockDeps = makeMockDeps({
    // runTask succeeds and commits
    runTask: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: 'ok' }),
    // verify passes
    verify: async () => ({ ok: true }),
    // no reviews needed — should not reach them
    reviewCode: async () => { throw new Error('should not reach review'); },
    reviewSecurity: async () => { throw new Error('should not reach review'); },
  });
  await runPipeline(job, mockDeps, (p) => emitted.push(p));

  const failed = emitted.find((e) => e.type === 'failed');
  assert.ok(failed, '应 emit failed');
  assert.ok((failed as { type: 'failed'; error: string }).error.includes('lint'), 'error 应说明 lint 失败');
});

test('diff体量闸 — 超过 diffMaxFiles 时阻止进入 exec_review', async () => {
  const job = makeJob({ complexity: 'easy' });
  job.project.diffMaxFiles = 2; // 限制 2 个文件

  const emitted: OutboxPayload[] = [];
  const mockDeps = makeMockDeps({
    runTask: async () => ({ ok: true, transcriptRef: null, sessionId: null, finalText: 'ok' }),
    verify: async () => ({ ok: true }),
    // worktreeDiff returns a summary with 5 files changed
    worktreeDiff: async () => ({
      diffSummary: ' a.ts | 1+\n b.ts | 1+\n c.ts | 1+\n d.ts | 1+\n e.ts | 1+\n 5 files changed, 5 insertions(+)',
      commits: ['abc foo'],
    }),
    reviewCode: async () => { throw new Error('should not reach'); },
    reviewSecurity: async () => { throw new Error('should not reach'); },
  });
  await runPipeline(job, mockDeps, (p) => emitted.push(p));

  const failed = emitted.find((e) => e.type === 'failed');
  assert.ok(failed, '应 emit failed');
  assert.ok((failed as { error: string }).error.includes('diff'), 'error 应说明 diff 超限');
});

Check the existing pipeline.test.ts structure for makeJob and makeMockDeps helpers — use the same pattern.

  • Step 2: Run tests to verify they fail
tsx --test test/pipeline.test.ts 2>&1 | grep -E "fail|Error" | head -5
  • Step 3: Add diffMaxFiles to schema.sql and db.ts

In /Users/wangjia/code/maestro/src/store/schema.sql, add to CREATE TABLE IF NOT EXISTS projects:

  diff_max_files     INTEGER NOT NULL DEFAULT 100    -- diff 体量闸:超过此文件数则硬失败(0=禁用)

In /Users/wangjia/code/maestro/src/store/db.ts, add:

ensureColumn(db, 'projects', 'diff_max_files', 'diff_max_files INTEGER NOT NULL DEFAULT 100');
  • Step 4: Add diffMaxFiles to types.ts and mappers.ts

In /Users/wangjia/code/maestro/src/model/types.ts, in Project interface add:

diffMaxFiles: number;    // diff 体量闸(默认 1000=禁用)

In /Users/wangjia/code/maestro/src/store/mappers.ts, in ProjectRow add:

diff_max_files: number;

In rowToProject add:

diffMaxFiles: r.diff_max_files ?? 100,
  • Step 5: Add checks分项闸 to pipeline.ts

In /Users/wangjia/code/maestro/src/executor/pipeline.ts, in the runPipeline function, after the verify step (around line 146), add:

  // 3b. 分项 checkslint/typecheck/build 等)— project.checks 是 JSON "{lint:'cmd', typecheck:'cmd'}"
  if (job.project.checks) {
    let checksObj: Record<string, string>;
    try {
      checksObj = JSON.parse(job.project.checks) as Record<string, string>;
    } catch {
      checksObj = {};
    }
    for (const [name, cmd] of Object.entries(checksObj)) {
      emit({ type: 'phase', phase: `checking:${name}` });
      try {
        const { execSync } = await import('node:child_process');
        execSync(cmd, { cwd: wt.dir, stdio: 'pipe', timeout: 60_000 });
      } catch (e) {
        const msg = (e as { stderr?: Buffer; message?: string }).stderr?.toString().trim()
          || (e as Error).message || `${name} 检查失败`;
        emit({ type: 'failed', error: `分项检查 [${name}] 失败:${msg.slice(0, 500)}`, transcriptRef: rr.transcriptRef, sessionId: rr.sessionId });
        emit({ type: 'done' });
        return;
      }
    }
  }
  • Step 6: Add diff体量闸 to pipeline.ts

In /Users/wangjia/code/maestro/src/executor/pipeline.ts, after the worktreeDiff call (around line 149), add:

  // 4b. diff 体量闸(文件数超限时硬失败)
  const maxFiles = job.project.diffMaxFiles ?? 100;
  if (maxFiles > 0) {
    const fileCount = diff.diffSummary.split('\n').filter((l) => l.includes('|')).length;
    if (fileCount > maxFiles) {
      emit({
        type: 'failed',
        error: `diff 体量超限:改动 ${fileCount} 个文件(上限 ${maxFiles}),请缩小任务范围`,
        transcriptRef: rr.transcriptRef,
        sessionId: rr.sessionId,
      });
      emit({ type: 'done' });
      return;
    }
  }
  • Step 7: Update PipelineDeps to make checks injectable for tests

The execSync in checks must be injectable for tests. Instead of importing inline, add an optional runCheck to PipelineDeps:

// In PipelineDeps interface:
runCheck?: (cmd: string, cwd: string) => void;  // throws on non-zero exit
// Default implementation in realDeps:
runCheck: (cmd, cwd) => {
  const { execSync } = require('node:child_process');
  execSync(cmd, { cwd, stdio: 'pipe', timeout: 60_000 });
},

Update the checks loop in pipeline.ts to use deps.runCheck ?? defaultRunCheck.

  • Step 8: Run tests
npm test 2>&1 | tail -20

Expected: new pipeline tests pass; all existing tests pass.

  • Step 9: Typecheck
npm run typecheck
  • Step 10: Commit
git add src/executor/pipeline.ts src/store/schema.sql src/store/db.ts src/model/types.ts src/store/mappers.ts test/pipeline.test.ts
git commit -m "feat(pipeline): 分项 checks 硬闸 + diff 体量闸(diffMaxFiles"

Final Verification

  • Run full test suite
npm test

Expected: all tests pass.

  • Run typecheck
npm run typecheck

Expected: no errors.

  • Build
npm run build

Expected: compiles cleanly to dist/.

  • Smoke test — start daemon and confirm it starts
MAESTRO_ORCH_INTERVAL=0 npm run dev &
sleep 3
curl -s http://127.0.0.1:4517/api/projects | head -5
kill %1

Expected: daemon starts, returns JSON for projects endpoint.


What Phase 2 Covers (not in this plan)

  • SSE streaming (cc.ts onToken → daemon EventEmitter → GET /api/tasks/:id/stream)
  • Hook contract (.maestro/hooks.ts + shell hooks)
  • Trace ID propagation (JobSpec.traceId, outbox携带)
  • Phase events → WebSocket broadcast
  • Backend restructuring (store Repos / daemon split / API routes)
  • Frontend migration (React 18 + Vite + Zustand)