@mjasnikovs/pi-task 0.2.0 → 0.2.2
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 +29 -0
- package/dist/index.js +2 -0
- package/dist/shared/child-process.js +25 -4
- package/dist/task/auto-commit.d.ts +20 -0
- package/dist/task/auto-commit.js +56 -0
- package/dist/task/auto-io.d.ts +17 -0
- package/dist/task/auto-io.js +124 -0
- package/dist/task/auto-orchestrator.d.ts +28 -0
- package/dist/task/auto-orchestrator.js +298 -0
- package/dist/task/auto-prompts.d.ts +15 -0
- package/dist/task/auto-prompts.js +66 -0
- package/dist/task/inline-markdown.d.ts +18 -0
- package/dist/task/inline-markdown.js +28 -0
- package/dist/task/orchestrator.d.ts +28 -0
- package/dist/task/orchestrator.js +42 -9
- package/dist/task/parsers.d.ts +16 -0
- package/dist/task/parsers.js +70 -0
- package/dist/task/phases.d.ts +2 -1
- package/dist/task/phases.js +126 -100
- package/dist/task/prompts.d.ts +24 -1
- package/dist/task/prompts.js +40 -5
- package/dist/task/widget.d.ts +19 -0
- package/dist/task/widget.js +73 -15
- package/package.json +18 -5
package/README.md
CHANGED
|
@@ -61,6 +61,9 @@ pi install npm:@mjasnikovs/pi-task
|
|
|
61
61
|
| `/task-list` | Open the task list in an editor dialog. |
|
|
62
62
|
| `/task-resume [id]` | Resume the most recent (or named) unfinished task. |
|
|
63
63
|
| `/task-cancel` | Cancel the running task (soft-terminal — still resumable). |
|
|
64
|
+
| `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
|
|
65
|
+
| `/task-auto-resume` | Resume the active `/task-auto` run at the next unfinished task. |
|
|
66
|
+
| `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
|
|
64
67
|
|
|
65
68
|
## The pipeline
|
|
66
69
|
|
|
@@ -74,6 +77,32 @@ pi install npm:@mjasnikovs/pi-task
|
|
|
74
77
|
|
|
75
78
|
The finished spec is delivered to your main `pi` conversation via `sendUserMessage`, so you keep working in the same chat — no context handoff, no copy-paste.
|
|
76
79
|
|
|
80
|
+
## Orchestrating multiple tasks — `/task-auto`
|
|
81
|
+
|
|
82
|
+
A real feature is usually several tasks, not one. `/task-auto` is a thin planner on top of the single-task pipeline:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
/task-auto add multi-tenant billing
|
|
86
|
+
│
|
|
87
|
+
▼
|
|
88
|
+
┌──────────┐ ┌──────────┐ ┌─────────────┐
|
|
89
|
+
│ clarify │──▶│ decompose│──▶│ TASK_AUTO_… │ resumable list of task titles
|
|
90
|
+
│ gray │ │ → titles │ │ .md (titles) │
|
|
91
|
+
│ areas │ └──────────┘ └──────┬───────┘
|
|
92
|
+
└──────────┘ │
|
|
93
|
+
┌────────────▼─────────────┐
|
|
94
|
+
│ for each unchecked title │
|
|
95
|
+
│ → full /task pipeline │ (spec + implement)
|
|
96
|
+
│ → wait until it finishes │
|
|
97
|
+
│ → check the box, next │
|
|
98
|
+
└────────────────────────────┘
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- **It only produces titles.** All the depth — refine, research, grill, compose, critique — is `/task`'s job, run fresh per title. `/task-auto` never researches or specs anything itself.
|
|
102
|
+
- **Clarify first.** It asks the few clarifying questions whose answers change how the feature splits, then decomposes the answers into an ordered list of task titles written to `.pi-tasks/TASK_AUTO_NNNN.md`.
|
|
103
|
+
- **Sequential, blocking.** Each title runs through `/task` to a spec, the spec is implemented, and the loop waits for that to finish before starting the next title. No overlap.
|
|
104
|
+
- **Crash- and cancel-safe.** Progress is the markdown checkboxes in the AUTO file. `/task-auto-resume` (no id) automatically picks up the active run at the first unchecked title. If a title's `/task` run fails, the loop stops and leaves the run resumable.
|
|
105
|
+
|
|
77
106
|
## Bundled tools
|
|
78
107
|
|
|
79
108
|
`pi-task` also registers four MCP-style worker tools (formerly `@mjasnikovs/pi-worker`). All are parallel-execution-capable, so the parent session can issue several calls in one turn.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { registerTask } from './task/orchestrator.js';
|
|
2
|
+
import { registerTaskAuto } from './task/auto-orchestrator.js';
|
|
2
3
|
import { registerWorkers } from './workers/index.js';
|
|
3
4
|
export default function (pi) {
|
|
4
5
|
registerTask(pi);
|
|
6
|
+
registerTaskAuto(pi);
|
|
5
7
|
registerWorkers(pi);
|
|
6
8
|
}
|
|
@@ -27,6 +27,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
27
27
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
28
28
|
});
|
|
29
29
|
let firstByteFired = false;
|
|
30
|
+
// json-events lines can split across data chunks; this holds the trailing
|
|
31
|
+
// partial line between chunks so events spanning a boundary still parse.
|
|
32
|
+
// In json-events mode we deliberately do NOT accumulate the full raw
|
|
33
|
+
// stream: a long-running child emits hundreds of MB of events and
|
|
34
|
+
// `stdout += chunk` would overflow V8's max string length (≈512MB),
|
|
35
|
+
// crashing the process. We only need the parsed text, so we drop the raw
|
|
36
|
+
// bytes once drained.
|
|
37
|
+
let lineBuffer = '';
|
|
30
38
|
proc.stdout?.on('data', (d) => {
|
|
31
39
|
if (!firstByteFired) {
|
|
32
40
|
firstByteFired = true;
|
|
@@ -35,15 +43,22 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
35
43
|
if (discardStdout)
|
|
36
44
|
return;
|
|
37
45
|
const chunk = d.toString();
|
|
38
|
-
stdout += chunk;
|
|
39
46
|
if (isJsonEvents) {
|
|
40
|
-
drainJsonEvents(chunk, opts);
|
|
47
|
+
lineBuffer = drainJsonEvents(lineBuffer + chunk, opts);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
stdout += chunk;
|
|
41
51
|
}
|
|
42
52
|
});
|
|
43
53
|
proc.stderr?.on('data', (d) => {
|
|
44
54
|
stderr += d.toString();
|
|
45
55
|
});
|
|
46
56
|
proc.on('close', (code) => {
|
|
57
|
+
// Flush a final event that wasn't newline-terminated.
|
|
58
|
+
if (isJsonEvents && lineBuffer.trim().length > 0) {
|
|
59
|
+
drainJsonEvents(lineBuffer + '\n', opts);
|
|
60
|
+
lineBuffer = '';
|
|
61
|
+
}
|
|
47
62
|
const text = isJsonEvents ? (finalText || textDeltaAccum).trim() : undefined;
|
|
48
63
|
resolve({ stdout, stderr, exitCode: code ?? 0, aborted, text });
|
|
49
64
|
});
|
|
@@ -65,8 +80,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
65
80
|
signal.addEventListener('abort', kill, { once: true });
|
|
66
81
|
}
|
|
67
82
|
// ─── JSON event-stream processing ──────────────────────────────────
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Parse every complete (newline-terminated) JSON line in `buf` and
|
|
85
|
+
* return the trailing partial line, which the caller carries into the
|
|
86
|
+
* next chunk. This avoids both losing events that span a chunk boundary
|
|
87
|
+
* and buffering the entire stream.
|
|
88
|
+
*/
|
|
89
|
+
function drainJsonEvents(buf, jsonOpts) {
|
|
70
90
|
let nl;
|
|
71
91
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
72
92
|
const line = buf.slice(0, nl).trim();
|
|
@@ -83,6 +103,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
83
103
|
// Non-JSON line (startup banner, etc.) — ignore.
|
|
84
104
|
}
|
|
85
105
|
}
|
|
106
|
+
return buf;
|
|
86
107
|
}
|
|
87
108
|
function handleEvent(evt, jsonOpts) {
|
|
88
109
|
const t = typeof evt.type === 'string' ? evt.type : '';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task git commit for /task-auto.
|
|
3
|
+
*
|
|
4
|
+
* After each decomposed task passes, runAutoLoop snapshots the working tree into
|
|
5
|
+
* a single commit so the run produces one commit per task. This is best-effort:
|
|
6
|
+
* outside a git repo, with nothing staged, or on any git error we report the
|
|
7
|
+
* reason and let the loop continue (the task already succeeded).
|
|
8
|
+
*/
|
|
9
|
+
import { type SpawnFn } from '../shared/child-process.js';
|
|
10
|
+
export interface CommitResult {
|
|
11
|
+
committed: boolean;
|
|
12
|
+
/** Short, human-readable reason when committed === false. */
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Stage everything (`git add -A`) and commit it with `message`. Honors
|
|
17
|
+
* .gitignore via git itself. Never throws — failures surface as
|
|
18
|
+
* `{committed: false, reason}` so the caller can warn and keep going.
|
|
19
|
+
*/
|
|
20
|
+
export declare function gitCommitAll(cwd: string, message: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<CommitResult>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task git commit for /task-auto.
|
|
3
|
+
*
|
|
4
|
+
* After each decomposed task passes, runAutoLoop snapshots the working tree into
|
|
5
|
+
* a single commit so the run produces one commit per task. This is best-effort:
|
|
6
|
+
* outside a git repo, with nothing staged, or on any git error we report the
|
|
7
|
+
* reason and let the loop continue (the task already succeeded).
|
|
8
|
+
*/
|
|
9
|
+
import { runChildDefault } from '../shared/child-process.js';
|
|
10
|
+
function firstLine(s) {
|
|
11
|
+
const line = s.split('\n').find(l => l.trim().length > 0);
|
|
12
|
+
return (line ?? s).trim();
|
|
13
|
+
}
|
|
14
|
+
async function git(cwd, args, signal, spawnFn) {
|
|
15
|
+
const r = await runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
|
|
16
|
+
return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode, aborted: r.aborted };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Stage everything (`git add -A`) and commit it with `message`. Honors
|
|
20
|
+
* .gitignore via git itself. Never throws — failures surface as
|
|
21
|
+
* `{committed: false, reason}` so the caller can warn and keep going.
|
|
22
|
+
*/
|
|
23
|
+
export async function gitCommitAll(cwd, message, signal, spawnFn) {
|
|
24
|
+
// 1. Is this a git work tree at all?
|
|
25
|
+
const inside = await git(cwd, ['rev-parse', '--is-inside-work-tree'], signal, spawnFn);
|
|
26
|
+
if (inside.aborted)
|
|
27
|
+
return { committed: false, reason: 'cancelled' };
|
|
28
|
+
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
|
|
29
|
+
return { committed: false, reason: 'not a git repository' };
|
|
30
|
+
}
|
|
31
|
+
// 2. Stage all working-tree changes (new, modified, deleted).
|
|
32
|
+
const add = await git(cwd, ['add', '-A'], signal, spawnFn);
|
|
33
|
+
if (add.aborted)
|
|
34
|
+
return { committed: false, reason: 'cancelled' };
|
|
35
|
+
if (add.exitCode !== 0) {
|
|
36
|
+
return { committed: false, reason: `git add failed: ${firstLine(add.stderr)}` };
|
|
37
|
+
}
|
|
38
|
+
// 3. Anything staged? `git diff --cached --quiet` exits 0 when the index
|
|
39
|
+
// matches HEAD (nothing to commit), 1 when there are staged changes.
|
|
40
|
+
const diff = await git(cwd, ['diff', '--cached', '--quiet'], signal, spawnFn);
|
|
41
|
+
if (diff.aborted)
|
|
42
|
+
return { committed: false, reason: 'cancelled' };
|
|
43
|
+
if (diff.exitCode === 0)
|
|
44
|
+
return { committed: false, reason: 'nothing to commit' };
|
|
45
|
+
// 4. Commit. A failure here is usually missing user.name/user.email config.
|
|
46
|
+
const commit = await git(cwd, ['commit', '-m', message], signal, spawnFn);
|
|
47
|
+
if (commit.aborted)
|
|
48
|
+
return { committed: false, reason: 'cancelled' };
|
|
49
|
+
if (commit.exitCode !== 0) {
|
|
50
|
+
return {
|
|
51
|
+
committed: false,
|
|
52
|
+
reason: `git commit failed: ${firstLine(commit.stderr || commit.stdout)}`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { committed: true };
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface TaskEntry {
|
|
2
|
+
index: number;
|
|
3
|
+
title: string;
|
|
4
|
+
done: boolean;
|
|
5
|
+
producedId?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function allocateAutoId(cwd: string): Promise<string>;
|
|
8
|
+
/** Parse a decompose-phase model output into a clean list of titles. */
|
|
9
|
+
export declare function parseDecomposeList(raw: string): string[];
|
|
10
|
+
/** Parse the "## tasks" checkbox list. */
|
|
11
|
+
export declare function parseTaskList(body: string): TaskEntry[];
|
|
12
|
+
/** Build the initial AUTO-file body. */
|
|
13
|
+
export declare function buildAutoBody(feature: string, clarifications: string, titles: string[]): string;
|
|
14
|
+
/** Check off the Nth checkbox line, stamping the produced TASK_NNNN id. */
|
|
15
|
+
export declare function checkOffTask(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
|
|
16
|
+
/** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
|
|
17
|
+
export declare function findResumableAuto(cwd: string): Promise<string | null>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AUTO-file I/O & parsing for /task-auto.
|
|
3
|
+
*
|
|
4
|
+
* Thin layer over task-io/task-parsers: a TASK_AUTO_NNNN.md is a normal task
|
|
5
|
+
* file (same front matter) whose body holds feature prompt, clarifications, and
|
|
6
|
+
* a markdown checkbox list of task titles. The checkboxes are the resume cursor.
|
|
7
|
+
*/
|
|
8
|
+
import * as fsp from 'node:fs/promises';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { tasksDir, ensureTasksDir, readTaskFile, setTaskSection } from './task-io.js';
|
|
11
|
+
import { extractSection, parseFrontMatter } from './task-parsers.js';
|
|
12
|
+
import { RESUMABLE_STATES } from './task-types.js';
|
|
13
|
+
const AUTO_FILE_RE = /^(TASK_AUTO_\d{4,})\.md$/;
|
|
14
|
+
const MAX_TASKS = 30;
|
|
15
|
+
export async function allocateAutoId(cwd) {
|
|
16
|
+
await ensureTasksDir(cwd);
|
|
17
|
+
const entries = await fsp.readdir(tasksDir(cwd));
|
|
18
|
+
let max = 0;
|
|
19
|
+
for (const e of entries) {
|
|
20
|
+
const m = AUTO_FILE_RE.exec(e);
|
|
21
|
+
if (m) {
|
|
22
|
+
const n = parseInt(m[1].slice('TASK_AUTO_'.length), 10);
|
|
23
|
+
if (n > max)
|
|
24
|
+
max = n;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return `TASK_AUTO_${String(max + 1).padStart(4, '0')}`;
|
|
28
|
+
}
|
|
29
|
+
/** Parse a decompose-phase model output into a clean list of titles. */
|
|
30
|
+
export function parseDecomposeList(raw) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const line of raw.split('\n')) {
|
|
33
|
+
const m = /^\s*(?:-\s*\[\s*[xX ]?\s*\]\s*|-\s+|\d+[.)]\s+)(.+?)\s*$/.exec(line);
|
|
34
|
+
if (m && m[1].trim().length > 0)
|
|
35
|
+
out.push(m[1].trim());
|
|
36
|
+
if (out.length >= MAX_TASKS)
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
const CHECKBOX_RE = /^- \[([ xX])\]\s+(.+?)\s*$/;
|
|
42
|
+
const PRODUCED_ID_RE = /^(TASK_\d{4,})\s{2,}(.+)$/;
|
|
43
|
+
/** Parse the "## tasks" checkbox list. */
|
|
44
|
+
export function parseTaskList(body) {
|
|
45
|
+
const section = extractSection(body, 'tasks');
|
|
46
|
+
if (section === null)
|
|
47
|
+
return [];
|
|
48
|
+
const entries = [];
|
|
49
|
+
let index = 0;
|
|
50
|
+
for (const line of section.split('\n')) {
|
|
51
|
+
const m = CHECKBOX_RE.exec(line.trim());
|
|
52
|
+
if (!m)
|
|
53
|
+
continue;
|
|
54
|
+
const done = m[1].toLowerCase() === 'x';
|
|
55
|
+
const rest = m[2].trim();
|
|
56
|
+
if (done) {
|
|
57
|
+
const idm = PRODUCED_ID_RE.exec(rest);
|
|
58
|
+
if (idm) {
|
|
59
|
+
entries.push({ index, title: idm[2].trim(), done: true, producedId: idm[1] });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
entries.push({ index, title: rest, done: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
entries.push({ index, title: rest, done: false });
|
|
67
|
+
}
|
|
68
|
+
index++;
|
|
69
|
+
}
|
|
70
|
+
return entries;
|
|
71
|
+
}
|
|
72
|
+
/** Build the initial AUTO-file body. */
|
|
73
|
+
export function buildAutoBody(feature, clarifications, titles) {
|
|
74
|
+
const tasks = titles.map(t => `- [ ] ${t}`).join('\n');
|
|
75
|
+
return (`\n## feature prompt\n\n${feature.trim() || '(none)'}\n\n`
|
|
76
|
+
+ `## clarifications\n\n${clarifications.trim() || '(none)'}\n\n`
|
|
77
|
+
+ `## tasks\n\n${tasks}\n`);
|
|
78
|
+
}
|
|
79
|
+
/** Check off the Nth checkbox line, stamping the produced TASK_NNNN id. */
|
|
80
|
+
export async function checkOffTask(cwd, id, index, producedId, title) {
|
|
81
|
+
const { body } = await readTaskFile(cwd, id);
|
|
82
|
+
const section = extractSection(body, 'tasks') ?? '';
|
|
83
|
+
const lines = section.split('\n');
|
|
84
|
+
let seen = -1;
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
if (!CHECKBOX_RE.test(lines[i].trim()))
|
|
87
|
+
continue;
|
|
88
|
+
seen++;
|
|
89
|
+
if (seen === index) {
|
|
90
|
+
lines[i] = producedId ? `- [x] ${producedId} ${title}` : `- [x] ${title}`;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (seen < index) {
|
|
95
|
+
throw new Error(`checkOffTask: index ${index} out of range in ${id} (only ${seen + 1} checkboxes found)`);
|
|
96
|
+
}
|
|
97
|
+
await setTaskSection(cwd, id, 'tasks', lines.join('\n'));
|
|
98
|
+
}
|
|
99
|
+
/** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
|
|
100
|
+
export async function findResumableAuto(cwd) {
|
|
101
|
+
await ensureTasksDir(cwd);
|
|
102
|
+
const entries = await fsp.readdir(tasksDir(cwd));
|
|
103
|
+
const candidates = [];
|
|
104
|
+
for (const f of entries) {
|
|
105
|
+
const m = AUTO_FILE_RE.exec(f);
|
|
106
|
+
if (!m)
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
const raw = await fsp.readFile(path.join(tasksDir(cwd), f), 'utf8');
|
|
110
|
+
const fm = parseFrontMatter(raw);
|
|
111
|
+
if (!fm)
|
|
112
|
+
continue;
|
|
113
|
+
if (!RESUMABLE_STATES.includes(fm.state))
|
|
114
|
+
continue;
|
|
115
|
+
const st = await fsp.stat(path.join(tasksDir(cwd), f));
|
|
116
|
+
candidates.push({ id: m[1], mtime: st.mtimeMs });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* skip unreadable */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
123
|
+
return candidates.length > 0 ? candidates[0].id : null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { RunSingleTaskResult } from './orchestrator.js';
|
|
3
|
+
import { type CommitResult } from './auto-commit.js';
|
|
4
|
+
/**
|
|
5
|
+
* Injectable seams so the planner and loop are testable without spawning pi.
|
|
6
|
+
* `runChild` is used by planAuto; `runTask` is used by runAutoLoop.
|
|
7
|
+
*/
|
|
8
|
+
export interface AutoDeps {
|
|
9
|
+
runChild: (name: string, tools: string, prompt: string) => Promise<string>;
|
|
10
|
+
runTask: (ctx: ExtensionCommandContext, cwd: string, title: string) => Promise<RunSingleTaskResult>;
|
|
11
|
+
/** Snapshot the working tree into one commit after a task passes. */
|
|
12
|
+
commit: (cwd: string, message: string) => Promise<CommitResult>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Expand any @file references in the feature text by appending each referenced
|
|
16
|
+
* file's contents, so the planning children (clarify, decompose) always see the
|
|
17
|
+
* real spec inline instead of relying on the model to open the file itself.
|
|
18
|
+
* Without this, clarify on a one-line "Implement @spec.md" tends to bail with
|
|
19
|
+
* NONE because, to the model, the request looks small and unambiguous.
|
|
20
|
+
* Unreadable mentions (typos, non-file @tokens) are left untouched; the feature
|
|
21
|
+
* is returned verbatim when nothing readable is referenced.
|
|
22
|
+
*/
|
|
23
|
+
export declare function expandFeatureMentions(cwd: string, feature: string): Promise<string>;
|
|
24
|
+
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
25
|
+
export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
|
|
26
|
+
export declare function requestAutoCancel(): void;
|
|
27
|
+
export declare function runAutoLoop(ctx: ExtensionCommandContext, cwd: string, id: string, deps: AutoDeps): Promise<void>;
|
|
28
|
+
export declare function registerTaskAuto(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /task-auto — plans a feature into a resumable list of task titles, then runs
|
|
3
|
+
* each title through the existing single-task pipeline one at a time.
|
|
4
|
+
*
|
|
5
|
+
* This module currently holds the planning half (AutoDeps + planAuto). The run
|
|
6
|
+
* loop, command handlers, and defaultDeps are added by the next task.
|
|
7
|
+
*/
|
|
8
|
+
import * as fsp from 'node:fs/promises';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { runSingleTask } from './orchestrator.js';
|
|
11
|
+
import { parseClarifyList, deriveTitle } from './parsers.js';
|
|
12
|
+
import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
|
|
13
|
+
import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT } from './auto-prompts.js';
|
|
14
|
+
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, findResumableAuto } from './auto-io.js';
|
|
15
|
+
import { writeTaskFile, readTaskFile, updateTaskFrontMatter } from './task-io.js';
|
|
16
|
+
import { gitCommitAll } from './auto-commit.js';
|
|
17
|
+
import { runPhaseChild, USER_CANCELLED } from './child-runner.js';
|
|
18
|
+
import { startAutoLoader } from './widget.js';
|
|
19
|
+
// Matches pi's @-file completion token (a path after @, until whitespace).
|
|
20
|
+
const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
|
|
21
|
+
/**
|
|
22
|
+
* Expand any @file references in the feature text by appending each referenced
|
|
23
|
+
* file's contents, so the planning children (clarify, decompose) always see the
|
|
24
|
+
* real spec inline instead of relying on the model to open the file itself.
|
|
25
|
+
* Without this, clarify on a one-line "Implement @spec.md" tends to bail with
|
|
26
|
+
* NONE because, to the model, the request looks small and unambiguous.
|
|
27
|
+
* Unreadable mentions (typos, non-file @tokens) are left untouched; the feature
|
|
28
|
+
* is returned verbatim when nothing readable is referenced.
|
|
29
|
+
*/
|
|
30
|
+
export async function expandFeatureMentions(cwd, feature) {
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const blocks = [];
|
|
33
|
+
for (const m of feature.matchAll(MENTION_RE)) {
|
|
34
|
+
const rel = m[1];
|
|
35
|
+
if (seen.has(rel))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(rel);
|
|
38
|
+
try {
|
|
39
|
+
const body = await fsp.readFile(path.resolve(cwd, rel), 'utf8');
|
|
40
|
+
if (body.trim().length > 0) {
|
|
41
|
+
blocks.push(`--- contents of ${rel} ---\n${body.trim()}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// not a readable file — leave the @token in place, skip expansion
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return blocks.length === 0 ? feature : `${feature.trim()}\n\n${blocks.join('\n\n')}`;
|
|
49
|
+
}
|
|
50
|
+
/** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
|
|
51
|
+
export async function planAuto(ctx, cwd, feature, deps) {
|
|
52
|
+
// clarify — sequential & adaptive: ask one question at a time, feeding every
|
|
53
|
+
// answer back into the next call so later questions react to earlier ones
|
|
54
|
+
// (e.g. a framework choice reshapes what gets asked). Each question is shown
|
|
55
|
+
// with the model's recommended default pre-filled (Enter to accept, type to
|
|
56
|
+
// override); we never auto-answer. The model emits NONE when nothing remains.
|
|
57
|
+
const theme = ctx.ui.theme;
|
|
58
|
+
// Inline any @file spec the user referenced so clarify/decompose reason over
|
|
59
|
+
// the real content, not a one-line "Implement @file" that reads as trivial.
|
|
60
|
+
const featureForModel = await expandFeatureMentions(cwd, feature);
|
|
61
|
+
const answers = [];
|
|
62
|
+
// Open-ended: keep asking until the model emits NONE or the user dismisses.
|
|
63
|
+
for (;;) {
|
|
64
|
+
const qRaw = await deps.runChild('auto-clarify', 'read', AUTO_CLARIFY_PROMPT(featureForModel, answers.join('\n')));
|
|
65
|
+
const parsed = parseClarifyList(qRaw);
|
|
66
|
+
if (parsed.length === 0)
|
|
67
|
+
break; // NONE / nothing left to ask
|
|
68
|
+
const { question, suggested } = parsed[0];
|
|
69
|
+
// Render markdown (bold/code) for the displayed prompt; keep plain text
|
|
70
|
+
// for the editable default and the persisted file.
|
|
71
|
+
const shownQ = renderInlineMarkdown(question, theme);
|
|
72
|
+
const plainQ = stripInlineMarkdown(question);
|
|
73
|
+
const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
|
|
74
|
+
const title = suggested ?
|
|
75
|
+
`${shownQ}\n${theme.fg('muted', 'Recommended:')}\n\n${renderInlineMarkdown(suggested, theme)}\n\n${theme.fg('muted', 'press Enter to accept')}`
|
|
76
|
+
: `${shownQ}\n${theme.fg('muted', '(no recommendation — please answer)')}`;
|
|
77
|
+
const a = await ctx.ui.input(title, plainSuggested);
|
|
78
|
+
if (a === undefined) {
|
|
79
|
+
ctx.ui.notify('/task-auto cancelled.', 'warning');
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const typed = a.trim();
|
|
83
|
+
let answer;
|
|
84
|
+
if (typed.length === 0 && plainSuggested) {
|
|
85
|
+
answer = `${plainSuggested} (accepted recommendation)`;
|
|
86
|
+
}
|
|
87
|
+
else if (typed.length === 0) {
|
|
88
|
+
answer = '(skipped)';
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
answer = typed;
|
|
92
|
+
}
|
|
93
|
+
answers.push(`Q${answers.length + 1}: ${plainQ}\nA${answers.length + 1}: ${answer}`);
|
|
94
|
+
}
|
|
95
|
+
if (answers.length === 0) {
|
|
96
|
+
ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
|
|
97
|
+
}
|
|
98
|
+
const clarifications = answers.join('\n');
|
|
99
|
+
// decompose
|
|
100
|
+
const listRaw = await deps.runChild('auto-decompose', 'read', AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications));
|
|
101
|
+
const titles = parseDecomposeList(listRaw);
|
|
102
|
+
if (titles.length === 0) {
|
|
103
|
+
ctx.ui.notify('/task-auto: no tasks produced from the feature.', 'warning');
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// persist
|
|
107
|
+
const id = await allocateAutoId(cwd);
|
|
108
|
+
const now = new Date().toISOString();
|
|
109
|
+
const fm = {
|
|
110
|
+
id,
|
|
111
|
+
state: 'in_progress',
|
|
112
|
+
phase: 'done',
|
|
113
|
+
created_at: now,
|
|
114
|
+
updated_at: now,
|
|
115
|
+
title: deriveTitle(feature)
|
|
116
|
+
};
|
|
117
|
+
await writeTaskFile(cwd, fm, buildAutoBody(feature, clarifications, titles));
|
|
118
|
+
return id;
|
|
119
|
+
}
|
|
120
|
+
/** The two feature-level planning children, shown as steps in the loader. */
|
|
121
|
+
const AUTO_PLAN_STEPS = {
|
|
122
|
+
'auto-clarify': { step: 'clarify', stepNum: 1 },
|
|
123
|
+
'auto-decompose': { step: 'decompose', stepNum: 2 }
|
|
124
|
+
};
|
|
125
|
+
const AUTO_PLAN_STEP_TOTAL = 2;
|
|
126
|
+
function defaultDeps(ctx, cwd, signal, title) {
|
|
127
|
+
// Captured by the loader's getState so the widget mirrors the child's latest
|
|
128
|
+
// output line and context usage, exactly like the single-task phase widget.
|
|
129
|
+
let lastLine;
|
|
130
|
+
let contextUsage;
|
|
131
|
+
const parentContextWindow = ctx.model?.contextWindow ?? 0;
|
|
132
|
+
const phaseDeps = {
|
|
133
|
+
cwd,
|
|
134
|
+
taskId: '',
|
|
135
|
+
signal,
|
|
136
|
+
onChildOutput: (line) => {
|
|
137
|
+
lastLine = line;
|
|
138
|
+
},
|
|
139
|
+
onContextUsage: snapshot => {
|
|
140
|
+
const cw = snapshot.contextWindow > 0 ?
|
|
141
|
+
snapshot.contextWindow
|
|
142
|
+
: contextUsage?.contextWindow || parentContextWindow;
|
|
143
|
+
const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
|
|
144
|
+
contextUsage = { tokens: snapshot.tokens, contextWindow: cw, percent };
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
runChild: async (name, tools, prompt) => {
|
|
149
|
+
// Planning children are slow LLM calls with no UI of their own; show
|
|
150
|
+
// the same status block as /task so this never goes silent until the
|
|
151
|
+
// drill dialog.
|
|
152
|
+
lastLine = undefined;
|
|
153
|
+
contextUsage = undefined;
|
|
154
|
+
const startedAt = Date.now();
|
|
155
|
+
const { step, stepNum } = AUTO_PLAN_STEPS[name] ?? { step: name, stepNum: 1 };
|
|
156
|
+
const stopLoader = startAutoLoader(ctx, () => ({
|
|
157
|
+
title,
|
|
158
|
+
step,
|
|
159
|
+
stepNum,
|
|
160
|
+
stepTotal: AUTO_PLAN_STEP_TOTAL,
|
|
161
|
+
startedAt,
|
|
162
|
+
lastLine,
|
|
163
|
+
contextUsage
|
|
164
|
+
}));
|
|
165
|
+
try {
|
|
166
|
+
return await runPhaseChild(phaseDeps, name, tools, prompt);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
stopLoader();
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
runTask: (c, cwd2, t) => runSingleTask(c, cwd2, t, { waitForImplementation: true }),
|
|
173
|
+
commit: (cwd2, message) => gitCommitAll(cwd2, message, signal)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// ─── Loop ────────────────────────────────────────────────────────────────────
|
|
177
|
+
let cancelRequested = false;
|
|
178
|
+
export function requestAutoCancel() {
|
|
179
|
+
cancelRequested = true;
|
|
180
|
+
}
|
|
181
|
+
export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
182
|
+
cancelRequested = false;
|
|
183
|
+
// Each task runs in its own fresh session (deps.runTask → ctx.newSession),
|
|
184
|
+
// which tears down the current session and leaves the ctx we passed in stale.
|
|
185
|
+
// Adopt the replacement ctx the runner hands back and use it for all further
|
|
186
|
+
// UI and the next task — reusing the captured ctx throws "stale ctx".
|
|
187
|
+
let active = ctx;
|
|
188
|
+
try {
|
|
189
|
+
for (;;) {
|
|
190
|
+
if (cancelRequested) {
|
|
191
|
+
active.ui.notify(`${id} cancelled — resume with /task-auto-resume.`, 'warning');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const { body } = await readTaskFile(cwd, id);
|
|
195
|
+
const entries = parseTaskList(body);
|
|
196
|
+
const next = entries.find(e => !e.done);
|
|
197
|
+
if (!next) {
|
|
198
|
+
await updateTaskFrontMatter(cwd, id, { state: 'completed' });
|
|
199
|
+
active.ui.notify(`${id} complete — all ${entries.length} tasks done.`, 'info');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
active.ui.notify(`${id}: task ${next.index + 1}/${entries.length} — ${next.title}`, 'info');
|
|
203
|
+
const res = await deps.runTask(active, cwd, next.title);
|
|
204
|
+
active = res.ctx ?? active;
|
|
205
|
+
if (res.sessionCancelled) {
|
|
206
|
+
active.ui.notify(`${id} paused — could not start a session. Run /task-auto-resume to retry.`, 'warning');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
211
|
+
active.ui.notify(`${id} stopped at "${next.title}" — fix and run /task-auto-resume.`, 'error');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// res.ok === true means runner.run() completed, so res.taskId is the
|
|
215
|
+
// allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
|
|
216
|
+
// empty id by writing a plain checked line, but that path is unreachable.
|
|
217
|
+
await checkOffTask(cwd, id, next.index, res.taskId, next.title);
|
|
218
|
+
// Commit the task's work (and the just-written check-off) as one
|
|
219
|
+
// snapshot. Best-effort: a failed/empty commit only warns — the task
|
|
220
|
+
// already passed, so the run continues.
|
|
221
|
+
const message = `task: ${next.title} (${res.taskId})`;
|
|
222
|
+
const commit = await deps.commit(cwd, message);
|
|
223
|
+
if (commit.committed) {
|
|
224
|
+
active.ui.notify(`${id}: committed "${next.title}".`, 'info');
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
active.ui.notify(`${id}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, 'warning');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
cancelRequested = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// ─── Command handlers ────────────────────────────────────────────────────────
|
|
236
|
+
async function handleTaskAuto(args, ctx) {
|
|
237
|
+
await ctx.waitForIdle();
|
|
238
|
+
const cwd = ctx.cwd;
|
|
239
|
+
const raw = args.trim();
|
|
240
|
+
if (raw.length === 0) {
|
|
241
|
+
ctx.ui.setEditorText('/task-auto ');
|
|
242
|
+
ctx.ui.notify('Describe the feature after /task-auto (use @ for file completion).', 'info');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const abort = new AbortController();
|
|
246
|
+
const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
|
|
247
|
+
let id;
|
|
248
|
+
try {
|
|
249
|
+
id = await planAuto(ctx, cwd, raw, deps);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
253
|
+
if (msg === USER_CANCELLED) {
|
|
254
|
+
ctx.ui.notify('/task-auto cancelled.', 'warning');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
ctx.ui.notify(`/task-auto planning failed: ${msg}`, 'error');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!id)
|
|
261
|
+
return;
|
|
262
|
+
await runAutoLoop(ctx, cwd, id, deps);
|
|
263
|
+
}
|
|
264
|
+
async function handleTaskAutoResume(_args, ctx) {
|
|
265
|
+
await ctx.waitForIdle();
|
|
266
|
+
const cwd = ctx.cwd;
|
|
267
|
+
const id = await findResumableAuto(cwd);
|
|
268
|
+
if (!id) {
|
|
269
|
+
ctx.ui.notify('No resumable /task-auto run.', 'info');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
ctx.ui.notify(`Resuming ${id}…`, 'info');
|
|
273
|
+
await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
|
|
274
|
+
const abort = new AbortController();
|
|
275
|
+
// Resume only runs the loop (runTask); no planning children, so the loader
|
|
276
|
+
// title is unused here — pass the id for clarity if that ever changes.
|
|
277
|
+
await runAutoLoop(ctx, cwd, id, defaultDeps(ctx, cwd, abort.signal, id));
|
|
278
|
+
}
|
|
279
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
280
|
+
async function handleTaskAutoCancel(_args, ctx) {
|
|
281
|
+
requestAutoCancel();
|
|
282
|
+
ctx.ui.notify('Stopping /task-auto after the current task…', 'warning');
|
|
283
|
+
}
|
|
284
|
+
// ─── Registration ────────────────────────────────────────────────────────────
|
|
285
|
+
export function registerTaskAuto(pi) {
|
|
286
|
+
pi.registerCommand('task-auto', {
|
|
287
|
+
description: 'Plan a feature into tasks and run them. Usage: /task-auto <feature>',
|
|
288
|
+
handler: handleTaskAuto
|
|
289
|
+
});
|
|
290
|
+
pi.registerCommand('task-auto-resume', {
|
|
291
|
+
description: 'Resume the active /task-auto run.',
|
|
292
|
+
handler: handleTaskAutoResume
|
|
293
|
+
});
|
|
294
|
+
pi.registerCommand('task-auto-cancel', {
|
|
295
|
+
description: 'Stop the running /task-auto loop after the current task.',
|
|
296
|
+
handler: handleTaskAutoCancel
|
|
297
|
+
});
|
|
298
|
+
}
|