nanny-ai 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Liv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "nanny-ai",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight AI agent loop orchestrator. Ralph Wiggum loops with just enough structure.",
5
+ "module": "src/main.ts",
6
+ "type": "module",
7
+ "bin": {
8
+ "nanny": "./src/main.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "dev": "bun run src/main.ts",
15
+ "build": "bun build --compile src/main.ts --outfile nanny",
16
+ "build:linux": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile nanny-linux-x64",
17
+ "build:mac-arm": "bun build --compile --target=bun-darwin-arm64 src/main.ts --outfile nanny-darwin-arm64",
18
+ "build:mac-x64": "bun build --compile --target=bun-darwin-x64 src/main.ts --outfile nanny-darwin-x64",
19
+ "test": "bun test",
20
+ "format": "bunx biome format --write src/",
21
+ "lint": "bunx biome lint src/",
22
+ "check": "bunx biome check src/"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/Michaelliv/nannycli.git"
27
+ },
28
+ "homepage": "https://github.com/Michaelliv/nannycli#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/Michaelliv/nannycli/issues"
31
+ },
32
+ "keywords": [
33
+ "cli",
34
+ "ai",
35
+ "agent",
36
+ "loop",
37
+ "orchestrator",
38
+ "ralph-wiggum",
39
+ "tdd",
40
+ "claude",
41
+ "cursor",
42
+ "aider"
43
+ ],
44
+ "license": "MIT",
45
+ "dependencies": {
46
+ "chalk": "^5.6.2",
47
+ "commander": "^13.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "@biomejs/biome": "^2.3.14",
51
+ "bun-types": "latest"
52
+ },
53
+ "peerDependencies": {
54
+ "typescript": "^5.0.0"
55
+ }
56
+ }
@@ -0,0 +1,143 @@
1
+ import chalk from "chalk";
2
+ import type { Task, TaskCheck } from "../core/types.ts";
3
+ import { loadState, nextTaskId, saveState } from "../core/state.ts";
4
+
5
+ interface AddOptions {
6
+ file: string;
7
+ json?: boolean;
8
+ quiet?: boolean;
9
+ check?: string;
10
+ checkAgent?: string;
11
+ target?: string;
12
+ stdin?: boolean;
13
+ }
14
+
15
+ interface StdinTask {
16
+ description: string;
17
+ check?: TaskCheck | string;
18
+ }
19
+
20
+ export async function add(
21
+ description: string | undefined,
22
+ options: AddOptions,
23
+ ): Promise<void> {
24
+ const state = loadState(options.file);
25
+
26
+ if (options.stdin) {
27
+ const input = await readStdin();
28
+ const parsed = JSON.parse(input) as StdinTask[];
29
+
30
+ if (!Array.isArray(parsed)) {
31
+ if (options.json) {
32
+ console.log(
33
+ JSON.stringify({ ok: false, error: "invalid_input", message: "Expected JSON array" }),
34
+ );
35
+ } else {
36
+ console.error(chalk.red("✗"), "Expected a JSON array of tasks");
37
+ }
38
+ process.exit(1);
39
+ }
40
+
41
+ const added: Task[] = [];
42
+ for (const item of parsed) {
43
+ const task = createTask(
44
+ state.tasks,
45
+ nextTaskId(state) + added.length,
46
+ typeof item === "string" ? item : item.description,
47
+ resolveCheck(typeof item === "string" ? undefined : item.check),
48
+ state.maxAttempts,
49
+ );
50
+ added.push(task);
51
+ }
52
+ state.tasks.push(...added);
53
+ saveState(options.file, state);
54
+
55
+ if (options.json) {
56
+ console.log(JSON.stringify({ ok: true, added: added.length, tasks: added }));
57
+ } else if (!options.quiet) {
58
+ console.log(chalk.green("✓"), `Added ${added.length} task(s)`);
59
+ for (const t of added) {
60
+ console.log(chalk.dim(` ${t.id}. ${t.description}`));
61
+ }
62
+ console.log();
63
+ console.log(`Start with ${chalk.bold("nanny next")}`);
64
+ }
65
+ return;
66
+ }
67
+
68
+ if (!description) {
69
+ if (options.json) {
70
+ console.log(
71
+ JSON.stringify({ ok: false, error: "missing_description" }),
72
+ );
73
+ } else {
74
+ console.error(chalk.red("✗"), "Task description required");
75
+ console.error(` Usage: ${chalk.bold("nanny add <description>")}`);
76
+ console.error(` Bulk: ${chalk.bold('echo \'[{"description": "..."}]\' | nanny add --stdin')}`);
77
+ }
78
+ process.exit(1);
79
+ }
80
+
81
+ const check = buildCheck(options);
82
+ const id = nextTaskId(state);
83
+ const task = createTask(state.tasks, id, description, check, state.maxAttempts);
84
+ state.tasks.push(task);
85
+ saveState(options.file, state);
86
+
87
+ if (options.json) {
88
+ console.log(JSON.stringify({ ok: true, task }));
89
+ } else if (!options.quiet) {
90
+ console.log(chalk.green("✓"), `Task ${id} added`);
91
+ console.log(chalk.dim(` ${description}`));
92
+ if (check) {
93
+ if (check.command) console.log(chalk.dim(` Check: ${check.command}`));
94
+ if (check.agent) console.log(chalk.dim(` Scorer: ${check.agent}`));
95
+ }
96
+ }
97
+ }
98
+
99
+ function createTask(
100
+ _existing: Task[],
101
+ id: number,
102
+ description: string,
103
+ check: TaskCheck | undefined,
104
+ maxAttempts: number,
105
+ ): Task {
106
+ return {
107
+ id,
108
+ description,
109
+ ...(check ? { check } : {}),
110
+ status: "pending",
111
+ attempts: 0,
112
+ maxAttempts,
113
+ };
114
+ }
115
+
116
+ function buildCheck(options: AddOptions): TaskCheck | undefined {
117
+ if (!options.check && !options.checkAgent) return undefined;
118
+ const check: TaskCheck = {};
119
+ if (options.check) check.command = options.check;
120
+ if (options.checkAgent) check.agent = options.checkAgent;
121
+ if (options.target) check.target = Number.parseInt(options.target, 10);
122
+ return check;
123
+ }
124
+
125
+ function resolveCheck(
126
+ check: TaskCheck | string | undefined,
127
+ ): TaskCheck | undefined {
128
+ if (!check) return undefined;
129
+ if (typeof check === "string") return { command: check };
130
+ return check;
131
+ }
132
+
133
+ async function readStdin(): Promise<string> {
134
+ const chunks: string[] = [];
135
+ const reader = Bun.stdin.stream().getReader();
136
+ const decoder = new TextDecoder();
137
+ while (true) {
138
+ const { done, value } = await reader.read();
139
+ if (done) break;
140
+ chunks.push(decoder.decode(value, { stream: true }));
141
+ }
142
+ return chunks.join("");
143
+ }
@@ -0,0 +1,73 @@
1
+ import chalk from "chalk";
2
+ import {
3
+ appendLog,
4
+ getRunningTask,
5
+ loadState,
6
+ saveState,
7
+ } from "../core/state.ts";
8
+
9
+ interface DoneOptions {
10
+ file: string;
11
+ json?: boolean;
12
+ quiet?: boolean;
13
+ }
14
+
15
+ export async function done(
16
+ summary: string | undefined,
17
+ options: DoneOptions,
18
+ ): Promise<void> {
19
+ const state = loadState(options.file);
20
+ const task = getRunningTask(state);
21
+
22
+ if (!task) {
23
+ if (options.json) {
24
+ console.log(
25
+ JSON.stringify({ ok: false, error: "no_running_task" }),
26
+ );
27
+ } else {
28
+ console.error(chalk.red("✗"), "No task is currently running");
29
+ console.error(` Start one with ${chalk.bold("nanny next")}`);
30
+ }
31
+ process.exit(1);
32
+ }
33
+
34
+ task.status = "done";
35
+ task.finishedAt = new Date().toISOString();
36
+ if (summary) task.summary = summary;
37
+
38
+ appendLog(
39
+ state,
40
+ task.id,
41
+ "done",
42
+ summary ?? `Task ${task.id} completed`,
43
+ );
44
+ saveState(options.file, state);
45
+
46
+ const pending = state.tasks.filter((t) => t.status === "pending");
47
+ const total = state.tasks.length;
48
+ const completed = state.tasks.filter((t) => t.status === "done").length;
49
+
50
+ if (options.json) {
51
+ console.log(
52
+ JSON.stringify({
53
+ ok: true,
54
+ taskId: task.id,
55
+ completed,
56
+ total,
57
+ remaining: pending.length,
58
+ }),
59
+ );
60
+ } else if (!options.quiet) {
61
+ console.log(chalk.green("✓"), `Task ${task.id} done`, chalk.dim(`(${completed}/${total})`));
62
+ if (summary) {
63
+ console.log(chalk.dim(` ${summary.slice(0, 120)}`));
64
+ }
65
+ if (pending.length > 0) {
66
+ console.log();
67
+ console.log(`Next: ${chalk.bold("nanny next")}`);
68
+ } else if (completed === total) {
69
+ console.log();
70
+ console.log(chalk.green("🎉 All tasks complete!"));
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,86 @@
1
+ import chalk from "chalk";
2
+ import {
3
+ appendLog,
4
+ getRunningTask,
5
+ loadState,
6
+ saveState,
7
+ } from "../core/state.ts";
8
+
9
+ interface FailOptions {
10
+ file: string;
11
+ json?: boolean;
12
+ quiet?: boolean;
13
+ }
14
+
15
+ export async function fail(
16
+ error: string,
17
+ options: FailOptions,
18
+ ): Promise<void> {
19
+ const state = loadState(options.file);
20
+ const task = getRunningTask(state);
21
+
22
+ if (!task) {
23
+ if (options.json) {
24
+ console.log(
25
+ JSON.stringify({ ok: false, error: "no_running_task" }),
26
+ );
27
+ } else {
28
+ console.error(chalk.red("✗"), "No task is currently running");
29
+ console.error(` Start one with ${chalk.bold("nanny next")}`);
30
+ }
31
+ process.exit(1);
32
+ }
33
+
34
+ const exhausted = task.attempts >= task.maxAttempts;
35
+
36
+ task.status = "failed";
37
+ task.lastError = error;
38
+ task.finishedAt = new Date().toISOString();
39
+
40
+ // If not exhausted, auto-reset to pending for next pickup
41
+ if (!exhausted) {
42
+ task.status = "pending";
43
+ }
44
+
45
+ appendLog(
46
+ state,
47
+ task.id,
48
+ "fail",
49
+ `Attempt ${task.attempts}/${task.maxAttempts}: ${error.slice(0, 200)}`,
50
+ );
51
+ saveState(options.file, state);
52
+
53
+ if (options.json) {
54
+ console.log(
55
+ JSON.stringify({
56
+ ok: true,
57
+ taskId: task.id,
58
+ attempt: task.attempts,
59
+ maxAttempts: task.maxAttempts,
60
+ exhausted,
61
+ status: task.status,
62
+ }),
63
+ );
64
+ } else if (!options.quiet) {
65
+ if (exhausted) {
66
+ console.log(
67
+ chalk.red("✗"),
68
+ `Task ${task.id} failed — exhausted ${task.maxAttempts} attempts`,
69
+ );
70
+ console.log(chalk.red(` ${error.slice(0, 120)}`));
71
+ console.log();
72
+ console.log(
73
+ `Retry with ${chalk.bold("nanny retry")} or move on with ${chalk.bold("nanny next")}`,
74
+ );
75
+ } else {
76
+ console.log(
77
+ chalk.yellow("↻"),
78
+ `Task ${task.id} failed — will retry`,
79
+ chalk.dim(`(${task.attempts}/${task.maxAttempts})`),
80
+ );
81
+ console.log(chalk.dim(` ${error.slice(0, 120)}`));
82
+ console.log();
83
+ console.log(`Continue with ${chalk.bold("nanny next")}`);
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,89 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import chalk from "chalk";
3
+ import type { NannyState } from "../core/types.ts";
4
+ import { loadState, saveState } from "../core/state.ts";
5
+
6
+ interface InitOptions {
7
+ file: string;
8
+ json?: boolean;
9
+ quiet?: boolean;
10
+ maxAttempts: string;
11
+ force?: boolean;
12
+ }
13
+
14
+ export async function init(
15
+ goal: string,
16
+ options: InitOptions,
17
+ ): Promise<void> {
18
+ const filePath = options.file;
19
+
20
+ if (existsSync(filePath) && !options.force) {
21
+ const existing = loadState(filePath);
22
+ const counts = {
23
+ total: existing.tasks.length,
24
+ done: existing.tasks.filter((t) => t.status === "done").length,
25
+ failed: existing.tasks.filter((t) => t.status === "failed").length,
26
+ running: existing.tasks.filter((t) => t.status === "running").length,
27
+ pending: existing.tasks.filter((t) => t.status === "pending").length,
28
+ };
29
+
30
+ if (options.json) {
31
+ console.log(
32
+ JSON.stringify({
33
+ ok: false,
34
+ error: "run_exists",
35
+ goal: existing.goal,
36
+ ...counts,
37
+ hint: "Use --force to replace the existing run.",
38
+ }),
39
+ );
40
+ } else {
41
+ console.error(
42
+ chalk.red("✗"),
43
+ `A run already exists: ${chalk.bold(existing.goal)}`,
44
+ );
45
+ console.error(
46
+ chalk.dim(
47
+ ` ${counts.done}/${counts.total} done, ${counts.failed} failed, ${counts.running} running, ${counts.pending} pending`,
48
+ ),
49
+ );
50
+ console.error();
51
+ console.error(
52
+ ` Use ${chalk.bold("nanny init --force")} to replace it.`,
53
+ );
54
+ console.error(
55
+ ` Use ${chalk.bold("nanny status")} to check the current run.`,
56
+ );
57
+ }
58
+ process.exit(1);
59
+ }
60
+
61
+ if (existsSync(filePath) && options.force) {
62
+ rmSync(filePath);
63
+ }
64
+
65
+ const now = new Date().toISOString();
66
+ const state: NannyState = {
67
+ version: 1,
68
+ goal,
69
+ maxAttempts: Number.parseInt(options.maxAttempts, 10),
70
+ tasks: [],
71
+ log: [],
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ };
75
+
76
+ saveState(filePath, state);
77
+
78
+ if (options.json) {
79
+ console.log(JSON.stringify({ ok: true, goal, file: filePath }));
80
+ } else if (!options.quiet) {
81
+ console.log(chalk.green("✓"), "Run created");
82
+ console.log(chalk.dim(` Goal: ${goal}`));
83
+ console.log(chalk.dim(` File: ${filePath}`));
84
+ console.log();
85
+ console.log(
86
+ `Add tasks with ${chalk.bold("nanny add")} or pipe JSON with ${chalk.bold("nanny add --stdin")}`,
87
+ );
88
+ }
89
+ }
@@ -0,0 +1,50 @@
1
+ import chalk from "chalk";
2
+ import { loadState } from "../core/state.ts";
3
+
4
+ interface ListOptions {
5
+ file: string;
6
+ json?: boolean;
7
+ quiet?: boolean;
8
+ }
9
+
10
+ const STATUS_ICON: Record<string, string> = {
11
+ done: chalk.green("✓"),
12
+ failed: chalk.red("✗"),
13
+ running: chalk.blue("▶"),
14
+ pending: chalk.dim("○"),
15
+ };
16
+
17
+ export async function list(options: ListOptions): Promise<void> {
18
+ const state = loadState(options.file);
19
+
20
+ if (options.json) {
21
+ console.log(JSON.stringify({ goal: state.goal, tasks: state.tasks }));
22
+ return;
23
+ }
24
+
25
+ if (state.tasks.length === 0) {
26
+ console.log(chalk.dim("No tasks."));
27
+ console.log(`Add some with ${chalk.bold("nanny add")}`);
28
+ return;
29
+ }
30
+
31
+ console.log(chalk.bold(state.goal));
32
+ console.log();
33
+
34
+ for (const task of state.tasks) {
35
+ const icon = STATUS_ICON[task.status] ?? "?";
36
+ const attempts =
37
+ task.attempts > 0
38
+ ? chalk.dim(` (${task.attempts}/${task.maxAttempts})`)
39
+ : "";
40
+
41
+ console.log(` ${icon} ${task.id}. ${task.description}${attempts}`);
42
+
43
+ if (task.status === "done" && task.summary) {
44
+ console.log(chalk.green(` ${task.summary.slice(0, 120)}`));
45
+ }
46
+ if (task.status === "failed" && task.lastError) {
47
+ console.log(chalk.red(` ${task.lastError.slice(0, 120)}`));
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,49 @@
1
+ import chalk from "chalk";
2
+ import { loadState } from "../core/state.ts";
3
+
4
+ interface LogOptions {
5
+ file: string;
6
+ lines: string;
7
+ json?: boolean;
8
+ quiet?: boolean;
9
+ }
10
+
11
+ const EVENT_STYLE: Record<string, (s: string) => string> = {
12
+ start: chalk.blue,
13
+ done: chalk.green,
14
+ fail: chalk.red,
15
+ retry: chalk.yellow,
16
+ };
17
+
18
+ const EVENT_ICON: Record<string, string> = {
19
+ start: "▶",
20
+ done: "✓",
21
+ fail: "✗",
22
+ retry: "↻",
23
+ };
24
+
25
+ export async function log(options: LogOptions): Promise<void> {
26
+ const state = loadState(options.file);
27
+ const n = Number.parseInt(options.lines, 10);
28
+ const entries = state.log.slice(-n);
29
+
30
+ if (options.json) {
31
+ console.log(JSON.stringify({ entries }));
32
+ return;
33
+ }
34
+
35
+ if (entries.length === 0) {
36
+ console.log(chalk.dim("No log entries."));
37
+ return;
38
+ }
39
+
40
+ for (const entry of entries) {
41
+ const icon = EVENT_ICON[entry.event] ?? " ";
42
+ const style = EVENT_STYLE[entry.event] ?? chalk.dim;
43
+ const time = new Date(entry.timestamp).toLocaleTimeString();
44
+
45
+ console.log(
46
+ ` ${chalk.dim(time)} ${style(icon)} ${chalk.dim(`[${entry.taskId}]`)} ${entry.message}`,
47
+ );
48
+ }
49
+ }
@@ -0,0 +1,146 @@
1
+ import chalk from "chalk";
2
+ import {
3
+ appendLog,
4
+ getNextPendingTask,
5
+ getRunningTask,
6
+ loadState,
7
+ saveState,
8
+ } from "../core/state.ts";
9
+
10
+ interface NextOptions {
11
+ file: string;
12
+ json?: boolean;
13
+ quiet?: boolean;
14
+ }
15
+
16
+ export async function next(options: NextOptions): Promise<void> {
17
+ const state = loadState(options.file);
18
+
19
+ // Already have a running task
20
+ const running = getRunningTask(state);
21
+ if (running) {
22
+ if (options.json) {
23
+ console.log(
24
+ JSON.stringify({
25
+ ok: true,
26
+ task: running,
27
+ resumed: true,
28
+ }),
29
+ );
30
+ } else {
31
+ console.log(chalk.yellow("▶"), `Task ${running.id} is already running`);
32
+ console.log(chalk.dim(` ${running.description}`));
33
+ console.log();
34
+ console.log(
35
+ `Complete with ${chalk.bold("nanny done")} or ${chalk.bold("nanny fail")}`,
36
+ );
37
+ }
38
+ return;
39
+ }
40
+
41
+ // Get next pending task
42
+ const task = getNextPendingTask(state);
43
+
44
+ if (!task) {
45
+ const failed = state.tasks.filter((t) => t.status === "failed");
46
+ const done = state.tasks.filter((t) => t.status === "done");
47
+
48
+ if (done.length === state.tasks.length) {
49
+ // All done
50
+ if (options.json) {
51
+ console.log(
52
+ JSON.stringify({
53
+ ok: true,
54
+ done: true,
55
+ total: state.tasks.length,
56
+ completed: done.length,
57
+ }),
58
+ );
59
+ } else {
60
+ console.log(chalk.green("✓"), `All ${state.tasks.length} tasks complete.`);
61
+ }
62
+ return;
63
+ }
64
+
65
+ if (failed.length > 0) {
66
+ // Stuck
67
+ if (options.json) {
68
+ console.log(
69
+ JSON.stringify({
70
+ ok: true,
71
+ stuck: true,
72
+ failed: failed.map((t) => ({
73
+ id: t.id,
74
+ description: t.description,
75
+ attempts: t.attempts,
76
+ lastError: t.lastError,
77
+ })),
78
+ }),
79
+ );
80
+ } else {
81
+ console.log(
82
+ chalk.red("✗"),
83
+ `Stuck — ${failed.length} task(s) failed`,
84
+ );
85
+ for (const t of failed) {
86
+ console.log(
87
+ chalk.dim(` ${t.id}. ${t.description} (${t.attempts} attempts)`),
88
+ );
89
+ if (t.lastError) {
90
+ console.log(chalk.red(` ${t.lastError.slice(0, 120)}`));
91
+ }
92
+ }
93
+ console.log();
94
+ console.log(
95
+ `Retry with ${chalk.bold("nanny retry")} or ${chalk.bold("nanny retry <id>")}`,
96
+ );
97
+ }
98
+ process.exit(1);
99
+ }
100
+
101
+ // No tasks at all
102
+ if (options.json) {
103
+ console.log(JSON.stringify({ ok: true, done: true, total: 0, completed: 0 }));
104
+ } else {
105
+ console.log(chalk.dim("No tasks. Add some with"), chalk.bold("nanny add"));
106
+ }
107
+ return;
108
+ }
109
+
110
+ // Claim the task
111
+ task.status = "running";
112
+ task.attempts += 1;
113
+ task.startedAt = new Date().toISOString();
114
+ appendLog(state, task.id, "start", `Attempt ${task.attempts}/${task.maxAttempts}: ${task.description}`);
115
+ saveState(options.file, state);
116
+
117
+ if (options.json) {
118
+ console.log(
119
+ JSON.stringify({
120
+ ok: true,
121
+ task: {
122
+ id: task.id,
123
+ description: task.description,
124
+ ...(task.check ? { check: task.check } : {}),
125
+ attempt: task.attempts,
126
+ maxAttempts: task.maxAttempts,
127
+ ...(task.lastError ? { previousError: task.lastError } : {}),
128
+ },
129
+ }),
130
+ );
131
+ } else if (!options.quiet) {
132
+ console.log(chalk.blue("▶"), `Task ${task.id}: ${task.description}`);
133
+ if (task.check?.command) {
134
+ console.log(chalk.dim(` Check: ${task.check.command}`));
135
+ }
136
+ if (task.check?.agent) {
137
+ console.log(chalk.dim(` Scorer: ${task.check.agent}`));
138
+ }
139
+ console.log(
140
+ chalk.dim(` Attempt: ${task.attempts}/${task.maxAttempts}`),
141
+ );
142
+ if (task.lastError) {
143
+ console.log(chalk.red(` Previous error: ${task.lastError.slice(0, 120)}`));
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,129 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import chalk from "chalk";
4
+
5
+ interface OnboardOptions {
6
+ file?: string;
7
+ json?: boolean;
8
+ quiet?: boolean;
9
+ }
10
+
11
+ const NANNY_INSTRUCTIONS = `
12
+ <nanny>
13
+ Use \`nanny\` to orchestrate multi-step tasks. Nanny is a lightweight task state machine — you drive the loop, it tracks state.
14
+
15
+ <workflow>
16
+ 1. \`nanny init "goal" --json\` — Create a run (use \`--force\` to replace existing)
17
+ 2. \`echo '[{"description": "..."}]' | nanny add --stdin --json\` — Add tasks (bulk JSON)
18
+ 3. \`nanny next --json\` — Get and claim the next pending task
19
+ 4. Perform the task (run code, delegate to sub-agent, etc.)
20
+ 5. \`nanny done "summary" --json\` or \`nanny fail "error" --json\` — Record result
21
+ 6. Repeat from step 3 until \`nanny next\` returns \`{"ok":true,"done":true}\`
22
+ </workflow>
23
+
24
+ <commands>
25
+ - \`nanny init <goal> --json\` — Create a run (\`--force\` to replace, \`--max-attempts <n>\` for retries)
26
+ - \`nanny add <desc> --json\` — Add a single task (\`--check "npm test"\` for verification command)
27
+ - \`nanny add --stdin --json\` — Bulk add from JSON array on stdin
28
+ - \`nanny next --json\` — Get + start next task (returns task with previousError on retries)
29
+ - \`nanny done "summary" --json\` — Complete the running task
30
+ - \`nanny fail "error" --json\` — Fail the running task (auto-requeues if under max attempts)
31
+ - \`nanny retry [id] --json\` — Reset an exhausted failed task to pending
32
+ - \`nanny status --json\` — Progress overview
33
+ - \`nanny list --json\` — All tasks with status
34
+ - \`nanny log --json\` — Execution history
35
+ </commands>
36
+
37
+ <task-format>
38
+ When adding tasks via \`--stdin\`, pipe a JSON array:
39
+ \`\`\`json
40
+ [
41
+ {"description": "create users table", "check": "npm test"},
42
+ {"description": "implement endpoint"},
43
+ {"description": "verify coverage", "check": {"command": "npm test", "agent": "check coverage > 80%", "target": 80}}
44
+ ]
45
+ \`\`\`
46
+ </task-format>
47
+
48
+ <retry-loop>
49
+ When \`nanny next --json\` returns a task with \`previousError\`, use that error as context to fix the issue.
50
+ The Ralph Wiggum loop: attempt → check → feed back errors → retry. Failures are data.
51
+ </retry-loop>
52
+
53
+ <rules>
54
+ - ALWAYS use \`--json\` flag for structured output
55
+ - ALWAYS call \`nanny next\` to claim a task before working on it
56
+ - ALWAYS call \`nanny done\` or \`nanny fail\` after each task — never leave a task running
57
+ - When \`nanny next\` returns \`done: true\`, the run is complete — stop looping
58
+ - When \`nanny next\` returns \`stuck: true\`, decide whether to \`nanny retry\` or report to the user
59
+ - Do not write to \`.nanny/state.json\` directly — always use the CLI
60
+ </rules>
61
+ </nanny>
62
+ `.trim();
63
+
64
+ const MARKER = "<nanny>";
65
+
66
+ export async function onboard(options: OnboardOptions = {}): Promise<void> {
67
+ const cwd = process.cwd();
68
+ const claudeDir = join(cwd, ".claude");
69
+ const claudeMd = join(claudeDir, "CLAUDE.md");
70
+ const agentsMd = join(cwd, "AGENTS.md");
71
+
72
+ // Find target: prefer existing AGENTS.md, then .claude/CLAUDE.md, then create .claude/CLAUDE.md
73
+ let targetFile: string;
74
+ if (existsSync(agentsMd)) {
75
+ targetFile = agentsMd;
76
+ } else if (existsSync(claudeMd)) {
77
+ targetFile = claudeMd;
78
+ } else {
79
+ targetFile = claudeMd;
80
+ }
81
+
82
+ let existingContent = "";
83
+ if (existsSync(targetFile)) {
84
+ existingContent = readFileSync(targetFile, "utf-8");
85
+ }
86
+
87
+ // Idempotent
88
+ if (existingContent.includes(MARKER)) {
89
+ if (options.json) {
90
+ console.log(
91
+ JSON.stringify({
92
+ ok: true,
93
+ file: targetFile,
94
+ status: "already_onboarded",
95
+ }),
96
+ );
97
+ } else if (!options.quiet) {
98
+ console.log(chalk.green("✓"), "Already onboarded");
99
+ console.log(chalk.dim(` ${targetFile}`));
100
+ }
101
+ return;
102
+ }
103
+
104
+ // Ensure directory exists for .claude/CLAUDE.md
105
+ const targetDir = join(targetFile, "..");
106
+ if (!existsSync(targetDir)) {
107
+ mkdirSync(targetDir, { recursive: true });
108
+ }
109
+
110
+ if (existingContent) {
111
+ writeFileSync(
112
+ targetFile,
113
+ `${existingContent.trimEnd()}\n\n${NANNY_INSTRUCTIONS}\n`,
114
+ );
115
+ } else {
116
+ writeFileSync(targetFile, `${NANNY_INSTRUCTIONS}\n`);
117
+ }
118
+
119
+ if (options.json) {
120
+ console.log(JSON.stringify({ ok: true, file: targetFile }));
121
+ } else if (!options.quiet) {
122
+ console.log(
123
+ chalk.green("✓"),
124
+ `Added nanny instructions to ${chalk.bold(targetFile)}`,
125
+ );
126
+ console.log();
127
+ console.log(chalk.dim("Your agent now knows how to use nanny!"));
128
+ }
129
+ }
@@ -0,0 +1,83 @@
1
+ import chalk from "chalk";
2
+ import {
3
+ appendLog,
4
+ getTaskById,
5
+ loadState,
6
+ saveState,
7
+ } from "../core/state.ts";
8
+
9
+ interface RetryOptions {
10
+ file: string;
11
+ json?: boolean;
12
+ quiet?: boolean;
13
+ }
14
+
15
+ export async function retry(
16
+ idArg: string | undefined,
17
+ options: RetryOptions,
18
+ ): Promise<void> {
19
+ const state = loadState(options.file);
20
+
21
+ let task;
22
+
23
+ if (idArg) {
24
+ const id = Number.parseInt(idArg, 10);
25
+ task = getTaskById(state, id);
26
+ if (!task) {
27
+ if (options.json) {
28
+ console.log(JSON.stringify({ ok: false, error: "not_found", id }));
29
+ } else {
30
+ console.error(chalk.red("✗"), `Task ${id} not found`);
31
+ }
32
+ process.exit(1);
33
+ }
34
+ } else {
35
+ // Default: last failed task
36
+ const failed = state.tasks.filter((t) => t.status === "failed");
37
+ if (failed.length === 0) {
38
+ if (options.json) {
39
+ console.log(
40
+ JSON.stringify({ ok: false, error: "no_failed_tasks" }),
41
+ );
42
+ } else {
43
+ console.error(chalk.red("✗"), "No failed tasks to retry");
44
+ }
45
+ process.exit(1);
46
+ }
47
+ task = failed[failed.length - 1];
48
+ }
49
+
50
+ if (task.status !== "failed") {
51
+ if (options.json) {
52
+ console.log(
53
+ JSON.stringify({
54
+ ok: false,
55
+ error: "not_failed",
56
+ id: task.id,
57
+ status: task.status,
58
+ }),
59
+ );
60
+ } else {
61
+ console.error(
62
+ chalk.red("✗"),
63
+ `Task ${task.id} is ${task.status}, not failed`,
64
+ );
65
+ }
66
+ process.exit(1);
67
+ }
68
+
69
+ task.status = "pending";
70
+ task.attempts = 0;
71
+
72
+ appendLog(state, task.id, "retry", `Reset to pending for retry`);
73
+ saveState(options.file, state);
74
+
75
+ if (options.json) {
76
+ console.log(JSON.stringify({ ok: true, taskId: task.id }));
77
+ } else if (!options.quiet) {
78
+ console.log(chalk.green("↻"), `Task ${task.id} reset to pending`);
79
+ console.log(chalk.dim(` ${task.description}`));
80
+ console.log();
81
+ console.log(`Pick it up with ${chalk.bold("nanny next")}`);
82
+ }
83
+ }
@@ -0,0 +1,96 @@
1
+ import chalk from "chalk";
2
+ import { getRunningTask, loadState } from "../core/state.ts";
3
+
4
+ interface StatusOptions {
5
+ file: string;
6
+ json?: boolean;
7
+ quiet?: boolean;
8
+ }
9
+
10
+ export async function status(options: StatusOptions): Promise<void> {
11
+ const state = loadState(options.file);
12
+
13
+ const counts = {
14
+ total: state.tasks.length,
15
+ done: state.tasks.filter((t) => t.status === "done").length,
16
+ failed: state.tasks.filter((t) => t.status === "failed").length,
17
+ pending: state.tasks.filter((t) => t.status === "pending").length,
18
+ running: state.tasks.filter((t) => t.status === "running").length,
19
+ };
20
+
21
+ const running = getRunningTask(state);
22
+
23
+ if (options.json) {
24
+ console.log(
25
+ JSON.stringify({
26
+ goal: state.goal,
27
+ ...counts,
28
+ ...(running
29
+ ? {
30
+ currentTask: {
31
+ id: running.id,
32
+ description: running.description,
33
+ attempt: running.attempts,
34
+ maxAttempts: running.maxAttempts,
35
+ },
36
+ }
37
+ : {}),
38
+ }),
39
+ );
40
+ return;
41
+ }
42
+
43
+ console.log(chalk.bold(state.goal));
44
+ console.log();
45
+
46
+ if (counts.total === 0) {
47
+ console.log(chalk.dim(" No tasks yet."));
48
+ console.log();
49
+ console.log(`Add tasks with ${chalk.bold("nanny add")}`);
50
+ return;
51
+ }
52
+
53
+ const bar = renderBar(counts.done, counts.failed, counts.total);
54
+ console.log(` ${bar} ${counts.done}/${counts.total}`);
55
+ console.log();
56
+
57
+ if (running) {
58
+ console.log(
59
+ chalk.blue(` ▶ running: ${running.description}`),
60
+ chalk.dim(`(attempt ${running.attempts}/${running.maxAttempts})`),
61
+ );
62
+ }
63
+ if (counts.done > 0) {
64
+ console.log(chalk.green(` ✓ ${counts.done} done`));
65
+ }
66
+ if (counts.failed > 0) {
67
+ console.log(chalk.red(` ✗ ${counts.failed} failed`));
68
+ }
69
+ if (counts.pending > 0) {
70
+ console.log(chalk.dim(` ○ ${counts.pending} pending`));
71
+ }
72
+
73
+ if (counts.done === counts.total) {
74
+ console.log();
75
+ console.log(chalk.green(" 🎉 All tasks complete!"));
76
+ }
77
+ }
78
+
79
+ function renderBar(
80
+ done: number,
81
+ failed: number,
82
+ total: number,
83
+ ): string {
84
+ const width = 30;
85
+ if (total === 0) return chalk.dim("░".repeat(width));
86
+
87
+ const doneW = Math.round((done / total) * width);
88
+ const failW = Math.round((failed / total) * width);
89
+ const restW = width - doneW - failW;
90
+
91
+ return (
92
+ chalk.green("█".repeat(doneW)) +
93
+ chalk.red("█".repeat(failW)) +
94
+ chalk.dim("░".repeat(Math.max(0, restW)))
95
+ );
96
+ }
@@ -0,0 +1,50 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { LogEntry, NannyState, Task } from "./types.ts";
4
+
5
+ export function loadState(filePath: string): NannyState {
6
+ if (!existsSync(filePath)) {
7
+ throw new Error(`No run found. Run 'nanny init <goal>' first.`);
8
+ }
9
+ return JSON.parse(readFileSync(filePath, "utf-8"));
10
+ }
11
+
12
+ export function saveState(filePath: string, state: NannyState): void {
13
+ const dir = dirname(filePath);
14
+ if (!existsSync(dir)) {
15
+ mkdirSync(dir, { recursive: true });
16
+ }
17
+ state.updatedAt = new Date().toISOString();
18
+ writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`);
19
+ }
20
+
21
+ export function appendLog(
22
+ state: NannyState,
23
+ taskId: number,
24
+ event: LogEntry["event"],
25
+ message: string,
26
+ ): void {
27
+ state.log.push({
28
+ timestamp: new Date().toISOString(),
29
+ taskId,
30
+ event,
31
+ message,
32
+ });
33
+ }
34
+
35
+ export function getRunningTask(state: NannyState): Task | undefined {
36
+ return state.tasks.find((t) => t.status === "running");
37
+ }
38
+
39
+ export function getNextPendingTask(state: NannyState): Task | undefined {
40
+ return state.tasks.find((t) => t.status === "pending");
41
+ }
42
+
43
+ export function getTaskById(state: NannyState, id: number): Task | undefined {
44
+ return state.tasks.find((t) => t.id === id);
45
+ }
46
+
47
+ export function nextTaskId(state: NannyState): number {
48
+ if (state.tasks.length === 0) return 1;
49
+ return Math.max(...state.tasks.map((t) => t.id)) + 1;
50
+ }
@@ -0,0 +1,40 @@
1
+ export type TaskStatus = "pending" | "running" | "done" | "failed";
2
+
3
+ export interface TaskCheck {
4
+ /** Shell command to verify (e.g. "npm test") */
5
+ command?: string;
6
+ /** Prompt for an agent scorer */
7
+ agent?: string;
8
+ /** Score threshold (0-100) for agent checks */
9
+ target?: number;
10
+ }
11
+
12
+ export interface Task {
13
+ id: number;
14
+ description: string;
15
+ check?: TaskCheck;
16
+ status: TaskStatus;
17
+ attempts: number;
18
+ maxAttempts: number;
19
+ summary?: string;
20
+ lastError?: string;
21
+ startedAt?: string;
22
+ finishedAt?: string;
23
+ }
24
+
25
+ export interface LogEntry {
26
+ timestamp: string;
27
+ taskId: number;
28
+ event: "start" | "done" | "fail" | "retry";
29
+ message: string;
30
+ }
31
+
32
+ export interface NannyState {
33
+ version: 1;
34
+ goal: string;
35
+ maxAttempts: number;
36
+ tasks: Task[];
37
+ log: LogEntry[];
38
+ createdAt: string;
39
+ updatedAt: string;
40
+ }
package/src/main.ts ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander";
4
+ import { version } from "../package.json";
5
+ import { init } from "./commands/init.ts";
6
+ import { add } from "./commands/add.ts";
7
+ import { next } from "./commands/next.ts";
8
+ import { done } from "./commands/done.ts";
9
+ import { fail } from "./commands/fail.ts";
10
+ import { retry } from "./commands/retry.ts";
11
+ import { status } from "./commands/status.ts";
12
+ import { list } from "./commands/list.ts";
13
+ import { log } from "./commands/log.ts";
14
+ import { onboard } from "./commands/onboard.ts";
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name("nanny")
20
+ .description("Lightweight AI agent task orchestrator")
21
+ .version(version)
22
+ .option("--json", "Structured JSON output")
23
+ .option("-q, --quiet", "Suppress non-essential output")
24
+ .option(
25
+ "-f, --file <path>",
26
+ "State file path",
27
+ ".nanny/state.json",
28
+ );
29
+
30
+ program
31
+ .command("init")
32
+ .description("Create a new run")
33
+ .argument("<goal>", "What needs to be accomplished")
34
+ .option("--max-attempts <n>", "Max attempts per task", "3")
35
+ .option("--force", "Replace existing run")
36
+ .action((goal, opts) => init(goal, { ...program.opts(), ...opts }));
37
+
38
+ program
39
+ .command("add")
40
+ .description("Add a task")
41
+ .argument("[description]", "Task description")
42
+ .option("--check <command>", "Shell command to verify (e.g. npm test)")
43
+ .option("--check-agent <prompt>", "Agent scorer prompt")
44
+ .option("--target <n>", "Score threshold for agent check (0-100)")
45
+ .option("--stdin", "Read tasks from JSON stdin")
46
+ .action((description, opts) =>
47
+ add(description, { ...program.opts(), ...opts }),
48
+ );
49
+
50
+ program
51
+ .command("next")
52
+ .description("Get and start the next pending task")
53
+ .action((opts) => next({ ...program.opts(), ...opts }));
54
+
55
+ program
56
+ .command("done")
57
+ .description("Complete the current task")
58
+ .argument("[summary]", "Summary of what was done")
59
+ .action((summary, opts) => done(summary, { ...program.opts(), ...opts }));
60
+
61
+ program
62
+ .command("fail")
63
+ .description("Fail the current task")
64
+ .argument("<error>", "What went wrong")
65
+ .action((error, opts) => fail(error, { ...program.opts(), ...opts }));
66
+
67
+ program
68
+ .command("retry")
69
+ .description("Reset a failed task to pending")
70
+ .argument("[id]", "Task ID (defaults to last failed)")
71
+ .action((id, opts) => retry(id, { ...program.opts(), ...opts }));
72
+
73
+ program
74
+ .command("status")
75
+ .description("Progress overview")
76
+ .action((opts) => status({ ...program.opts(), ...opts }));
77
+
78
+ program
79
+ .command("list")
80
+ .description("All tasks with status")
81
+ .action((opts) => list({ ...program.opts(), ...opts }));
82
+
83
+ program
84
+ .command("log")
85
+ .description("Execution history")
86
+ .option("-n, --lines <n>", "Number of entries to show", "20")
87
+ .action((opts) => log({ ...program.opts(), ...opts }));
88
+
89
+ program
90
+ .command("onboard")
91
+ .description("Add nanny instructions to your agent config")
92
+ .action((opts) => onboard({ ...program.opts(), ...opts }));
93
+
94
+ program.parse();