diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ee92fd..e7dc3843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,11 @@ ### Added +- Added the `task-list` prepare hook for replacing assignment tasks from + repo-local `.task-runner/tasks.yml` files after host-side prepare steps; + replacement tasks are frozen into the manifest/reset seed and are not + re-read on resume or reset. + ([#134](https://github.com/kcosr/task-runner/pull/134)) - Added run-group-aware runtime interpolation for fresh cwd values and launcher command/args, plus `TASK_RUNNER_RUN_ID`, `TASK_RUNNER_RUN_GROUP_ID`, and `TASK_RUNNER_CWD` backend wrapper env. diff --git a/README.md b/README.md index 76fa38dc..8ce816b1 100644 --- a/README.md +++ b/README.md @@ -241,12 +241,56 @@ Launchers apply only to subprocess-backed execution (`claude`, `cursor`, the built-in `direct` launcher. Launcher command and args are runtime-interpolated before they are frozen -into the manifest. A short-term persistent-container workflow can combine -a run-group workspace path with a launcher wrapper: +into the manifest. Container-backed workflows should keep the host +workspace cwd and container cwd explicit: prepare hooks run from the host +cwd, while the launcher receives the container cwd it should enter. +Task-runner does not manage container lifecycle, startup, shutdown, or +cleanup. ```yaml # assignment.md -cwd: "/home/kevin/agent-workspaces/{{run_group_id}}/repo" +cwd: "{{host_workspace_root}}/{{run_group_id}}/repo" # host cwd +vars: + repo_url: + type: string + required: true + sources: [cli, web] + branch: + type: string + required: true + sources: [cli, web] + host_workspace_root: + type: string + default: /srv/agent-workspaces + sources: [cli, web] + container_workspace_root: + type: string + default: /workspace/agent-workspaces + sources: [cli, web] + image: + type: string + default: agent-dev + sources: [cli, web] +hooks: + prepare: + - builtin: command + with: + mode: status + command: bash + cwd: / + args: + - -lc + - | + set -euo pipefail + target="{{cwd}}" + mkdir -p "$(dirname "$target")" + if [ -d "$target/.git" ]; then + git -C "$target" fetch origin "{{branch}}" --prune + git -C "$target" checkout "{{branch}}" + git -C "$target" reset --hard "origin/{{branch}}" + else + git clone --branch "{{branch}}" "{{repo_url}}" "$target" + fi ``` ```yaml @@ -254,14 +298,14 @@ cwd: "/home/kevin/agent-workspaces/{{run_group_id}}/repo" launcher: command: aw-tr-launch args: - - agent-dev - - "{{cwd}}" - - "{{run_group_id}}" + - "{{image}}" + - "{{container_workspace_root}}/{{run_group_id}}/repo" ``` -Task-runner does not manage the container lifecycle or cleanup. The -wrapper receives the frozen cwd and run group id so it can enter a -workspace that another process prepared. +In this pattern the clone/update happens on the host at `{{cwd}}`. The +launcher wrapper receives the matching container cwd so it can enter an +already-running container that sees the same repository at its own mount +path. Agents may also author backend-owned argv tokens: diff --git a/docs/agents-and-assignments.md b/docs/agents-and-assignments.md index d2bee1b1..746320ed 100644 --- a/docs/agents-and-assignments.md +++ b/docs/agents-and-assignments.md @@ -312,6 +312,65 @@ Assignments may also mix reusable task refs with inline task objects: as explicit task file paths - inline objects stay local to the assignment +Repo-local task lists can also be loaded at prepare time with the +first-party `task-list` hook. This is for workflows where an earlier +prepare hook clones or updates a repository on the host and the actual +task list lives inside that repository: + +```yaml +hooks: + prepare: + - builtin: task-list + with: + path: "{{cwd}}/.task-runner/tasks.yml" + mode: replace + missing: continue + empty: keep-existing +``` + +The hook config is strict. The only supported keys are `path`, `mode`, +`missing`, and `empty`; the only supported values are +`mode: replace`, `missing: continue`, and `empty: keep-existing`. +`path` is runtime-interpolated like other hook config strings. A missing +repo-local task-list file continues with the assignment-authored tasks, +and an existing empty task list also keeps those tasks. An existing file +with invalid YAML, unsupported schema keys, missing task refs, duplicate +ids, invalid task shapes, or invalid task-local hooks fails prepare. + +The repo-local task-list file is YAML: + +```yaml +schemaVersion: 1 +tasks: + - orient + - ./tasks/review.md + - id: local-check + title: Check local behavior + body: | + Inspect the repository-specific state. + hooks: + - path: ./hooks/local-guard.mjs +``` + +Task-list entries use the same task entry shape as assignment `tasks`: +inline task objects, named task refs, and explicit relative or absolute +task file paths. Relative file refs resolve from the task-list file's +directory; named refs still resolve from `${TASK_RUNNER_CONFIG_DIR}/tasks`. +Task-local path hooks inside loaded tasks are made absolute relative to +the task-list file before they are frozen into the run. + +Security: a repo-local `tasks.yml` may declare task-local `hooks[]` +entries with `path: ./...` references. Those hook modules are loaded +and executed by task-runner on task transitions. Treat any repository +used with `task-list` as trusted code, or avoid task-local path hooks in +repo-local task lists. + +If the hook replaces tasks, the replacement list becomes the frozen +manifest task set. Resume, reset, ready-start, initialized reconfigure, +and recurring reset/clone do not re-read the repo-local task-list file. +Changing or deleting `.task-runner/tasks.yml` after init affects only a +new fresh run or reinit, not the existing run. + Named task definitions are markdown files under `${TASK_RUNNER_CONFIG_DIR}/tasks/.md`: @@ -491,10 +550,12 @@ Hook mutation boundaries: - `prepare` may mutate run config (`cwd`, backend/model/effort, timeout/unrestricted, prompts, locked fields), runtime vars, hook - state, note/pin metadata, task patches, and attachments. Backend args - are resolved from the final selected backend after prepare changes. + state, note/pin metadata, task patches, full task replacement through + `setTasks`, and attachments. Backend args are resolved from the final + selected backend after prepare changes. - non-prepare phases may mutate run config, hook state, note/pin - metadata, task patches, and attachments, but not runtime vars. + metadata, task patches, and attachments, but not runtime vars or + `setTasks`. - task-transition hooks run transactionally around `task set`, `task append-notes`, `task add`, and the run loop's own task writes. If a task-transition hook rejects, the requested task edit rolls back, @@ -506,6 +567,11 @@ Built-in hooks: - `git-worktree` runs in `prepare` and `beforeAttempt`. It ensures a git worktree, switches the run `cwd` to that path, and in `prepare` also projects `worktree_path` into runtime vars. +- `task-list` runs in `prepare`. It reads a repo-local YAML task list and + replaces the run task set when the file exists and is non-empty. Missing + files continue, empty lists keep the assignment-authored tasks, invalid + existing files fail prepare, and the resulting task list is frozen for + resume/reset. - `command` runs in every phase. `mode: status` treats exit code `0` as success and a non-zero exit code as block/reject. `mode: json` requires exit code `0` and parses a full hook result from stdout; diff --git a/docs/design.md b/docs/design.md index b3a9c66f..97838e53 100644 --- a/docs/design.md +++ b/docs/design.md @@ -92,6 +92,15 @@ plain task shape before run creation; runtime still performs later `{{var}}` interpolation against the resolved tasks during brief construction. +Assignments can also replace the authored task list during `prepare` +through a hook `setTasks` mutation. The first-party `task-list` hook uses +that mutation to load a repo-local YAML file, typically +`.task-runner/tasks.yml`, after earlier prepare hooks have cloned or +updated the host cwd. The repo-local file uses `schemaVersion: 1` and a +strict `tasks:` array with the same inline/ref task entries as +assignment `tasks`. Missing repo-local files continue with the authored +assignment tasks; invalid existing files fail prepare. + Canonical definition identity comes from the on-disk key: - agents: slash-relative directory under `agents/` @@ -240,17 +249,21 @@ Fresh `run` / `init`: 3. run `prepare` hooks before the first manifest write 4. freeze the resolved descriptors plus any prepare-time mutations into `manifest.resolvedHooks`, `manifest.runtimeVars`, `manifest.cwd`, - `manifest.hookState`, prompt state, attachments, and reset seed + `manifest.hookState`, prompt state, attachments, final task list, and + reset seed -Resume and reset do not re-run prepare hooks from current source files. -They reuse the frozen manifest descriptor/config and the prepare outputs -captured at first write. +Resume, reset, ready-start, initialized reconfigure, and recurring +reset/clone do not re-run prepare hooks from current source files. They +reuse the frozen manifest descriptor/config and the prepare outputs +captured at first write, including replacement task lists. Phase behavior: -- `prepare` may mutate runtime vars and all other hook-owned run state. +- `prepare` may mutate runtime vars, replace the full task list with + `setTasks`, and mutate all other hook-owned run state. - `beforeAttempt`, `afterAttempt`, and `afterExit` may continue, block, - or request a follow-up prompt reinvocation. + or request a follow-up prompt reinvocation. Non-prepare `setTasks` + mutations are rejected. - `taskTransition` wraps all task mutations from the run loop and task command surfaces. Task-local `tasks[].hooks[]` run before root `hooks.taskTransition[]`, and rejections roll back the requested task @@ -273,6 +286,16 @@ The built-in `git-worktree` hook runs in `prepare` and `beforeAttempt`. It creates or reuses a worktree, switches the run cwd to that path, and in `prepare` also projects `worktree_path` into runtime vars. +The built-in `task-list` hook runs in `prepare` only. Its config is +strictly `{ path, mode: "replace", missing: "continue", +empty: "keep-existing" }`. When the file is missing it continues without +mutating tasks. When the file exists and is empty it keeps the +assignment-authored tasks. When the file exists and is invalid, prepare +fails. When the file exists and contains tasks, it emits `setTasks`; the +resolved replacement task list, task-local hooks, manifest +`finalTasks`, `tasksTotal`, `tasksCompleted`, `resetSeed.finalTasks`, +`assignment-seed.md`, and `brief` are all built from that replacement. + Declarative `when` support remains narrow: attempt-phase hooks support `when.sessionIndex` and `when.attemptIndexInSession`, while task-transition hooks support `when.taskId`, `when.taskIds`, `when.fromStatus`, @@ -343,7 +366,7 @@ blocked with the rest completed or blocked → `blocked`; otherwise runtime-interpolates prefix launcher command/args 11. builds the provisional prepare manifest 12. runs prepare hooks, then freezes final cwd/runtime vars/backend - outputs, task text, and launcher values + outputs, task list, task text, task-local hooks, and launcher values 13. composes and stores `brief` 14. imports complete backend-owned history when `--backend-session-id` is present and the backend supports history reads @@ -452,6 +475,9 @@ It uses the frozen agent, assignment, hooks, launcher, tasks, cwd, schedule, selected backend args, and backend-specific config already stored on the manifest, rerenders the brief/reset seed, and commits the replacement manifest only after validation and prepare/rendering succeed. +If the initialized run was created from a repo-local task-list +replacement, reconfigure uses the frozen replacement tasks and does not +re-read the repo-local task-list file. It appends `run.reconfigured` with changed var keys and a message-changed boolean, not secret values or message text. diff --git a/docs/examples.md b/docs/examples.md index 7220b70f..cc223f3b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -11,6 +11,125 @@ separate `backendArgs..extraArgs` tokens. Custom backend modules live under `${TASK_RUNNER_CONFIG_DIR}/backends//` and are trusted local code, not sandboxed plugin packages. +## Containerized exploratory codebase assistant + +This pattern keeps repository preparation on the host while invoking the +agent inside a pre-existing container. Task-runner does not create, +start, stop, or clean up the container; the launcher is only a subprocess +prefix. + +```yaml +# ~/.config/task-runner/launchers/container-agent.yaml +schemaVersion: 1 +name: container-agent +command: aw-tr-launch +args: + - "{{image}}" + - "{{container_workspace_root}}/{{run_group_id}}/repo" +``` + +```md + +--- +schemaVersion: 1 +name: container-codex +backend: codex +launcher: container-agent +--- +Explore the target codebase and keep task notes concrete. +``` + +```md + +--- +schemaVersion: 1 +name: explore-container +cwd: "{{host_workspace_root}}/{{run_group_id}}/repo" +vars: + repo_url: + type: enum + required: true + sources: [cli, web] + values: + - git@github.com:org/repo-one.git + - git@github.com:org/repo-two.git + branch: + type: string + required: true + sources: [cli, web] + host_workspace_root: + type: string + default: /home/kevin/agent-workspaces + sources: [cli, web] + container_workspace_root: + type: string + default: /workspace/agent-workspaces + sources: [cli, web] + image: + type: enum + default: agent-dev + sources: [cli, web] + values: [agent-dev] +hooks: + prepare: + - builtin: command + with: + mode: status + command: bash + cwd: / + args: + - -lc + - | + set -euo pipefail + target="{{cwd}}" + mkdir -p "$(dirname "$target")" + if [ -d "$target/.git" ]; then + git -C "$target" fetch origin "{{branch}}" --prune + git -C "$target" checkout "{{branch}}" + git -C "$target" reset --hard "origin/{{branch}}" + else + git clone --branch "{{branch}}" "{{repo_url}}" "$target" + fi + - builtin: task-list + with: + path: "{{cwd}}/.task-runner/tasks.yml" + mode: replace + missing: continue + empty: keep-existing +tasks: + - id: orient + title: Orient to the repository +--- +Inspect the repository at the prepared cwd and report what matters. +``` + +Run it with separate host and container roots: + +```bash +task-runner run \ + --agent container-codex \ + --assignment explore-container \ + --var repo_url=git@github.com:org/repo-one.git \ + --var branch=main +``` + +The prepare hooks clone or update the repository at +`${host_workspace_root}//repo` on the host. The launcher +receives the matching container cwd +`${container_workspace_root}//repo`, which must already be +mounted into the container by external lifecycle tooling. `aw-tr-launch` +is expected to append the backend command and backend args after the +launcher args: + +```bash +aw-tr-launch +``` + +If the prepared repo does not contain `.task-runner/tasks.yml`, the +assignment falls back to its authored `orient` task. If the file exists +but is invalid, prepare fails. Once a run is initialized, resume and +reset use the frozen task list and do not re-read the repo-local file. + ## Bundled agents ### `implementer` diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 78856c38..97e7c20e 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { basename, dirname, extname, relative, resolve } from "node:path"; +import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path"; import matter from "gray-matter"; import { type AgentLauncherReference, @@ -13,11 +13,14 @@ import { type AuthoredAssignmentConfig, type LauncherDefinitionConfig, type TaskDef, + type TaskListConfig, agentConfigSchema, assignmentConfigSchema, authoredAssignmentConfigSchema, launcherDefinitionSchema, + taskDefListSchema, taskDefinitionConfigSchema, + taskListConfigSchema, } from "../core/config/schema.js"; import { definitionLayout, @@ -113,6 +116,17 @@ export class TaskConfigError extends Error { } } +export class TaskListConfigError extends Error { + constructor( + public readonly sourcePath: string, + public readonly issues: string, + public override readonly cause?: unknown, + ) { + super(`Invalid task list config at ${sourcePath}:\n${issues}`); + this.name = "TaskListConfigError"; + } +} + export class TaskNotFoundError extends Error { constructor( public readonly arg: string, @@ -821,8 +835,23 @@ function launcherCanonicalNameFromPath(sourcePath: string): string { return canonicalDefinitionIdFromPath("launcher", sourcePath); } -function toLauncherIssues(issues: { path: PathSegment[]; message: string }[]): string { - return formatConfigIssues(issues); +function loadYamlData( + sourcePath: string, + createError: (issues: string, cause?: unknown) => Error, + options: { rejectDocumentSeparators?: boolean } = {}, +): unknown { + try { + const raw = readFileSync(sourcePath, "utf8"); + if (options.rejectDocumentSeparators && /^---\s*$/m.test(raw)) { + throw new Error("YAML document separators are not supported"); + } + return matter(`---\n${raw}\n---\n`).data; + } catch (error) { + throw createError( + error instanceof Error ? ` - ${error.message}` : ` - ${String(error)}`, + error, + ); + } } function loadLauncherYaml(sourcePath: string): unknown { @@ -834,15 +863,7 @@ function loadLauncherYaml(sourcePath: string): unknown { ); } - try { - const raw = readFileSync(sourcePath, "utf8"); - return matter(`---\n${raw}\n---\n`).data; - } catch (error) { - throw new LauncherConfigError( - sourcePath, - error instanceof Error ? ` - ${error.message}` : ` - ${String(error)}`, - ); - } + return loadYamlData(sourcePath, (issues) => new LauncherConfigError(sourcePath, issues)); } function loadLauncherDefinitionFromPath( @@ -852,7 +873,7 @@ function loadLauncherDefinitionFromPath( const configData = loadLauncherYaml(sourcePath); const parsed = launcherDefinitionSchema.safeParse(configData); if (!parsed.success) { - throw new LauncherConfigError(sourcePath, toLauncherIssues(parsed.error.issues)); + throw new LauncherConfigError(sourcePath, formatConfigIssues(parsed.error.issues)); } const config = parsed.data; @@ -958,12 +979,12 @@ function resolveNamedTaskPath(name: string): { path: string; searched: string[] return resolveNamedFilePath(resolveTasksRoot(), name, [".md"]); } -function resolveTaskPath(ref: string, assignmentSourcePath: string): string { - const resolved = resolveStringRef(ref, dirname(assignmentSourcePath)); +function resolveTaskPath(ref: string, taskListSourcePath: string): string { + const resolved = resolveStringRef(ref, dirname(taskListSourcePath)); if (resolved.kind === "path") { if (!existsSync(resolved.path)) { throw new AssignmentConfigError( - assignmentSourcePath, + taskListSourcePath, ` - task reference "${ref}" not found at ${resolved.path}`, ); } @@ -973,7 +994,7 @@ function resolveTaskPath(ref: string, assignmentSourcePath: string): string { const { path, searched } = resolveNamedTaskPath(resolved.name); if (!path) { throw new AssignmentConfigError( - assignmentSourcePath, + taskListSourcePath, ` - task reference "${ref}" not found\n searched:\n${searched.map((candidate) => ` - ${candidate}`).join("\n")}`, ); } @@ -1013,14 +1034,14 @@ function loadTaskDefinitionFromPath( }; } -function resolveAssignmentTasks( - config: AuthoredAssignmentConfig, +function resolveAuthoredTaskEntriesFromSource( + tasks: AuthoredAssignmentConfig["tasks"], sourcePath: string, options: { strictIdentity: boolean }, ): TaskDef[] { const resolvedTasks: TaskDef[] = []; - for (const [index, entry] of config.tasks.entries()) { + for (const [index, entry] of tasks.entries()) { if (typeof entry !== "string") { resolvedTasks.push(entry); continue; @@ -1043,6 +1064,64 @@ function resolveAssignmentTasks( return resolvedTasks; } +function resolveAssignmentTasks( + config: AuthoredAssignmentConfig, + sourcePath: string, + options: { strictIdentity: boolean }, +): TaskDef[] { + return resolveAuthoredTaskEntriesFromSource(config.tasks, sourcePath, options); +} + +function parseTaskListYaml(sourcePath: string): TaskListConfig { + const configData = loadYamlData( + sourcePath, + (issues, cause) => new TaskListConfigError(sourcePath, issues, cause), + { rejectDocumentSeparators: true }, + ); + const parsed = taskListConfigSchema.safeParse(configData); + if (!parsed.success) { + throw new TaskListConfigError(sourcePath, formatConfigIssues(parsed.error.issues)); + } + return parsed.data; +} + +function absolutizeTaskHookPaths(tasks: readonly TaskDef[], sourcePath: string): TaskDef[] { + return tasks.map((task) => ({ + ...task, + hooks: task.hooks.map((hook) => { + if (hook.path === undefined) { + return hook; + } + return { + ...hook, + path: isAbsolute(hook.path) ? hook.path : resolve(dirname(sourcePath), hook.path), + }; + }), + })); +} + +export function loadRepoLocalTaskList(sourcePath: string): TaskDef[] { + const config = parseTaskListYaml(sourcePath); + let tasks: TaskDef[]; + try { + tasks = resolveAuthoredTaskEntriesFromSource(config.tasks, sourcePath, { + strictIdentity: true, + }); + } catch (error) { + if (error instanceof AssignmentConfigError) { + throw new TaskListConfigError(sourcePath, error.issues); + } + throw error; + } + + const resolvedTasks = taskDefListSchema.safeParse(tasks); + if (!resolvedTasks.success) { + throw new TaskListConfigError(sourcePath, formatConfigIssues(resolvedTasks.error.issues)); + } + + return absolutizeTaskHookPaths(resolvedTasks.data, sourcePath); +} + function loadAgentDefinitionFromPath( sourcePath: string, options: { strictIdentity: boolean }, @@ -1106,6 +1185,7 @@ function loadAssignmentDefinitionFromPath( return { config: resolvedConfig.data, + sourceConfig: authored.config, instructions: authored.instructions, sourcePath, }; diff --git a/packages/core/src/core/config/loaded.ts b/packages/core/src/core/config/loaded.ts index 60370c4c..8e6a5309 100644 --- a/packages/core/src/core/config/loaded.ts +++ b/packages/core/src/core/config/loaded.ts @@ -4,6 +4,7 @@ import type { AgentLauncherReference } from "./launchers.js"; import { type AgentConfig, type AssignmentConfig, + type AuthoredAssignmentConfig, DEFAULT_AGENT_TIMEOUT_SEC, DEFAULT_AGENT_UNRESTRICTED, type LockableField, @@ -27,6 +28,7 @@ export interface LoadedAgent { export interface LoadedAssignment { config: AssignmentConfig; + sourceConfig?: AuthoredAssignmentConfig; instructions: string; sourcePath: string; } diff --git a/packages/core/src/core/config/schema.ts b/packages/core/src/core/config/schema.ts index 07f1fa4e..e86d5042 100644 --- a/packages/core/src/core/config/schema.ts +++ b/packages/core/src/core/config/schema.ts @@ -164,6 +164,17 @@ const taskIdSchema = z .regex(/^[A-Za-z0-9._:/-]+$/, "task id must match [A-Za-z0-9._:/-]+") .max(128); +function taskIdsAreUnique(tasks: readonly T[], getId: (task: T) => string | null): boolean { + const ids = new Set(); + for (const task of tasks) { + const id = getId(task); + if (id === null) continue; + if (ids.has(id)) return false; + ids.add(id); + } + return true; +} + const taskMetadataSchema = z.object({ title: z .string() @@ -178,6 +189,28 @@ export const taskDefSchema = taskMetadataSchema.extend({ body: z.string().optional().default(""), }); +export const taskDefListSchema = z + .array(taskDefSchema) + .max(100) + .refine((tasks) => taskIdsAreUnique(tasks, (task) => task.id), { + message: "task ids must be unique", + }); + +export const resolvedTaskSchema = taskDefSchema + .required({ + body: true, + hooks: true, + }) + .strict(); + +export const resolvedTaskListSchema = z + .array(resolvedTaskSchema) + .min(1, "setTasks must include at least one task") + .max(100) + .refine((tasks) => taskIdsAreUnique(tasks, (task) => task.id), { + message: "task ids must be unique", + }); + export const taskDefinitionConfigSchema = taskMetadataSchema.extend({ schemaVersion: z.literal(1), id: taskIdSchema.optional(), @@ -185,6 +218,13 @@ export const taskDefinitionConfigSchema = taskMetadataSchema.extend({ export const authoredAssignmentTaskEntrySchema = z.union([z.string().trim().min(1), taskDefSchema]); +export const taskListConfigSchema = z + .object({ + schemaVersion: z.literal(1), + tasks: z.array(authoredAssignmentTaskEntrySchema).max(100), + }) + .strict(); + export const assignmentHooksSchema = z .object({ prepare: z.array(baseHookEntrySchema(z.record(z.string(), z.unknown()))).default([]), @@ -369,34 +409,16 @@ export const authoredAssignmentConfigSchema = assignmentConfigBaseSchema .extend({ tasks: z.array(authoredAssignmentTaskEntrySchema).max(100).default([]), }) - .refine( - (c) => { - const ids = new Set(); - for (const task of c.tasks) { - if (typeof task === "string") continue; - if (ids.has(task.id)) return false; - ids.add(task.id); - } - return true; - }, - { message: "task ids must be unique", path: ["tasks"] }, - ); + .refine((c) => taskIdsAreUnique(c.tasks, (task) => (typeof task === "string" ? null : task.id)), { + message: "task ids must be unique", + path: ["tasks"], + }); export const assignmentConfigSchema = assignmentConfigBaseSchema .extend({ - tasks: z.array(taskDefSchema).max(100).default([]), + tasks: taskDefListSchema.default([]), }) - .refine( - (c) => { - const ids = new Set(); - for (const t of c.tasks) { - if (ids.has(t.id)) return false; - ids.add(t.id); - } - return true; - }, - { message: "task ids must be unique", path: ["tasks"] }, - ); + .strict(); export type AssignmentConfig = z.infer; export type AuthoredAssignmentConfig = z.infer; @@ -406,6 +428,8 @@ export type AssignmentHookEntry = z.infer & { export type AssignmentHooks = z.infer; export type AuthoredAssignmentTaskEntry = z.infer; export type TaskDef = z.infer; +export type ResolvedTaskDef = z.infer; export type TaskDefinitionConfig = z.infer; +export type TaskListConfig = z.infer; export type TaskTransitionHookEntry = z.infer; export type VarDef = z.infer; diff --git a/packages/core/src/core/hooks/builtin-task-list.ts b/packages/core/src/core/hooks/builtin-task-list.ts new file mode 100644 index 00000000..6cc0c95a --- /dev/null +++ b/packages/core/src/core/hooks/builtin-task-list.ts @@ -0,0 +1,72 @@ +import { TaskListConfigError, loadRepoLocalTaskList } from "../../config/loader.js"; +import { defineHook } from "../../hooks.js"; +import type { HookResult, PrepareHookContext, ResolvedTask } from "./types.js"; + +interface TaskListHookConfig { + path: string; + mode: "replace"; + missing: "continue"; + empty: "keep-existing"; +} + +function parseConfig(config: unknown): TaskListHookConfig { + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error("task-list hook requires an object config"); + } + const record = config as Record; + for (const key of Object.keys(record)) { + if (key !== "path" && key !== "mode" && key !== "missing" && key !== "empty") { + throw new Error(`task-list hook does not support config key "${key}"`); + } + } + if (typeof record.path !== "string" || record.path.trim().length === 0) { + throw new Error("task-list hook requires string config path"); + } + if (record.mode !== "replace") { + throw new Error('task-list hook mode must be "replace"'); + } + if (record.missing !== "continue") { + throw new Error('task-list hook missing must be "continue"'); + } + if (record.empty !== "keep-existing") { + throw new Error('task-list hook empty must be "keep-existing"'); + } + return { + path: record.path, + mode: record.mode, + missing: record.missing, + empty: record.empty, + }; +} + +export default defineHook({ + name: "task-list", + prepare(ctx: PrepareHookContext): HookResult { + const config = parseConfig(ctx.config); + let tasks: ResolvedTask[]; + try { + tasks = loadRepoLocalTaskList(config.path); + } catch (error) { + if (isMissingTaskListFile(error)) { + return { action: "continue" }; + } + throw error; + } + if (tasks.length === 0) { + return { action: "continue" }; + } + return { + action: "continue", + mutate: { + setTasks: tasks, + }, + }; + }, +}); + +function isMissingTaskListFile(error: unknown): boolean { + const cause = error instanceof TaskListConfigError ? error.cause : undefined; + return ( + typeof cause === "object" && cause !== null && (cause as { code?: unknown }).code === "ENOENT" + ); +} diff --git a/packages/core/src/core/hooks/loader.ts b/packages/core/src/core/hooks/loader.ts index 0b3d2fc1..79a6d1ac 100644 --- a/packages/core/src/core/hooks/loader.ts +++ b/packages/core/src/core/hooks/loader.ts @@ -8,7 +8,7 @@ import { } from "../../config/runtime-paths.js"; import { importDefaultOrModule } from "../../util/module-loader.js"; import type { LoadedAssignment } from "../config/loaded.js"; -import type { HookPhase } from "../config/schema.js"; +import type { HookPhase, TaskDef } from "../config/schema.js"; import { HookConfigError } from "./errors.js"; import { builtinHookModule } from "./registry.js"; import type { @@ -50,8 +50,8 @@ function resolveNamedHookPath(id: string, env: NodeJS.ProcessEnv): string { ); } -function resolvePathHookPath(path: string, assignment: LoadedAssignment): string { - const baseDir = resolve(assignment.sourcePath, ".."); +function resolvePathHookPath(path: string, sourcePath: string): string { + const baseDir = resolve(sourcePath, ".."); const resolvedPath = isAbsolute(path) ? path : resolve(baseDir, path); if (!existsSync(resolvedPath)) { throw new HookConfigError(`hook path ${resolvedPath} was not found`); @@ -101,83 +101,120 @@ export function resolveAssignmentHooks( } const descriptors: ResolvedHookDescriptor[] = []; + pushTaskLocalHookDescriptors({ + descriptors, + tasks: assignment.config.tasks, + sourcePath: assignment.sourcePath, + vars, + env, + }); + + for (const phase of Object.keys(assignment.config.hooks) as HookPhase[]) { + const entries = assignment.config.hooks[phase]; + entries.forEach((entry, index) => { + pushResolvedDescriptor(descriptors, phase, index, entry, assignment.sourcePath, vars, env); + }); + } + return descriptors; +} + +function pushResolvedDescriptor( + descriptors: ResolvedHookDescriptor[], + phase: HookPhase, + index: number, + entry: { + builtin?: string; + name?: string; + path?: string; + when?: unknown; + with?: unknown; + }, + sourcePath: string, + vars: Record, + env: NodeJS.ProcessEnv, + scope: { + taskScopeId?: string; + hookIdPrefix?: string; + } = {}, +) { const configDir = resolveTaskRunnerConfigDir(env); - const pushResolvedDescriptor = ( - phase: HookPhase, - index: number, - entry: { - builtin?: string; - name?: string; - path?: string; - when?: unknown; - with?: unknown; - }, - scope: { - taskScopeId?: string; - hookIdPrefix?: string; - } = {}, - ) => { - const config = interpolateHookValue(entry.with, vars); - const when = interpolateHookValue(entry.when ?? null, vars) as HookWhen | null; - const scopeTaskId = scope.taskScopeId ?? null; - validateTaskScopedWhen(phase, index, scopeTaskId, when); - const hookIdPrefix = scope.hookIdPrefix ? `${scope.hookIdPrefix}:` : ""; - if (entry.builtin) { - descriptors.push({ - hookId: `${phase}:${hookIdPrefix}${index}:${entry.builtin}`, - phase, - source: { builtin: entry.builtin }, - resolvedPath: null, - taskScopeId: scopeTaskId, - when, - config, - }); - return; - } - if (entry.name) { - descriptors.push({ - hookId: `${phase}:${hookIdPrefix}${index}:${entry.name}`, - phase, - source: { name: entry.name, path: `${configDir}/hooks/${entry.name}` }, - resolvedPath: resolveNamedHookPath(entry.name, env), - taskScopeId: scopeTaskId, - when, - config, - }); - return; - } - if (!entry.path) { - throw new HookConfigError(`hook ${phase}[${index}] is missing a hook source`); - } + const config = interpolateHookValue(entry.with, vars); + const when = interpolateHookValue(entry.when ?? null, vars) as HookWhen | null; + const scopeTaskId = scope.taskScopeId ?? null; + validateTaskScopedWhen(phase, index, scopeTaskId, when); + const hookIdPrefix = scope.hookIdPrefix ? `${scope.hookIdPrefix}:` : ""; + if (entry.builtin) { descriptors.push({ - hookId: `${phase}:${hookIdPrefix}${index}:${entry.path}`, + hookId: `${phase}:${hookIdPrefix}${index}:${entry.builtin}`, phase, - source: { path: entry.path }, - resolvedPath: resolvePathHookPath( - interpolateHookValue(entry.path, vars) as string, - assignment, - ), + source: { builtin: entry.builtin }, + resolvedPath: null, taskScopeId: scopeTaskId, when, config, }); - }; - - assignment.config.tasks.forEach((task) => { - task.hooks.forEach((entry, index) => { - pushResolvedDescriptor("taskTransition", index, entry, { - taskScopeId: task.id, - hookIdPrefix: `task:${task.id}`, - }); + return; + } + if (entry.name) { + descriptors.push({ + hookId: `${phase}:${hookIdPrefix}${index}:${entry.name}`, + phase, + source: { name: entry.name, path: `${configDir}/hooks/${entry.name}` }, + resolvedPath: resolveNamedHookPath(entry.name, env), + taskScopeId: scopeTaskId, + when, + config, }); + return; + } + if (!entry.path) { + throw new HookConfigError(`hook ${phase}[${index}] is missing a hook source`); + } + descriptors.push({ + hookId: `${phase}:${hookIdPrefix}${index}:${entry.path}`, + phase, + source: { path: entry.path }, + resolvedPath: resolvePathHookPath(interpolateHookValue(entry.path, vars) as string, sourcePath), + taskScopeId: scopeTaskId, + when, + config, }); +} - for (const phase of Object.keys(assignment.config.hooks) as HookPhase[]) { - const entries = assignment.config.hooks[phase]; - entries.forEach((entry, index) => { - pushResolvedDescriptor(phase, index, entry); +function pushTaskLocalHookDescriptors(options: { + descriptors: ResolvedHookDescriptor[]; + tasks: readonly TaskDef[]; + sourcePath: string; + vars: Record; + env: NodeJS.ProcessEnv; +}): void { + for (const task of options.tasks) { + task.hooks.forEach((entry, index) => { + pushResolvedDescriptor( + options.descriptors, + "taskTransition", + index, + entry, + options.sourcePath, + options.vars, + options.env, + { + taskScopeId: task.id, + hookIdPrefix: `task:${task.id}`, + }, + ); }); } +} + +export function resolveTaskLocalHookDescriptors( + tasks: readonly TaskDef[], + sourcePath: string, + vars: Record, + env: NodeJS.ProcessEnv = process.env, +): ResolvedHookDescriptor[] { + const descriptors: ResolvedHookDescriptor[] = []; + pushTaskLocalHookDescriptors({ descriptors, tasks, sourcePath, vars, env }); return descriptors; } diff --git a/packages/core/src/core/hooks/registry.ts b/packages/core/src/core/hooks/registry.ts index 50ecb946..a33b330a 100644 --- a/packages/core/src/core/hooks/registry.ts +++ b/packages/core/src/core/hooks/registry.ts @@ -1,6 +1,7 @@ import builtinCommandHook from "./builtin-command.js"; import builtinGitWorktreeHook from "./builtin-git-worktree.js"; import builtinRequireChildrenSuccessHook from "./builtin-require-children-success.js"; +import builtinTaskListHook from "./builtin-task-list.js"; import { HookConfigError } from "./errors.js"; import type { HookModule } from "./types.js"; @@ -8,8 +9,11 @@ const BUILTIN_HOOKS: Record = { command: builtinCommandHook, "require-children-success": builtinRequireChildrenSuccessHook, "git-worktree": builtinGitWorktreeHook, + "task-list": builtinTaskListHook, }; +const SKIP_RECONFIGURE_REPLAY_PREPARE_HOOKS = new Set(["task-list"]); + export function builtinHookModule(id: string): HookModule { const hook = BUILTIN_HOOKS[id]; if (!hook) { @@ -19,3 +23,7 @@ export function builtinHookModule(id: string): HookModule { } return hook; } + +export function shouldReplayPrepareBuiltinOnReconfigure(id: string): boolean { + return !SKIP_RECONFIGURE_REPLAY_PREPARE_HOOKS.has(id); +} diff --git a/packages/core/src/core/hooks/runtime.ts b/packages/core/src/core/hooks/runtime.ts index 0b092bda..cfdb88ca 100644 --- a/packages/core/src/core/hooks/runtime.ts +++ b/packages/core/src/core/hooks/runtime.ts @@ -1,8 +1,8 @@ -import { basename } from "node:path"; +import { basename, isAbsolute } from "node:path"; import type { TaskState, TaskStatus } from "../../assignment/model.js"; import type { RunAttachment } from "../../contracts/attachments.js"; import { shortId } from "../../util/short-id.js"; -import type { LockableField } from "../config/schema.js"; +import { type HookPhase, type LockableField, resolvedTaskListSchema } from "../config/schema.js"; import { getAttachment, removeAttachmentFiles, @@ -29,6 +29,7 @@ import type { HookResult, PrepareHookContext, ResolvedHookDescriptor, + ResolvedTask, TaskTransitionHookContext, TaskTransitionHookWhen, TaskTransitionResult, @@ -56,6 +57,7 @@ interface AttemptResultSnapshot { interface HookExecutionState { manifest: RunManifest; tasks: Map; + replacementTasks: ResolvedTask[] | null; initialPrompt: string; attemptPrompt: string; eventContext: RunEventWriteContext; @@ -214,11 +216,48 @@ function applyTaskPatches( } } +function cloneResolvedTask(task: ResolvedTask): ResolvedTask { + return structuredClone(task); +} + +function taskMapFromResolvedTasks(tasks: readonly ResolvedTask[]): { + taskMap: Map; + resolvedTasks: ResolvedTask[]; +} { + const parsed = resolvedTaskListSchema.safeParse(tasks); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const path = issue?.path.length ? `[${issue.path.join(".")}]` : ""; + throw new HookRuntimeError(`hook setTasks${path}: ${issue?.message ?? "invalid tasks"}`); + } + + const next = new Map(); + const resolvedTasks: ResolvedTask[] = []; + for (const [taskIndex, task] of parsed.data.entries()) { + for (const [hookIndex, hook] of task.hooks.entries()) { + if (hook.path !== undefined && !isAbsolute(hook.path)) { + throw new HookRuntimeError( + `hook setTasks[${taskIndex}.hooks.${hookIndex}.path]: path hooks must be absolute`, + ); + } + } + resolvedTasks.push(cloneResolvedTask(task)); + next.set(task.id, { + id: task.id, + title: task.title, + body: task.body, + status: "pending", + notes: "", + }); + } + return { taskMap: next, resolvedTasks }; +} + async function applyMutations( state: HookExecutionState, mutate: HookMutations | undefined, options: { - allowVars: boolean; + phase: HookPhase; }, ): Promise { if (!mutate) { @@ -257,7 +296,7 @@ async function applyMutations( } if (mutate.vars) { - if (!options.allowVars) { + if (options.phase !== "prepare") { throw new HookRuntimeError("hook vars mutations are only allowed during prepare"); } state.manifest.runtimeVars = { @@ -290,6 +329,20 @@ async function applyMutations( state.manifest.pinned = mutate.pinned; } + if (mutate.setTasks) { + if (options.phase !== "prepare") { + throw new HookRuntimeError("hook setTasks mutations are only allowed during prepare"); + } + if (mutate.patchTasks) { + throw new HookRuntimeError( + "hook cannot combine patchTasks and setTasks in the same mutation", + ); + } + const replacement = taskMapFromResolvedTasks(mutate.setTasks); + state.tasks = replacement.taskMap; + state.replacementTasks = replacement.resolvedTasks; + } + if (mutate.patchTasks) { applyTaskPatches(state.tasks, mutate.patchTasks); } @@ -523,7 +576,7 @@ export async function runPrepareHooks( ); continue; } - await applyMutations(state, hookResult.mutate, { allowVars: true }); + await applyMutations(state, hookResult.mutate, { phase: "prepare" }); if (hookResult.action === "block") { throw new HookRuntimeError(hookResult.reason); } @@ -600,7 +653,7 @@ export async function runAttemptHooks( ); continue; } - await applyMutations(state, result.mutate, { allowVars: false }); + await applyMutations(state, result.mutate, { phase }); recordHookAudit( state, descriptor, @@ -690,7 +743,7 @@ export async function runTaskTransitionHooks( ); continue; } - await applyMutations(state, result.mutate, { allowVars: false }); + await applyMutations(state, result.mutate, { phase: "taskTransition" }); recordHookAudit( state, descriptor, @@ -738,6 +791,7 @@ export function createHookExecutionState( return { manifest, tasks, + replacementTasks: null, initialPrompt: prompts.initialPrompt, attemptPrompt: prompts.attemptPrompt ?? prompts.initialPrompt, eventContext, @@ -763,6 +817,7 @@ export function cloneHookExecutionState(state: HookExecutionState): HookExecutio hookAudits: state.manifest.hookAudits.map((audit) => ({ ...audit })), }, tasks: cloneTasks(state.tasks), + replacementTasks: state.replacementTasks?.map(cloneResolvedTask) ?? null, initialPrompt: state.initialPrompt, attemptPrompt: state.attemptPrompt, eventContext: { ...state.eventContext }, diff --git a/packages/core/src/core/hooks/types.ts b/packages/core/src/core/hooks/types.ts index 8ed8df5d..b5aa0aca 100644 --- a/packages/core/src/core/hooks/types.ts +++ b/packages/core/src/core/hooks/types.ts @@ -1,6 +1,11 @@ import type { TaskStatus } from "../../assignment/model.js"; import type { RunAttachment } from "../../contracts/attachments.js"; -import type { HookPhase, LockableField } from "../config/schema.js"; +import type { + HookPhase, + LockableField, + ResolvedTaskDef, + TaskTransitionHookEntry, +} from "../config/schema.js"; import type { ManifestStatus, RunManifest } from "../run/manifest.js"; export interface HookSourceDescriptor { @@ -55,6 +60,8 @@ export interface HookTaskPatch { notesAppend?: string; } +export type ResolvedTask = ResolvedTaskDef; + export interface HookAttachmentAdd { sourcePath: string; name?: string; @@ -82,6 +89,7 @@ export interface HookMutations { note?: string | null; pinned?: boolean; patchTasks?: HookTaskPatch[]; + setTasks?: ResolvedTask[]; attachments?: { add?: HookAttachmentAdd[]; remove?: string[]; diff --git a/packages/core/src/core/run/reconfigure.ts b/packages/core/src/core/run/reconfigure.ts index 0de0f2a6..9533e2c4 100644 --- a/packages/core/src/core/run/reconfigure.ts +++ b/packages/core/src/core/run/reconfigure.ts @@ -9,6 +9,7 @@ import { cloneResolvedLauncherConfig } from "../config/launchers.js"; import { loadedAgentFromManifest } from "../config/loaded.js"; import type { LoadedAgent, LoadedAssignment } from "../config/loaded.js"; import type { LockableField, VarDef } from "../config/schema.js"; +import { shouldReplayPrepareBuiltinOnReconfigure } from "../hooks/registry.js"; import { type ResolvedResumeTarget, ResumeError, @@ -361,11 +362,18 @@ async function reconfigureResolvedRun( resume: resolved, initialize: true, stageInitialize: true, - resolvedHooksOverride: previous.resolvedHooks.map((descriptor) => ({ - ...descriptor, - source: { ...descriptor.source }, - when: descriptor.when ? { ...descriptor.when } : null, - })), + resolvedHooksOverride: previous.resolvedHooks + .filter((descriptor) => { + if (descriptor.phase !== "prepare" || descriptor.source.builtin === undefined) { + return true; + } + return shouldReplayPrepareBuiltinOnReconfigure(descriptor.source.builtin); + }) + .map((descriptor) => ({ + ...descriptor, + source: { ...descriptor.source }, + when: descriptor.when ? { ...descriptor.when } : null, + })), overrides: buildReconfigureOverrides(previous, loaded, loadedAssignment, nextMessage), }); diff --git a/packages/core/src/core/run/run-loop.ts b/packages/core/src/core/run/run-loop.ts index 86769f97..1fe9ed77 100644 --- a/packages/core/src/core/run/run-loop.ts +++ b/packages/core/src/core/run/run-loop.ts @@ -1,5 +1,6 @@ -import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { copyFileSync, mkdirSync, rmSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; +import matter from "gray-matter"; import type { TaskState, TaskStatus } from "../../assignment/model.js"; import { BackendConfigError, resolveBackend } from "../../backends/registry.js"; import { @@ -11,7 +12,7 @@ import { import { resolveTaskRunnerCommand } from "../../task-runner-command.js"; import { normalizeOptionalRunName } from "../../util/run-name.js"; import { shortId } from "../../util/short-id.js"; -import { appendTextFileDurable } from "../../util/write-file-atomic.js"; +import { appendTextFileDurable, writeTextFileAtomic } from "../../util/write-file-atomic.js"; import { cloneBackendConfig, cloneResolvedBackendArgs, @@ -27,8 +28,8 @@ import type { import { interpolate } from "../config/interpolate.js"; import { type ResolvedLauncherConfig, cloneResolvedLauncherConfig } from "../config/launchers.js"; import type { LoadedAgent, LoadedAssignment } from "../config/loaded.js"; -import type { LockableField, VarDef } from "../config/schema.js"; -import { resolveAssignmentHooks } from "../hooks/loader.js"; +import type { LockableField, TaskDef, VarDef } from "../config/schema.js"; +import { resolveAssignmentHooks, resolveTaskLocalHookDescriptors } from "../hooks/loader.js"; import { createHookExecutionState, runAttemptHooks, runPrepareHooks } from "../hooks/runtime.js"; import type { ResolvedHookDescriptor } from "../hooks/types.js"; import { validateRunGroupId } from "./groups.js"; @@ -506,6 +507,30 @@ function copyFrozenAssignmentSeed(sourceManifest: RunManifest, targetWorkspaceDi copyFileSync(workspaceAssignmentPath(sourceManifest.workspaceDir), targetAssignmentPath); } +function taskSeedEntries(tasks: Map): Array<{ + id: string; + title: string; + body?: string; +}> { + return Array.from(tasks.values()).map((task) => ({ + id: task.id, + title: task.title, + ...(task.body.length > 0 ? { body: task.body } : {}), + })); +} + +function writeAssignmentSeedSnapshot( + assignment: LoadedAssignment, + targetPath: string, + tasks: Map, +): void { + const rendered = matter.stringify(assignment.instructions, { + ...(assignment.sourceConfig ?? assignment.config), + tasks: taskSeedEntries(tasks), + }); + writeTextFileAtomic(targetPath, rendered.endsWith("\n") ? rendered : `${rendered}\n`); +} + function copyFrozenAgentSeed(sourceManifest: RunManifest, targetWorkspaceDir: string): void { if (sourceManifest.agent.sourcePath === null) { return; @@ -1119,6 +1144,25 @@ function syncFreshTasksToFinalInjectedVars( return synced; } +function syncReplacementTasksToFinalInjectedVars( + replacementTasks: readonly TaskDef[], + currentTasks: Map, + finalInjectedVars: Record, +): Map { + const synced = new Map(); + for (const task of replacementTasks) { + const existing = currentTasks.get(task.id); + synced.set(task.id, { + id: task.id, + title: interpolate(task.title, finalInjectedVars), + body: interpolate(task.body ?? "", finalInjectedVars), + status: existing?.status ?? "pending", + notes: existing?.notes ?? "", + }); + } + return synced; +} + function resolveRuntimeBackend(backendId: string, fallback: Backend): Backend { if (backendId === fallback.id) { return fallback; @@ -1517,6 +1561,7 @@ export async function runAgent(opts: RunOptions): Promise { isResume || priorReady ? (resume?.manifest.resolvedHooks ?? []) : (opts.resolvedHooksOverride ?? resolveAssignmentHooks(loadedAssignment, injectedVars)); + let finalResolvedHookDescriptors = resolvedHookDescriptors; const initialSchedule = !isResume && !priorReady ? (() => { @@ -1542,6 +1587,7 @@ export async function runAgent(opts: RunOptions): Promise { ? [...resume.manifest.lockedFields] : null : null; + let shouldWriteFinalTaskSeedSnapshot = false; let tasks: Map; if (isResume && resume) { @@ -1739,12 +1785,30 @@ export async function runAgent(opts: RunOptions): Promise { hookNote = prepareState.manifest.note; hookPinned = prepareState.manifest.pinned; hookLockedFields = [...prepareState.manifest.lockedFields]; - tasks = syncFreshTasksToFinalInjectedVars( - loadedAssignment, - tasks, - prePrepareInjectedVars, - injectedVars, - ); + if (prepareState.replacementTasks) { + shouldWriteFinalTaskSeedSnapshot = true; + tasks = syncReplacementTasksToFinalInjectedVars( + prepareState.replacementTasks, + tasks, + injectedVars, + ); + const rootHookDescriptors = resolvedHookDescriptors.filter( + (descriptor) => descriptor.taskScopeId === null, + ); + const taskHookDescriptors = resolveTaskLocalHookDescriptors( + prepareState.replacementTasks, + loadedAssignment?.sourcePath ?? workspaceDir, + injectedVars, + ); + finalResolvedHookDescriptors = [...taskHookDescriptors, ...rootHookDescriptors]; + } else { + tasks = syncFreshTasksToFinalInjectedVars( + loadedAssignment, + tasks, + prePrepareInjectedVars, + injectedVars, + ); + } } // If the caller is importing an existing backend session, validate it @@ -1937,7 +2001,7 @@ export async function runAgent(opts: RunOptions): Promise { runtimeVarSources: cloneRuntimeVarSources(runtimeVarSources), execution, brief: initialPrompt, - resolvedHooks: resolvedHookDescriptors.map((descriptor) => ({ + resolvedHooks: finalResolvedHookDescriptors.map((descriptor) => ({ ...descriptor, source: { ...descriptor.source }, when: descriptor.when ? { ...descriptor.when } : null, @@ -2008,7 +2072,11 @@ export async function runAgent(opts: RunOptions): Promise { if ((resume === undefined || isReinitialize) && loadedAssignment?.sourcePath) { if (loadedAssignment.sourcePath !== assignmentSeedPath) { - copyFileSync(loadedAssignment.sourcePath, assignmentSeedPath); + if (shouldWriteFinalTaskSeedSnapshot) { + writeAssignmentSeedSnapshot(loadedAssignment, assignmentSeedPath, tasks); + } else { + copyFileSync(loadedAssignment.sourcePath, assignmentSeedPath); + } } } else if (isReinitialize) { rmSync(assignmentSeedPath, { force: true }); diff --git a/packages/core/src/hooks.ts b/packages/core/src/hooks.ts index 08e05990..d9c40bd7 100644 --- a/packages/core/src/hooks.ts +++ b/packages/core/src/hooks.ts @@ -12,8 +12,11 @@ export { type HookMutations, type HookResult, type HookTaskPatch, + type HookContextTasks, type PrepareHookContext, + type ResolvedTask, type ResolvedHookDescriptor, type TaskTransitionHookContext, type TaskTransitionResult, } from "./core/hooks/types.js"; +export type { TaskTransitionHookEntry } from "./core/config/schema.js"; diff --git a/test/config-loader.test.mjs b/test/config-loader.test.mjs index a470704e..fed45d90 100644 --- a/test/config-loader.test.mjs +++ b/test/config-loader.test.mjs @@ -12,6 +12,7 @@ import { LauncherConfigError, LauncherNotFoundError, TaskConfigError, + TaskListConfigError, TaskNotFoundError, listAgentDefinitions, listAgents, @@ -22,6 +23,7 @@ import { loadAgentConfig, loadAssignmentConfig, loadLauncherConfig, + loadRepoLocalTaskList, loadTaskConfig, resolveAgentPath, resolveAssignmentPath, @@ -1335,6 +1337,219 @@ Assignment body. ); })); +test("loadRepoLocalTaskList resolves inline, named, relative, parent-relative, and absolute task refs", () => + withRuntimeRoots("task-runner-loader-", ({ rootDir, configDir }) => { + writeTask( + configDir, + "orient", + `--- +schemaVersion: 1 +title: Orient repo +--- +Read README.md. +`, + ); + const taskListDir = join(rootDir, ".task-runner"); + const nestedTasksDir = join(taskListDir, "tasks"); + mkdirSync(nestedTasksDir, { recursive: true }); + writeFileSync( + join(nestedTasksDir, "config.md"), + `--- +schemaVersion: 1 +title: Configure runtime +--- +Tune config. +`, + ); + const siblingTaskPath = join(rootDir, "sibling.md"); + writeFileSync( + siblingTaskPath, + `--- +schemaVersion: 1 +title: Sibling task +--- +Check sibling. +`, + ); + const absoluteTaskPath = join(rootDir, "absolute.md"); + writeFileSync( + absoluteTaskPath, + `--- +schemaVersion: 1 +id: absolute-custom +title: Absolute task +hooks: + - path: ./hooks/absolute.mjs +--- +Check absolute. +`, + ); + mkdirSync(join(taskListDir, "hooks"), { recursive: true }); + writeFileSync(join(taskListDir, "hooks", "absolute.mjs"), "export default { name: 'x' };\n"); + + const taskListPath = join(taskListDir, "tasks.yml"); + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: + - orient + - ./tasks/config.md + - ../sibling.md + - ${absoluteTaskPath} + - id: inline-task + title: Inline task + body: Inline body +`, + ); + + const tasks = loadRepoLocalTaskList(taskListPath); + assert.deepEqual( + tasks.map((task) => ({ id: task.id, title: task.title, body: task.body })), + [ + { id: "orient", title: "Orient repo", body: "Read README.md." }, + { id: "config", title: "Configure runtime", body: "Tune config." }, + { id: "sibling", title: "Sibling task", body: "Check sibling." }, + { id: "absolute-custom", title: "Absolute task", body: "Check absolute." }, + { id: "inline-task", title: "Inline task", body: "Inline body" }, + ], + ); + assert.equal(tasks[3].hooks[0].path, join(taskListDir, "hooks", "absolute.mjs")); + })); + +test("loadRepoLocalTaskList rejects invalid yaml", () => + withRuntimeRoots("task-runner-loader-", ({ rootDir }) => { + const taskListPath = join(rootDir, ".task-runner", "tasks.yml"); + mkdirSync(dirname(taskListPath), { recursive: true }); + writeFileSync(taskListPath, "schemaVersion: 1\ntasks:\n - [unterminated\n"); + assert.throws( + () => loadRepoLocalTaskList(taskListPath), + (error) => { + assert.ok(error instanceof TaskListConfigError); + assert.match(error.message, /Invalid task list config/); + assert.match(error.message, /tasks\.yml/); + return true; + }, + ); + })); + +test("loadRepoLocalTaskList rejects yaml document separators", () => + withRuntimeRoots("task-runner-loader-", ({ rootDir }) => { + const taskListPath = join(rootDir, ".task-runner", "tasks.yml"); + mkdirSync(dirname(taskListPath), { recursive: true }); + writeFileSync(taskListPath, "schemaVersion: 1\ntasks: []\n---\nextra: ignored\n"); + assert.throws( + () => loadRepoLocalTaskList(taskListPath), + (error) => { + assert.ok(error instanceof TaskListConfigError); + assert.match(error.message, /YAML document separators are not supported/); + return true; + }, + ); + })); + +test("loadRepoLocalTaskList rejects missing schemaVersion and unsupported top-level keys", () => + withRuntimeRoots("task-runner-loader-", ({ rootDir }) => { + const taskListPath = join(rootDir, ".task-runner", "tasks.yml"); + mkdirSync(dirname(taskListPath), { recursive: true }); + writeFileSync(taskListPath, "tasks: []\nextra: true\n"); + assert.throws( + () => loadRepoLocalTaskList(taskListPath), + (error) => { + assert.ok(error instanceof TaskListConfigError); + assert.match(error.message, /schemaVersion/); + assert.match(error.message, /Unrecognized key/); + return true; + }, + ); + })); + +test("loadRepoLocalTaskList rejects missing task refs, duplicate ids, and invalid task hooks", () => + withRuntimeRoots("task-runner-loader-", ({ rootDir }) => { + const taskListPath = join(rootDir, ".task-runner", "tasks.yml"); + mkdirSync(dirname(taskListPath), { recursive: true }); + writeFileSync(taskListPath, "schemaVersion: 1\ntasks:\n - missing-task\n"); + assert.throws( + () => loadRepoLocalTaskList(taskListPath), + (error) => { + assert.ok(error instanceof TaskListConfigError); + assert.match(error.message, /missing-task/); + return true; + }, + ); + + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: + - id: dup + title: One + - id: dup + title: Two +`, + ); + assert.throws( + () => loadRepoLocalTaskList(taskListPath), + (error) => { + assert.ok(error instanceof TaskListConfigError); + assert.match(error.message, /task ids must be unique/); + return true; + }, + ); + + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: + - id: bad-title + title: "bad\\ntitle" +`, + ); + assert.throws( + () => loadRepoLocalTaskList(taskListPath), + (error) => { + assert.ok(error instanceof TaskListConfigError); + assert.match(error.message, /single line/); + return true; + }, + ); + + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: +${Array.from({ length: 101 }, (_value, index) => ` - id: t${index}\n title: Task ${index}`).join("\n")} +`, + ); + assert.throws( + () => loadRepoLocalTaskList(taskListPath), + (error) => { + assert.ok(error instanceof TaskListConfigError); + assert.match(error.message, /at most 100/); + return true; + }, + ); + + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: + - id: bad-hook + title: Bad hook + hooks: + - builtin: a + name: b +`, + ); + assert.throws( + () => loadRepoLocalTaskList(taskListPath), + (error) => { + assert.ok(error instanceof TaskListConfigError); + assert.match(error.message, /exactly one of `builtin`, `name`, or `path`/); + return true; + }, + ); + })); + test("loadAssignmentConfig hard-fails when a referenced task id mismatches its canonical file id", () => withRuntimeRoots("task-runner-loader-", ({ rootDir, configDir }) => { writeTask( diff --git a/test/run-loop-schedule-recurrence.test.mjs b/test/run-loop-schedule-recurrence.test.mjs index f95dfe9e..4cd856e1 100644 --- a/test/run-loop-schedule-recurrence.test.mjs +++ b/test/run-loop-schedule-recurrence.test.mjs @@ -416,3 +416,83 @@ test("run-loop schedules: reuse, reset, and clone recurrence modes use frozen re assert.equal(cloneManifest.schedule.recurrence.mode, "clone"); assert.equal(cloneManifest.resetSeed.parentRunId, reuse.runId); }); + +test("run-loop schedules: reset and clone recurrence preserve task-list replacement tasks", async () => { + const dir = tempDir(); + writeBundle(dir); + const taskListDir = join(dir, ".task-runner"); + mkdirSync(taskListDir, { recursive: true }); + const taskListPath = join(taskListDir, "tasks.yml"); + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: + - id: replacement + title: Replacement +`, + ); + writeFileSync( + join(dir, "assignments", "scheduled-work", "assignment.md"), + `--- +schemaVersion: 1 +name: scheduled-work +hooks: + prepare: + - builtin: task-list + with: + path: ${JSON.stringify(taskListPath)} + mode: replace + missing: continue + empty: keep-existing +tasks: + - id: original + title: Original +maxRetries: 0 +--- +Scheduled work. +`, + ); + + const reset = await initRun(dir, { + schedule: { cron: "*/5 * * * *", timezone: "UTC", mode: "reset" }, + }); + assert.deepEqual(Object.keys(reset.manifest.finalTasks), ["replacement"]); + writeFileSync(taskListPath, "schemaVersion: 1\ntasks:\n - [invalid\n"); + readyInitializedRun(dir, reset.runId); + setDue(reset.workspaceDir); + await runReady(dir, reset.runId, (ctx) => + setTaskStatusesForPrompt(ctx.prompt, { replacement: "completed" }, dir), + ); + const resetManifest = readManifest(reset.workspaceDir); + assert.equal(resetManifest.status, "ready"); + assert.deepEqual(Object.keys(resetManifest.finalTasks), ["replacement"]); + assert.equal(resetManifest.finalTasks.replacement.status, "pending"); + + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: + - id: replacement + title: Replacement +`, + ); + const clone = await initRun(dir, { + schedule: { cron: "*/5 * * * *", timezone: "UTC", mode: "clone" }, + }); + writeFileSync(taskListPath, "schemaVersion: 1\ntasks:\n - id: changed\n title: Changed\n"); + readyInitializedRun(dir, clone.runId); + setDue(clone.workspaceDir); + await runReady(dir, clone.runId, (ctx) => + setTaskStatusesForPrompt(ctx.prompt, { replacement: "completed" }, dir), + ); + + const cloneManifest = repoManifests(dir, clone.manifest.repo).find( + (manifest) => + manifest.runId !== clone.runId && + manifest.status === "ready" && + manifest.schedule?.recurrence?.mode === "clone", + ); + assert.ok(cloneManifest); + assert.deepEqual(Object.keys(cloneManifest.finalTasks), ["replacement"]); + assert.equal(cloneManifest.finalTasks.replacement.status, "pending"); +}); diff --git a/test/run-loop.test.mjs b/test/run-loop.test.mjs index 91b8cbe6..58113760 100644 --- a/test/run-loop.test.mjs +++ b/test/run-loop.test.mjs @@ -11,11 +11,13 @@ import { import { tmpdir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import { test } from "node:test"; +import { reconfigureRun } from "../packages/core/dist/app/service.js"; import { codexBackend } from "../packages/core/dist/backends/codex.js"; import { BackendConfigError, loadCustomBackends } from "../packages/core/dist/backends/registry.js"; import { loadAgentConfig, loadAssignmentConfig } from "../packages/core/dist/config/loader.js"; import { readStatus, + readyRun, resetRun, setRunGroup, setTask, @@ -1125,6 +1127,385 @@ Work on the repo. Plan at {{cwd}}. ); }); +test("task-list replacement rebuilds task-local hooks and keeps assignment-level hook order", async () => { + const dir = tempDir(); + writeAgent(dir, "three", THREE_AGENT); + writeNamedHook( + dir, + "old-local-guard", + `export default { + name: "old-local-guard", + taskTransition() { + throw new Error("old task-local hook should not remain after replacement"); + }, +}; +`, + ); + writeNamedHook( + dir, + "assignment-guard", + `export default { + name: "assignment-guard", + taskTransition(ctx) { + return { + accept: true, + mutate: { + state: { + hookOrder: ((ctx.state.hookOrder ?? "") + "assignment"), + }, + }, + }; + }, +}; +`, + ); + const repoTaskDir = join(dir, ".task-runner"); + const repoHookDir = join(repoTaskDir, "hooks"); + mkdirSync(repoHookDir, { recursive: true }); + writeFileSync( + join(repoHookDir, "replacement-local.mjs"), + `export default { + name: "replacement-local", + taskTransition(ctx) { + return { + accept: true, + mutate: { + state: { + hookOrder: ((ctx.state.hookOrder ?? "") + "local,"), + }, + }, + }; + }, +}; +`, + ); + const taskListPath = join(repoTaskDir, "tasks.yml"); + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: + - id: replacement + title: Replacement + hooks: + - path: ./hooks/replacement-local.mjs +`, + ); + writeAssignment( + dir, + "replacement-hook-order-work", + `--- +schemaVersion: 1 +name: replacement-hook-order-work +hooks: + prepare: + - builtin: task-list + with: + path: ${JSON.stringify(taskListPath)} + mode: replace + missing: continue + empty: keep-existing + taskTransition: + - name: assignment-guard +tasks: + - id: original + title: Original + hooks: + - name: old-local-guard +--- +Work. +`, + ); + + const initialized = await initWithOptions(dir, "replacement-hook-order-work"); + const initManifest = JSON.parse(readFileSync(join(initialized.workspaceDir, "run.json"), "utf8")); + assert.deepEqual( + initManifest.resolvedHooks + .filter((descriptor) => descriptor.phase === "taskTransition") + .map((descriptor) => ({ + taskScopeId: descriptor.taskScopeId, + source: descriptor.source.name ?? descriptor.source.path, + })), + [ + { + taskScopeId: "replacement", + source: join(repoHookDir, "replacement-local.mjs"), + }, + { + taskScopeId: null, + source: "assignment-guard", + }, + ], + ); + assert.deepEqual( + initManifest.resolvedHooks.filter((descriptor) => descriptor.taskScopeId === "original"), + [], + ); + assert.equal( + initManifest.resolvedHooks.some((descriptor) => descriptor.source.name === "old-local-guard"), + false, + ); + + await withSharedRuntimeEnv(dir, () => + setTask(initialized.runId, "replacement", { status: "completed" }), + ); + const finalManifest = JSON.parse( + readFileSync(join(initialized.workspaceDir, "run.json"), "utf8"), + ); + assert.equal(finalManifest.hookState.hookOrder, "local,assignment"); +}); + +test("task-list prepare hook preserves existing tasks for missing and empty task-list files", async () => { + const dir = tempDir(); + writeAgent(dir, "three", THREE_AGENT); + const missingPath = join(dir, ".task-runner", "missing.yml"); + writeAssignment( + dir, + "missing-task-list-work", + `--- +schemaVersion: 1 +name: missing-task-list-work +hooks: + prepare: + - builtin: task-list + with: + path: ${JSON.stringify(missingPath)} + mode: replace + missing: continue + empty: keep-existing +tasks: + - id: original + title: Original +--- +Work. +`, + ); + + const missing = await initWithOptions(dir, "missing-task-list-work"); + assert.deepEqual(Object.keys(missing.manifest.finalTasks), ["original"]); + + const taskListDir = join(dir, ".task-runner"); + mkdirSync(taskListDir, { recursive: true }); + const emptyPath = join(taskListDir, "empty.yml"); + writeFileSync(emptyPath, "schemaVersion: 1\ntasks: []\n"); + writeAssignment( + dir, + "empty-task-list-work", + `--- +schemaVersion: 1 +name: empty-task-list-work +hooks: + prepare: + - builtin: task-list + with: + path: ${JSON.stringify(emptyPath)} + mode: replace + missing: continue + empty: keep-existing +tasks: + - id: original + title: Original +--- +Work. +`, + ); + + const empty = await initWithOptions(dir, "empty-task-list-work"); + assert.deepEqual(Object.keys(empty.manifest.finalTasks), ["original"]); +}); + +test("task-list prepare hook fails invalid config and invalid task-list files with path-bearing errors", async () => { + const dir = tempDir(); + writeAgent(dir, "three", THREE_AGENT); + const taskListDir = join(dir, ".task-runner"); + mkdirSync(taskListDir, { recursive: true }); + + const cases = [ + { + name: "bad-yaml", + file: "bad-yaml.yml", + body: "schemaVersion: 1\ntasks:\n - [unterminated\n", + expected: /bad-yaml\.yml/, + }, + { + name: "bad-schema", + file: "bad-schema.yml", + body: "schemaVersion: 2\ntasks: []\n", + expected: /schemaVersion/, + }, + { + name: "bad-ref", + file: "bad-ref.yml", + body: "schemaVersion: 1\ntasks:\n - missing-task\n", + expected: /missing-task/, + }, + { + name: "bad-shape", + file: "bad-shape.yml", + body: 'schemaVersion: 1\ntasks:\n - id: bad\n title: "bad\\ntitle"\n', + expected: /single line/, + }, + { + name: "duplicate", + file: "duplicate.yml", + body: "schemaVersion: 1\ntasks:\n - id: dup\n title: One\n - id: dup\n title: Two\n", + expected: /task ids must be unique/, + }, + { + name: "too-many", + file: "too-many.yml", + body: `schemaVersion: 1 +tasks: +${Array.from({ length: 101 }, (_value, index) => ` - id: t${index}\n title: Task ${index}`).join("\n")} +`, + expected: /at most 100/, + }, + ]; + + for (const entry of cases) { + const taskListPath = join(taskListDir, entry.file); + writeFileSync(taskListPath, entry.body); + writeAssignment( + dir, + `task-list-${entry.name}`, + `--- +schemaVersion: 1 +name: task-list-${entry.name} +hooks: + prepare: + - builtin: task-list + with: + path: ${JSON.stringify(taskListPath)} + mode: replace + missing: continue + empty: keep-existing +tasks: + - id: original + title: Original +--- +Work. +`, + ); + await assert.rejects( + () => initWithOptions(dir, `task-list-${entry.name}`), + (error) => { + assert.match(error.message, entry.expected); + assert.match(error.message, new RegExp(entry.file.replace(".", "\\."))); + return true; + }, + ); + } + + writeAssignment( + dir, + "task-list-bad-config", + `--- +schemaVersion: 1 +name: task-list-bad-config +hooks: + prepare: + - builtin: task-list + with: + path: ${JSON.stringify(join(taskListDir, "missing.yml"))} + mode: append + missing: continue + empty: keep-existing +tasks: + - id: original + title: Original +--- +Work. +`, + ); + await assert.rejects( + () => initWithOptions(dir, "task-list-bad-config"), + /task-list hook mode must be "replace"/, + ); +}); + +test("task-list replacement is frozen across reconfigure, ready-start, resume, and reset", async () => { + const dir = tempDir(); + writeAgent(dir, "three", THREE_AGENT); + const taskListDir = join(dir, ".task-runner"); + mkdirSync(taskListDir, { recursive: true }); + const taskListPath = join(taskListDir, "tasks.yml"); + writeFileSync( + taskListPath, + `schemaVersion: 1 +tasks: + - id: replacement + title: Replacement {{target}} + body: Frozen {{target}} +`, + ); + writeAssignment( + dir, + "task-list-freeze-work", + `--- +schemaVersion: 1 +name: task-list-freeze-work +vars: + target: + type: string + default: alpha +hooks: + prepare: + - builtin: task-list + with: + path: ${JSON.stringify(taskListPath)} + mode: replace + missing: continue + empty: keep-existing +tasks: + - id: original + title: Original +--- +Work. +`, + ); + + const initialized = await initWithOptions(dir, "task-list-freeze-work"); + assert.equal(initialized.manifest.finalTasks.replacement.title, "Replacement alpha"); + assert.equal(initialized.manifest.finalTasks.original, undefined); + + writeFileSync(taskListPath, "schemaVersion: 1\ntasks:\n - [invalid\n"); + await withSharedRuntimeEnv(dir, () => + reconfigureRun(initialized.runId, { vars: { target: "beta" } }), + ); + const reconfiguredManifest = JSON.parse( + readFileSync(join(initialized.workspaceDir, "run.json"), "utf8"), + ); + assert.equal(reconfiguredManifest.finalTasks.replacement.title, "Replacement alpha"); + assert.equal(reconfiguredManifest.finalTasks.original, undefined); + + await withSharedRuntimeEnv(dir, () => readyRun(initialized.runId)); + const readyResumeTarget = withSharedRuntimeEnv(dir, () => resolveResumeTarget(initialized.runId)); + const resumed = await runWithMock( + dir, + async (ctx) => { + setTaskStatusesForPrompt(ctx.prompt, { replacement: "completed" }, dir); + return { + exitCode: 0, + signal: null, + timedOut: false, + sessionId: "session-task-list-freeze", + transcript: "done", + rawStdout: "", + rawStderr: "", + }; + }, + {}, + { resume: readyResumeTarget, backendId: "claude" }, + ); + assert.equal(resumed.outcome.manifest.finalTasks.replacement.title, "Replacement alpha"); + assert.equal(resumed.outcome.manifest.finalTasks.replacement.status, "completed"); + assert.equal(resumed.outcome.manifest.finalTasks.original, undefined); + + const reset = await withSharedRuntimeEnv(dir, () => resetRun(initialized.runId)); + assert.equal(reset.manifest.finalTasks.replacement.title, "Replacement alpha"); + assert.equal(reset.manifest.finalTasks.replacement.status, "pending"); + assert.equal(reset.manifest.finalTasks.original, undefined); +}); + test("taskTransition hooks receive canonical run and assignment context", async () => { const dir = tempDir(); const assignmentPath = writeAssignment( @@ -1656,6 +2037,199 @@ Work. ); }); +test("command builtin json prepare hooks can replace the fresh task list with setTasks", async () => { + const dir = tempDir(); + writeAgent(dir, "three", THREE_AGENT); + const scriptPath = writeNodeScript( + dir, + "prepare-set-tasks.mjs", + `process.stdout.write(JSON.stringify({ + action: "continue", + mutate: { + setTasks: [ + { id: "replacement-one", title: "Replacement {{scope}}", body: "Body {{scope}}", hooks: [] }, + { id: "replacement-two", title: "Second replacement", body: "", hooks: [] }, + ], + }, +}));\n`, + ); + writeAssignment( + dir, + "set-tasks-work", + `--- +schemaVersion: 1 +name: set-tasks-work +vars: + scope: + type: string + default: prepared +hooks: + prepare: + - builtin: command + with: + mode: json + command: ${JSON.stringify(process.execPath)} + args: + - ${JSON.stringify(scriptPath)} +tasks: + - id: original + title: Original +--- +Work. +`, + ); + + const { outcome } = await runWithMock( + dir, + async () => { + throw new Error("backend should not run during init"); + }, + {}, + { assignmentName: "set-tasks-work", backendId: "claude", initialize: true }, + ); + + assert.deepEqual(Object.keys(outcome.manifest.finalTasks), [ + "replacement-one", + "replacement-two", + ]); + assert.equal(outcome.manifest.finalTasks["replacement-one"].title, "Replacement prepared"); + assert.equal(outcome.manifest.finalTasks["replacement-one"].body, "Body prepared"); + assert.equal(outcome.manifest.finalTasks["replacement-one"].status, "pending"); + assert.equal(outcome.manifest.finalTasks["replacement-one"].notes, ""); + assert.equal(outcome.manifest.tasksTotal, 2); + assert.deepEqual(Object.keys(outcome.manifest.resetSeed.finalTasks), [ + "replacement-one", + "replacement-two", + ]); + assert.match(outcome.manifest.brief, /task-runner task list/); + const assignmentSeed = readFileSync(join(outcome.workspaceDir, "assignment-seed.md"), "utf8"); + assert.match(assignmentSeed, /replacement-one/); + assert.doesNotMatch(assignmentSeed, /original/); +}); + +test("latest prepare setTasks mutation wins", async () => { + const dir = tempDir(); + writeAgent(dir, "three", THREE_AGENT); + const firstScript = writeNodeScript( + dir, + "prepare-set-tasks-first.mjs", + `process.stdout.write(JSON.stringify({ + action: "continue", + mutate: { + setTasks: [ + { id: "first-only", title: "First only", body: "", hooks: [] }, + ], + }, +}));\n`, + ); + const secondScript = writeNodeScript( + dir, + "prepare-set-tasks-second.mjs", + `process.stdout.write(JSON.stringify({ + action: "continue", + mutate: { + setTasks: [ + { id: "second-one", title: "Second one", body: "", hooks: [] }, + { id: "second-two", title: "Second two", body: "", hooks: [] }, + ], + }, +}));\n`, + ); + writeAssignment( + dir, + "latest-set-tasks-work", + `--- +schemaVersion: 1 +name: latest-set-tasks-work +hooks: + prepare: + - builtin: command + with: + mode: json + command: ${JSON.stringify(process.execPath)} + args: + - ${JSON.stringify(firstScript)} + - builtin: command + with: + mode: json + command: ${JSON.stringify(process.execPath)} + args: + - ${JSON.stringify(secondScript)} +tasks: + - id: original + title: Original +--- +Work. +`, + ); + + const { outcome } = await runWithMock( + dir, + async () => { + throw new Error("backend should not run during init"); + }, + {}, + { assignmentName: "latest-set-tasks-work", backendId: "claude", initialize: true }, + ); + + assert.deepEqual(Object.keys(outcome.manifest.finalTasks), ["second-one", "second-two"]); + assert.equal(outcome.manifest.tasksTotal, 2); + assert.equal(outcome.manifest.finalTasks["first-only"], undefined); + assert.deepEqual( + outcome.manifest.resolvedHooks.filter((descriptor) => descriptor.taskScopeId === "first-only"), + [], + ); +}); + +test("prepare setTasks rejects empty replacement lists", async () => { + const dir = tempDir(); + writeAgent(dir, "three", THREE_AGENT); + const scriptPath = writeNodeScript( + dir, + "prepare-empty-set-tasks.mjs", + `process.stdout.write(JSON.stringify({ + action: "continue", + mutate: { + setTasks: [], + }, +}));\n`, + ); + writeAssignment( + dir, + "empty-set-tasks-work", + `--- +schemaVersion: 1 +name: empty-set-tasks-work +hooks: + prepare: + - builtin: command + with: + mode: json + command: ${JSON.stringify(process.execPath)} + args: + - ${JSON.stringify(scriptPath)} +tasks: + - id: original + title: Original +--- +Work. +`, + ); + + await assert.rejects( + () => + runWithMock( + dir, + async () => { + throw new Error("backend should not run after rejected setTasks"); + }, + {}, + { assignmentName: "empty-set-tasks-work", backendId: "claude", initialize: true }, + ), + /setTasks must include at least one task/, + ); +}); + test("command builtin status mode can block before attempts without invoking the backend", async () => { const dir = tempDir(); writeAgent(dir, "three", THREE_AGENT); @@ -1757,6 +2331,57 @@ Work. ); }); +test("non-prepare hooks reject setTasks mutations", async () => { + const dir = tempDir(); + writeAgent(dir, "three", THREE_AGENT); + const scriptPath = writeNodeScript( + dir, + "before-set-tasks.mjs", + `process.stdout.write(JSON.stringify({ + action: "continue", + mutate: { + setTasks: [ + { id: "late", title: "Late replacement", body: "", hooks: [] }, + ], + }, +}));\n`, + ); + writeAssignment( + dir, + "late-set-tasks-work", + `--- +schemaVersion: 1 +name: late-set-tasks-work +hooks: + beforeAttempt: + - builtin: command + with: + mode: json + command: ${JSON.stringify(process.execPath)} + args: + - ${JSON.stringify(scriptPath)} +tasks: + - id: t1 + title: First +--- +Work. +`, + ); + + await assert.rejects( + () => + runWithMock( + dir, + async () => { + throw new Error("backend should not run after rejected beforeAttempt setTasks"); + }, + {}, + { assignmentName: "late-set-tasks-work", backendId: "claude" }, + ), + /hook setTasks mutations are only allowed during prepare/, + ); +}); + test("beforeAttempt hooks persist changes before invoke and afterAttempt hooks can reinvoke with a follow-up prompt", async () => { const dir = tempDir(); writeAgent(dir, "three", THREE_AGENT);