From 3a2103dec1a05d94b159001be239ca310d88cdf0 Mon Sep 17 00:00:00 2001 From: zhoukailian <2415699291@qq.com> Date: Mon, 22 Jun 2026 09:40:23 +0800 Subject: [PATCH 1/2] feat: isolate v2 product dev qa runtime --- .npmignore | 4 + CHANGELOG.md | 7 + README.md | 10 +- package-lock.json | 22 +- package.json | 2 +- packages/cli/package.json | 8 +- packages/cli/src/index.ts | 467 +++++++++++++----- packages/migrator/package.json | 4 +- packages/migrator/src/migrator.ts | 2 +- packages/migrator/test/migrator.test.ts | 2 +- packages/migrator/vitest.config.ts | 2 + packages/runtime/package.json | 16 +- packages/runtime/src/V2/product-dev-qa.ts | 4 +- packages/runtime/src/index.ts | 119 +---- .../runtime/test/V2/product-dev-qa.test.ts | 2 +- packages/server/package.json | 4 +- packages/server/src/index.ts | 151 +++++- packages/server/test/server.test.ts | 2 +- packages/server/vitest.config.ts | 2 + 19 files changed, 528 insertions(+), 302 deletions(-) diff --git a/.npmignore b/.npmignore index 87357f3..05450a7 100644 --- a/.npmignore +++ b/.npmignore @@ -1,10 +1,14 @@ .test_tmp/ .DS_Store +.claude/ .venv/ .superpowers/ .worktrees/ docs/superpowers/ node_modules/ +__pycache__/ +*.py[cod] +.pytest_cache/ build/ *.tsbuildinfo .codex/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e30e73..48ab3ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [0.3.0-alpha.2] - 2026-06-21 + +- Split V1 and V2 runtime entrypoints so `product-dev-qa` uses the V2 store, config, worktree, status, and session routing paths without falling back to V1 runtime exports. +- Added explicit `@agent-team-runtime/runtime/V1` and `@agent-team-runtime/runtime/V2` subpath exports for versioned runtime consumers. +- Added the simplified V2 `deliver`/`approve` CLI path with isolated task worktrees by default and source session-index mirroring. +- Preserved cleaner V2 execution workflow summaries by leaving pending stage summaries empty until a stage actually runs. + ## [0.3.0-alpha.1] - 2026-06-16 - Added agent run heartbeats and reusable runtime status snapshots for observing long-running JS workflow stages. diff --git a/README.md b/README.md index d118afc..a6d55dd 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,14 @@ packages/runtime/test/ V1 和 V2 各自保留 `schema`、`store`、`runner`、`skill-routing` 等基础文件;公共逻辑允许重复,优先保证分层边界清楚。 +Runtime package 的导入边界也按版本显式区分: + +- `@agent-team-runtime/runtime`:只保留 V1 兼容导出,供旧调用方继续使用。 +- `@agent-team-runtime/runtime/V1`:V1 runtime 的显式入口。 +- `@agent-team-runtime/runtime/V2`:V2 `product-dev-qa` workflow 的显式入口。 + +V2 命令链路不能从 V1 `RuntimeStore`、V1 config 或 V1 worktree helper 读取状态。CLI 的 `agt deliver`、`agt approve`、`agt run --workflow product-dev-qa`、以及 product-dev-qa session 的 `status/inspect/continue` 都通过 V2 runtime 入口执行;跨 worktree 的 `session-index.json` 只是共享索引文件,不代表 V2 依赖 V1 runtime。 + ## 安装 前提: @@ -146,7 +154,7 @@ agt approve 第一次 `go` 通过 Product 需求合同检查,第二次 `go` 通过 Dev 技术方案检查。Dev 实现完成后会自动进入 QA;QA 失败会直接回到 `dev:implementation` 生成新 attempt。AGT 不会自动 commit、push、merge 或发 PR。 -`agt deliver` 等价于 `agt run "..." --workflow product-dev-qa --task-worktree`,默认会为需求创建隔离 worktree。`agt approve` 等价于对当前最新 session 执行 `go` 决策。 +`agt deliver` 是 V2 `product-dev-qa` 的简化入口,默认会为需求创建隔离 worktree。`agt run "..." --workflow product-dev-qa` 也会走 V2 runtime,但仍遵守 `run` 的 worktree 语义:只有显式传 `--task-worktree` 或项目配置启用 task worktree 时才创建 worktree。`agt approve` 等价于对当前最新 V2 session 执行 `go` 决策。 常用参数: diff --git a/package-lock.json b/package-lock.json index f357515..249a351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-team-runtime", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-team-runtime", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "workspaces": [ "apps/web", "packages/*" @@ -5841,11 +5841,11 @@ }, "packages/cli": { "name": "@agent-team-runtime/cli", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "dependencies": { - "@agent-team-runtime/migrator": "0.3.0-alpha.1", - "@agent-team-runtime/runtime": "0.3.0-alpha.1", - "@agent-team-runtime/server": "0.3.0-alpha.1", + "@agent-team-runtime/migrator": "0.3.0-alpha.2", + "@agent-team-runtime/runtime": "0.3.0-alpha.2", + "@agent-team-runtime/server": "0.3.0-alpha.2", "commander": "^14.0.2" }, "bin": { @@ -5858,9 +5858,9 @@ }, "packages/migrator": { "name": "@agent-team-runtime/migrator", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "dependencies": { - "@agent-team-runtime/runtime": "0.3.0-alpha.1", + "@agent-team-runtime/runtime": "0.3.0-alpha.2", "zod": "^4.1.12" }, "devDependencies": { @@ -5871,7 +5871,7 @@ }, "packages/runtime": { "name": "@agent-team-runtime/runtime", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "dependencies": { "@openai/agents": "^0.11.6", "execa": "^9.6.0", @@ -5885,9 +5885,9 @@ }, "packages/server": { "name": "@agent-team-runtime/server", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "dependencies": { - "@agent-team-runtime/runtime": "0.3.0-alpha.1", + "@agent-team-runtime/runtime": "0.3.0-alpha.2", "@fastify/static": "^9.1.3", "@fastify/websocket": "^11.2.0", "fastify": "^5.6.2" diff --git a/package.json b/package.json index 12696bf..1e85a37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-team-runtime", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "private": false, "type": "module", "description": "Traceable JS agent workflow runtime for software tasks.", diff --git a/packages/cli/package.json b/packages/cli/package.json index 45ea5ca..1ab5613 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@agent-team-runtime/cli", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -12,9 +12,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@agent-team-runtime/migrator": "0.3.0-alpha.1", - "@agent-team-runtime/runtime": "0.3.0-alpha.1", - "@agent-team-runtime/server": "0.3.0-alpha.1", + "@agent-team-runtime/migrator": "0.3.0-alpha.2", + "@agent-team-runtime/runtime": "0.3.0-alpha.2", + "@agent-team-runtime/server": "0.3.0-alpha.2", "commander": "^14.0.2" }, "devDependencies": { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1b14750..a11f1f7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,27 +1,36 @@ #!/usr/bin/env node import { Command } from "commander"; import { createHash } from "node:crypto"; -import { readFile, readdir, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { migrateLegacySessions } from "@agent-team-runtime/migrator"; import { - createTaskWorktree, - initRuntime, - isProductDevQaSession, - PRODUCT_DEV_QA_WORKFLOW_ID, - recordProductDevQaHumanDecision, recordHumanDecision, - readSessionStatus, - runProductDevQaWorkflow, runWorkflow, + initRuntime, + createTaskWorktree, + readSessionStatus, type AgentRole, type RequestSourceRecord, type RunResult, type RuntimeConfig, type SessionStatusSnapshot, type RuntimeProfile, - RuntimeStore, -} from "@agent-team-runtime/runtime"; + RuntimeStore as V1RuntimeStore, +} from "@agent-team-runtime/runtime/V1"; +import { + createTaskWorktree as createV2TaskWorktree, + isProductDevQaSession, + PRODUCT_DEV_QA_WORKFLOW_ID, + recordProductDevQaHumanDecision, + readSessionStatus as readV2SessionStatus, + runProductDevQaWorkflow, + RuntimeStore as V2RuntimeStore, + type RunResult as V2RunResult, + type RuntimeProfile as V2RuntimeProfile, + type SessionStatusSnapshot as V2SessionStatusSnapshot, +} from "@agent-team-runtime/runtime/V2"; import { runServer } from "@agent-team-runtime/server"; const program = new Command(); @@ -29,7 +38,7 @@ const program = new Command(); program .name("agt") .description("Agent Team Runtime JS CLI") - .version("0.3.0-alpha.1"); + .version("0.3.0-alpha.2"); program .command("init") @@ -86,18 +95,14 @@ program .action(async (messageParts: string[], options: RunOptions) => { const sourceRepoRoot = path.resolve(options.repoRoot); const sourceStateRoot = path.resolve(options.stateRoot ?? path.join(sourceRepoRoot, ".agt")); - const sourceStore = new RuntimeStore(sourceStateRoot); - const config = await sourceStore.loadConfig(); if (options.continue || options.sessionId) { - const target = await resolveContinuation(sourceStore, options.sessionId); + const target = await resolveContinuation(sourceStateRoot, options.sessionId); const result = await runExistingWorkflow({ - repoRoot: target.repoRoot, - stateRoot: target.stateRoot, - sessionId: target.sessionId, - humanGates: Boolean(options.humanGates || config.human_gates), + target, + humanGates: options.humanGates, }); - await mirrorSessionIndex(sourceStore, target.stateRoot, result.session_id); + await mirrorSessionIndex(sourceStateRoot, target.stateRoot, result.session_id); printResult(result); return; } @@ -108,6 +113,23 @@ program throw new Error("agt run requires a request message, --from, or --from-dir."); } + const workflowId = options.workflow ? parseWorkflowId(options.workflow) : ""; + if (workflowId === PRODUCT_DEV_QA_WORKFLOW_ID) { + const result = await startProductDevQaRun({ + sourceRepoRoot, + sourceStateRoot, + request, + requestInput, + profile: options.profile ? (parseProfile(options.profile) as V2RuntimeProfile) : undefined, + taskWorktree: options.taskWorktree, + defaultTaskWorktree: false, + }); + printResult(result); + return; + } + + const sourceStore = new V1RuntimeStore(sourceStateRoot); + const config = await sourceStore.loadConfig(); const useTaskWorktree = options.taskWorktree || (config.task_worktree.enabled && options.taskWorktree !== false); let repoRoot = sourceRepoRoot; let stateRoot = sourceStateRoot; @@ -126,29 +148,18 @@ program console.log(`branch: ${worktree.branch}`); } - const workflowId = options.workflow ? parseWorkflowId(options.workflow) : ""; - const result = workflowId === PRODUCT_DEV_QA_WORKFLOW_ID - ? await runProductDevQaWorkflow({ - request, - profile: options.profile ? parseProfile(options.profile) : config.default_profile, - repoRoot, - projectRoot: sourceRepoRoot, - stateRoot, - worktree, - requestSources: requestInput.sources, - }) - : await runWorkflow({ - request, - profile: options.profile ? parseProfile(options.profile) : config.default_profile, - repoRoot, - projectRoot: sourceRepoRoot, - stateRoot, - worktree, - requestSources: requestInput.sources, - humanGates: Boolean(options.humanGates || config.human_gates), - }); + const result = await runWorkflow({ + request, + profile: options.profile ? parseProfile(options.profile) : config.default_profile, + repoRoot, + projectRoot: sourceRepoRoot, + stateRoot, + worktree, + requestSources: requestInput.sources, + humanGates: Boolean(options.humanGates || config.human_gates), + }); if (useTaskWorktree) { - await mirrorSessionIndex(sourceStore, stateRoot, result.session_id); + await mirrorSessionIndex(sourceStateRoot, stateRoot, result.session_id); } printResult(result); }); @@ -174,11 +185,9 @@ program .option("--target-role ", "role to reset to when --decision rework") .option("--state-root ", "state root", path.join(process.cwd(), ".agt")) .action(async (sessionId: string, options: DecisionOptions) => { - const sourceStore = new RuntimeStore(path.resolve(options.stateRoot)); - const target = await resolveSessionStore(sourceStore, sessionId); - const targetStore = new RuntimeStore(target.stateRoot); - const productDevQa = await isProductDevQaSession(targetStore, target.sessionId); - const result = productDevQa + const sourceStateRoot = path.resolve(options.stateRoot); + const target = await resolveSessionTarget(sourceStateRoot, sessionId); + const result = target.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID ? await recordProductDevQaHumanDecision({ stateRoot: target.stateRoot, sessionId: target.sessionId, @@ -190,7 +199,7 @@ program decision: parseDecision(options.decision), targetRole: options.targetRole ? parseAgentRole(options.targetRole) : undefined, }); - await mirrorSessionIndex(sourceStore, target.stateRoot, result.session_id); + await mirrorSessionIndex(sourceStateRoot, target.stateRoot, result.session_id); printResult(result); }); @@ -213,11 +222,11 @@ program .option("--stalled-after ", "mark running agent runs as stalled after this many seconds without heartbeat", "120") .option("--json", "print JSON status") .action(async (sessionId: string | undefined, options: StatusOptions) => { - const store = new RuntimeStore(path.resolve(options.stateRoot)); + const sourceStateRoot = path.resolve(options.stateRoot); const target = sessionId - ? await resolveSessionStore(store, sessionId) - : await resolveLatestSessionStore(store); - const targetStore = new RuntimeStore(target.stateRoot); + ? await resolveSessionTarget(sourceStateRoot, sessionId) + : await resolveLatestSessionTarget(sourceStateRoot); + const targetStore = runtimeStoreForSession(target); const intervalMs = parsePositiveSeconds(options.interval, "--interval") * 1000; const stalledAfterMs = parsePositiveSeconds(options.stalledAfter, "--stalled-after") * 1000; await printStatusOnce(targetStore, target.sessionId, { ...options, stalledAfterMs }); @@ -238,9 +247,8 @@ program .argument("", "session id") .option("--state-root ", "state root", path.join(process.cwd(), ".agt")) .action(async (sessionId: string, options: { stateRoot: string }) => { - const sourceStore = new RuntimeStore(path.resolve(options.stateRoot)); - const target = await resolveSessionStore(sourceStore, sessionId); - const store = new RuntimeStore(target.stateRoot); + const target = await resolveSessionTarget(path.resolve(options.stateRoot), sessionId); + const store = runtimeStoreForSession(target); const session = await store.loadSession(target.sessionId); const deliveryWorkflow = await store.loadDeliveryWorkflow(target.sessionId); const executionWorkflow = await store.loadExecutionWorkflow(target.sessionId); @@ -348,6 +356,60 @@ type StatusOptions = { json?: boolean; }; +type SessionTarget = { + sessionId: string; + repoRoot: string; + stateRoot: string; + workflowId: string; +}; + +type RuntimeStoreLike = V1RuntimeStore | V2RuntimeStore; +type AnyRunResult = RunResult | V2RunResult; +type AnySessionStatusSnapshot = SessionStatusSnapshot | V2SessionStatusSnapshot; +type RequestInput = { request: string; sources: RequestSourceRecord[] }; + +type SessionMeta = { + session_id: string; + request: string; + workflow_id: string; + delivery_status: "in_progress" | "waiting_human" | "blocked" | "done"; + execution_status: "in_progress" | "waiting_human" | "blocked" | "done"; + status: "in_progress" | "waiting_human" | "blocked" | "done"; + current_phase: "requirement" | "development" | "verification" | "handoff"; + current_stage: string; + profile: "quick" | "investigate" | "full"; + project_root: string; + repo_root: string; + state_root: string; + created_at: string; + updated_at: string; + worktree?: { + branch?: string; + base_ref?: string; + base_commit?: string; + }; +}; + +type SessionIndexEntry = { + session_id: string; + request: string; + workflow_id: string; + delivery_status: SessionMeta["delivery_status"]; + execution_status: SessionMeta["execution_status"]; + status: SessionMeta["status"]; + current_phase: SessionMeta["current_phase"]; + current_stage: string; + profile: SessionMeta["profile"]; + project_root: string; + worktree_path: string; + state_root: string; + branch: string; + base_ref: string; + base_commit: string; + updated_at: string; + created_at: string; +}; + function parseProfile(value: string): RuntimeProfile { if (value === "quick" || value === "investigate" || value === "full") { return value; @@ -474,86 +536,152 @@ function sourceRecord(type: RequestSourceRecord["type"], filePath: string, conte }; } -async function resolveContinuation(store: RuntimeStore, requestedSessionId?: string): Promise<{ - sessionId: string; - repoRoot: string; - stateRoot: string; -}> { +async function resolveContinuation(sourceStateRoot: string, requestedSessionId?: string): Promise { if (requestedSessionId) { - return resolveSessionStore(store, requestedSessionId); + return resolveSessionTarget(sourceStateRoot, requestedSessionId); } - const index = await store.loadSessionIndex(); + const index = await readSessionIndex(sourceStateRoot); const entry = index.sessions.find((item) => item.status === "in_progress" || item.status === "waiting_human"); if (entry) { - return { sessionId: entry.session_id, repoRoot: entry.worktree_path, stateRoot: entry.state_root }; + return hydrateSessionTarget(entry.state_root, entry.session_id, { + repoRoot: entry.worktree_path, + workflowId: entry.workflow_id, + }); } - const latest = await store.latestSession(); + const latest = await latestSessionInStateRoot(sourceStateRoot); if (!latest) { throw new Error("No unfinished session found."); } - return { sessionId: latest.session_id, repoRoot: latest.repo_root, stateRoot: latest.state_root }; + return latest; } -async function resolveSessionStore(store: RuntimeStore, requestedSessionId: string): Promise<{ - sessionId: string; - repoRoot: string; - stateRoot: string; -}> { - try { - const session = await store.loadSession(requestedSessionId); - return { sessionId: session.session_id, repoRoot: session.repo_root, stateRoot: session.state_root }; - } catch { - const index = await store.loadSessionIndex(); - const entry = index.sessions.find((item) => item.session_id === requestedSessionId); - if (!entry) { - throw new Error(`Session not found: ${requestedSessionId}`); - } - const targetStore = new RuntimeStore(entry.state_root); - const session = await targetStore.loadSession(requestedSessionId); - return { sessionId: session.session_id, repoRoot: session.repo_root, stateRoot: session.state_root }; +async function resolveSessionTarget(sourceStateRoot: string, requestedSessionId: string): Promise { + const local = await hydrateSessionTarget(sourceStateRoot, requestedSessionId).catch(() => null); + if (local) { + return local; + } + + const index = await readSessionIndex(sourceStateRoot); + const entry = index.sessions.find((item) => item.session_id === requestedSessionId); + if (!entry) { + throw new Error(`Session not found: ${requestedSessionId}`); } + return hydrateSessionTarget(entry.state_root, requestedSessionId, { + repoRoot: entry.worktree_path, + workflowId: entry.workflow_id, + }); } -async function resolveLatestSessionStore(store: RuntimeStore): Promise<{ - sessionId: string; - repoRoot: string; - stateRoot: string; -}> { - const latest = await store.latestSession(); - const index = await store.loadSessionIndex(); +async function resolveLatestSessionTarget(sourceStateRoot: string): Promise { + const latest = await latestSessionInStateRoot(sourceStateRoot); + const index = await readSessionIndex(sourceStateRoot); const indexed = index.sessions[0]; - if (indexed && (!latest || indexed.updated_at.localeCompare(latest.updated_at) >= 0)) { - return { sessionId: indexed.session_id, repoRoot: indexed.worktree_path, stateRoot: indexed.state_root }; + if (indexed && (!latest || indexed.updated_at.localeCompare(latest.updatedAt) >= 0)) { + return hydrateSessionTarget(indexed.state_root, indexed.session_id, { + repoRoot: indexed.worktree_path, + workflowId: indexed.workflow_id, + }); } if (latest) { - return { sessionId: latest.session_id, repoRoot: latest.repo_root, stateRoot: latest.state_root }; + return latest; } throw new Error("No sessions found."); } +async function latestSessionInStateRoot(stateRoot: string): Promise<(SessionTarget & { updatedAt: string }) | null> { + const index = await readSessionIndex(stateRoot); + if (index.sessions.length > 0) { + const indexed = index.sessions[0]!; + const target = await hydrateSessionTarget(indexed.state_root, indexed.session_id, { + repoRoot: indexed.worktree_path, + workflowId: indexed.workflow_id, + updatedAt: indexed.updated_at, + }); + return { ...target, updatedAt: target.updatedAt ?? indexed.updated_at }; + } + const sessions = await listLocalSessionMetas(stateRoot); + const candidates = sessions.map((session) => ({ + sessionId: session.session_id, + repoRoot: session.repo_root, + stateRoot: session.state_root, + workflowId: session.workflow_id ?? "", + updatedAt: session.updated_at, + })); + candidates.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + return candidates[0] ?? null; +} + +async function hydrateSessionTarget( + stateRoot: string, + sessionId: string, + fallback?: { repoRoot?: string; workflowId?: string; updatedAt?: string }, +): Promise { + const session = await readSessionMeta(stateRoot, sessionId).catch(() => null); + if (session) { + return { + sessionId: session.session_id, + repoRoot: session.repo_root, + stateRoot: session.state_root, + workflowId: session.workflow_id ?? "", + updatedAt: session.updated_at, + }; + } + + if (fallback) { + return { + sessionId, + repoRoot: fallback.repoRoot ?? "", + stateRoot, + workflowId: fallback.workflowId ?? "", + updatedAt: fallback.updatedAt, + }; + } + + throw new Error(`Session not found: ${sessionId}`); +} + async function startProductDevQaDelivery( messageParts: string[], options: DeliverOptions, -): Promise { +): Promise { const sourceRepoRoot = path.resolve(options.repoRoot); const sourceStateRoot = path.resolve(options.stateRoot ?? path.join(sourceRepoRoot, ".agt")); - const sourceStore = new RuntimeStore(sourceStateRoot); - const config = await sourceStore.loadConfig(); const requestInput = await readRequestInput(messageParts, options); const request = requestInput.request; if (!request) { throw new Error("agt deliver requires a request message, --from, or --from-dir."); } - const useTaskWorktree = options.taskWorktree !== false; - let repoRoot = sourceRepoRoot; - let stateRoot = sourceStateRoot; + return startProductDevQaRun({ + sourceRepoRoot, + sourceStateRoot, + request, + requestInput, + taskWorktree: options.taskWorktree, + defaultTaskWorktree: true, + }); +} + +async function startProductDevQaRun(args: { + sourceRepoRoot: string; + sourceStateRoot: string; + request: string; + requestInput: RequestInput; + profile?: V2RuntimeProfile; + taskWorktree?: boolean; + defaultTaskWorktree: boolean; +}): Promise { + const sourceStore = new V2RuntimeStore(args.sourceStateRoot); + const config = await sourceStore.loadConfig(); + const useTaskWorktree = args.taskWorktree ?? (args.defaultTaskWorktree || config.task_worktree.enabled); + let repoRoot = args.sourceRepoRoot; + let stateRoot = args.sourceStateRoot; let worktree; if (useTaskWorktree) { - const created = await createTaskWorktree({ - projectRoot: sourceRepoRoot, - stateRoot: sourceStateRoot, - request, + const created = await createV2TaskWorktree({ + projectRoot: args.sourceRepoRoot, + stateRoot: args.sourceStateRoot, + request: args.request, config, }); repoRoot = created.repoRoot; @@ -564,16 +692,16 @@ async function startProductDevQaDelivery( } const result = await runProductDevQaWorkflow({ - request, - profile: config.default_profile, + request: args.request, + profile: args.profile ?? config.default_profile, repoRoot, - projectRoot: sourceRepoRoot, + projectRoot: args.sourceRepoRoot, stateRoot, worktree, - requestSources: requestInput.sources, + requestSources: args.requestInput.sources, }); if (useTaskWorktree) { - await mirrorSessionIndex(sourceStore, stateRoot, result.session_id); + await mirrorSessionIndex(args.sourceStateRoot, stateRoot, result.session_id); } return result; } @@ -581,12 +709,12 @@ async function startProductDevQaDelivery( async function approveProductDevQaGate( sessionId: string | undefined, options: ApproveOptions, -): Promise { - const sourceStore = new RuntimeStore(path.resolve(options.stateRoot)); +): Promise { + const sourceStateRoot = path.resolve(options.stateRoot); const target = sessionId - ? await resolveSessionStore(sourceStore, sessionId) - : await resolveLatestSessionStore(sourceStore); - const targetStore = new RuntimeStore(target.stateRoot); + ? await resolveSessionTarget(sourceStateRoot, sessionId) + : await resolveLatestSessionTarget(sourceStateRoot); + const targetStore = new V2RuntimeStore(target.stateRoot); const productDevQa = await isProductDevQaSession(targetStore, target.sessionId); if (!productDevQa) { throw new Error("agt approve only supports product-dev-qa sessions. Use `agt decision --decision go` for other workflows."); @@ -596,39 +724,64 @@ async function approveProductDevQaGate( sessionId: target.sessionId, decision: "go", }); - await mirrorSessionIndex(sourceStore, target.stateRoot, result.session_id); + await mirrorSessionIndex(sourceStateRoot, target.stateRoot, result.session_id); return result; } -async function mirrorSessionIndex(sourceStore: RuntimeStore, targetStateRoot: string, sessionId: string): Promise { - const targetStore = new RuntimeStore(targetStateRoot); - const session = await targetStore.loadSession(sessionId); - await sourceStore.upsertSessionIndex(session); +async function mirrorSessionIndex(sourceStateRoot: string, targetStateRoot: string, sessionId: string): Promise { + const target = await hydrateSessionTarget(targetStateRoot, sessionId); + const session = await readSessionMeta(target.stateRoot, target.sessionId); + const index = await readSessionIndex(sourceStateRoot); + const entry: SessionIndexEntry = { + session_id: session.session_id, + request: session.request, + workflow_id: session.workflow_id ?? "", + delivery_status: session.delivery_status, + execution_status: session.execution_status, + status: session.status, + current_phase: session.current_phase, + current_stage: session.current_stage, + profile: session.profile, + project_root: session.project_root || session.repo_root, + worktree_path: session.repo_root, + state_root: session.state_root, + branch: session.worktree?.branch ?? "", + base_ref: session.worktree?.base_ref ?? "", + base_commit: session.worktree?.base_commit ?? "", + created_at: session.created_at, + updated_at: session.updated_at, + }; + const existing = index.sessions.findIndex((item) => item.session_id === session.session_id); + if (existing >= 0) { + index.sessions[existing] = { ...index.sessions[existing], ...entry, created_at: index.sessions[existing].created_at }; + } else { + index.sessions.push(entry); + } + index.sessions.sort((left, right) => right.updated_at.localeCompare(left.updated_at)); + await writeSessionIndex(sourceStateRoot, index); } async function runExistingWorkflow(args: { - repoRoot: string; - stateRoot: string; - sessionId: string; - humanGates: boolean; -}): Promise { - const store = new RuntimeStore(args.stateRoot); - if (await isProductDevQaSession(store, args.sessionId)) { + target: SessionTarget; + humanGates?: boolean; +}): Promise { + if (args.target.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID) { return runProductDevQaWorkflow({ - repoRoot: args.repoRoot, - stateRoot: args.stateRoot, - sessionId: args.sessionId, + repoRoot: args.target.repoRoot, + stateRoot: args.target.stateRoot, + sessionId: args.target.sessionId, }); } + const config = await new V1RuntimeStore(args.target.stateRoot).loadConfig(); return runWorkflow({ - repoRoot: args.repoRoot, - stateRoot: args.stateRoot, - sessionId: args.sessionId, - humanGates: args.humanGates, + repoRoot: args.target.repoRoot, + stateRoot: args.target.stateRoot, + sessionId: args.target.sessionId, + humanGates: Boolean(args.humanGates || config.human_gates), }); } -function printResult(result: RunResult): void { +function printResult(result: AnyRunResult): void { console.log(`session_id: ${result.session_id}`); console.log(`profile: ${result.profile}`); console.log(`delivery_status: ${result.delivery_status}`); @@ -645,11 +798,13 @@ function printResult(result: RunResult): void { } async function printStatusOnce( - store: RuntimeStore, + store: RuntimeStoreLike, sessionId: string, options: StatusOptions & { stalledAfterMs: number }, -): Promise { - const snapshot = await readSessionStatus(store, sessionId, { stalledAfterMs: options.stalledAfterMs }); +): Promise { + const snapshot = store instanceof V2RuntimeStore + ? await readV2SessionStatus(store, sessionId, { stalledAfterMs: options.stalledAfterMs }) + : await readSessionStatus(store, sessionId, { stalledAfterMs: options.stalledAfterMs }); if (options.json) { console.log(JSON.stringify(snapshot, null, 2)); } else { @@ -658,7 +813,7 @@ async function printStatusOnce( return snapshot; } -function printStatus(snapshot: SessionStatusSnapshot): void { +function printStatus(snapshot: AnySessionStatusSnapshot): void { console.log(`generated_at: ${snapshot.generated_at}`); console.log(`session_id: ${snapshot.session_id}`); console.log(`profile: ${snapshot.profile}`); @@ -725,7 +880,47 @@ function printIndentedValue(label: string, value: string, continuationIndent: st } } -function isWatchableRuntimeStatus(status: SessionStatusSnapshot["runtime_status"]): boolean { +function runtimeStoreForSession(target: SessionTarget): RuntimeStoreLike { + return target.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID + ? new V2RuntimeStore(target.stateRoot) + : new V1RuntimeStore(target.stateRoot); +} + +async function readSessionMeta(stateRoot: string, sessionId: string): Promise { + const sessionPath = path.join(stateRoot, "sessions", sessionId, "session.json"); + return JSON.parse(await readFile(sessionPath, "utf8")) as SessionMeta; +} + +async function readSessionIndex(stateRoot: string): Promise<{ schema_version: 1; sessions: SessionIndexEntry[] }> { + const indexPath = path.join(stateRoot, "session-index.json"); + if (!existsSync(indexPath)) { + return { schema_version: 1, sessions: [] }; + } + return JSON.parse(await readFile(indexPath, "utf8")) as { schema_version: 1; sessions: SessionIndexEntry[] }; +} + +async function listLocalSessionMetas(stateRoot: string): Promise { + const sessionsDir = path.join(stateRoot, "sessions"); + if (!existsSync(sessionsDir)) { + return []; + } + const entries = await readdir(sessionsDir, { withFileTypes: true }); + const sessions = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => readSessionMeta(stateRoot, entry.name).catch(() => null)), + ); + return sessions + .filter((session): session is SessionMeta => session !== null) + .sort((left, right) => right.updated_at.localeCompare(left.updated_at)); +} + +async function writeSessionIndex(stateRoot: string, index: { schema_version: 1; sessions: SessionIndexEntry[] }): Promise { + await mkdir(stateRoot, { recursive: true }); + await writeFile(path.join(stateRoot, "session-index.json"), `${JSON.stringify(index, null, 2)}\n`); +} + +function isWatchableRuntimeStatus(status: AnySessionStatusSnapshot["runtime_status"]): boolean { return status === "running" || status === "stalled" || status === "in_progress"; } diff --git a/packages/migrator/package.json b/packages/migrator/package.json index bce4c81..db6aefb 100644 --- a/packages/migrator/package.json +++ b/packages/migrator/package.json @@ -1,6 +1,6 @@ { "name": "@agent-team-runtime/migrator", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -10,7 +10,7 @@ "test": "vitest run" }, "dependencies": { - "@agent-team-runtime/runtime": "0.3.0-alpha.1", + "@agent-team-runtime/runtime": "0.3.0-alpha.2", "zod": "^4.1.12" }, "devDependencies": { diff --git a/packages/migrator/src/migrator.ts b/packages/migrator/src/migrator.ts index 1c852ad..e2f865a 100644 --- a/packages/migrator/src/migrator.ts +++ b/packages/migrator/src/migrator.ts @@ -13,7 +13,7 @@ import { readJson, writeJson, nowIso, -} from "@agent-team-runtime/runtime"; +} from "@agent-team-runtime/runtime/V1"; export type MigrationOptions = { sourceRoot: string; diff --git a/packages/migrator/test/migrator.test.ts b/packages/migrator/test/migrator.test.ts index e4e022e..1ddd934 100644 --- a/packages/migrator/test/migrator.test.ts +++ b/packages/migrator/test/migrator.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { RuntimeStore } from "@agent-team-runtime/runtime"; +import { RuntimeStore } from "@agent-team-runtime/runtime/V1"; import { migrateLegacySessions } from "../src/index.js"; describe("migrator", () => { diff --git a/packages/migrator/vitest.config.ts b/packages/migrator/vitest.config.ts index 970ba75..05d757b 100644 --- a/packages/migrator/vitest.config.ts +++ b/packages/migrator/vitest.config.ts @@ -4,6 +4,8 @@ import path from "node:path"; export default defineConfig({ resolve: { alias: { + "@agent-team-runtime/runtime/V1": path.resolve(__dirname, "../runtime/src/V1/index.ts"), + "@agent-team-runtime/runtime/V2": path.resolve(__dirname, "../runtime/src/V2/index.ts"), "@agent-team-runtime/runtime": path.resolve(__dirname, "../runtime/src/index.ts"), "@agent-team-runtime/migrator": path.resolve(__dirname, "src/index.ts"), }, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b766b17..97bc6b6 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,9 +1,23 @@ { "name": "@agent-team-runtime/runtime", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./V1": { + "types": "./dist/V1/index.d.ts", + "default": "./dist/V1/index.js" + }, + "./V2": { + "types": "./dist/V2/index.d.ts", + "default": "./dist/V2/index.js" + } + }, "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/packages/runtime/src/V2/product-dev-qa.ts b/packages/runtime/src/V2/product-dev-qa.ts index 8762360..d6d8062 100644 --- a/packages/runtime/src/V2/product-dev-qa.ts +++ b/packages/runtime/src/V2/product-dev-qa.ts @@ -344,7 +344,7 @@ async function ensureProductDevQaExecutionWorkflow( artifact_path: "", files_changed: [], commands_run: [], - summary: STAGES[key].currentStage, + summary: "", })), updated_at: updatedAt, })); @@ -1001,7 +1001,7 @@ async function resetExecutionFromStage( files_changed: [], commands_run: [], completed_at: undefined, - summary: STAGES[EXECUTION_STAGE_ORDER[index] as StageKey].currentStage, + summary: "", } : step, ), diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 294688b..c773f5e 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,118 +1 @@ -export { - RuntimeStore, - readJson, - writeJson, - type CreateSessionOptions, -} from "./V1/store.js"; -export { - runWorkflow, - recordHumanDecision, - stepsForProfile, - type RunWorkflowOptions, - type WorkflowStepPlan, -} from "./V1/profiles.js"; -export { - readSessionStatus, - buildSessionStatusSnapshot, - summarizeAgentRun, - DEFAULT_STALLED_AFTER_MS, - type AgentRunRuntimeStatus, - type SessionRuntimeStatus, - type AgentRunStatusSnapshot, - type SessionStatusSnapshot, -} from "./V1/status.js"; -export { - createInitialDeliveryWorkflow, - projectDeliveryWorkflow, - deliveryPhaseForRole, - DELIVERY_PHASES, -} from "./V1/delivery-projector.js"; -export { - buildAgentRunner, - OpenAISandboxRunner, - LocalFallbackRunner, - type AgentRunner, - type AgentTask, - type AgentTaskResult, -} from "./V1/runner.js"; -export { - initRuntime, - createTaskWorktree, - currentBranch, - type InitRuntimeOptions, - type TaskWorktreeResult, -} from "./V1/worktree.js"; -export { - renderSkillInjection, - resolveSkillRouting, - skillRoutingMetadata, - type SkillRoutingDecision, - type ResolvedSkill, -} from "./V1/skill-routing.js"; -export { - nowIso, - ProfileSchema, - WorkflowStatusSchema, - DeliveryPhaseSchema, - DeliveryPhaseStatusSchema, - AgentRoleSchema, - ToolCallKindSchema, - WorktreeRecordSchema, - RequestSourceSchema, - SessionSchema, - EvidenceRefSchema, - DeliveryBlockerSchema, - DeliveryWorkflowPhaseSchema, - DeliveryWorkflowSchema, - ExecutionWorkflowStepSchema, - ExecutionWorkflowSchema, - RuntimeEventSchema, - AgentRunSchema, - PromptTraceSchema, - ArtifactRecordSchema, - ToolCallSchema, - RunResultSchema, - ProductDevQaStageRoleSchema, - ProductDevQaStageStepSchema, - ProductDevQaWorkflowRunStatusSchema, - ProductDevQaWorkflowRunSchema, - RuntimeConfigSchema, - SessionIndexEntrySchema, - SessionIndexSchema, - type RuntimeProfile, - type WorkflowStatus, - type DeliveryPhase, - type DeliveryPhaseStatus, - type AgentRole, - type ToolCallKind, - type WorktreeRecord, - type RequestSourceRecord, - type SessionRecord, - type EvidenceRef, - type DeliveryBlocker, - type DeliveryWorkflowPhase, - type DeliveryWorkflowRecord, - type ExecutionWorkflowStep, - type ExecutionWorkflowRecord, - type RuntimeEvent, - type AgentRunRecord, - type PromptTraceRecord, - type ArtifactRecord, - type ToolCallRecord, - type RunResult, - type ProductDevQaStageRole, - type ProductDevQaStageStep, - type ProductDevQaWorkflowRunStatus, - type ProductDevQaWorkflowRunRecord, - type RuntimeConfig, - type SessionIndexEntry, - type SessionIndex, -} from "./V1/schema.js"; -export { - PRODUCT_DEV_QA_WORKFLOW_ID, - runProductDevQaWorkflow, - recordProductDevQaHumanDecision, - isProductDevQaSession, - type RunProductDevQaWorkflowOptions, - type RecordProductDevQaDecisionOptions, -} from "./V2/product-dev-qa.js"; +export * from "./V1/index.js"; diff --git a/packages/runtime/test/V2/product-dev-qa.test.ts b/packages/runtime/test/V2/product-dev-qa.test.ts index 7c51aef..869da31 100644 --- a/packages/runtime/test/V2/product-dev-qa.test.ts +++ b/packages/runtime/test/V2/product-dev-qa.test.ts @@ -10,7 +10,7 @@ import { recordProductDevQaHumanDecision, runProductDevQaWorkflow, RuntimeStore, -} from "../../src/index.js"; +} from "../../src/V2/index.js"; class ProductDevQaRunner implements AgentRunner { readonly name = "local_fallback" as const; diff --git a/packages/server/package.json b/packages/server/package.json index 6ee9170..fcdd81b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@agent-team-runtime/server", - "version": "0.3.0-alpha.1", + "version": "0.3.0-alpha.2", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -10,7 +10,7 @@ "test": "vitest run" }, "dependencies": { - "@agent-team-runtime/runtime": "0.3.0-alpha.1", + "@agent-team-runtime/runtime": "0.3.0-alpha.2", "@fastify/static": "^9.1.3", "@fastify/websocket": "^11.2.0", "fastify": "^5.6.2" diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 46e8b38..8e570a7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,20 +2,25 @@ import Fastify from "fastify"; import fastifyStatic from "@fastify/static"; import websocket from "@fastify/websocket"; import { existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { - RuntimeStore, type AgentRunRecord, type ArtifactRecord, type DeliveryWorkflowRecord, type ExecutionWorkflowRecord, - PRODUCT_DEV_QA_WORKFLOW_ID, type ProductDevQaWorkflowRunRecord, type PromptTraceRecord, type RuntimeEvent, - type SessionRecord, + type SessionRecord as V1SessionRecord, type ToolCallRecord, -} from "@agent-team-runtime/runtime"; + RuntimeStore as V1RuntimeStore, +} from "@agent-team-runtime/runtime/V1"; +import { + PRODUCT_DEV_QA_WORKFLOW_ID, + RuntimeStore as V2RuntimeStore, + type SessionRecord as V2SessionRecord, +} from "@agent-team-runtime/runtime/V2"; export type CreateServerOptions = { stateRoot: string; @@ -23,7 +28,7 @@ export type CreateServerOptions = { }; type HydratedSession = { - session: SessionRecord; + session: AnySessionRecord; deliveryWorkflow: DeliveryWorkflowRecord; executionWorkflow: ExecutionWorkflowRecord; workflowRun: ProductDevQaWorkflowRunRecord | null; @@ -34,9 +39,12 @@ type HydratedSession = { agentRuns: AgentRunRecord[]; }; +type RuntimeStoreLike = V1RuntimeStore | V2RuntimeStore; +type AnySessionRecord = V1SessionRecord | V2SessionRecord; + export async function createServer(options: CreateServerOptions) { const app = Fastify({ logger: false }); - const store = new RuntimeStore(options.stateRoot); + const stateRoot = path.resolve(options.stateRoot); await app.register(websocket); const webDist = resolveWebDist(options.webDist); @@ -48,28 +56,29 @@ export async function createServer(options: CreateServerOptions) { } app.get("/api/config", async () => { - return { config: await store.loadConfig(), state_root: store.stateRoot }; + const store = await latestRuntimeStore(stateRoot); + return { config: await store.loadConfig(), state_root: stateRoot }; }); app.get("/api/session-index", async () => { - return await store.loadSessionIndex(); + return await readSessionIndex(stateRoot); }); app.get("/api/console/snapshot", async () => { - return buildConsoleSnapshot(await hydrateSessions(store)); + return buildConsoleSnapshot(await hydrateSessions(stateRoot)); }); app.get("/api/projects", async () => { - return { projects: buildConsoleSnapshot(await hydrateSessions(store)).projects }; + return { projects: buildConsoleSnapshot(await hydrateSessions(stateRoot)).projects }; }); app.get("/api/sessions", async () => { - return { sessions: await store.listSessions() }; + return { sessions: await listSessions(stateRoot) }; }); app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId", async (request, reply) => { try { - const hydrated = await hydrateSession(store, request.params.sessionId); + const hydrated = await hydrateSession(stateRoot, request.params.sessionId); return { session: hydrated.session, workflow_run: hydrated.workflowRun, @@ -90,6 +99,7 @@ export async function createServer(options: CreateServerOptions) { app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", async (request, reply) => { try { + const store = await runtimeStoreForSession(stateRoot, request.params.sessionId); return { events: await store.readEvents(request.params.sessionId) }; } catch (error) { return reply.status(404).send({ error: error instanceof Error ? error.message : String(error) }); @@ -98,6 +108,7 @@ export async function createServer(options: CreateServerOptions) { app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/tool-calls", async (request, reply) => { try { + const store = await runtimeStoreForSession(stateRoot, request.params.sessionId); return { tool_calls: await store.readToolCalls(request.params.sessionId) }; } catch (error) { return reply.status(404).send({ error: error instanceof Error ? error.message : String(error) }); @@ -106,6 +117,7 @@ export async function createServer(options: CreateServerOptions) { app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/prompts", async (request, reply) => { try { + const store = await runtimeStoreForSession(stateRoot, request.params.sessionId); return { prompts: await store.readPromptTraces(request.params.sessionId) }; } catch (error) { return reply.status(404).send({ error: error instanceof Error ? error.message : String(error) }); @@ -116,6 +128,7 @@ export async function createServer(options: CreateServerOptions) { "/api/sessions/:sessionId/prompts/:promptId", async (request, reply) => { try { + const store = await runtimeStoreForSession(stateRoot, request.params.sessionId); const payload = await store.readPromptContent(request.params.promptId); if (payload.prompt.session_id !== request.params.sessionId) { return reply.status(404).send({ error: "Prompt does not belong to this session." }); @@ -129,6 +142,7 @@ export async function createServer(options: CreateServerOptions) { app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/artifacts", async (request, reply) => { try { + const store = await runtimeStoreForSession(stateRoot, request.params.sessionId); return { artifacts: await store.readArtifacts(request.params.sessionId) }; } catch (error) { return reply.status(404).send({ error: error instanceof Error ? error.message : String(error) }); @@ -139,6 +153,7 @@ export async function createServer(options: CreateServerOptions) { "/api/sessions/:sessionId/artifacts/:artifactName", async (request, reply) => { try { + const store = await runtimeStoreForSession(stateRoot, request.params.sessionId); return await store.readArtifactContent(request.params.sessionId, decodeURIComponent(request.params.artifactName)); } catch (error) { return reply.status(404).send({ error: error instanceof Error ? error.message : String(error) }); @@ -148,6 +163,7 @@ export async function createServer(options: CreateServerOptions) { app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/agent-runs", async (request, reply) => { try { + const store = await runtimeStoreForSession(stateRoot, request.params.sessionId); return { agent_runs: await store.listAgentRuns(request.params.sessionId) }; } catch (error) { return reply.status(404).send({ error: error instanceof Error ? error.message : String(error) }); @@ -156,19 +172,19 @@ export async function createServer(options: CreateServerOptions) { app.get("/api/session", async (request, reply) => { const query = request.query as { session_id?: string }; - const sessionId = query.session_id ?? (await store.listSessions())[0]?.session_id; + const sessionId = query.session_id ?? (await listSessions(stateRoot))[0]?.session_id; if (!sessionId) { return reply.status(404).send({ error: "No workflow session exists yet." }); } try { - return buildPanelSnapshot(await hydrateSession(store, sessionId)); + return buildPanelSnapshot(await hydrateSession(stateRoot, sessionId)); } catch (error) { return reply.status(404).send({ error: error instanceof Error ? error.message : String(error) }); } }); app.get("/ws/runtime", { websocket: true }, async (socket) => { - socket.send(JSON.stringify({ type: "hello", state_root: store.stateRoot })); + socket.send(JSON.stringify({ type: "hello", state_root: stateRoot })); }); if (webDist) { @@ -189,12 +205,12 @@ export async function runServer(options: CreateServerOptions & { host: string; p return `http://${options.host}:${options.port}`; } -async function hydrateSessions(store: RuntimeStore): Promise { - const sessions = await store.listSessions(); +async function hydrateSessions(stateRoot: string): Promise { + const sessions = await listSessions(stateRoot); const hydrated: HydratedSession[] = []; for (const session of sessions) { try { - hydrated.push(await hydrateSession(store, session.session_id)); + hydrated.push(await hydrateSession(stateRoot, session.session_id)); } catch { // Skip malformed sessions in the aggregate view; detail endpoint reports the error. } @@ -202,7 +218,8 @@ async function hydrateSessions(store: RuntimeStore): Promise return hydrated; } -async function hydrateSession(store: RuntimeStore, sessionId: string): Promise { +async function hydrateSession(sourceStateRoot: string, sessionId: string): Promise { + const store = await runtimeStoreForSession(sourceStateRoot, sessionId); const session = await store.loadSession(sessionId); const deliveryWorkflow = await store.loadDeliveryWorkflow(sessionId); const executionWorkflow = await store.loadExecutionWorkflow(sessionId); @@ -222,6 +239,100 @@ async function hydrateSession(store: RuntimeStore, sessionId: string): Promise { + const target = await resolveSessionTarget(sourceStateRoot, sessionId); + return target.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID + ? new V2RuntimeStore(target.stateRoot) + : new V1RuntimeStore(target.stateRoot); +} + +async function latestRuntimeStore(stateRoot: string): Promise { + const latest = await latestSession(stateRoot); + if (!latest) { + return new V1RuntimeStore(stateRoot); + } + return latest.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID + ? new V2RuntimeStore(latest.stateRoot) + : new V1RuntimeStore(latest.stateRoot); +} + +async function listSessions(stateRoot: string): Promise { + const index = await readSessionIndex(stateRoot); + if (index.sessions.length > 0) { + const sessions = await Promise.all( + index.sessions.map((entry) => readSessionRecord(entry.state_root, entry.session_id).catch(() => null)), + ); + return sessions.filter((session): session is AnySessionRecord => session !== null); + } + const sessionsDir = path.join(stateRoot, "sessions"); + if (!existsSync(sessionsDir)) { + return []; + } + const entries = await readdir(sessionsDir, { withFileTypes: true }); + const sessions = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => readSessionRecord(stateRoot, entry.name).catch(() => null)), + ); + return sessions + .filter((session): session is AnySessionRecord => session !== null) + .sort((left, right) => right.updated_at.localeCompare(left.updated_at)); +} + +async function latestSession(stateRoot: string): Promise<{ sessionId: string; stateRoot: string; workflowId: string } | null> { + const sessions = await listSessions(stateRoot); + const latest = sessions[0]; + if (!latest) { + return null; + } + return { + sessionId: latest.session_id, + stateRoot: latest.state_root, + workflowId: latest.workflow_id ?? "", + }; +} + +async function resolveSessionTarget( + sourceStateRoot: string, + sessionId: string, +): Promise<{ sessionId: string; stateRoot: string; workflowId: string }> { + const local = await readSessionRecord(sourceStateRoot, sessionId).catch(() => null); + if (local) { + return { sessionId: local.session_id, stateRoot: local.state_root, workflowId: local.workflow_id ?? "" }; + } + const index = await readSessionIndex(sourceStateRoot); + const entry = index.sessions.find((item) => item.session_id === sessionId); + if (!entry) { + throw new Error(`Session not found: ${sessionId}`); + } + const session = await readSessionRecord(entry.state_root, sessionId).catch(() => null); + return { + sessionId, + stateRoot: session?.state_root ?? entry.state_root, + workflowId: session?.workflow_id ?? entry.workflow_id ?? "", + }; +} + +async function readSessionRecord(stateRoot: string, sessionId: string): Promise { + return JSON.parse(await readFile(path.join(stateRoot, "sessions", sessionId, "session.json"), "utf8")) as AnySessionRecord; +} + +async function readSessionIndex(stateRoot: string): Promise<{ schema_version: 1; sessions: Array<{ + session_id: string; + workflow_id: string; + state_root: string; +}> }> { + const indexPath = path.join(stateRoot, "session-index.json"); + if (!existsSync(indexPath)) { + return { schema_version: 1, sessions: [] }; + } + return JSON.parse(await readFile(indexPath, "utf8")) as { schema_version: 1; sessions: Array<{ + session_id: string; + workflow_id: string; + state_root: string; + }> }; +} + function buildConsoleSnapshot(items: HydratedSession[]) { const projects = new Map(); for (const item of items) { @@ -280,7 +391,7 @@ function buildConsoleSnapshot(items: HydratedSession[]) { }; } -function ensureWorktreeSummary(project: any, session: SessionRecord) { +function ensureWorktreeSummary(project: any, session: AnySessionRecord) { let worktree = project.worktrees.find((item: any) => item.worktree_path === session.repo_root); if (!worktree) { worktree = { diff --git a/packages/server/test/server.test.ts b/packages/server/test/server.test.ts index cd65572..705ae55 100644 --- a/packages/server/test/server.test.ts +++ b/packages/server/test/server.test.ts @@ -9,7 +9,7 @@ import { type AgentTaskResult, runWorkflow, RuntimeStore, -} from "@agent-team-runtime/runtime"; +} from "@agent-team-runtime/runtime/V1"; import { createServer } from "../src/index.js"; class FakeRunner implements AgentRunner { diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 49e169c..1f6a9cf 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -4,6 +4,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@agent-team-runtime/runtime/V1": path.resolve(__dirname, "../runtime/src/V1/index.ts"), + "@agent-team-runtime/runtime/V2": path.resolve(__dirname, "../runtime/src/V2/index.ts"), "@agent-team-runtime/runtime": path.resolve(__dirname, "../runtime/src/index.ts"), "@agent-team-runtime/server": path.resolve(__dirname, "src/index.ts"), }, From dedef4fe1ca838ae7bc38c3368c07979f81c9dc9 Mon Sep 17 00:00:00 2001 From: zhoukailian <2415699291@qq.com> Date: Mon, 22 Jun 2026 10:18:46 +0800 Subject: [PATCH 2/2] feat: add v2-only agt2 cli --- README.md | 33 +- ...6-06-21-product-dev-qa-workflow-runtime.md | 50 +- package-lock.json | 8 +- package.json | 4 +- packages/cli/package.json | 4 +- packages/cli/src/V2/index.ts | 739 ++++++++++++++++++ packages/cli/src/index.ts | 225 +----- packages/runtime/src/V2/product-dev-qa.ts | 2 +- packages/runtime/src/V2/schema.ts | 2 +- packages/runtime/src/V2/skill-routing.ts | 2 +- packages/runtime/src/V2/worktree.ts | 4 +- packages/runtime/test/V2/defaults.test.ts | 39 + 12 files changed, 855 insertions(+), 257 deletions(-) create mode 100644 packages/cli/src/V2/index.ts create mode 100644 packages/runtime/test/V2/defaults.test.ts diff --git a/README.md b/README.md index a6d55dd..8ff2c46 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ AGT 的目标是把“一个需求应该如何被交付”固化为可配置、可追踪、可审计、可恢复的 workflow:需求进入后,runtime 先让模型生成初始需求摘要,再负责创建隔离执行环境、组织角色、注入 skills、生成每阶段最小上下文包、调用执行器、保存产物和验证证据,最后给出本地是否完成的交付结论。 -新的入口是 `agt` CLI。runtime 自己负责 session、两层 workflow、agent run、tool call 和迁移记录;模型执行通过 OpenAI Agents SDK 的 `SandboxAgent` 完成。没有 OpenAI 环境变量时会自动使用 deterministic local fallback,方便测试和离线验证。 +CLI 入口按 runtime 版本分开:`agt` 保留 V1/legacy workflow,`agt2`/`agtv2` 只运行 V2 `product-dev-qa` workflow。runtime 自己负责 session、两层 workflow、agent run、tool call 和迁移记录;模型执行通过 OpenAI Agents SDK 的 `SandboxAgent` 完成。没有 OpenAI 环境变量时会自动使用 deterministic local fallback,方便测试和离线验证。 ## 产品定位 @@ -58,7 +58,7 @@ AGT 的稳定边界是:它负责把需求交付过程协议化、状态化和 ## 当前能力 -下面这一节描述的是当前仓库已经实现的 runtime 能力。V1 和 V2 在代码目录上分开,CLI 对外入口保持兼容。 +下面这一节描述的是当前仓库已经实现的 runtime 能力。V1 和 V2 在代码目录和 CLI 入口上都分开:`agt` 对应 V1,`agt2`/`agtv2` 对应 V2。 - `packages/runtime/src/V1/`:保留旧 runtime 能力,包括 `quick`、`investigate`、`full` profile,以及旧 delivery/execution projector。 - `packages/runtime/src/V2/`:承载新的交付 workflow,目前包含 `product-dev-qa`。 @@ -92,7 +92,7 @@ Runtime package 的导入边界也按版本显式区分: - `@agent-team-runtime/runtime/V1`:V1 runtime 的显式入口。 - `@agent-team-runtime/runtime/V2`:V2 `product-dev-qa` workflow 的显式入口。 -V2 命令链路不能从 V1 `RuntimeStore`、V1 config 或 V1 worktree helper 读取状态。CLI 的 `agt deliver`、`agt approve`、`agt run --workflow product-dev-qa`、以及 product-dev-qa session 的 `status/inspect/continue` 都通过 V2 runtime 入口执行;跨 worktree 的 `session-index.json` 只是共享索引文件,不代表 V2 依赖 V1 runtime。 +V2 命令链路不能从 V1 `RuntimeStore`、V1 config 或 V1 worktree helper 读取状态。CLI 的 V2 入口是 `agt2`/`agtv2`,默认状态目录是 `.agt2`;V1 入口 `agt` 默认状态目录仍然是 `.agt`。跨 worktree 的 `session-index.json` 只是同一版本 runtime 内部的索引文件,不代表 V2 依赖 V1 runtime。 ## 安装 @@ -112,6 +112,7 @@ npm run build ```bash node packages/cli/dist/index.js --help +node packages/cli/dist/V2/index.js --help node packages/cli/dist/index.js run "整理这个仓库的测试入口" ``` @@ -147,14 +148,14 @@ agt run "做一个跨模块需求" P0 本地交付 workflow: ```bash -agt deliver "实现一个需求,完成本地验证报告" -agt approve -agt approve +agt2 deliver "实现一个需求,完成本地验证报告" +agt2 approve +agt2 approve ``` 第一次 `go` 通过 Product 需求合同检查,第二次 `go` 通过 Dev 技术方案检查。Dev 实现完成后会自动进入 QA;QA 失败会直接回到 `dev:implementation` 生成新 attempt。AGT 不会自动 commit、push、merge 或发 PR。 -`agt deliver` 是 V2 `product-dev-qa` 的简化入口,默认会为需求创建隔离 worktree。`agt run "..." --workflow product-dev-qa` 也会走 V2 runtime,但仍遵守 `run` 的 worktree 语义:只有显式传 `--task-worktree` 或项目配置启用 task worktree 时才创建 worktree。`agt approve` 等价于对当前最新 V2 session 执行 `go` 决策。 +`agt2 deliver` 是 V2 `product-dev-qa` 的简化入口,默认会为需求创建隔离 worktree。`agt2 run` 是 `deliver` 的别名,但遵守 `run` 的 worktree 语义:只有显式传 `--task-worktree` 或 V2 项目配置启用 task worktree 时才创建 worktree。`agt2 approve` 等价于对当前最新 V2 session 执行 `go` 决策。 常用参数: @@ -162,8 +163,10 @@ agt approve agt run "..." --repo-root /path/to/repo --state-root /path/to/repo/.agt agt run --from docs/requirement.md --repo-root /path/to/repo agt run --from-dir docs/requirement-folder --repo-root /path/to/repo -agt deliver --from docs/requirement.md -agt deliver "..." --no-task-worktree +agt2 deliver --from docs/requirement.md +agt2 deliver "..." --no-task-worktree +agt2 status +agt2 inspect agt status agt status agt inspect @@ -189,14 +192,14 @@ SDK runner 会创建 `SandboxAgent`,把当前仓库挂载到 sandbox 的 `/wor - SDK 返回的 tool-like run item 会落到 `tool-calls.jsonl`。 - 每次 SDK run 会额外写一个 `-sdk-trace.json` artifact,记录 raw response 数量、new item 数量、last response id 和压缩后的 run item 摘要。 -默认模型和每个 profile 的最大 turns 可以写在 `.agt/config.json`: +默认模型和每个 profile 的最大 turns 可以写在 `.agt/config.json`;V2 则写在 `.agt2/config.json`。下面是 V2 示例: ```json { "schema_version": 1, "default_profile": "full", "default_model": "gpt-5.4-mini", - "state_root": ".agt", + "state_root": ".agt2", "max_turns": { "quick": 4, "investigate": 5, @@ -209,7 +212,7 @@ SDK runner 会创建 `SandboxAgent`,把当前仓库挂载到 sandbox 的 `/wor ## 状态目录 -默认状态目录是 `/.agt`: +V1 默认状态目录是 `/.agt`,V2 默认状态目录是 `/.agt2`。V1 目录结构示例: ```text .agt/ @@ -246,8 +249,8 @@ SDK runner 会创建 `SandboxAgent`,把当前仓库挂载到 sandbox 的 `/wor session-index.json ``` -这些文件是 runtime 的事实来源。控制台和 CLI 都从这里读取,不再从模型摘要反推状态。 -`agt init` 也会写入 `.agt/local/worktree-policy.json`,用于记录 task worktree 的默认策略。 +这些文件是 runtime 的事实来源。控制台和 CLI 都从这里读取,不再从模型摘要反推状态。V2 使用同样结构,但根目录是 `.agt2/`。 +`agt init` 会写入 `.agt/local/worktree-policy.json`,`agt2 init` 会写入 `.agt2/local/worktree-policy.json`,用于记录 task worktree 的默认策略。 `delivery-workflow.json` 是外层业务交付状态,面向用户和 CLI 默认展示:`requirement -> development -> verification -> handoff`。它记录每个 phase 的状态、blockers 和 evidence_refs。 @@ -307,6 +310,7 @@ migrator 会扫描: ```bash agt server --state-root /path/to/repo/.agt --host 127.0.0.1 --port 8765 +agt2 server --state-root /path/to/repo/.agt2 --host 127.0.0.1 --port 8766 ``` 主要接口: @@ -339,6 +343,7 @@ CLI smoke: ```bash node packages/cli/dist/index.js run "smoke quick js runtime" --repo-root . --state-root /tmp/agt-js-smoke-state +node packages/cli/dist/V2/index.js deliver "smoke product dev qa runtime" --repo-root . --state-root /tmp/agt2-js-smoke-state --no-task-worktree node packages/cli/dist/index.js migrate --from . --state-root /tmp/agt-js-migrate-state --dry-run node packages/cli/dist/index.js server --state-root /tmp/agt-js-smoke-state --host 127.0.0.1 --port 8765 ``` diff --git a/docs/workflow-specs/2026-06-21-product-dev-qa-workflow-runtime.md b/docs/workflow-specs/2026-06-21-product-dev-qa-workflow-runtime.md index 13180c7..b817a13 100644 --- a/docs/workflow-specs/2026-06-21-product-dev-qa-workflow-runtime.md +++ b/docs/workflow-specs/2026-06-21-product-dev-qa-workflow-runtime.md @@ -266,10 +266,10 @@ flowchart TD "role": "dev", "attempt": 2, "status": "validated", - "context_packet_path": ".agt/sessions/.../stages/dev/attempt-002/context-packet.json", - "prompt_trace_path": ".agt/sessions/.../stages/dev/attempt-002/prompt.md", - "candidate_path": ".agt/sessions/.../stages/dev/attempt-002/candidate.json", - "verdict_path": ".agt/sessions/.../stages/dev/attempt-002/verdict.json" + "context_packet_path": ".agt2/sessions/.../stages/dev/attempt-002/context-packet.json", + "prompt_trace_path": ".agt2/sessions/.../stages/dev/attempt-002/prompt.md", + "candidate_path": ".agt2/sessions/.../stages/dev/attempt-002/candidate.json", + "verdict_path": ".agt2/sessions/.../stages/dev/attempt-002/verdict.json" } ``` @@ -499,7 +499,7 @@ P0 先不把 skill 注入做成每次执行的细颗粒度产物,而是以项 建议 source of truth: ```text -.agt/project/skill-manifest.yaml +.agt2/project/skill-manifest.yaml ``` 它定义项目默认 workflow,以及每个 role 默认使用哪些 global skill 和项目角色 markdown: @@ -513,19 +513,19 @@ roles: - id: product-core source: builtin://roles/product-core version: "2026-06-21" - project_role_md: .agt/project/roles/product.md + project_role_md: .agt2/project/roles/product.md dev: global_skills: - id: dev-core source: builtin://roles/dev-core version: "2026-06-21" - project_role_md: .agt/project/roles/dev.md + project_role_md: .agt2/project/roles/dev.md qa: global_skills: - id: qa-core source: builtin://roles/qa-core version: "2026-06-21" - project_role_md: .agt/project/roles/qa.md + project_role_md: .agt2/project/roles/qa.md ``` 这样维护角色能力时,优先改项目级 manifest 和 role markdown,而不是改每次 run 的注入清单。 @@ -535,14 +535,14 @@ roles: 建议每个 stage 的最终提示输入由四层组成: 1. **项目级 skill manifest** - - 例如 `.agt/project/skill-manifest.yaml`,定义本项目每个 role 的默认能力组合。 + - 例如 `.agt2/project/skill-manifest.yaml`,定义本项目每个 role 的默认能力组合。 2. **全局角色 skill** - 例如 `product`, `dev`, `qa` 的稳定职责定义。 3. **项目角色 markdown** - - 例如 `.agt/project/roles/product.md` + - 例如 `.agt2/project/roles/product.md` - 用来放该项目重复出现的边界、坑点、约束。 4. **项目级 workflow contract** - - 例如 `.agt/project/workflows/product-dev-qa.yaml`,定义状态机、阶段输入输出和失败回路。 + - 例如 `.agt2/project/workflows/product-dev-qa.yaml`,定义状态机、阶段输入输出和失败回路。 5. **runtime 生成的 context packet** - 当前任务的最小上下文、输入产物、允许动作、必须输出。 @@ -554,19 +554,19 @@ roles: { "role": "qa", "skill_manifest_ref": { - "path": ".agt/project/skill-manifest.yaml", + "path": ".agt2/project/skill-manifest.yaml", "content_sha256": "...", "role": "qa" }, "role_context_refs": [ { "kind": "project_role_md", - "path": ".agt/project/roles/qa.md", + "path": ".agt2/project/roles/qa.md", "content_sha256": "..." }, { "kind": "workflow_contract", - "path": ".agt/project/workflows/product-dev-qa.yaml", + "path": ".agt2/project/workflows/product-dev-qa.yaml", "selector": "stages.qa", "content_sha256": "..." } @@ -579,7 +579,7 @@ roles: ### 8.3 建议目录 ```text -.agt/ +.agt2/ project/ skill-manifest.yaml roles/ @@ -598,7 +598,7 @@ P0 不做重型 memory 系统,只做轻量 project memory: - QA 发现某类项目级问题反复发生; - runtime 在报告里给出“建议写入角色文件”的条目; -- 人工确认后,把经验补到 `.agt/project/roles/*.md`。 +- 人工确认后,把经验补到 `.agt2/project/roles/*.md`。 例如: @@ -906,7 +906,7 @@ runtime 负责: "artifacts/qa-handoff.md" ], "skill_manifest_ref": { - "path": ".agt/project/skill-manifest.yaml", + "path": ".agt2/project/skill-manifest.yaml", "content_sha256": "...", "role": "qa" }, @@ -1051,7 +1051,7 @@ completion: - `acceptance_criteria_defined`:验收标准已定义。 - `qa_focus_defined`:QA 重点验证点已定义,也就是 Product 明确告诉 QA 后续要重点验证哪些边界、运行态或副作用。 -这个配置只管流程契约。Dev 的长期行为准则仍然放在 `.agt/project/roles/dev.md`,QA 的深验证习惯仍然放在 `.agt/project/roles/qa.md`。 +这个配置只管流程契约。Dev 的长期行为准则仍然放在 `.agt2/project/roles/dev.md`,QA 的深验证习惯仍然放在 `.agt2/project/roles/qa.md`。 ### 13.2 `ContextPacket` @@ -1071,12 +1071,12 @@ completion: "qa-handoff.md" ], "skill_manifest_ref": { - "path": ".agt/project/skill-manifest.yaml", + "path": ".agt2/project/skill-manifest.yaml", "content_sha256": "...", "role": "qa" }, "workflow_contract_ref": { - "path": ".agt/project/workflows/product-dev-qa.yaml", + "path": ".agt2/project/workflows/product-dev-qa.yaml", "content_sha256": "...", "stage": "qa" }, @@ -1089,10 +1089,10 @@ completion: ## 14. 目录与落盘布局 -建议在当前 `.agt` 基础上收敛到更明确的 session 结构: +建议在当前 `.agt2` 基础上收敛到更明确的 session 结构: ```text -.agt/ +.agt2/ project/ skill-manifest.yaml roles/ @@ -1181,7 +1181,7 @@ completion: 如果要支持真正的全环节可审计,建议再补一个统一事件账本: ```text -.agt/sessions//events.jsonl +.agt2/sessions//events.jsonl ``` 每条事件至少包含: @@ -1302,8 +1302,8 @@ P0 可以先继续复用当前的 OpenAI Agents SDK / local fallback runner, ### 第 5 步:补 project role markdown -- 支持 `.agt/project/skill-manifest.yaml` -- 支持 `.agt/project/roles/*.md` +- 支持 `.agt2/project/skill-manifest.yaml` +- 支持 `.agt2/project/roles/*.md` - 支持人工把重复问题沉淀进去 ### 第 6 步:补控制台视图 diff --git a/package-lock.json b/package-lock.json index 249a351..2fa8bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,9 @@ "zod": "^4.1.12" }, "bin": { - "agt": "packages/cli/dist/index.js" + "agt": "packages/cli/dist/index.js", + "agt2": "packages/cli/dist/V2/index.js", + "agtv2": "packages/cli/dist/V2/index.js" }, "devDependencies": { "@types/node": "^24.10.1", @@ -5849,7 +5851,9 @@ "commander": "^14.0.2" }, "bin": { - "agt": "dist/index.js" + "agt": "dist/index.js", + "agt2": "dist/V2/index.js", + "agtv2": "dist/V2/index.js" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/package.json b/package.json index 1e85a37..131bde1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "description": "Traceable JS agent workflow runtime for software tasks.", "bin": { - "agt": "packages/cli/dist/index.js" + "agt": "packages/cli/dist/index.js", + "agt2": "packages/cli/dist/V2/index.js", + "agtv2": "packages/cli/dist/V2/index.js" }, "workspaces": [ "apps/web", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1ab5613..67aa61b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,7 +5,9 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { - "agt": "dist/index.js" + "agt": "dist/index.js", + "agt2": "dist/V2/index.js", + "agtv2": "dist/V2/index.js" }, "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/cli/src/V2/index.ts b/packages/cli/src/V2/index.ts new file mode 100644 index 0000000..6e4b96e --- /dev/null +++ b/packages/cli/src/V2/index.ts @@ -0,0 +1,739 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + createTaskWorktree, + initRuntime, + isProductDevQaSession, + PRODUCT_DEV_QA_WORKFLOW_ID, + recordProductDevQaHumanDecision, + readSessionStatus, + runProductDevQaWorkflow, + RuntimeStore, + type RequestSourceRecord, + type RunResult, + type RuntimeConfig, + type RuntimeProfile, + type SessionStatusSnapshot, +} from "@agent-team-runtime/runtime/V2"; +import { runServer } from "@agent-team-runtime/server"; + +const VERSION = "0.3.0-alpha.2"; +const DEFAULT_STATE_DIR = ".agt2"; + +const program = new Command(); + +program + .name("agt2") + .description("Agent Team Runtime V2 CLI") + .version(VERSION); + +program + .command("init") + .option("--repo-root ", "repository root", process.cwd()) + .option("--state-root ", "state root; defaults to /.agt2") + .option("--default-profile ", "quick | investigate | full") + .option("--default-model ", "OpenAI model") + .option("--task-worktree", "enable isolated task worktrees by default") + .action(async (options: InitOptions) => { + const repoRoot = path.resolve(options.repoRoot); + const stateRoot = options.stateRoot ? path.resolve(options.stateRoot) : path.join(repoRoot, DEFAULT_STATE_DIR); + const config: Partial = { + state_root: options.stateRoot ?? DEFAULT_STATE_DIR, + }; + if (options.defaultProfile) { + config.default_profile = parseProfile(options.defaultProfile); + } + if (options.defaultModel) { + config.default_model = options.defaultModel; + } + if (options.taskWorktree) { + config.task_worktree = { + enabled: true, + base_ref_candidates: ["origin/test", "origin/main", "test", "main"], + branch_prefix: "feature/", + worktree_root: ".worktrees", + slug_max_length: 40, + }; + } + const result = await initRuntime({ repoRoot, stateRoot, config }); + console.log(`state_root: ${result.stateRoot}`); + console.log(`config_path: ${result.configPath}`); + }); + +program + .command("deliver") + .argument("[message...]", "task request") + .description("start a product-dev-qa delivery run with an isolated task worktree by default") + .option("--profile ", "quick | investigate | full") + .option("--repo-root ", "repository root", process.cwd()) + .option("--state-root ", "state root; defaults to /.agt2") + .option("--from ", "read request context from a file") + .option("--from-dir ", "read request context from all files under a directory") + .option("--no-task-worktree", "run in the current repository instead of an isolated task worktree") + .action(async (messageParts: string[], options: DeliverOptions) => { + const result = await startProductDevQaDelivery(messageParts, options, true); + printResult(result); + }); + +program + .command("run") + .argument("[message...]", "task request") + .description("alias for deliver; starts product-dev-qa") + .option("--profile ", "quick | investigate | full") + .option("--repo-root ", "repository root", process.cwd()) + .option("--state-root ", "state root; defaults to /.agt2") + .option("--from ", "read request context from a file") + .option("--from-dir ", "read request context from all files under a directory") + .option("--task-worktree", "create an isolated git worktree for this run") + .option("--no-task-worktree", "disable configured task worktree for this run") + .action(async (messageParts: string[], options: DeliverOptions) => { + const result = await startProductDevQaDelivery(messageParts, options, false); + printResult(result); + }); + +program + .command("decision") + .argument("", "session id") + .requiredOption("--decision ", "go | no-go") + .option("--state-root ", "state root", path.join(process.cwd(), DEFAULT_STATE_DIR)) + .action(async (sessionId: string, options: DecisionOptions) => { + const sourceStateRoot = path.resolve(options.stateRoot); + const target = await resolveSessionTarget(sourceStateRoot, sessionId); + await assertProductDevQaSession(target); + const result = await recordProductDevQaHumanDecision({ + stateRoot: target.stateRoot, + sessionId: target.sessionId, + decision: parseProductDevQaDecision(options.decision), + }); + await mirrorSessionIndex(sourceStateRoot, target.stateRoot, result.session_id); + printResult(result); + }); + +program + .command("approve") + .argument("[session-id]", "session id; defaults to latest session") + .description("approve the current product-dev-qa human gate") + .option("--state-root ", "state root", path.join(process.cwd(), DEFAULT_STATE_DIR)) + .action(async (sessionId: string | undefined, options: ApproveOptions) => { + const result = await approveProductDevQaGate(sessionId, options); + printResult(result); + }); + +program + .command("status") + .argument("[session-id]", "session id") + .option("--state-root ", "state root", path.join(process.cwd(), DEFAULT_STATE_DIR)) + .option("--watch", "poll status until the session leaves running/in_progress") + .option("--interval ", "poll interval for --watch", "5") + .option("--stalled-after ", "mark running agent runs as stalled after this many seconds without heartbeat", "120") + .option("--json", "print JSON status") + .action(async (sessionId: string | undefined, options: StatusOptions) => { + const sourceStateRoot = path.resolve(options.stateRoot); + const target = sessionId + ? await resolveSessionTarget(sourceStateRoot, sessionId) + : await resolveLatestSessionTarget(sourceStateRoot); + await assertProductDevQaSession(target); + const store = new RuntimeStore(target.stateRoot); + const intervalMs = parsePositiveSeconds(options.interval, "--interval") * 1000; + const stalledAfterMs = parsePositiveSeconds(options.stalledAfter, "--stalled-after") * 1000; + await printStatusOnce(store, target.sessionId, { ...options, stalledAfterMs }); + if (!options.watch) { + return; + } + while (true) { + await sleep(intervalMs); + const snapshot = await printStatusOnce(store, target.sessionId, { ...options, stalledAfterMs }); + if (!isWatchableRuntimeStatus(snapshot.runtime_status)) { + break; + } + } + }); + +program + .command("inspect") + .argument("", "session id") + .option("--state-root ", "state root", path.join(process.cwd(), DEFAULT_STATE_DIR)) + .action(async (sessionId: string, options: { stateRoot: string }) => { + const target = await resolveSessionTarget(path.resolve(options.stateRoot), sessionId); + await assertProductDevQaSession(target); + const store = new RuntimeStore(target.stateRoot); + const session = await store.loadSession(target.sessionId); + const deliveryWorkflow = await store.loadDeliveryWorkflow(target.sessionId); + const executionWorkflow = await store.loadExecutionWorkflow(target.sessionId); + const productDevQaWorkflow = await store.loadProductDevQaWorkflow(target.sessionId); + const toolCalls = await store.readToolCalls(target.sessionId); + const prompts = await store.readPromptTraces(target.sessionId); + const artifacts = await store.readArtifacts(target.sessionId); + const agentRuns = await store.listAgentRuns(target.sessionId); + console.log(JSON.stringify({ + session, + workflow_run: productDevQaWorkflow, + delivery_workflow: deliveryWorkflow, + execution_workflow: executionWorkflow, + prompts, + artifacts, + agent_runs: agentRuns, + tool_calls: toolCalls, + }, null, 2)); + }); + +program + .command("server") + .option("--state-root ", "state root", path.join(process.cwd(), DEFAULT_STATE_DIR)) + .option("--host ", "host", "127.0.0.1") + .option("--port ", "port", "8765") + .action(async (options: { stateRoot: string; host: string; port: string }) => { + const url = await runServer({ + stateRoot: path.resolve(options.stateRoot), + host: options.host, + port: Number(options.port), + }); + console.log(`server_url: ${url}`); + }); + +program.parseAsync().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); + +type InitOptions = { + repoRoot: string; + stateRoot?: string; + defaultProfile?: string; + defaultModel?: string; + taskWorktree?: boolean; +}; + +type DeliverOptions = { + profile?: string; + repoRoot: string; + stateRoot?: string; + from?: string; + fromDir?: string; + taskWorktree?: boolean; +}; + +type DecisionOptions = { + decision: string; + stateRoot: string; +}; + +type ApproveOptions = { + stateRoot: string; +}; + +type StatusOptions = { + stateRoot: string; + watch?: boolean; + interval: string; + stalledAfter: string; + json?: boolean; +}; + +type SessionTarget = { + sessionId: string; + repoRoot: string; + stateRoot: string; + workflowId: string; +}; + +type RequestInput = { request: string; sources: RequestSourceRecord[] }; + +type SessionMeta = { + session_id: string; + request: string; + workflow_id: string; + delivery_status: "in_progress" | "waiting_human" | "blocked" | "done"; + execution_status: "in_progress" | "waiting_human" | "blocked" | "done"; + status: "in_progress" | "waiting_human" | "blocked" | "done"; + current_phase: "requirement" | "development" | "verification" | "handoff"; + current_stage: string; + profile: "quick" | "investigate" | "full"; + project_root: string; + repo_root: string; + state_root: string; + created_at: string; + updated_at: string; + worktree?: { + branch?: string; + base_ref?: string; + base_commit?: string; + }; +}; + +type SessionIndexEntry = { + session_id: string; + request: string; + workflow_id: string; + delivery_status: SessionMeta["delivery_status"]; + execution_status: SessionMeta["execution_status"]; + status: SessionMeta["status"]; + current_phase: SessionMeta["current_phase"]; + current_stage: string; + profile: SessionMeta["profile"]; + project_root: string; + worktree_path: string; + state_root: string; + branch: string; + base_ref: string; + base_commit: string; + updated_at: string; + created_at: string; +}; + +function parseProfile(value: string): RuntimeProfile { + if (value === "quick" || value === "investigate" || value === "full") { + return value; + } + throw new Error(`Unsupported profile: ${value}`); +} + +function parseProductDevQaDecision(value: string): "go" | "no-go" { + if (value === "go" || value === "no-go") { + return value; + } + throw new Error(`${PRODUCT_DEV_QA_WORKFLOW_ID} supports only go | no-go decisions.`); +} + +async function readRequestInput( + messageParts: string[], + options: Pick, +): Promise { + const message = messageParts.join(" ").trim(); + const sourceBlocks: string[] = []; + const sources: RequestSourceRecord[] = []; + if (options.from) { + const resolved = path.resolve(options.from); + const content = await readFile(resolved, "utf8"); + sourceBlocks.push(renderSourceBlock(resolved, content)); + sources.push(sourceRecord("file", resolved, content)); + } + if (options.fromDir) { + const root = path.resolve(options.fromDir); + const files = await collectRequestFiles(root); + for (const file of files) { + const content = await readFile(file, "utf8"); + sourceBlocks.push(renderSourceBlock(path.relative(root, file) || file, content)); + sources.push(sourceRecord("directory_file", file, content)); + } + } + return { + request: [message, ...sourceBlocks].filter(Boolean).join("\n\n"), + sources, + }; +} + +async function collectRequestFiles(root: string): Promise { + const rootStat = await stat(root); + if (rootStat.isFile()) { + return [root]; + } + if (!rootStat.isDirectory()) { + throw new Error(`--from-dir is not a directory: ${root}`); + } + const ignored = new Set([".git", ".agt", ".agt2", "node_modules", "dist", "build"]); + const files: string[] = []; + async function visit(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (ignored.has(entry.name)) { + continue; + } + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await visit(fullPath); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + } + await visit(root); + return files.sort(); +} + +function renderSourceBlock(label: string, content: string): string { + return [`# Source: ${label}`, "", content.trim()].join("\n"); +} + +function sourceRecord(type: RequestSourceRecord["type"], filePath: string, content: string): RequestSourceRecord { + return { + type, + path: filePath, + sha256: createHash("sha256").update(content).digest("hex"), + bytes: Buffer.byteLength(content, "utf8"), + }; +} + +async function resolveSessionTarget(sourceStateRoot: string, requestedSessionId: string): Promise { + const local = await hydrateSessionTarget(sourceStateRoot, requestedSessionId).catch(() => null); + if (local) { + return local; + } + + const index = await readSessionIndex(sourceStateRoot); + const entry = index.sessions.find((item) => item.session_id === requestedSessionId); + if (!entry) { + throw new Error(`Session not found: ${requestedSessionId}`); + } + return hydrateSessionTarget(entry.state_root, requestedSessionId, { + repoRoot: entry.worktree_path, + workflowId: entry.workflow_id, + }); +} + +async function resolveLatestSessionTarget(sourceStateRoot: string): Promise { + const latest = await latestSessionInStateRoot(sourceStateRoot); + const index = await readSessionIndex(sourceStateRoot); + const indexed = index.sessions[0]; + if (indexed && (!latest || indexed.updated_at.localeCompare(latest.updatedAt) >= 0)) { + return hydrateSessionTarget(indexed.state_root, indexed.session_id, { + repoRoot: indexed.worktree_path, + workflowId: indexed.workflow_id, + }); + } + if (latest) { + return latest; + } + throw new Error("No sessions found."); +} + +async function latestSessionInStateRoot(stateRoot: string): Promise<(SessionTarget & { updatedAt: string }) | null> { + const index = await readSessionIndex(stateRoot); + if (index.sessions.length > 0) { + const indexed = index.sessions[0]!; + const target = await hydrateSessionTarget(indexed.state_root, indexed.session_id, { + repoRoot: indexed.worktree_path, + workflowId: indexed.workflow_id, + updatedAt: indexed.updated_at, + }); + return { ...target, updatedAt: target.updatedAt ?? indexed.updated_at }; + } + const sessions = await listLocalSessionMetas(stateRoot); + const candidates = sessions.map((session) => ({ + sessionId: session.session_id, + repoRoot: session.repo_root, + stateRoot: session.state_root, + workflowId: session.workflow_id ?? "", + updatedAt: session.updated_at, + })); + candidates.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + return candidates[0] ?? null; +} + +async function hydrateSessionTarget( + stateRoot: string, + sessionId: string, + fallback?: { repoRoot?: string; workflowId?: string; updatedAt?: string }, +): Promise { + const session = await readSessionMeta(stateRoot, sessionId).catch(() => null); + if (session) { + return { + sessionId: session.session_id, + repoRoot: session.repo_root, + stateRoot: session.state_root, + workflowId: session.workflow_id ?? "", + updatedAt: session.updated_at, + }; + } + + if (fallback) { + return { + sessionId, + repoRoot: fallback.repoRoot ?? "", + stateRoot, + workflowId: fallback.workflowId ?? "", + updatedAt: fallback.updatedAt, + }; + } + + throw new Error(`Session not found: ${sessionId}`); +} + +async function startProductDevQaDelivery( + messageParts: string[], + options: DeliverOptions, + defaultTaskWorktree: boolean, +): Promise { + const sourceRepoRoot = path.resolve(options.repoRoot); + const sourceStateRoot = path.resolve(options.stateRoot ?? path.join(sourceRepoRoot, DEFAULT_STATE_DIR)); + const requestInput = await readRequestInput(messageParts, options); + const request = requestInput.request; + if (!request) { + throw new Error("agt2 deliver requires a request message, --from, or --from-dir."); + } + + return startProductDevQaRun({ + sourceRepoRoot, + sourceStateRoot, + request, + requestInput, + profile: options.profile ? parseProfile(options.profile) : undefined, + taskWorktree: options.taskWorktree, + defaultTaskWorktree, + }); +} + +async function startProductDevQaRun(args: { + sourceRepoRoot: string; + sourceStateRoot: string; + request: string; + requestInput: RequestInput; + profile?: RuntimeProfile; + taskWorktree?: boolean; + defaultTaskWorktree: boolean; +}): Promise { + const sourceStore = new RuntimeStore(args.sourceStateRoot); + const config = await sourceStore.loadConfig(); + const useTaskWorktree = args.taskWorktree ?? (args.defaultTaskWorktree || config.task_worktree.enabled); + let repoRoot = args.sourceRepoRoot; + let stateRoot = args.sourceStateRoot; + let worktree; + if (useTaskWorktree) { + const created = await createTaskWorktree({ + projectRoot: args.sourceRepoRoot, + stateRoot: args.sourceStateRoot, + request: args.request, + config, + }); + repoRoot = created.repoRoot; + stateRoot = created.stateRoot; + worktree = created.worktree; + console.log(`worktree_path: ${repoRoot}`); + console.log(`branch: ${worktree.branch}`); + } + + const result = await runProductDevQaWorkflow({ + request: args.request, + profile: args.profile ?? config.default_profile, + repoRoot, + projectRoot: args.sourceRepoRoot, + stateRoot, + worktree, + requestSources: args.requestInput.sources, + }); + if (useTaskWorktree) { + await mirrorSessionIndex(args.sourceStateRoot, stateRoot, result.session_id); + } + return result; +} + +async function approveProductDevQaGate( + sessionId: string | undefined, + options: ApproveOptions, +): Promise { + const sourceStateRoot = path.resolve(options.stateRoot); + const target = sessionId + ? await resolveSessionTarget(sourceStateRoot, sessionId) + : await resolveLatestSessionTarget(sourceStateRoot); + await assertProductDevQaSession(target); + const result = await recordProductDevQaHumanDecision({ + stateRoot: target.stateRoot, + sessionId: target.sessionId, + decision: "go", + }); + await mirrorSessionIndex(sourceStateRoot, target.stateRoot, result.session_id); + return result; +} + +async function assertProductDevQaSession(target: SessionTarget): Promise { + const store = new RuntimeStore(target.stateRoot); + const productDevQa = target.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID + || await isProductDevQaSession(store, target.sessionId).catch(() => false); + if (!productDevQa) { + throw new Error(`agt2 only supports ${PRODUCT_DEV_QA_WORKFLOW_ID} sessions.`); + } +} + +async function mirrorSessionIndex(sourceStateRoot: string, targetStateRoot: string, sessionId: string): Promise { + const target = await hydrateSessionTarget(targetStateRoot, sessionId); + const session = await readSessionMeta(target.stateRoot, target.sessionId); + const index = await readSessionIndex(sourceStateRoot); + const entry: SessionIndexEntry = { + session_id: session.session_id, + request: session.request, + workflow_id: session.workflow_id ?? "", + delivery_status: session.delivery_status, + execution_status: session.execution_status, + status: session.status, + current_phase: session.current_phase, + current_stage: session.current_stage, + profile: session.profile, + project_root: session.project_root || session.repo_root, + worktree_path: session.repo_root, + state_root: session.state_root, + branch: session.worktree?.branch ?? "", + base_ref: session.worktree?.base_ref ?? "", + base_commit: session.worktree?.base_commit ?? "", + created_at: session.created_at, + updated_at: session.updated_at, + }; + const existing = index.sessions.findIndex((item) => item.session_id === session.session_id); + if (existing >= 0) { + index.sessions[existing] = { ...index.sessions[existing], ...entry, created_at: index.sessions[existing].created_at }; + } else { + index.sessions.push(entry); + } + index.sessions.sort((left, right) => right.updated_at.localeCompare(left.updated_at)); + await writeSessionIndex(sourceStateRoot, index); +} + +function printResult(result: RunResult): void { + console.log(`session_id: ${result.session_id}`); + console.log(`profile: ${result.profile}`); + console.log(`delivery_status: ${result.delivery_status}`); + console.log(`current_phase: ${result.current_phase}`); + console.log(`execution_status: ${result.execution_status}`); + console.log(`current_stage: ${result.current_stage}`); + console.log(`repo_root: ${result.repo_root}`); + console.log(`state_root: ${result.state_root}`); + console.log(`session_dir: ${result.session_dir}`); + if (result.blocked_reason) { + console.log(`blocked_reason: ${result.blocked_reason}`); + } + console.log(`summary: ${result.summary}`); +} + +async function printStatusOnce( + store: RuntimeStore, + sessionId: string, + options: StatusOptions & { stalledAfterMs: number }, +): Promise { + const snapshot = await readSessionStatus(store, sessionId, { stalledAfterMs: options.stalledAfterMs }); + if (options.json) { + console.log(JSON.stringify(snapshot, null, 2)); + } else { + printStatus(snapshot); + } + return snapshot; +} + +function printStatus(snapshot: SessionStatusSnapshot): void { + console.log(`generated_at: ${snapshot.generated_at}`); + console.log(`session_id: ${snapshot.session_id}`); + console.log(`profile: ${snapshot.profile}`); + console.log(`delivery_status: ${snapshot.delivery_status}`); + console.log(`current_phase: ${snapshot.current_phase}`); + console.log("phases:"); + for (const phase of snapshot.phases) { + console.log(` ${phase.phase}: ${phase.status}`); + } + if (snapshot.blockers.length > 0) { + console.log("blockers:"); + for (const blocker of snapshot.blockers) { + printIndentedValue(` ${blocker.phase}${blocker.source_role ? `/${blocker.source_role}` : ""}`, blocker.reason, " "); + } + } + console.log(`execution_status: ${snapshot.execution_status}`); + console.log(`runtime_status: ${snapshot.runtime_status}`); + console.log(`current_stage: ${snapshot.current_stage}`); + console.log(`repo_root: ${snapshot.repo_root}`); + console.log(`state_root: ${snapshot.state_root}`); + if (snapshot.active_run) { + console.log(`active_run_id: ${snapshot.active_run.agent_run_id}`); + console.log(`active_role: ${snapshot.active_run.role}`); + console.log(`active_status: ${snapshot.active_run.runtime_status}`); + console.log(`active_started_at: ${snapshot.active_run.started_at}`); + console.log(`active_last_heartbeat_at: ${snapshot.active_run.last_heartbeat_at ?? ""}`); + console.log(`active_heartbeat_count: ${snapshot.active_run.heartbeat_count}`); + console.log(`active_elapsed: ${formatDuration(snapshot.active_run.elapsed_ms)}`); + if (snapshot.active_run.heartbeat_age_ms !== undefined) { + console.log(`active_heartbeat_age: ${formatDuration(snapshot.active_run.heartbeat_age_ms)}`); + } + } else if (snapshot.latest_run) { + console.log(`latest_run_id: ${snapshot.latest_run.agent_run_id}`); + console.log(`latest_role: ${snapshot.latest_run.role}`); + console.log(`latest_status: ${snapshot.latest_run.runtime_status}`); + console.log(`latest_elapsed: ${formatDuration(snapshot.latest_run.elapsed_ms)}`); + if (snapshot.latest_run.executor_status) { + console.log(`latest_executor_status: ${snapshot.latest_run.executor_status}`); + } + if (snapshot.latest_run.result_parse_status) { + console.log(`latest_result_parse_status: ${snapshot.latest_run.result_parse_status}`); + } + if (snapshot.latest_run.prompt_trace_id) { + console.log(`latest_prompt_trace_id: ${snapshot.latest_run.prompt_trace_id}`); + } + } + if (snapshot.latest_event) { + console.log(`latest_event: ${snapshot.latest_event.kind}${snapshot.latest_event.message ? ` - ${snapshot.latest_event.message}` : ""}`); + } + if (snapshot.latest_tool_call) { + console.log(`latest_tool_call: ${snapshot.latest_tool_call.name}`); + } + if (snapshot.blocked_reason) { + printIndentedValue("blocked_reason", snapshot.blocked_reason, " "); + } + console.log(`summary: ${snapshot.summary}`); +} + +function printIndentedValue(label: string, value: string, continuationIndent: string): void { + const lines = value.split("\n"); + console.log(`${label}: ${lines[0] ?? ""}`); + for (const line of lines.slice(1)) { + console.log(`${continuationIndent}${line}`); + } +} + +async function readSessionMeta(stateRoot: string, sessionId: string): Promise { + const sessionPath = path.join(stateRoot, "sessions", sessionId, "session.json"); + return JSON.parse(await readFile(sessionPath, "utf8")) as SessionMeta; +} + +async function readSessionIndex(stateRoot: string): Promise<{ schema_version: 1; sessions: SessionIndexEntry[] }> { + const indexPath = path.join(stateRoot, "session-index.json"); + if (!existsSync(indexPath)) { + return { schema_version: 1, sessions: [] }; + } + return JSON.parse(await readFile(indexPath, "utf8")) as { schema_version: 1; sessions: SessionIndexEntry[] }; +} + +async function listLocalSessionMetas(stateRoot: string): Promise { + const sessionsDir = path.join(stateRoot, "sessions"); + if (!existsSync(sessionsDir)) { + return []; + } + const entries = await readdir(sessionsDir, { withFileTypes: true }); + const sessions = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => readSessionMeta(stateRoot, entry.name).catch(() => null)), + ); + return sessions + .filter((session): session is SessionMeta => session !== null) + .sort((left, right) => right.updated_at.localeCompare(left.updated_at)); +} + +async function writeSessionIndex(stateRoot: string, index: { schema_version: 1; sessions: SessionIndexEntry[] }): Promise { + await mkdir(stateRoot, { recursive: true }); + await writeFile(path.join(stateRoot, "session-index.json"), `${JSON.stringify(index, null, 2)}\n`); +} + +function isWatchableRuntimeStatus(status: SessionStatusSnapshot["runtime_status"]): boolean { + return status === "running" || status === "stalled" || status === "in_progress"; +} + +function parsePositiveSeconds(value: string, label: string): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive number of seconds.`); + } + return parsed; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${minutes}m${remainder}s`; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a11f1f7..ddd95b4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,20 +17,8 @@ import { type RuntimeConfig, type SessionStatusSnapshot, type RuntimeProfile, - RuntimeStore as V1RuntimeStore, + RuntimeStore, } from "@agent-team-runtime/runtime/V1"; -import { - createTaskWorktree as createV2TaskWorktree, - isProductDevQaSession, - PRODUCT_DEV_QA_WORKFLOW_ID, - recordProductDevQaHumanDecision, - readSessionStatus as readV2SessionStatus, - runProductDevQaWorkflow, - RuntimeStore as V2RuntimeStore, - type RunResult as V2RunResult, - type RuntimeProfile as V2RuntimeProfile, - type SessionStatusSnapshot as V2SessionStatusSnapshot, -} from "@agent-team-runtime/runtime/V2"; import { runServer } from "@agent-team-runtime/server"; const program = new Command(); @@ -88,7 +76,6 @@ program .option("--from-dir ", "read request context from all files under a directory") .option("--session-id ", "existing session to continue") .option("--continue", "continue latest unfinished session from session-index.json") - .option("--workflow ", "workflow id; currently product-dev-qa") .option("--task-worktree", "create an isolated git worktree for this run") .option("--no-task-worktree", "disable configured task worktree for this run") .option("--human-gates", "stop full profile at ProductDefinition, TechnicalDesign, and final handoff gates") @@ -113,22 +100,7 @@ program throw new Error("agt run requires a request message, --from, or --from-dir."); } - const workflowId = options.workflow ? parseWorkflowId(options.workflow) : ""; - if (workflowId === PRODUCT_DEV_QA_WORKFLOW_ID) { - const result = await startProductDevQaRun({ - sourceRepoRoot, - sourceStateRoot, - request, - requestInput, - profile: options.profile ? (parseProfile(options.profile) as V2RuntimeProfile) : undefined, - taskWorktree: options.taskWorktree, - defaultTaskWorktree: false, - }); - printResult(result); - return; - } - - const sourceStore = new V1RuntimeStore(sourceStateRoot); + const sourceStore = new RuntimeStore(sourceStateRoot); const config = await sourceStore.loadConfig(); const useTaskWorktree = options.taskWorktree || (config.task_worktree.enabled && options.taskWorktree !== false); let repoRoot = sourceRepoRoot; @@ -164,55 +136,25 @@ program printResult(result); }); -program - .command("deliver") - .argument("[message...]", "task request") - .description("start a product-dev-qa delivery run with an isolated task worktree by default") - .option("--repo-root ", "repository root", process.cwd()) - .option("--state-root ", "state root; defaults to /.agt") - .option("--from ", "read request context from a file") - .option("--from-dir ", "read request context from all files under a directory") - .option("--no-task-worktree", "run in the current repository instead of an isolated task worktree") - .action(async (messageParts: string[], options: DeliverOptions) => { - const result = await startProductDevQaDelivery(messageParts, options); - printResult(result); - }); - program .command("decision") .argument("", "session id") - .requiredOption("--decision ", "go | no-go | rework; product-dev-qa supports go | no-go") + .requiredOption("--decision ", "go | no-go | rework") .option("--target-role ", "role to reset to when --decision rework") .option("--state-root ", "state root", path.join(process.cwd(), ".agt")) .action(async (sessionId: string, options: DecisionOptions) => { const sourceStateRoot = path.resolve(options.stateRoot); const target = await resolveSessionTarget(sourceStateRoot, sessionId); - const result = target.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID - ? await recordProductDevQaHumanDecision({ - stateRoot: target.stateRoot, - sessionId: target.sessionId, - decision: parseProductDevQaDecision(options.decision), - }) - : await recordHumanDecision({ - stateRoot: target.stateRoot, - sessionId: target.sessionId, - decision: parseDecision(options.decision), - targetRole: options.targetRole ? parseAgentRole(options.targetRole) : undefined, - }); + const result = await recordHumanDecision({ + stateRoot: target.stateRoot, + sessionId: target.sessionId, + decision: parseDecision(options.decision), + targetRole: options.targetRole ? parseAgentRole(options.targetRole) : undefined, + }); await mirrorSessionIndex(sourceStateRoot, target.stateRoot, result.session_id); printResult(result); }); -program - .command("approve") - .argument("[session-id]", "session id; defaults to latest session") - .description("approve the current product-dev-qa human gate") - .option("--state-root ", "state root", path.join(process.cwd(), ".agt")) - .action(async (sessionId: string | undefined, options: ApproveOptions) => { - const result = await approveProductDevQaGate(sessionId, options); - printResult(result); - }); - program .command("status") .argument("[session-id]", "session id") @@ -226,7 +168,7 @@ program const target = sessionId ? await resolveSessionTarget(sourceStateRoot, sessionId) : await resolveLatestSessionTarget(sourceStateRoot); - const targetStore = runtimeStoreForSession(target); + const targetStore = new RuntimeStore(target.stateRoot); const intervalMs = parsePositiveSeconds(options.interval, "--interval") * 1000; const stalledAfterMs = parsePositiveSeconds(options.stalledAfter, "--stalled-after") * 1000; await printStatusOnce(targetStore, target.sessionId, { ...options, stalledAfterMs }); @@ -248,20 +190,16 @@ program .option("--state-root ", "state root", path.join(process.cwd(), ".agt")) .action(async (sessionId: string, options: { stateRoot: string }) => { const target = await resolveSessionTarget(path.resolve(options.stateRoot), sessionId); - const store = runtimeStoreForSession(target); + const store = new RuntimeStore(target.stateRoot); const session = await store.loadSession(target.sessionId); const deliveryWorkflow = await store.loadDeliveryWorkflow(target.sessionId); const executionWorkflow = await store.loadExecutionWorkflow(target.sessionId); - const productDevQaWorkflow = session.workflow_id === PRODUCT_DEV_QA_WORKFLOW_ID - ? await store.loadProductDevQaWorkflow(target.sessionId) - : null; const toolCalls = await store.readToolCalls(target.sessionId); const prompts = await store.readPromptTraces(target.sessionId); const artifacts = await store.readArtifacts(target.sessionId); const agentRuns = await store.listAgentRuns(target.sessionId); console.log(JSON.stringify({ session, - workflow_run: productDevQaWorkflow, delivery_workflow: deliveryWorkflow, execution_workflow: executionWorkflow, prompts, @@ -325,29 +263,16 @@ type RunOptions = { fromDir?: string; sessionId?: string; continue?: boolean; - workflow?: string; taskWorktree?: boolean; humanGates?: boolean; }; -type DeliverOptions = { - repoRoot: string; - stateRoot?: string; - from?: string; - fromDir?: string; - taskWorktree?: boolean; -}; - type DecisionOptions = { decision: string; targetRole?: string; stateRoot: string; }; -type ApproveOptions = { - stateRoot: string; -}; - type StatusOptions = { stateRoot: string; watch?: boolean; @@ -363,9 +288,8 @@ type SessionTarget = { workflowId: string; }; -type RuntimeStoreLike = V1RuntimeStore | V2RuntimeStore; -type AnyRunResult = RunResult | V2RunResult; -type AnySessionStatusSnapshot = SessionStatusSnapshot | V2SessionStatusSnapshot; +type AnyRunResult = RunResult; +type AnySessionStatusSnapshot = SessionStatusSnapshot; type RequestInput = { request: string; sources: RequestSourceRecord[] }; type SessionMeta = { @@ -417,13 +341,6 @@ function parseProfile(value: string): RuntimeProfile { throw new Error(`Unsupported profile: ${value}`); } -function parseWorkflowId(value: string): typeof PRODUCT_DEV_QA_WORKFLOW_ID { - if (value === PRODUCT_DEV_QA_WORKFLOW_ID) { - return value; - } - throw new Error(`Unsupported workflow: ${value}`); -} - function parseDecision(value: string): "go" | "no-go" | "rework" { if (value === "go" || value === "no-go" || value === "rework") { return value; @@ -431,13 +348,6 @@ function parseDecision(value: string): "go" | "no-go" | "rework" { throw new Error(`Unsupported decision: ${value}`); } -function parseProductDevQaDecision(value: string): "go" | "no-go" { - if (value === "go" || value === "no-go") { - return value; - } - throw new Error(`${PRODUCT_DEV_QA_WORKFLOW_ID} supports only go | no-go decisions.`); -} - function parseAgentRole(value: string): AgentRole { const allowed = new Set([ "planner", @@ -640,94 +550,6 @@ async function hydrateSessionTarget( throw new Error(`Session not found: ${sessionId}`); } -async function startProductDevQaDelivery( - messageParts: string[], - options: DeliverOptions, -): Promise { - const sourceRepoRoot = path.resolve(options.repoRoot); - const sourceStateRoot = path.resolve(options.stateRoot ?? path.join(sourceRepoRoot, ".agt")); - const requestInput = await readRequestInput(messageParts, options); - const request = requestInput.request; - if (!request) { - throw new Error("agt deliver requires a request message, --from, or --from-dir."); - } - - return startProductDevQaRun({ - sourceRepoRoot, - sourceStateRoot, - request, - requestInput, - taskWorktree: options.taskWorktree, - defaultTaskWorktree: true, - }); -} - -async function startProductDevQaRun(args: { - sourceRepoRoot: string; - sourceStateRoot: string; - request: string; - requestInput: RequestInput; - profile?: V2RuntimeProfile; - taskWorktree?: boolean; - defaultTaskWorktree: boolean; -}): Promise { - const sourceStore = new V2RuntimeStore(args.sourceStateRoot); - const config = await sourceStore.loadConfig(); - const useTaskWorktree = args.taskWorktree ?? (args.defaultTaskWorktree || config.task_worktree.enabled); - let repoRoot = args.sourceRepoRoot; - let stateRoot = args.sourceStateRoot; - let worktree; - if (useTaskWorktree) { - const created = await createV2TaskWorktree({ - projectRoot: args.sourceRepoRoot, - stateRoot: args.sourceStateRoot, - request: args.request, - config, - }); - repoRoot = created.repoRoot; - stateRoot = created.stateRoot; - worktree = created.worktree; - console.log(`worktree_path: ${repoRoot}`); - console.log(`branch: ${worktree.branch}`); - } - - const result = await runProductDevQaWorkflow({ - request: args.request, - profile: args.profile ?? config.default_profile, - repoRoot, - projectRoot: args.sourceRepoRoot, - stateRoot, - worktree, - requestSources: args.requestInput.sources, - }); - if (useTaskWorktree) { - await mirrorSessionIndex(args.sourceStateRoot, stateRoot, result.session_id); - } - return result; -} - -async function approveProductDevQaGate( - sessionId: string | undefined, - options: ApproveOptions, -): Promise { - const sourceStateRoot = path.resolve(options.stateRoot); - const target = sessionId - ? await resolveSessionTarget(sourceStateRoot, sessionId) - : await resolveLatestSessionTarget(sourceStateRoot); - const targetStore = new V2RuntimeStore(target.stateRoot); - const productDevQa = await isProductDevQaSession(targetStore, target.sessionId); - if (!productDevQa) { - throw new Error("agt approve only supports product-dev-qa sessions. Use `agt decision --decision go` for other workflows."); - } - const result = await recordProductDevQaHumanDecision({ - stateRoot: target.stateRoot, - sessionId: target.sessionId, - decision: "go", - }); - await mirrorSessionIndex(sourceStateRoot, target.stateRoot, result.session_id); - return result; -} - async function mirrorSessionIndex(sourceStateRoot: string, targetStateRoot: string, sessionId: string): Promise { const target = await hydrateSessionTarget(targetStateRoot, sessionId); const session = await readSessionMeta(target.stateRoot, target.sessionId); @@ -765,14 +587,7 @@ async function runExistingWorkflow(args: { target: SessionTarget; humanGates?: boolean; }): Promise { - if (args.target.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID) { - return runProductDevQaWorkflow({ - repoRoot: args.target.repoRoot, - stateRoot: args.target.stateRoot, - sessionId: args.target.sessionId, - }); - } - const config = await new V1RuntimeStore(args.target.stateRoot).loadConfig(); + const config = await new RuntimeStore(args.target.stateRoot).loadConfig(); return runWorkflow({ repoRoot: args.target.repoRoot, stateRoot: args.target.stateRoot, @@ -798,13 +613,11 @@ function printResult(result: AnyRunResult): void { } async function printStatusOnce( - store: RuntimeStoreLike, + store: RuntimeStore, sessionId: string, options: StatusOptions & { stalledAfterMs: number }, ): Promise { - const snapshot = store instanceof V2RuntimeStore - ? await readV2SessionStatus(store, sessionId, { stalledAfterMs: options.stalledAfterMs }) - : await readSessionStatus(store, sessionId, { stalledAfterMs: options.stalledAfterMs }); + const snapshot = await readSessionStatus(store, sessionId, { stalledAfterMs: options.stalledAfterMs }); if (options.json) { console.log(JSON.stringify(snapshot, null, 2)); } else { @@ -880,12 +693,6 @@ function printIndentedValue(label: string, value: string, continuationIndent: st } } -function runtimeStoreForSession(target: SessionTarget): RuntimeStoreLike { - return target.workflowId === PRODUCT_DEV_QA_WORKFLOW_ID - ? new V2RuntimeStore(target.stateRoot) - : new V1RuntimeStore(target.stateRoot); -} - async function readSessionMeta(stateRoot: string, sessionId: string): Promise { const sessionPath = path.join(stateRoot, "sessions", sessionId, "session.json"); return JSON.parse(await readFile(sessionPath, "utf8")) as SessionMeta; diff --git a/packages/runtime/src/V2/product-dev-qa.ts b/packages/runtime/src/V2/product-dev-qa.ts index d6d8062..ebd8ef8 100644 --- a/packages/runtime/src/V2/product-dev-qa.ts +++ b/packages/runtime/src/V2/product-dev-qa.ts @@ -161,7 +161,7 @@ const EXECUTION_STAGE_ORDER: StageKey[] = [ export async function runProductDevQaWorkflow(options: RunProductDevQaWorkflowOptions): Promise { const repoRoot = path.resolve(options.repoRoot); - const stateRoot = path.resolve(options.stateRoot ?? path.join(repoRoot, ".agt")); + const stateRoot = path.resolve(options.stateRoot ?? path.join(repoRoot, ".agt2")); const store = new RuntimeStore(stateRoot); const session = options.sessionId ? await store.loadSession(options.sessionId) diff --git a/packages/runtime/src/V2/schema.ts b/packages/runtime/src/V2/schema.ts index 083fece..5146c7a 100644 --- a/packages/runtime/src/V2/schema.ts +++ b/packages/runtime/src/V2/schema.ts @@ -302,7 +302,7 @@ export const RuntimeConfigSchema = z.object({ schema_version: z.literal(1), default_profile: ProfileSchema.default("full"), default_model: z.string().default("gpt-5.4-mini"), - state_root: z.string().default(".agt"), + state_root: z.string().default(".agt2"), max_turns: z .object({ quick: z.number().int().positive().default(4), diff --git a/packages/runtime/src/V2/skill-routing.ts b/packages/runtime/src/V2/skill-routing.ts index acff730..71b4367 100644 --- a/packages/runtime/src/V2/skill-routing.ts +++ b/packages/runtime/src/V2/skill-routing.ts @@ -149,7 +149,7 @@ async function routingConfigCandidates(repoRoot: string, projectRoot: string): P const projectConfigs = projectConfigRoot ? names.map((name) => path.join(projectConfigRoot, name, "skill-routing.yaml")) : []; - const repoLocal = roots.map((root) => path.join(root, ".agt", "skill-routing.yaml")); + const repoLocal = roots.map((root) => path.join(root, ".agt2", "skill-routing.yaml")); const discoverable = await discoverProjectRoutingConfigs(projectConfigRoot); return [...new Set([...explicit, ...projectConfigs, ...repoLocal, ...discoverable])]; } diff --git a/packages/runtime/src/V2/worktree.ts b/packages/runtime/src/V2/worktree.ts index 61ff138..b765908 100644 --- a/packages/runtime/src/V2/worktree.ts +++ b/packages/runtime/src/V2/worktree.ts @@ -19,7 +19,7 @@ export type TaskWorktreeResult = { export async function initRuntime(options: InitRuntimeOptions): Promise<{ stateRoot: string; configPath: string }> { const repoRoot = path.resolve(options.repoRoot); - const stateRoot = path.resolve(options.stateRoot ?? path.join(repoRoot, ".agt")); + const stateRoot = path.resolve(options.stateRoot ?? path.join(repoRoot, ".agt2")); const store = new RuntimeStore(stateRoot); await store.ensureLayout(); const config = await store.ensureConfig(options.config); @@ -54,7 +54,7 @@ export async function createTaskWorktree(options: { worktreePath = path.join(worktreeRoot, name); } await execa("git", ["worktree", "add", "-b", branch, worktreePath, base.ref], { cwd: projectRoot }); - const targetStateRoot = path.join(worktreePath, ".agt"); + const targetStateRoot = path.join(worktreePath, ".agt2"); const targetStore = new RuntimeStore(targetStateRoot); await targetStore.ensureConfig(config); return { diff --git a/packages/runtime/test/V2/defaults.test.ts b/packages/runtime/test/V2/defaults.test.ts new file mode 100644 index 0000000..035d165 --- /dev/null +++ b/packages/runtime/test/V2/defaults.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; + +import { createTaskWorktree, initRuntime } from "../../src/V2/index.js"; + +describe("V2 runtime defaults", () => { + it("uses .agt2 for the source runtime and task worktrees", async () => { + const repoRoot = await mkdtemp(path.join(tmpdir(), "agt2-defaults-")); + await execa("git", ["init"], { cwd: repoRoot }); + await writeFile(path.join(repoRoot, "README.md"), "v2 defaults\n"); + await execa("git", ["add", "README.md"], { cwd: repoRoot }); + await execa("git", [ + "-c", + "user.name=AGT Test", + "-c", + "user.email=agt-test@example.com", + "commit", + "-m", + "initial", + ], { cwd: repoRoot }); + + const initialized = await initRuntime({ repoRoot }); + expect(initialized.stateRoot).toBe(path.join(repoRoot, ".agt2")); + expect(initialized.configPath).toBe(path.join(repoRoot, ".agt2", "config.json")); + const config = JSON.parse(await readFile(initialized.configPath, "utf8")) as { state_root: string }; + expect(config.state_root).toBe(".agt2"); + + const worktree = await createTaskWorktree({ + projectRoot: repoRoot, + stateRoot: initialized.stateRoot, + request: "ship v2 defaults", + }); + expect(worktree.stateRoot).toBe(path.join(worktree.repoRoot, ".agt2")); + expect(worktree.worktree.policy_snapshot_path).toBe(path.join(worktree.repoRoot, ".agt2", "config.json")); + }); +});