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 +21 -0
- package/package.json +56 -0
- package/src/commands/add.ts +143 -0
- package/src/commands/done.ts +73 -0
- package/src/commands/fail.ts +86 -0
- package/src/commands/init.ts +89 -0
- package/src/commands/list.ts +50 -0
- package/src/commands/log.ts +49 -0
- package/src/commands/next.ts +146 -0
- package/src/commands/onboard.ts +129 -0
- package/src/commands/retry.ts +83 -0
- package/src/commands/status.ts +96 -0
- package/src/core/state.ts +50 -0
- package/src/core/types.ts +40 -0
- package/src/main.ts +94 -0
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();
|