@laskaridis/afk 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,95 @@
1
- # Temporary Holding Version
1
+ # afk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Leave a coding agent implementing a feature, one task at a time, while you are away from keyboard.
4
+
5
+ `afk` is agent-neutral: it launches a fresh session of the coding agent you choose, hands it your instructions, reads the
6
+ agent's final response, and repeats until the feature is reported complete, blocked, or the iteration limit is reached.
7
+
8
+ Requires Node.js **22.13.0 or later**.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install --global @laskaridis/afk
14
+ ```
15
+
16
+ This installs the `afk` command. Install and authenticate a supported coding agent before running it.
17
+
18
+ ## Usage
19
+
20
+ ```
21
+ afk [options] [prompt...]
22
+ ```
23
+
24
+ The feature instructions come from the prompt arguments, `--prompt-file <path>`, or stdin.
25
+
26
+ | Option | Description |
27
+ | ----------------------------- | ---------------------------------------------------------------------- |
28
+ | `--agent <name>` | Coding agent for every session: `codex` or `claude` (default: `codex`) |
29
+ | `--prompt-file <path>` | Read the feature instructions from a file |
30
+ | `--cwd <directory>` | Workspace directory for every session (default: current directory) |
31
+ | `--max-iterations <count>` | Maximum number of agent sessions to launch (default: 10) |
32
+ | `--session-timeout <seconds>` | Stop the run if a single session exceeds this many seconds |
33
+ | `--model <name>` | Model for every session, passed to the agent unchanged |
34
+ | `--reasoning-effort <level>` | Reasoning effort for every session, passed to the agent unchanged |
35
+
36
+ Run `afk --help` for the exit codes.
37
+
38
+ Progress is printed to stderr as `[afk]` lines. When a session fails to execute, the last 20 lines of the agent's stderr
39
+ are printed after the `Execution error` line.
40
+
41
+ ## Supported agents
42
+
43
+ Prerequisites are not checked up front: a missing executable is a launch failure and follows the normal retry-once
44
+ policy. In every case the prompt is delivered on stdin and the workspace is the directory given by `--cwd`.
45
+
46
+ ### Codex (`--agent codex`)
47
+
48
+ - **Prerequisites:** Codex CLI installed and authenticated, with `--approve-for-me` support.
49
+ - **Launch:** `codex exec --approve-for-me -C <workspace> --output-last-message <file> [--model <model>] [--config model_reasoning_effort="<effort>"] -`.
50
+ The final message is the content of `<file>`, a temporary file outside the workspace that `afk` removes after the
51
+ session; Codex's stdout is a progress log and is ignored.
52
+ - **Honored settings:** workspace, `--model`, `--reasoning-effort`.
53
+
54
+ ### Claude Code (`--agent claude`)
55
+
56
+ - **Prerequisites:** Claude Code CLI installed and authenticated; `auto` permission mode available for the account and
57
+ model; the `code` skill discoverable by Claude Code (for example under `.claude/skills`).
58
+ - **Launch:** `claude -p --output-format json --permission-mode auto [--model <model>] [--effort <effort>]`, run in the
59
+ workspace. The final message is the `result` field of the JSON object on stdout; `is_error: true` is an execution error.
60
+ - **Honored settings:** workspace, `--model`, `--reasoning-effort` (passed as `--effort`).
61
+
62
+ `afk` never selects a full-access mode for any agent.
63
+
64
+ ## Development
65
+
66
+ ```
67
+ pnpm check # test, typecheck, lint, prettier
68
+ pnpm test # build once, then run both test suites
69
+ pnpm test:unit # run unit tests without building
70
+ pnpm test:integration # build, then run CLI integration tests
71
+ pnpm test:coverage # build, test, and generate coverage reports
72
+ ```
73
+
74
+ See [Testing](doc/TESTING.md#coverage) for coverage reports in CI and their scope.
75
+
76
+ ## Publishing
77
+
78
+ The [Publish to npm](.github/workflows/publish.yml) workflow runs only when started manually from `main`. It uses npm
79
+ trusted publishing through GitHub Actions OIDC, so it needs no npm token secret. In the `@laskaridis/afk` package settings
80
+ on npmjs.com, add a **GitHub Actions** trusted publisher with these values:
81
+
82
+ | Setting | Value |
83
+ | -------------------- | ------------- |
84
+ | Organization or user | `laskaridis` |
85
+ | Repository | `afk3` |
86
+ | Workflow filename | `publish.yml` |
87
+
88
+ Leave the optional environment name empty and enable **Allow npm publish**. Once the workflow is on `main` and the
89
+ trusted publisher is configured, run it from the repository's **Actions** tab.
90
+
91
+ The workflow runs `pnpm check` and publishes the version in `package.json`. Bump that version before each later release;
92
+ npm will not accept a version that has already been published or unpublished. The package is currently set to `1.0.0`;
93
+ `0.1.0` was previously unpublished. npm blocks republishing a fully unpublished package name for 24 hours. If the package
94
+ Settings page is no longer available, first publish an unused version interactively after that hold, configure the
95
+ trusted publisher, then bump the version again before running the workflow.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ /** Adapter for Claude Code, run in non-interactive print mode with JSON output. */
2
+ export class ClaudeAdapter {
3
+ name = "claude";
4
+ executable = "claude";
5
+ /** Builds `claude -p` arguments with JSON output and `auto` permission mode, plus `--model` and `--effort` if given. */
6
+ buildArgs({ model, effort }) {
7
+ // The prompt arrives on stdin in print mode; the workspace is the process's working directory.
8
+ const args = ["-p", "--output-format", "json", "--permission-mode", "auto"];
9
+ if (model !== undefined)
10
+ args.push("--model", model);
11
+ if (effort !== undefined)
12
+ args.push("--effort", effort);
13
+ return args;
14
+ }
15
+ /**
16
+ * Reads the final message from the `result` field of the JSON document on stdout. Empty output or a blank result
17
+ * is `missing`, unparseable output is `invalid`, and `is_error: true` is an `agentError` carrying the `subtype`.
18
+ */
19
+ extractFinalMessage(stdout) {
20
+ if (stdout.trim() === "")
21
+ return { missing: true };
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(stdout);
25
+ }
26
+ catch {
27
+ return { invalid: true, reason: "agent output could not be parsed" };
28
+ }
29
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
30
+ return { invalid: true, reason: "agent output could not be parsed" };
31
+ }
32
+ const { is_error, subtype, result } = parsed;
33
+ if (is_error === true) {
34
+ return { agentError: true, detail: typeof subtype === "string" && subtype !== "" ? subtype : "unknown" };
35
+ }
36
+ return typeof result === "string" && result.trim() !== "" ? { message: result } : { missing: true };
37
+ }
38
+ }
@@ -0,0 +1,33 @@
1
+ /** Adapter for Codex, run through `codex exec` with the final message written to a file. */
2
+ export class CodexAdapter {
3
+ name = "codex";
4
+ executable = "codex";
5
+ /**
6
+ * Builds `codex exec` arguments: the working directory, `--output-last-message` when an output file is given,
7
+ * `--model` and the reasoning-effort config if given, and `-` so the prompt is read from stdin.
8
+ */
9
+ buildArgs({ cwd, outputFile, model, effort }) {
10
+ const args = ["exec", "--approve-for-me", "-C", cwd];
11
+ // Stdout is a progress log; the final message is only reliable in the output file.
12
+ if (outputFile !== undefined)
13
+ args.push("--output-last-message", outputFile);
14
+ if (model !== undefined)
15
+ args.push("--model", model);
16
+ if (effort !== undefined)
17
+ args.push("--config", `model_reasoning_effort=${JSON.stringify(effort)}`);
18
+ // `-` makes Codex read the prompt from stdin.
19
+ args.push("-");
20
+ return args;
21
+ }
22
+ /**
23
+ * Codex's stdout is a progress log (reasoning, tool calls), so it is ignored here. Instead `buildArgs` asks Codex
24
+ * to write its last message to a file via `--output-last-message`; the session reads that file after Codex exits
25
+ * and hands its content in as `outputFile`.
26
+ *
27
+ * The content is returned as-is. Finding the `<afk-harness-result>` block inside it is the workflow step's job.
28
+ * No file, or a blank one, means Codex never produced a final message.
29
+ */
30
+ extractFinalMessage(_stdout, outputFile) {
31
+ return outputFile === undefined || outputFile.trim() === "" ? { missing: true } : { message: outputFile };
32
+ }
33
+ }
@@ -0,0 +1,22 @@
1
+ import { ClaudeAdapter } from "./claude.js";
2
+ import { CodexAdapter } from "./codex.js";
3
+ /** The single list of supported agents; register new adapters here. */
4
+ export class AgentRegistry {
5
+ adapters;
6
+ /** @param adapters The supported agents; defaults to all built-in adapters. Names must be unique. */
7
+ constructor(adapters = [new CodexAdapter(), new ClaudeAdapter()]) {
8
+ this.adapters = new Map(adapters.map((adapter) => [adapter.name, adapter]));
9
+ }
10
+ /** The agent used when `--agent` is not given. */
11
+ get defaultName() {
12
+ return "codex";
13
+ }
14
+ /** Supported agent names in registration order. */
15
+ names() {
16
+ return [...this.adapters.keys()];
17
+ }
18
+ /** The adapter registered under `name`, or `undefined` if there is none. */
19
+ get(name) {
20
+ return this.adapters.get(name);
21
+ }
22
+ }
@@ -0,0 +1,41 @@
1
+ /** Process exit codes of the CLI, by the outcome that produces them. */
2
+ export const EXIT_CODES = {
3
+ complete: 0,
4
+ blocked: 1,
5
+ inputError: 2,
6
+ executionError: 3,
7
+ iterationLimit: 4,
8
+ timeout: 5,
9
+ interrupted: 130,
10
+ };
11
+ const EXIT_CODE_MEANINGS = {
12
+ complete: "the feature was reported complete",
13
+ blocked: "the code skill reported a blocker",
14
+ inputError: "invalid options or prompt input; no session was launched",
15
+ executionError: "the agent failed to execute twice in a row",
16
+ iterationLimit: "the iteration limit was reached without reported completion",
17
+ timeout: "a session exceeded --session-timeout",
18
+ interrupted: "interrupted by the user (Ctrl+C)",
19
+ };
20
+ /** The "Exit codes" section appended to `--help`, one line per code with its meaning. */
21
+ export function formatExitCodeHelp() {
22
+ const lines = Object.keys(EXIT_CODES).map((name) => ` ${String(EXIT_CODES[name]).padEnd(4)}${EXIT_CODE_MEANINGS[name]}`);
23
+ return `\nExit codes:\n${lines.join("\n")}\n`;
24
+ }
25
+ /** Maps the terminal event of a run to the process exit code. */
26
+ export function exitCodeFor(result) {
27
+ switch (result.type) {
28
+ case "WorkflowFinished":
29
+ return EXIT_CODES.complete;
30
+ case "WorkflowBlocked":
31
+ return EXIT_CODES.blocked;
32
+ case "WorkflowFailed":
33
+ return EXIT_CODES.executionError;
34
+ case "MaxIterationsReached":
35
+ return EXIT_CODES.iterationLimit;
36
+ case "WorkflowTimedOut":
37
+ return EXIT_CODES.timeout;
38
+ case "WorkflowInterrupted":
39
+ return EXIT_CODES.interrupted;
40
+ }
41
+ }
@@ -0,0 +1,91 @@
1
+ import { statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { resolve } from "node:path";
4
+ import { Command, CommanderError, InvalidArgumentError } from "commander";
5
+ import { DEFAULT_MAX_ITERATIONS } from "../workflow/workflow-executor.js";
6
+ import { EXIT_CODES, formatExitCodeHelp } from "./cli-exit-codes.js";
7
+ const MAX_TIMER_MS = 2 ** 31 - 1;
8
+ function parseMaxIterations(value) {
9
+ if (!/^[1-9]\d*$/.test(value)) {
10
+ throw new InvalidArgumentError("must be a positive integer.");
11
+ }
12
+ return Number(value);
13
+ }
14
+ function parseSessionTimeout(value) {
15
+ if (!/^\d+(\.\d+)?$/.test(value) || Number(value) <= 0) {
16
+ throw new InvalidArgumentError("must be a positive number of seconds.");
17
+ }
18
+ // setTimeout treats delays above 2^31-1 ms as 1 ms.
19
+ if (Number(value) * 1000 > MAX_TIMER_MS) {
20
+ throw new InvalidArgumentError(`must be at most ${Math.floor(MAX_TIMER_MS / 1000)} seconds.`);
21
+ }
22
+ return Number(value);
23
+ }
24
+ function parseNonEmpty(value) {
25
+ if (value.trim() === "")
26
+ throw new InvalidArgumentError("must not be empty.");
27
+ return value;
28
+ }
29
+ function parseCwd(value) {
30
+ const directory = resolve(value);
31
+ let isDirectory = false;
32
+ try {
33
+ isDirectory = statSync(directory).isDirectory();
34
+ }
35
+ catch {
36
+ // reported below
37
+ }
38
+ if (!isDirectory)
39
+ throw new InvalidArgumentError(`${value} is not an existing directory.`);
40
+ return directory;
41
+ }
42
+ function readVersion() {
43
+ // Resolves to the repository's package.json from both src/cli/ and dist/cli/.
44
+ const require = createRequire(import.meta.url);
45
+ return require("../../package.json").version;
46
+ }
47
+ /**
48
+ * Parse and validate argv. Help wins over every other option.
49
+ *
50
+ * Help, version and invalid input are not thrown: they are returned as `{ kind: "exit" }` with code 0 for help and
51
+ * version and `EXIT_CODES.inputError` otherwise, after commander has printed its output.
52
+ *
53
+ * @param argv The full process argument vector, including the node and script entries.
54
+ * @param registry Supplies the valid `--agent` names and the default.
55
+ */
56
+ export function parseOptions(argv, registry) {
57
+ const parseAgent = (value) => {
58
+ if (!registry.names().includes(value)) {
59
+ throw new InvalidArgumentError(`must be one of: ${registry.names().join(", ")}.`);
60
+ }
61
+ return value;
62
+ };
63
+ const program = new Command()
64
+ .name("afk")
65
+ .description("Leave the coding agent implementing a feature, one task at a time, while you are away from keyboard")
66
+ .version(readVersion())
67
+ .argument("[prompt...]", "feature instructions (or use --prompt-file, or pipe on stdin)")
68
+ .option("--agent <name>", `coding agent for every session (supported: ${registry.names().join(", ")}; default: ${registry.defaultName})`, parseAgent, registry.defaultName)
69
+ .option("--prompt-file <path>", "read the feature instructions from a file")
70
+ .option("--cwd <directory>", "workspace directory for every session (default: current directory)", parseCwd)
71
+ .option("--max-iterations <count>", "maximum number of agent sessions to launch", parseMaxIterations, DEFAULT_MAX_ITERATIONS)
72
+ .option("--session-timeout <seconds>", "stop the run if a single session exceeds this many seconds", parseSessionTimeout)
73
+ .option("--model <name>", "model for every session, passed to the agent unchanged (default: the agent's configuration)", parseNonEmpty)
74
+ .option("--reasoning-effort <level>", "reasoning effort for every session, passed to the agent unchanged (default: the agent's configuration)", parseNonEmpty)
75
+ .addHelpText("after", formatExitCodeHelp())
76
+ .exitOverride();
77
+ try {
78
+ const optionArgs = argv.slice(2);
79
+ const endOfOptions = optionArgs.indexOf("--");
80
+ const valueOptions = new Set(program.options.filter((o) => o.required || o.optional).map((o) => o.long));
81
+ const helpRequested = (endOfOptions === -1 ? optionArgs : optionArgs.slice(0, endOfOptions)).some((arg, i, args) => (arg === "--help" || arg === "-h") && !valueOptions.has(args[i - 1]));
82
+ program.parse(helpRequested ? [...argv.slice(0, 2), "--help"] : argv);
83
+ }
84
+ catch (error) {
85
+ if (error instanceof CommanderError)
86
+ return { kind: "exit", code: error.exitCode === 0 ? 0 : EXIT_CODES.inputError };
87
+ throw error;
88
+ }
89
+ const { cwd = process.cwd(), ...rest } = program.opts();
90
+ return { kind: "run", options: { ...rest, cwd, positional: program.args } };
91
+ }
@@ -0,0 +1,58 @@
1
+ import { AgentRegistry } from "../agents/registry.js";
2
+ import { ConsoleReporter } from "../reporting/console-reporter.js";
3
+ import { SessionExecutorFactory } from "../session/session-executor-factory.js";
4
+ import { WorkflowExecutor } from "../workflow/workflow-executor.js";
5
+ import { EXIT_CODES, exitCodeFor } from "./cli-exit-codes.js";
6
+ import { resolvePrompt } from "./launch-prompt.js";
7
+ import { parseOptions } from "./cli-options.js";
8
+ async function readAllStdin() {
9
+ process.stdin.setEncoding("utf8");
10
+ let text = "";
11
+ for await (const chunk of process.stdin)
12
+ text += chunk;
13
+ return text;
14
+ }
15
+ /** Parses options, resolves the launch prompt, wires and runs the workflow, and returns the process exit code. */
16
+ export class CLI {
17
+ registry = new AgentRegistry();
18
+ /**
19
+ * Runs the CLI.
20
+ *
21
+ * @param argv The full process argument vector, including the node and script entries.
22
+ * @returns The exit code for the process; input problems are reported on stderr and returned, not thrown.
23
+ */
24
+ async run(argv) {
25
+ const parsed = parseOptions(argv, this.registry);
26
+ if (parsed.kind === "exit")
27
+ return parsed.code;
28
+ const { options } = parsed;
29
+ const resolved = await resolvePrompt({
30
+ positional: options.positional,
31
+ promptFile: options.promptFile,
32
+ stdinIsTTY: Boolean(process.stdin.isTTY),
33
+ readStdin: readAllStdin,
34
+ });
35
+ if (!resolved.ok) {
36
+ process.stderr.write(`[afk] input error: ${resolved.message}\n`);
37
+ return EXIT_CODES.inputError;
38
+ }
39
+ const interruption = new AbortController();
40
+ // Repeated signals are absorbed: the first one already stops the run.
41
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
42
+ process.on(signal, () => interruption.abort());
43
+ const graceMs = Number(process.env.AFK_TERMINATION_GRACE_MS) || undefined;
44
+ const sessionFactory = new SessionExecutorFactory(interruption.signal, options.sessionTimeout, graceMs);
45
+ const workflowOptions = {
46
+ adapter: this.registry.get(options.agent),
47
+ model: options.model,
48
+ effort: options.reasoningEffort,
49
+ maxIterations: options.maxIterations,
50
+ sessionTimeoutSeconds: options.sessionTimeout,
51
+ cwd: options.cwd,
52
+ };
53
+ const launchPrompt = { text: resolved.prompt };
54
+ const workflow = new WorkflowExecutor(workflowOptions, launchPrompt, sessionFactory);
55
+ workflow.subscribe(new ConsoleReporter(process.stderr));
56
+ return exitCodeFor(await workflow.run());
57
+ }
58
+ }
@@ -0,0 +1,47 @@
1
+ import { readFile } from "node:fs/promises";
2
+ const isBlank = (text) => text.trim() === "";
3
+ /**
4
+ * Resolve exactly one launch prompt; sources are never combined.
5
+ *
6
+ * Never throws for bad input: no source, several sources, an unreadable file or a blank prompt are returned as
7
+ * `{ ok: false, message }`.
8
+ */
9
+ export async function resolvePrompt(input) {
10
+ if (input.positional.length > 1) {
11
+ return { ok: false, message: "expected a single prompt argument, got " + input.positional.length };
12
+ }
13
+ // Stdin is only a fallback: a TTY is never read, and an explicit source means a
14
+ // non-TTY stdin that is left open (cron, CI) must not block the run.
15
+ const explicit = input.positional.length === 1 || input.promptFile !== undefined;
16
+ const stdin = input.stdinIsTTY || explicit ? "" : await input.readStdin();
17
+ const sources = [];
18
+ if (input.positional.length === 1)
19
+ sources.push({ name: "positional argument", text: input.positional[0] });
20
+ if (input.promptFile !== undefined) {
21
+ try {
22
+ sources.push({ name: `prompt file ${input.promptFile}`, text: await readFile(input.promptFile, "utf8") });
23
+ }
24
+ catch (error) {
25
+ const reason = error instanceof Error ? error.message : String(error);
26
+ return { ok: false, message: `cannot read prompt file ${input.promptFile}: ${reason}` };
27
+ }
28
+ }
29
+ if (!isBlank(stdin))
30
+ sources.push({ name: "stdin", text: stdin });
31
+ if (sources.length === 0) {
32
+ return {
33
+ ok: false,
34
+ message: "no prompt provided: pass a prompt argument, --prompt-file <path>, or pipe it on stdin",
35
+ };
36
+ }
37
+ if (sources.length > 1) {
38
+ return {
39
+ ok: false,
40
+ message: `multiple prompt sources given (${sources.map((s) => s.name).join(", ")}); use exactly one`,
41
+ };
42
+ }
43
+ const [source] = sources;
44
+ if (isBlank(source.text))
45
+ return { ok: false, message: `the ${source.name} is empty` };
46
+ return { ok: true, prompt: source.text };
47
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { CLI } from "./cli/cli.js";
3
+ new CLI().run(process.argv).then((code) => process.exit(code), (error) => {
4
+ process.stderr.write(`[afk] ${error instanceof Error ? error.message : String(error)}\n`);
5
+ process.exit(2);
6
+ });
package/dist/code.md ADDED
@@ -0,0 +1,152 @@
1
+ ## Goal
2
+
3
+ You are an autonomous coding agent stuck on a loop, writing code to implement the feature that has been assigned to you by the user.
4
+
5
+ **Important:** While you are working on the task the user is AFK, so don't assume they are available to answer questions or provide clarifications.
6
+
7
+ ## User input
8
+
9
+ The user MUST specify a list of tasks and a feature specification to you. If either is missing, STOP and return a `blocked` result with the missing input and exact unblock condition, as per the required coding protocol below.
10
+
11
+ The user MAY also explicitly specify one task to work on by its exact task id (for example, `Work on TASK-011`). When provided, this assignment takes precedence over automatic task selection.
12
+
13
+ ## Workflow
14
+
15
+ ### 1. Understand what needs to be done.
16
+
17
+ Before you write a single line of code:
18
+ - Read the user-provided spec file and task list to understand the broader context of the task.
19
+ - Read the feature's implementation notes (if available) to understand what has been done so far.
20
+ - Inspect the relevant codebase as needed to build understanding.
21
+ - Make sure you read any documentation about ubiquitous domain language if present in the project.
22
+
23
+ ### 2. Select ONE task and implement it.
24
+
25
+ - If the user explicitly specified a task, select only that task from the provided task list. Do not pick a different task or fall back to automatic selection. An explicit assignment may revisit a task already marked complete; inspect its current implementation and any supplied findings before making changes.
26
+ - If the specified task cannot be uniquely identified in the task list, STOP and return `blocked` with `task_id:null`, explaining the problem and requiring a valid, unambiguous task id.
27
+ - If the specified task has pending dependencies, STOP and return `blocked` with that task's exact id, identifying the dependencies that must be completed. Do not implement a dependency instead.
28
+ - If the user did not explicitly specify a task, pick the **highest priority** task from the task list provided which is not implemented yet and doesn't have any pending dependencies.
29
+ - Make sure you are working on a single task at a time.
30
+ - Use the tdd skill (RED -> GREEN -> REFACTOR) to implement that single task. Check for any relevant skills available.
31
+ - Follow the established ubiquitous domain language used in the project, don't invent your own.
32
+ - Follow the established architecture, domain boundaries, and project conventions. Do not make material architectural or product-policy decisions that contradict or extend the specification.
33
+ - Constrain your changes strictly to the scope of the task implemented. Do not introduce unrelated behavior, speculative abstractions, or unnecessary refactoring.
34
+ - Assume you are working in the correct isolated project workspace for this feature. No need to create git branch or worktree.
35
+ - Prefer minimal, cohesive changes that satisfy the task's acceptance criteria.
36
+ - Keep notes of your progress using the progress report format shown below (create it if missing).
37
+
38
+ ### 3. Sign-off.
39
+
40
+ - Don't assume your code passes that tests. Run all test with the broadest available test command to verify they all pass.
41
+ - Mark the task as complete in the task list. Before marking a task completed, evaluate every acceptance criterion from the task definition. Record the evidence supporting each criterion. Passing tests alone are insufficient if they do not establish that the acceptance criteria have been satisfied.
42
+ - Commit only files modified by the current task and explicitly authorized bookkeeping files. Never stage or commit unrelated or pre-existing changes.
43
+ - Ensure you record the verification evidence and update the task status before committing them together with the implementation.
44
+ - Respond with the appropriate response contract based on the stop condition, as explained below.
45
+
46
+ For non-trivial changes, delegate a read-only review of the selected task’s implementation against its specification, acceptance criteria, and scope. Assess concrete findings and resolve completion blockers before marking the task complete.
47
+
48
+ ## Constraints
49
+
50
+ - Validation tasks are read-only except for evidence/reporting files such as the task list provided by the user and `<feature-name-slug>.code.md`.
51
+ - If validation finds an defect create a follow-up task instead of fixing the defect inside the validation task.
52
+ - Every task marked completed must have a matching progress entry in `<feature-name-slug>.code.md` using the exact task id.
53
+ - Record all modified files and relevant implementation decisions in the progress report.
54
+ - Never commit unrelated or pre-existing changes.
55
+
56
+ ## Implementation notes file and progress report format
57
+
58
+ Store implementation notes in `<feature-name-slug>.code.md` in the same folder as the user-provided specification. Derive the filename by replacing the specification's `.spec.md` suffix with `.code.md` (for example, `specs/001-afk-cli-core.spec.md` uses `specs/001-afk-cli-core.code.md`). If the specification does not end in `.spec.md`, replace its final extension with `.code.md`. Use this same notes file across iterations and create it if missing.
59
+
60
+ You leave notes to yourself in `<feature-name-slug>.code.md` which you can revisit in successive iterations. For each iteration APPEND, NEVER replace, ALWAYS append to maintain traceability:
61
+
62
+ ```
63
+ ## [Timestamp]
64
+ - Task id
65
+ - What was implemented:
66
+ - ...
67
+ - Files changed:
68
+ - ...
69
+ - Verification:
70
+ Records concrete evidence on how you know the task is actually done, for example:
71
+ - commands run and their outcome
72
+ - checks performed
73
+ - observed runtime results
74
+ - artifacts prduced
75
+ - ...
76
+ - Learnings for future iterations:
77
+ - Patterns discovered (e.g., "this codebase uses X for Y")
78
+ - Gotchas encountered (e.g., "don't forget to update Z when changing W")
79
+ - Useful context (e.g., "the evaluation panel is in component X")
80
+ - Scope deviations:
81
+ - Must explicitly list all unplanned files touched and why.
82
+ - Follow up needed: If a validation or smoke-check task finds a defect, explicitly name the new fix task instead of silently folding the fix into the validation enty.
83
+ ---
84
+ ```
85
+
86
+ Keeping all sections are important - they help future iterations avoid repeating mistakes and understand the codebase better.
87
+
88
+ ## Consolidate discoveries
89
+
90
+ If you discover something that will be relevant for future iterations, add it to the `## Project discovery` section at the TOP of `<feature-name-slug>.code.md`. This section should consolidate the most important learnings:
91
+
92
+ ```
93
+ ## Project discovery
94
+ - Use `sql<number>` template for aggregations
95
+ - Always use `IF NOT EXISTS` for migrations
96
+ - Export types from actions.ts for UI components
97
+ ---
98
+ ```
99
+
100
+ Only add discoveries that are **general and reusable**, not task-specific details.
101
+
102
+ ## Coding loop protocol
103
+
104
+ Treat these instructions as a stable API between you and host coding harness. They are mandatory and need to follow them **to the letter**.
105
+
106
+ ### Response
107
+
108
+ Your final response to the user MUST end with exactly one result block, wrapped in the tags below and carrying this session's nonce, `{{NONCE}}`. The harness parses the last block with this exact nonce, so copy it exactly and do not place any other text after the block.
109
+
110
+ ```
111
+ <afk-harness-result nonce="{{NONCE}}">
112
+ {"outcome":"complete","task_id":"TASK-011","task_plan_file":"specs/feature.plan.json"}
113
+ </afk-harness-result>
114
+ ```
115
+
116
+ The block body MUST be exactly one JSON object and nothing else: do not wrap it in markdown fences or add prose inside the block.
117
+
118
+ The JSON object MUST use one of these shapes:
119
+
120
+ - `{"outcome":"repeat","task_id":"TASK-011","task_plan_file":"specs/feature.plan.json"}`
121
+ - `{"outcome":"complete","task_id":"TASK-011","task_plan_file":"specs/feature.plan.json"}`
122
+ - `{"outcome":"blocked","blocker_text":"...","task_id":"TASK-011","task_plan_file":"specs/feature.plan.json"}`
123
+ - `{"outcome":"fail","failure_summary":"...","task_id":"TASK-011","task_plan_file":"specs/feature.plan.json"}`
124
+
125
+ If no task could be selected, use `blocked` with `task_id:null`. For example, when no task plan file was specified:
126
+ `{"outcome":"blocked","blocker_text":"Provide a task plan file so a task can be selected.","task_id":null,"task_plan_file":null}`
127
+
128
+ Rules:
129
+
130
+ - `outcome` must be one of `repeat`, `complete`, `blocked`, or `fail`.
131
+ - Always include `task_id` and `task_plan_file` in the result block for every outcome.
132
+ - `task_id` must be the exact identifier from the task list of the task selected for implementation during this iteration. Always retain this identifier even if the attempt was blocked or failed and the task was not implemented. Use `null` only when no task plan file was specified or no task could be selected; in either case, the outcome MUST be `blocked`. Do not substitute the identifier of a follow-up task for the task attempted.
133
+ - `task_plan_file` must be the path to the actual task list plan file used during this iteration, relative to the project root when possible. Use `null` only when no task plan file was available. Do not substitute the specification or implementation notes file.
134
+ - Each metadata value must be a non-empty string or JSON `null`, never an empty string or the string `"null"`.
135
+ - `blocked` responses must include a non-empty `blocker_text` string.
136
+ - `fail` responses must include a non-empty `failure_summary` string.
137
+ - No fields other than `outcome`, `task_id`, `task_plan_file`, and the outcome's required `blocker_text` or `failure_summary` are allowed.
138
+ - Leading and trailing whitespace around the JSON object inside the block is allowed, but any
139
+ other text inside the block is a contract violation.
140
+ - A block with a different nonce, or no block at all, is a contract violation.
141
+
142
+ ### Stop condition
143
+
144
+ After completing a task, check if ALL tasks have been completed:
145
+
146
+ - If ALL tasks are complete and passing, use the `complete` outcome.
147
+ - If there are still tasks incomplete, use the `repeat` outcome.
148
+ - If progress is blocked by environment/runtime prerequisites after a revalidation pass (same blocker signature, no code-level next step), use the `blocked` outcome and provide the blocker reason plus exact unblock condition.
149
+ - If the required specification or task list is missing, use the `blocked` outcome and identify what the user must provide.
150
+ - If no task could be selected for implementation, use the `blocked` outcome with `task_id:null` and explain why selection was impossible and the exact unblock condition.
151
+ - If a permission denial or material ambiguity leaves no authorized next step after checking the available context, use the `blocked` outcome and provide the reason plus exact unblock condition. Continue with an authorized alternative when one exists.
152
+ - If validation finds a defect, use the `fail` outcome and generate a follow-up fix task instead of silently folding the fix into the validation entry.
@@ -0,0 +1,46 @@
1
+ /** Writes today's `[afk]` status lines for workflow events to its sink. WorkflowFailed has no final line. */
2
+ export class ConsoleReporter {
3
+ sink;
4
+ /** @param sink Where the status lines are written. */
5
+ constructor(sink) {
6
+ this.sink = sink;
7
+ }
8
+ /** Writes the status line for `event`, if it has one; other events are ignored. */
9
+ onEvent(event) {
10
+ switch (event.type) {
11
+ case "IterationStarted":
12
+ if (!event.retry)
13
+ this.line(`Iteration ${event.iteration}/${event.limit}: starting agent session`);
14
+ break;
15
+ case "StepExecutionRetried":
16
+ this.line(`Iteration ${event.iteration}/${event.limit}: retrying agent session after execution error`);
17
+ break;
18
+ case "StepExecutionFailed":
19
+ this.line(`Task failure: ${event.failureSummary}`);
20
+ break;
21
+ case "SessionExecutionErrorOccurred":
22
+ this.line(`Execution error: ${event.detail}`, event.stderrTail);
23
+ break;
24
+ case "WorkflowFinished":
25
+ this.line("Run complete: feature complete");
26
+ break;
27
+ case "WorkflowBlocked":
28
+ this.line(`Run blocked: ${event.blockerText}`);
29
+ break;
30
+ case "MaxIterationsReached":
31
+ this.line(`Run stopped: iteration limit of ${event.limit} reached without reported completion`);
32
+ break;
33
+ case "WorkflowTimedOut":
34
+ this.line(`Run stopped: agent session timed out after ${event.seconds} seconds`);
35
+ break;
36
+ case "WorkflowInterrupted":
37
+ this.line("Run interrupted by user");
38
+ break;
39
+ default:
40
+ break;
41
+ }
42
+ }
43
+ line(message, extra = []) {
44
+ this.sink.write([`[afk] ${message}`, ...extra].map((text) => `${text}\n`).join(""));
45
+ }
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { SessionExecutor } from "./session-executor.js";
2
+ /** Holds the run-wide session settings so every session gets the same signal, timeout and grace period. */
3
+ export class SessionExecutorFactory {
4
+ signal;
5
+ timeoutSeconds;
6
+ graceMs;
7
+ /**
8
+ * @param signal Aborting it interrupts the running session and ends later ones as interrupted.
9
+ * @param timeoutSeconds Per-session timeout; no timeout when omitted.
10
+ * @param graceMs Wait between termination escalation steps; the executor's default when omitted.
11
+ */
12
+ constructor(signal, timeoutSeconds, graceMs) {
13
+ this.signal = signal;
14
+ this.timeoutSeconds = timeoutSeconds;
15
+ this.graceMs = graceMs;
16
+ }
17
+ /** Creates a new `SessionExecutor` for `adapter` with the run-wide settings. */
18
+ create(adapter) {
19
+ return new SessionExecutor(adapter, this.signal, this.timeoutSeconds, this.graceMs);
20
+ }
21
+ }
@@ -0,0 +1,160 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ /** Default wait between termination escalation steps (SIGINT, SIGTERM, SIGKILL). */
6
+ export const DEFAULT_TERMINATION_GRACE_MS = 5000;
7
+ /** How many trailing stderr lines are kept for reporting a failed session. */
8
+ export const STDERR_TAIL_LINES = 20;
9
+ /** Runs exactly one non-interactive agent process and classifies how the session ended. */
10
+ export class SessionExecutor {
11
+ adapter;
12
+ signal;
13
+ timeoutSeconds;
14
+ graceMs;
15
+ constructor(adapter,
16
+ /** Aborting interrupts the child and ends the session as `interrupted` once it has exited. */
17
+ signal,
18
+ /** Interrupts and terminates the child once this many seconds have elapsed. */
19
+ timeoutSeconds,
20
+ /** Wait between escalation steps (SIGINT, then SIGTERM, then SIGKILL). */
21
+ graceMs = DEFAULT_TERMINATION_GRACE_MS) {
22
+ this.adapter = adapter;
23
+ this.signal = signal;
24
+ this.timeoutSeconds = timeoutSeconds;
25
+ this.graceMs = graceMs;
26
+ }
27
+ /**
28
+ * Runs one agent process to the end and classifies how it ended.
29
+ *
30
+ * Resolves with a `SessionResult` for every outcome, including launch failure, timeout and interruption; it is not
31
+ * expected to reject. If the signal is already aborted no process is started.
32
+ */
33
+ async execute(input) {
34
+ if (this.signal.aborted)
35
+ return { kind: "interrupted" };
36
+ const outputDir = await mkdtemp(join(tmpdir(), "afk-session-"));
37
+ try {
38
+ return await this.run(input, join(outputDir, "final-message.txt"));
39
+ }
40
+ finally {
41
+ await rm(outputDir, { recursive: true, force: true });
42
+ }
43
+ }
44
+ async run(input, outputPath) {
45
+ const args = this.adapter.buildArgs({
46
+ cwd: input.cwd,
47
+ outputFile: outputPath,
48
+ model: input.model,
49
+ effort: input.effort,
50
+ });
51
+ let interrupted = false;
52
+ let timedOut = false;
53
+ const outcome = await new Promise((resolve) => {
54
+ const child = spawn(this.adapter.executable, args, { cwd: input.cwd, stdio: ["pipe", "pipe", "pipe"] });
55
+ const cleanups = [];
56
+ const settle = (result) => {
57
+ cleanups.forEach((cleanup) => cleanup());
58
+ resolve(result);
59
+ };
60
+ let stdout = "";
61
+ child.stdout.setEncoding("utf8");
62
+ child.stdout.on("data", (chunk) => (stdout += chunk));
63
+ const stderr = new TailBuffer(STDERR_TAIL_LINES);
64
+ child.stderr.setEncoding("utf8");
65
+ child.stderr.on("data", (chunk) => stderr.write(chunk));
66
+ // The agent may exit before reading its prompt; that surfaces through its exit status.
67
+ child.stdin.on("error", () => { });
68
+ child.stdin.end(input.prompt);
69
+ child.once("error", (error) => settle({ error }));
70
+ child.once("close", (exitCode, exitSignal) => settle({ exitCode, signal: exitSignal, stdout, stderrTail: stderr.lines() }));
71
+ // Forward the interruption, then escalate until the child is gone. Never detached.
72
+ const escalation = [];
73
+ const interrupt = () => {
74
+ if (interrupted)
75
+ return;
76
+ interrupted = true;
77
+ // A timeout already started the escalation; the user's interrupt just takes over the reason.
78
+ if (!timedOut)
79
+ terminate();
80
+ };
81
+ const terminate = () => {
82
+ child.kill("SIGINT");
83
+ escalation.push(setTimeout(() => child.kill("SIGTERM"), this.graceMs));
84
+ escalation.push(setTimeout(() => child.kill("SIGKILL"), this.graceMs * 2));
85
+ };
86
+ if (this.timeoutSeconds !== undefined) {
87
+ const timer = setTimeout(() => {
88
+ if (interrupted)
89
+ return;
90
+ timedOut = true;
91
+ terminate();
92
+ }, this.timeoutSeconds * 1000);
93
+ cleanups.push(() => clearTimeout(timer));
94
+ }
95
+ if (this.signal.aborted)
96
+ interrupt();
97
+ else
98
+ this.signal.addEventListener("abort", interrupt, { once: true });
99
+ cleanups.push(() => {
100
+ this.signal.removeEventListener("abort", interrupt);
101
+ escalation.forEach(clearTimeout);
102
+ });
103
+ });
104
+ if ("error" in outcome)
105
+ return { kind: "launch-failed", detail: `failed to launch agent: ${outcome.error.message}` };
106
+ if (interrupted)
107
+ return { kind: "interrupted" };
108
+ if (timedOut)
109
+ return { kind: "timed-out" };
110
+ const outputFile = await readFile(outputPath, "utf8").catch(() => undefined);
111
+ return this.classify(outcome, outputFile);
112
+ }
113
+ /** Signal, then agent-reported error, then non-zero exit, then invalid, then missing. */
114
+ classify(exited, outputFile) {
115
+ const { exitCode, signal, stderrTail } = exited;
116
+ const failed = (detail) => ({ kind: "exited-unsuccessfully", detail, stderrTail });
117
+ if (signal)
118
+ return failed(`agent exited unsuccessfully with signal ${signal}`);
119
+ const extracted = this.adapter.extractFinalMessage(exited.stdout, outputFile);
120
+ if ("agentError" in extracted) {
121
+ return failed(exitCode === 0
122
+ ? `agent exited unsuccessfully (agent reported an error: ${extracted.detail})`
123
+ : `agent exited unsuccessfully with exit code ${exitCode} (agent reported an error: ${extracted.detail})`);
124
+ }
125
+ // A message from a failed process is never trusted.
126
+ if (exitCode !== 0)
127
+ return failed(`agent exited unsuccessfully with exit code ${exitCode}`);
128
+ if ("invalid" in extracted) {
129
+ return {
130
+ kind: "invalid-final-message",
131
+ detail: `agent final response is invalid: ${extracted.reason}`,
132
+ stderrTail,
133
+ };
134
+ }
135
+ if ("missing" in extracted) {
136
+ return { kind: "missing-final-message", detail: "agent exited without a final response", stderrTail };
137
+ }
138
+ return { kind: "success", finalMessage: extracted.message, stderrTail };
139
+ }
140
+ }
141
+ /** Keeps the last `max` lines of a text stream, including an unterminated final line. */
142
+ class TailBuffer {
143
+ max;
144
+ completed = [];
145
+ partial = "";
146
+ constructor(max) {
147
+ this.max = max;
148
+ }
149
+ write(chunk) {
150
+ const parts = (this.partial + chunk).split("\n");
151
+ this.partial = parts.pop() ?? "";
152
+ this.completed.push(...parts);
153
+ if (this.completed.length > this.max)
154
+ this.completed = this.completed.slice(-this.max);
155
+ }
156
+ lines() {
157
+ const all = this.partial === "" ? this.completed : [...this.completed, this.partial];
158
+ return all.slice(-this.max);
159
+ }
160
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { parseResultBlock } from "./result-block.js";
4
+ // Copied next to the compiled `workflow/` folder by the build script.
5
+ const HARNESS = readFileSync(new URL("../code.md", import.meta.url), "utf8");
6
+ const NONCE_PLACEHOLDER = "{{NONCE}}";
7
+ function buildSessionPrompt(launchPrompt, nonce) {
8
+ return `${launchPrompt}\n\n---\n${HARNESS.replaceAll(NONCE_PLACEHOLDER, nonce)}\n`;
9
+ }
10
+ function toStepResult(block) {
11
+ const metadata = {};
12
+ if (block.task_id !== undefined)
13
+ metadata.task_id = block.task_id;
14
+ if (block.task_plan_file !== undefined)
15
+ metadata.task_plan_file = block.task_plan_file;
16
+ switch (block.outcome) {
17
+ case "repeat":
18
+ return { type: "Continue", ...metadata };
19
+ case "complete":
20
+ return { type: "Finished", ...metadata };
21
+ case "fail":
22
+ return { type: "StepFailed", failureSummary: block.failure_summary, ...metadata };
23
+ case "blocked":
24
+ return { type: "Blocked", blockerText: block.blocker_text, ...metadata };
25
+ }
26
+ }
27
+ /** One coding session: fresh nonce, session prompt, run, and the agent's result block as a step result. */
28
+ export class CodingStep {
29
+ sessions;
30
+ name = "coding";
31
+ /** @param sessions Runs the agent process for this step. */
32
+ constructor(sessions) {
33
+ this.sessions = sessions;
34
+ }
35
+ /**
36
+ * Appends the harness instructions to `launchPrompt`, runs one session and reads the agent's result block.
37
+ * Session problems and contract violations come back as `SessionFailed`, not as exceptions.
38
+ */
39
+ async execute(launchPrompt, options) {
40
+ const nonce = randomBytes(4).toString("hex");
41
+ const session = await this.sessions.execute({
42
+ prompt: buildSessionPrompt(launchPrompt, nonce),
43
+ cwd: options.cwd,
44
+ model: options.model,
45
+ effort: options.effort,
46
+ });
47
+ switch (session.kind) {
48
+ case "interrupted":
49
+ return { type: "Interrupted" };
50
+ case "timed-out":
51
+ return { type: "Timeout" };
52
+ case "launch-failed":
53
+ return { type: "SessionFailed", detail: session.detail, stderrTail: [] };
54
+ case "exited-unsuccessfully":
55
+ case "missing-final-message":
56
+ case "invalid-final-message":
57
+ return { type: "SessionFailed", detail: session.detail, stderrTail: session.stderrTail };
58
+ case "success": {
59
+ const block = parseResultBlock(session.finalMessage, nonce);
60
+ if ("violation" in block) {
61
+ return {
62
+ type: "SessionFailed",
63
+ detail: `agent final response is invalid: ${block.violation}`,
64
+ stderrTail: session.stderrTail,
65
+ };
66
+ }
67
+ return toStepResult(block);
68
+ }
69
+ }
70
+ }
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ /** The body of the last complete result block carrying this session's nonce, so echoed examples and noise are ignored. */
2
+ function extractResultBody(text, nonce) {
3
+ const open = `<afk-harness-result nonce="${nonce}">`;
4
+ const close = "</afk-harness-result>";
5
+ let body;
6
+ for (let start = text.indexOf(open); start !== -1; start = text.indexOf(open, start + open.length)) {
7
+ const end = text.indexOf(close, start + open.length);
8
+ if (end === -1)
9
+ break;
10
+ body = text.slice(start + open.length, end);
11
+ }
12
+ return body;
13
+ }
14
+ const TEXT_FIELD_BY_OUTCOME = {
15
+ blocked: "blocker_text",
16
+ fail: "failure_summary",
17
+ };
18
+ /**
19
+ * Finds and validates the result block in an agent's final message.
20
+ *
21
+ * Only the last complete block carrying `nonce` counts. Never throws: a missing message, a missing block, malformed
22
+ * JSON or a field that breaks the contract is returned as a `ContractViolation`.
23
+ *
24
+ * @param text The agent's final message, if it produced one.
25
+ * @param nonce The session nonce the block must carry.
26
+ */
27
+ export function parseResultBlock(text, nonce) {
28
+ if (text === undefined || text.trim() === "") {
29
+ return { violation: "the final message is missing or empty" };
30
+ }
31
+ const body = extractResultBody(text, nonce);
32
+ if (body === undefined) {
33
+ return { violation: `the final message has no <afk-harness-result nonce="${nonce}"> block` };
34
+ }
35
+ let value;
36
+ try {
37
+ value = JSON.parse(body);
38
+ }
39
+ catch {
40
+ return {
41
+ violation: "the result block body is not a single JSON object (found prose, Markdown fences, or malformed JSON)",
42
+ };
43
+ }
44
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
45
+ return { violation: "the result block is valid JSON but not a JSON object" };
46
+ }
47
+ const fields = value;
48
+ const metadata = {};
49
+ for (const key of ["task_id", "task_plan_file"]) {
50
+ if (!Object.hasOwn(fields, key))
51
+ continue;
52
+ const detail = fields[key];
53
+ if (detail !== null && (typeof detail !== "string" || detail.trim() === "")) {
54
+ return { violation: `"${key}" must be a non-empty string or null` };
55
+ }
56
+ metadata[key] = detail;
57
+ }
58
+ const outcomeKeys = Object.keys(fields).filter((key) => key !== "task_id" && key !== "task_plan_file");
59
+ const outcome = fields.outcome;
60
+ if (metadata.task_id === null && outcome !== "blocked") {
61
+ return { violation: 'a null "task_id" requires outcome "blocked" because no task could be selected' };
62
+ }
63
+ if (outcome === "repeat" || outcome === "complete") {
64
+ const extra = outcomeKeys.filter((key) => key !== "outcome");
65
+ if (extra.length > 0) {
66
+ return { violation: `outcome "${outcome}" must have no additional fields, found: ${extra.join(", ")}` };
67
+ }
68
+ return { outcome, ...metadata };
69
+ }
70
+ if (outcome === "blocked" || outcome === "fail") {
71
+ const field = TEXT_FIELD_BY_OUTCOME[outcome];
72
+ const extra = outcomeKeys.filter((key) => key !== "outcome" && key !== field);
73
+ if (extra.length > 0) {
74
+ return { violation: `outcome "${outcome}" has unsupported fields: ${extra.join(", ")}` };
75
+ }
76
+ const detail = fields[field];
77
+ if (typeof detail !== "string" || detail.trim() === "") {
78
+ return { violation: `outcome "${outcome}" requires a non-empty string "${field}"` };
79
+ }
80
+ return outcome === "blocked"
81
+ ? { outcome, blocker_text: detail, ...metadata }
82
+ : { outcome, failure_summary: detail, ...metadata };
83
+ }
84
+ return {
85
+ violation: `missing or unsupported "outcome" (expected repeat, complete, blocked, or fail)`,
86
+ };
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
1
+ import { CodingStep } from "./coding-step.js";
2
+ /** Iteration limit used when the user does not set one. */
3
+ export const DEFAULT_MAX_ITERATIONS = 10;
4
+ /** An immutable copy of just the task fields of a step result, safe to embed in published events. */
5
+ function taskOf(result) {
6
+ return Object.freeze({ task_id: result.task_id, task_plan_file: result.task_plan_file });
7
+ }
8
+ /**
9
+ * Runs the workflow loop and owns its policy.
10
+ *
11
+ * Each iteration runs one fresh `CodingStep` in a new session and interprets its result:
12
+ *
13
+ * - `Continue` and `StepFailed` end the iteration and the loop moves on to the next one.
14
+ * - `Finished`, `Blocked`, `Timeout` and `Interrupted` end the run immediately.
15
+ * - `SessionFailed` is retried once: a second consecutive failure ends the run, and any other result resets the
16
+ * allowance. Retries count toward the iteration limit.
17
+ * - Reaching `maxIterations` without a terminal outcome ends the run as `MaxIterationsReached`.
18
+ *
19
+ * Everything that happens is published as a frozen `WorkflowEvent` to the subscribed observers, and the run resolves
20
+ * with the single terminal event. The executor reports nothing itself: output is up to the observers.
21
+ */
22
+ export class WorkflowExecutor {
23
+ options;
24
+ launchPrompt;
25
+ sessions;
26
+ observers = [];
27
+ /**
28
+ * @param options Run configuration: the agent adapter, the iteration limit, and the working directory, model, effort
29
+ * and session timeout passed to each step.
30
+ * @param launchPrompt The user's prompt, given to every step.
31
+ * @param sessions Creates the session executor for each iteration's step.
32
+ */
33
+ constructor(options, launchPrompt, sessions) {
34
+ this.options = options;
35
+ this.launchPrompt = launchPrompt;
36
+ this.sessions = sessions;
37
+ }
38
+ /** Registers an observer for all later events. Observers are notified in subscription order. */
39
+ subscribe(observer) {
40
+ this.observers.push(observer);
41
+ }
42
+ /**
43
+ * Runs iterations until a terminal outcome and returns the terminal event, which is also the last event published.
44
+ * Subscribe observers first: events published before they subscribe are not replayed.
45
+ */
46
+ async run() {
47
+ const { maxIterations } = this.options;
48
+ this.publish({ type: "WorkflowStarted", maxIterations });
49
+ let previousError;
50
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
51
+ const retry = previousError !== undefined;
52
+ this.publish({ type: "IterationStarted", iteration, limit: maxIterations, retry });
53
+ if (previousError !== undefined) {
54
+ this.publish({ type: "StepExecutionRetried", iteration, limit: maxIterations, previousError });
55
+ }
56
+ const step = new CodingStep(this.sessions.create(this.options.adapter));
57
+ this.publish({ type: "StepExecutionStarted", iteration, stepName: step.name });
58
+ const result = await step.execute(this.launchPrompt.text, {
59
+ cwd: this.options.cwd,
60
+ model: this.options.model,
61
+ effort: this.options.effort,
62
+ });
63
+ // Set when this iteration ends the run. IterationFinished is published first, then the terminal event.
64
+ let terminal;
65
+ switch (result.type) {
66
+ case "Continue":
67
+ this.publish({ type: "StepExecutionFinished", iteration, task: taskOf(result) });
68
+ break;
69
+ case "Finished":
70
+ this.publish({ type: "StepExecutionFinished", iteration, task: taskOf(result) });
71
+ terminal = { type: "WorkflowFinished", task: taskOf(result) };
72
+ break;
73
+ case "StepFailed":
74
+ this.publish({
75
+ type: "StepExecutionFailed",
76
+ iteration,
77
+ failureSummary: result.failureSummary,
78
+ task: taskOf(result),
79
+ });
80
+ break;
81
+ case "Blocked":
82
+ this.publish({
83
+ type: "StepExecutionBlocked",
84
+ iteration,
85
+ blockerText: result.blockerText,
86
+ task: taskOf(result),
87
+ });
88
+ terminal = { type: "WorkflowBlocked", blockerText: result.blockerText, task: taskOf(result) };
89
+ break;
90
+ case "SessionFailed":
91
+ this.publish({
92
+ type: "SessionExecutionErrorOccurred",
93
+ iteration,
94
+ detail: result.detail,
95
+ stderrTail: result.stderrTail,
96
+ });
97
+ // One retry per consecutive error; a second consecutive error stops the run.
98
+ if (previousError !== undefined) {
99
+ terminal = { type: "WorkflowFailed", detail: result.detail, stderrTail: result.stderrTail };
100
+ }
101
+ else if (iteration === maxIterations) {
102
+ terminal = { type: "MaxIterationsReached", limit: maxIterations };
103
+ }
104
+ break;
105
+ case "Timeout":
106
+ terminal = { type: "WorkflowTimedOut", seconds: this.options.sessionTimeoutSeconds ?? 0 };
107
+ break;
108
+ case "Interrupted":
109
+ terminal = { type: "WorkflowInterrupted" };
110
+ break;
111
+ }
112
+ this.publish({ type: "IterationFinished", iteration, outcome: result.type });
113
+ if (terminal !== undefined)
114
+ return this.publish(terminal);
115
+ // Only a session failure carries over: it makes the next iteration a retry.
116
+ previousError = result.type === "SessionFailed" ? result.detail : undefined;
117
+ }
118
+ return this.publish({ type: "MaxIterationsReached", limit: maxIterations });
119
+ }
120
+ /** Freezes the event, delivers it to every observer in order, and returns it so callers can return terminal events. */
121
+ publish(event) {
122
+ const frozen = Object.freeze(event);
123
+ for (const observer of this.observers)
124
+ observer.onEvent(frozen);
125
+ return frozen;
126
+ }
127
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@laskaridis/afk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Leave a coding agent implementing a feature, one task at a time, while you are away from keyboard",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/laskaridis/afk3.git"
8
+ },
9
+ "bugs": "https://github.com/laskaridis/afk3/issues",
10
+ "homepage": "https://github.com/laskaridis/afk3#readme",
11
+ "type": "module",
12
+ "packageManager": "pnpm@9.15.1",
13
+ "bin": {
14
+ "afk": "dist/cli.js"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=22.13.0"
24
+ },
25
+ "scripts": {
26
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node -e \"const fs=require('fs');fs.copyFileSync('src/code.md','dist/code.md');fs.chmodSync('dist/cli.js',0o755)\"",
27
+ "prepack": "npm run build",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "eslint .",
30
+ "lint:fix": "eslint . --fix",
31
+ "format": "prettier --write .",
32
+ "format:check": "prettier --check .",
33
+ "check": "pnpm test && pnpm typecheck && pnpm lint && pnpm format:check && pnpm depcheck",
34
+ "test": "pnpm run build && vitest run",
35
+ "test:coverage": "pnpm run build && vitest run --coverage",
36
+ "test:unit": "vitest run test/unit",
37
+ "test:integration": "pnpm run build && vitest run test/integration",
38
+ "depcheck": "depcruise src --config .dependency-cruiser.cjs"
39
+ },
40
+ "dependencies": {
41
+ "commander": "^15.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@eslint/js": "^10.0.1",
45
+ "@types/node": "^26.6.6",
46
+ "@vitest/coverage-v8": "5.0.5",
47
+ "dependency-cruiser": "^18.7.2",
48
+ "eslint": "^10.13.0",
49
+ "eslint-config-prettier": "^10.1.8",
50
+ "globals": "^17.13.0",
51
+ "prettier": "^3.9.10",
52
+ "typescript": "^6.0.3",
53
+ "typescript-eslint": "^8.72.1",
54
+ "vitest": "^5.0.5"
55
+ }
56
+ }