@mjasnikovs/pi-task 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/index.js +2 -0
- package/dist/remote/bridge.d.ts +18 -0
- package/dist/remote/bridge.js +2 -0
- package/dist/remote/protocol.d.ts +9 -0
- package/dist/remote/ui-script.js +20 -0
- package/dist/task/accept-debt.d.ts +51 -2
- package/dist/task/accept-debt.js +140 -7
- package/dist/task/auto-orchestrator.d.ts +1 -0
- package/dist/task/auto-orchestrator.js +10 -1
- package/dist/task/final-gate.d.ts +54 -1
- package/dist/task/final-gate.js +115 -3
- package/dist/task/plan-io.d.ts +55 -0
- package/dist/task/plan-io.js +94 -0
- package/dist/task/plan-orchestrator.d.ts +52 -0
- package/dist/task/plan-orchestrator.js +234 -0
- package/dist/task/plan-prompts.d.ts +40 -0
- package/dist/task/plan-prompts.js +138 -0
- package/dist/task/plan-session.d.ts +184 -0
- package/dist/task/plan-session.js +373 -0
- package/dist/task/question-box.d.ts +8 -0
- package/dist/task/question-box.js +1 -1
- package/dist/task/spec-validation.d.ts +14 -0
- package/dist/task/spec-validation.js +21 -1
- package/dist/task/widget.d.ts +4 -0
- package/dist/task/widget.js +2 -2
- package/package.json +1 -1
package/dist/task/final-gate.js
CHANGED
|
@@ -1038,6 +1038,56 @@ function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
|
|
|
1038
1038
|
}
|
|
1039
1039
|
return { outcome: 'pass' };
|
|
1040
1040
|
}
|
|
1041
|
+
/** The command word of a shell line, past any leading `VAR=value` assignments. */
|
|
1042
|
+
function leadingBin(line) {
|
|
1043
|
+
for (const tok of line.trim().split(/\s+/)) {
|
|
1044
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok))
|
|
1045
|
+
continue;
|
|
1046
|
+
return tok;
|
|
1047
|
+
}
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
|
|
1052
|
+
* env-gap contract, so a debt whose reason NAMES that command can be closed by the
|
|
1053
|
+
* command itself rather than by a judgement about it.
|
|
1054
|
+
*
|
|
1055
|
+
* Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
|
|
1056
|
+
* is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
|
|
1057
|
+
* are all ordinary there. The leading command word is still resolved through
|
|
1058
|
+
* runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
|
|
1059
|
+
* gap (mx5 run 16's blindness, one level down).
|
|
1060
|
+
*
|
|
1061
|
+
* The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
|
|
1062
|
+
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
1063
|
+
* debt exactly as open as it was.
|
|
1064
|
+
*/
|
|
1065
|
+
export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe) {
|
|
1066
|
+
const bin = leadingBin(line);
|
|
1067
|
+
const runner = bin === null ? null : resolveRunner(bin);
|
|
1068
|
+
const r = spawnSync('sh', ['-c', line], {
|
|
1069
|
+
cwd,
|
|
1070
|
+
encoding: 'utf8',
|
|
1071
|
+
timeout: timeoutMs,
|
|
1072
|
+
env: runner ? runnerEnv(runner) : { ...process.env }
|
|
1073
|
+
});
|
|
1074
|
+
if (r.error)
|
|
1075
|
+
return { outcome: 'gap', detail: `shell did not spawn (${r.error.message})` };
|
|
1076
|
+
if (r.status === null)
|
|
1077
|
+
return { outcome: 'gap', detail: 'killed (timeout or signal)' };
|
|
1078
|
+
const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
|
|
1079
|
+
if (r.status === 0)
|
|
1080
|
+
return { outcome: 'pass' };
|
|
1081
|
+
if (isCommandNotFound(r.status, output)) {
|
|
1082
|
+
return { outcome: 'gap', detail: 'command not found (127)' };
|
|
1083
|
+
}
|
|
1084
|
+
if (ENV_GAP_OUTPUT_RE.test(output))
|
|
1085
|
+
return { outcome: 'gap', detail: 'missing browser/runtime' };
|
|
1086
|
+
if (INFRA_GAP_OUTPUT_RE.test(output) || extraGapRe?.test(output) === true) {
|
|
1087
|
+
return { outcome: 'gap', detail: 'external infrastructure unreachable' };
|
|
1088
|
+
}
|
|
1089
|
+
return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
|
|
1090
|
+
}
|
|
1041
1091
|
/**
|
|
1042
1092
|
* The full-skip blindness guard (mx5 run 16, validated): dynamic commands were
|
|
1043
1093
|
* DISCOVERED but every single one skipped as an environment gap, so the gate
|
|
@@ -1204,12 +1254,16 @@ export { taskThatIntroduced };
|
|
|
1204
1254
|
* must pass `false` (unprovable ⇒ stays open), never a guess.
|
|
1205
1255
|
*/
|
|
1206
1256
|
export async function deriveOpenDebts(cwd, staticOk) {
|
|
1207
|
-
const { open: openRaw, resolved } = recheckAcceptDebts(await readAcceptDebts(cwd), {
|
|
1257
|
+
const { open: openRaw, resolved, trail } = recheckAcceptDebts(await readAcceptDebts(cwd), {
|
|
1208
1258
|
staticOk,
|
|
1209
1259
|
// Cross-task-deletion debts auto-close iff the deleted file is back in the
|
|
1210
1260
|
// tree — a deterministic existence check, corroborating the per-file
|
|
1211
1261
|
// provenance the record already carries.
|
|
1212
|
-
fileExists: rel => existsSync(path.join(cwd, rel))
|
|
1262
|
+
fileExists: rel => existsSync(path.join(cwd, rel)),
|
|
1263
|
+
// VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
|
|
1264
|
+
// by running that command, under the gate's own env-gap contract and behind
|
|
1265
|
+
// the no-write guard below.
|
|
1266
|
+
rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
|
|
1213
1267
|
});
|
|
1214
1268
|
if (resolved.length > 0)
|
|
1215
1269
|
await writeAcceptDebts(cwd, openRaw);
|
|
@@ -1219,7 +1273,65 @@ export async function deriveOpenDebts(cwd, staticOk) {
|
|
|
1219
1273
|
// a deletion instruction. Pure git-history lookup; degrades to no annotation.
|
|
1220
1274
|
const openDebts = annotateDebtConflicts(openRaw, p => taskThatIntroduced(cwd, p));
|
|
1221
1275
|
const debtNote = buildAcceptDebtNote(openDebts);
|
|
1222
|
-
return { openDebts, ...(debtNote ? { debtNote } : {}) };
|
|
1276
|
+
return { openDebts, ...(debtNote ? { debtNote } : {}), ...(trail.length > 0 ? { trail } : {}) };
|
|
1277
|
+
}
|
|
1278
|
+
/** Per-command ceiling for a debt re-run (`inv-bounded`). */
|
|
1279
|
+
const DEBT_RERUN_TIMEOUT_MS = 300_000;
|
|
1280
|
+
/**
|
|
1281
|
+
* Extra infrastructure-gap shapes recognised ONLY when re-running a debt's command,
|
|
1282
|
+
* never in the gate's own verdicts. A driver that reports its connection simply
|
|
1283
|
+
* closed (`ERR_POSTGRES_CONNECTION_CLOSED` — what bun's SQL client says when the
|
|
1284
|
+
* database is not there at all, as on this box with the mx5 container stopped) is an
|
|
1285
|
+
* absent dependency, and calling that "the defect is still present" would be a
|
|
1286
|
+
* finding the environment invented. Kept out of INFRA_GAP_OUTPUT_RE on purpose: in a
|
|
1287
|
+
* gate verdict the same wording can be a real fault the suite must own, and only the
|
|
1288
|
+
* debt re-check needs the conservative reading — where it costs nothing, because gap
|
|
1289
|
+
* and fail both leave the debt open.
|
|
1290
|
+
*/
|
|
1291
|
+
const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|ECONNRESET/i;
|
|
1292
|
+
/**
|
|
1293
|
+
* Re-run ONE debt's stored VERIFY command for the re-check, with the no-write guard
|
|
1294
|
+
* (`inv-no-write`) wrapped around it.
|
|
1295
|
+
*
|
|
1296
|
+
* A VERIFY command is the project's own command and may legitimately write (a build
|
|
1297
|
+
* emits `dist/`, a suite writes a snapshot). What it may NOT do is turn the tree into
|
|
1298
|
+
* a passing tree and have that count as the debt being fixed — the run would then be
|
|
1299
|
+
* certifying its own side effect. So tracked state is captured before and after, and
|
|
1300
|
+
* a pass that came with a tracked change is downgraded to INCONCLUSIVE with the
|
|
1301
|
+
* change named. Untracked output is left alone: it is what a build legitimately
|
|
1302
|
+
* produces, and `git status --porcelain` in a repo with the usual ignores does not
|
|
1303
|
+
* see it.
|
|
1304
|
+
*
|
|
1305
|
+
* A repository the guard cannot read (no git, git absent) is not a licence to skip
|
|
1306
|
+
* the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
|
|
1307
|
+
* assumption rather than an observation.
|
|
1308
|
+
*/
|
|
1309
|
+
export function rerunDebtVerifyCommand(cwd, command) {
|
|
1310
|
+
const tracked = () => {
|
|
1311
|
+
const r = spawnSync('git', ['status', '--porcelain', '--untracked-files=no'], {
|
|
1312
|
+
cwd,
|
|
1313
|
+
encoding: 'utf8',
|
|
1314
|
+
timeout: 60_000
|
|
1315
|
+
});
|
|
1316
|
+
return r.error || r.status !== 0 ? null : (r.stdout ?? '');
|
|
1317
|
+
};
|
|
1318
|
+
const before = tracked();
|
|
1319
|
+
const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE);
|
|
1320
|
+
if (r.outcome === 'fail')
|
|
1321
|
+
return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
|
|
1322
|
+
if (r.outcome === 'gap')
|
|
1323
|
+
return { outcome: 'gap', detail: r.detail };
|
|
1324
|
+
const after = tracked();
|
|
1325
|
+
if (before === null || after === null) {
|
|
1326
|
+
return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
|
|
1327
|
+
}
|
|
1328
|
+
if (before !== after) {
|
|
1329
|
+
return {
|
|
1330
|
+
outcome: 'gap',
|
|
1331
|
+
detail: 'the re-run itself CHANGED tracked files — a command that edits the tree into a pass proves nothing'
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
return { outcome: 'pass' };
|
|
1223
1335
|
}
|
|
1224
1336
|
/**
|
|
1225
1337
|
* Run the final gate: static analysis first, then the lockfile consistency
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Next free TASK_PLAN_NNNN id. Mirrors allocateAutoId. */
|
|
2
|
+
export declare function allocatePlanId(cwd: string): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* How an answer to a model question was produced. Recorded verbatim in the plan
|
|
5
|
+
* file because "the user chose this" and "the recommendation was accepted by
|
|
6
|
+
* default" are different levels of evidence when you read the plan back later —
|
|
7
|
+
* the same distinction /task-auto draws with its "(accepted recommendation)" and
|
|
8
|
+
* "(YOLO)" stamps.
|
|
9
|
+
*/
|
|
10
|
+
export type AnswerSource = 'chosen' | 'accepted' | 'typed' | 'skipped' | 'yolo';
|
|
11
|
+
export type PlanEntry =
|
|
12
|
+
/** The model asked; the user answered. */
|
|
13
|
+
{
|
|
14
|
+
kind: 'decision';
|
|
15
|
+
question: string;
|
|
16
|
+
answer: string;
|
|
17
|
+
source: AnswerSource;
|
|
18
|
+
}
|
|
19
|
+
/** The user volunteered a decision without being asked. */
|
|
20
|
+
| {
|
|
21
|
+
kind: 'stated';
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
/** The user asked; the model answered. Advisory — decides nothing on its own. */
|
|
25
|
+
| {
|
|
26
|
+
kind: 'note';
|
|
27
|
+
question: string;
|
|
28
|
+
answer: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The transcript as the MODEL sees it — fed back as `priorQA` on every next
|
|
32
|
+
* question, and prepended to the handoff prompt. Decisions are numbered so a
|
|
33
|
+
* later question can refer to one; notes and stated decisions are labelled by who
|
|
34
|
+
* said them, because a model answer the user merely READ must not be mistaken for
|
|
35
|
+
* a decision the user MADE.
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatPlanTranscript(entries: readonly PlanEntry[]): string;
|
|
38
|
+
/** Body of a fresh plan file, before any entry is recorded. */
|
|
39
|
+
export declare function buildPlanBody(task: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* The `## decisions` section: the human-readable transcript. Same content as
|
|
42
|
+
* {@link formatPlanTranscript} — the model and the reader see the same record, so
|
|
43
|
+
* there is no hidden channel.
|
|
44
|
+
*/
|
|
45
|
+
export declare function formatPlanDecisions(entries: readonly PlanEntry[]): string;
|
|
46
|
+
/**
|
|
47
|
+
* The prompt handed to /task when the user proceeds to execution.
|
|
48
|
+
*
|
|
49
|
+
* The task prompt leads, exactly as a bare `/task <prompt>` would, so refine sees
|
|
50
|
+
* a normal task description first; the decisions follow as an authoritative block.
|
|
51
|
+
* Anything the user did NOT settle is simply absent — /task's own grill phase asks
|
|
52
|
+
* about what is left, which is why this block never invents a decision to fill a
|
|
53
|
+
* gap.
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildHandoffPrompt(task: string, entries: readonly PlanEntry[]): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLAN-file I/O & transcript formatting for /task-plan.
|
|
3
|
+
*
|
|
4
|
+
* A TASK_PLAN_NNNN.md is a normal task file — same front matter, same section
|
|
5
|
+
* machinery (task-io.ts) — whose body holds the task prompt and the planning
|
|
6
|
+
* transcript. This mirrors auto-io.ts, which does exactly the same thing for
|
|
7
|
+
* /task-auto's TASK_AUTO_NNNN.md; nothing about the file format is new here.
|
|
8
|
+
*
|
|
9
|
+
* The transcript is the whole point of the command: it is what rides into /task
|
|
10
|
+
* as the handoff prompt, and it is what a human reads afterwards to see which
|
|
11
|
+
* decisions were made and who made them.
|
|
12
|
+
*/
|
|
13
|
+
import * as fsp from 'node:fs/promises';
|
|
14
|
+
import { tasksDir, ensureTasksDir } from './task-io.js';
|
|
15
|
+
const PLAN_FILE_RE = /^(TASK_PLAN_\d{4,})\.md$/;
|
|
16
|
+
/** Next free TASK_PLAN_NNNN id. Mirrors allocateAutoId. */
|
|
17
|
+
export async function allocatePlanId(cwd) {
|
|
18
|
+
await ensureTasksDir(cwd);
|
|
19
|
+
const entries = await fsp.readdir(tasksDir(cwd));
|
|
20
|
+
let max = 0;
|
|
21
|
+
for (const e of entries) {
|
|
22
|
+
const m = PLAN_FILE_RE.exec(e);
|
|
23
|
+
if (m) {
|
|
24
|
+
const n = parseInt(m[1].slice('TASK_PLAN_'.length), 10);
|
|
25
|
+
if (n > max)
|
|
26
|
+
max = n;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return `TASK_PLAN_${String(max + 1).padStart(4, '0')}`;
|
|
30
|
+
}
|
|
31
|
+
const SOURCE_STAMP = {
|
|
32
|
+
chosen: '',
|
|
33
|
+
accepted: ' (accepted recommendation)',
|
|
34
|
+
typed: '',
|
|
35
|
+
skipped: '',
|
|
36
|
+
yolo: ' (YOLO)'
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The transcript as the MODEL sees it — fed back as `priorQA` on every next
|
|
40
|
+
* question, and prepended to the handoff prompt. Decisions are numbered so a
|
|
41
|
+
* later question can refer to one; notes and stated decisions are labelled by who
|
|
42
|
+
* said them, because a model answer the user merely READ must not be mistaken for
|
|
43
|
+
* a decision the user MADE.
|
|
44
|
+
*/
|
|
45
|
+
export function formatPlanTranscript(entries) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
let n = 0;
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
if (e.kind === 'decision') {
|
|
50
|
+
n++;
|
|
51
|
+
lines.push(`Q${n}: ${e.question}`);
|
|
52
|
+
lines.push(`A${n}: ${e.answer}${SOURCE_STAMP[e.source]}`);
|
|
53
|
+
}
|
|
54
|
+
else if (e.kind === 'stated') {
|
|
55
|
+
lines.push(`DECIDED BY USER: ${e.text}`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
lines.push(`THE USER ASKED: ${e.question}`);
|
|
59
|
+
lines.push(`YOU ANSWERED: ${e.answer}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
/** Body of a fresh plan file, before any entry is recorded. */
|
|
65
|
+
export function buildPlanBody(task) {
|
|
66
|
+
return `\n## task prompt\n\n${task.trim() || '(none)'}\n\n## decisions\n\n(none yet)\n`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The `## decisions` section: the human-readable transcript. Same content as
|
|
70
|
+
* {@link formatPlanTranscript} — the model and the reader see the same record, so
|
|
71
|
+
* there is no hidden channel.
|
|
72
|
+
*/
|
|
73
|
+
export function formatPlanDecisions(entries) {
|
|
74
|
+
return entries.length === 0 ? '(none yet)' : formatPlanTranscript(entries);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The prompt handed to /task when the user proceeds to execution.
|
|
78
|
+
*
|
|
79
|
+
* The task prompt leads, exactly as a bare `/task <prompt>` would, so refine sees
|
|
80
|
+
* a normal task description first; the decisions follow as an authoritative block.
|
|
81
|
+
* Anything the user did NOT settle is simply absent — /task's own grill phase asks
|
|
82
|
+
* about what is left, which is why this block never invents a decision to fill a
|
|
83
|
+
* gap.
|
|
84
|
+
*/
|
|
85
|
+
export function buildHandoffPrompt(task, entries) {
|
|
86
|
+
const decisions = entries.filter(e => e.kind !== 'note');
|
|
87
|
+
if (decisions.length === 0)
|
|
88
|
+
return task.trim();
|
|
89
|
+
return (`${task.trim()}\n\n`
|
|
90
|
+
+ `PLANNING DECISIONS — these were settled with the user before this task was started. `
|
|
91
|
+
+ `They are authoritative: implement them as written, and do not re-open or contradict `
|
|
92
|
+
+ `them. They may not cover everything; decide anything they leave open as usual.\n\n`
|
|
93
|
+
+ `${formatPlanTranscript(decisions)}`);
|
|
94
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /task-plan — plan ONE task with the model, then hand it to /task.
|
|
3
|
+
*
|
|
4
|
+
* The command is a thin wiring layer. Everything it does is already in the
|
|
5
|
+
* codebase and is reused as-is:
|
|
6
|
+
*
|
|
7
|
+
* • the adaptive question loop, the duplicate backstop, the boxed picker and
|
|
8
|
+
* the A/B answer mapping → plan-session.ts (which reuses parsers.ts,
|
|
9
|
+
* question-dedup.ts, inline-markdown.ts,
|
|
10
|
+
* question-box.ts, yolo.ts)
|
|
11
|
+
* • the child process, its loop/leak/stall guards and retries → child-runner.ts
|
|
12
|
+
* • local TUI + remote browser prompt fan-out → remote/bridge.ts
|
|
13
|
+
* • the status widget → widget.ts
|
|
14
|
+
* • the plan file (.pi-tasks/TASK_PLAN_NNNN.md) → task-io.ts
|
|
15
|
+
* • the handoff itself → orchestrator.ts
|
|
16
|
+
*
|
|
17
|
+
* The handoff is deliberately the SAME call /task makes for a typed prompt —
|
|
18
|
+
* gated when `verify work` / `enforce guidelines` is on, fire-and-forget
|
|
19
|
+
* otherwise — so a planned task is not a second kind of task. The only thing
|
|
20
|
+
* /task receives that a bare /task would not is the decisions block.
|
|
21
|
+
*/
|
|
22
|
+
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
23
|
+
import { type PlanSessionDeps, type PlanOutcome } from './plan-session.js';
|
|
24
|
+
import { type PlanEntry } from './plan-io.js';
|
|
25
|
+
/**
|
|
26
|
+
* Build the session deps around a live command ctx: children with a status
|
|
27
|
+
* loader, prompts fanned out to the terminal AND any remote viewer, and the plan
|
|
28
|
+
* file rewritten after every entry.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildPlanDeps(ctx: ExtensionCommandContext, cwd: string, planId: string, task: string, signal: AbortSignal): PlanSessionDeps;
|
|
31
|
+
/**
|
|
32
|
+
* Write the transcript into the plan file. The decisions and the model's answers
|
|
33
|
+
* to the user's questions live in separate sections: only the decisions are
|
|
34
|
+
* authoritative for the implementation, and the file must not blur that.
|
|
35
|
+
*/
|
|
36
|
+
export declare function persistEntries(cwd: string, planId: string, entries: readonly PlanEntry[]): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Seams the command flow needs from the outside world. Defaulted to the real
|
|
39
|
+
* thing in {@link handleTaskPlan}; injected in tests so the whole flow — plan
|
|
40
|
+
* file, transcript, handoff prompt — can be exercised without a model or a
|
|
41
|
+
* session replacement.
|
|
42
|
+
*/
|
|
43
|
+
export interface PlanCommandDeps {
|
|
44
|
+
/** Build the session deps (children + dialogs) for this run. */
|
|
45
|
+
session(ctx: ExtensionCommandContext, cwd: string, planId: string, task: string, signal: AbortSignal): PlanSessionDeps;
|
|
46
|
+
/** Run the loop. */
|
|
47
|
+
run(deps: PlanSessionDeps): Promise<PlanOutcome>;
|
|
48
|
+
/** Hand the composed prompt to /task. Returns the produced task id, if any. */
|
|
49
|
+
handoff(ctx: ExtensionCommandContext, cwd: string, prompt: string): Promise<string | undefined>;
|
|
50
|
+
}
|
|
51
|
+
export declare function handleTaskPlan(args: string, ctx: ExtensionCommandContext, commandDeps?: PlanCommandDeps): Promise<void>;
|
|
52
|
+
export declare function registerTaskPlan(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /task-plan — plan ONE task with the model, then hand it to /task.
|
|
3
|
+
*
|
|
4
|
+
* The command is a thin wiring layer. Everything it does is already in the
|
|
5
|
+
* codebase and is reused as-is:
|
|
6
|
+
*
|
|
7
|
+
* • the adaptive question loop, the duplicate backstop, the boxed picker and
|
|
8
|
+
* the A/B answer mapping → plan-session.ts (which reuses parsers.ts,
|
|
9
|
+
* question-dedup.ts, inline-markdown.ts,
|
|
10
|
+
* question-box.ts, yolo.ts)
|
|
11
|
+
* • the child process, its loop/leak/stall guards and retries → child-runner.ts
|
|
12
|
+
* • local TUI + remote browser prompt fan-out → remote/bridge.ts
|
|
13
|
+
* • the status widget → widget.ts
|
|
14
|
+
* • the plan file (.pi-tasks/TASK_PLAN_NNNN.md) → task-io.ts
|
|
15
|
+
* • the handoff itself → orchestrator.ts
|
|
16
|
+
*
|
|
17
|
+
* The handoff is deliberately the SAME call /task makes for a typed prompt —
|
|
18
|
+
* gated when `verify work` / `enforce guidelines` is on, fire-and-forget
|
|
19
|
+
* otherwise — so a planned task is not a second kind of task. The only thing
|
|
20
|
+
* /task receives that a bare /task would not is the decisions block.
|
|
21
|
+
*/
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
24
|
+
import { PLAN_QUESTION_PROMPT, PLAN_ANSWER_PROMPT } from './plan-prompts.js';
|
|
25
|
+
import { runPlanSession, ASK_TITLE } from './plan-session.js';
|
|
26
|
+
import { allocatePlanId, buildPlanBody, buildHandoffPrompt, formatPlanDecisions } from './plan-io.js';
|
|
27
|
+
import { writeTaskFile, setTaskSection, updateTaskFrontMatter, tasksDir } from './task-io.js';
|
|
28
|
+
import { deriveTitle } from './parsers.js';
|
|
29
|
+
import { renderInlineMarkdown } from './inline-markdown.js';
|
|
30
|
+
import { expandFeatureMentions } from './auto-orchestrator.js';
|
|
31
|
+
import { runSingleTask, runGatedTask } from './orchestrator.js';
|
|
32
|
+
import { startAutoLoader } from './widget.js';
|
|
33
|
+
import { SessionUI, publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand } from '../remote/bridge.js';
|
|
34
|
+
import { getConfig } from '../config/config.js';
|
|
35
|
+
import { isYoloMode } from './yolo.js';
|
|
36
|
+
import { gateDebugWriter } from './debug-log.js';
|
|
37
|
+
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
38
|
+
import * as fsp from 'node:fs/promises';
|
|
39
|
+
/** Loader labels for the two planning children. */
|
|
40
|
+
const PLAN_STEPS = {
|
|
41
|
+
'plan-question': 'question',
|
|
42
|
+
'plan-answer': 'answering you'
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Build the session deps around a live command ctx: children with a status
|
|
46
|
+
* loader, prompts fanned out to the terminal AND any remote viewer, and the plan
|
|
47
|
+
* file rewritten after every entry.
|
|
48
|
+
*/
|
|
49
|
+
export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
50
|
+
const ui = new SessionUI(ctx);
|
|
51
|
+
const theme = ctx.ui.theme;
|
|
52
|
+
let lastLine;
|
|
53
|
+
let status;
|
|
54
|
+
let contextUsage;
|
|
55
|
+
const parentContextWindow = getParentContextWindow(ctx);
|
|
56
|
+
const title = deriveTitle(task);
|
|
57
|
+
const logDebug = gateDebugWriter((msg) => {
|
|
58
|
+
const line = `${new Date().toISOString()} ${msg}\n`;
|
|
59
|
+
void fsp.appendFile(path.join(tasksDir(cwd), `${planId}-debug.log`), line).catch(() => { });
|
|
60
|
+
});
|
|
61
|
+
const phaseDeps = {
|
|
62
|
+
cwd,
|
|
63
|
+
taskId: planId,
|
|
64
|
+
signal,
|
|
65
|
+
onChildOutput: line => {
|
|
66
|
+
lastLine = line;
|
|
67
|
+
},
|
|
68
|
+
onContextUsage: snapshot => {
|
|
69
|
+
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
70
|
+
},
|
|
71
|
+
...(logDebug && { logDebug })
|
|
72
|
+
};
|
|
73
|
+
/** Run a planning child under the shared /task-auto-style loader. */
|
|
74
|
+
const child = async (name, prompt) => {
|
|
75
|
+
lastLine = undefined;
|
|
76
|
+
contextUsage = undefined;
|
|
77
|
+
const startedAt = Date.now();
|
|
78
|
+
const stopLoader = startAutoLoader(ctx, () => ({
|
|
79
|
+
command: '/task-plan',
|
|
80
|
+
title,
|
|
81
|
+
step: status ?? PLAN_STEPS[name] ?? name,
|
|
82
|
+
stepNum: 1,
|
|
83
|
+
stepTotal: 1,
|
|
84
|
+
startedAt,
|
|
85
|
+
lastLine,
|
|
86
|
+
contextUsage
|
|
87
|
+
}));
|
|
88
|
+
try {
|
|
89
|
+
return await runPhaseChild(phaseDeps, name, 'read', prompt);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
stopLoader();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
generateQuestion: (priorQA, hint) => child('plan-question', prependHint(hint, PLAN_QUESTION_PROMPT(task, priorQA))),
|
|
97
|
+
answerUserQuestion: (priorQA, question) => child('plan-answer', PLAN_ANSWER_PROMPT(task, priorQA, question)),
|
|
98
|
+
ask: (spec) => ui.ask(spec),
|
|
99
|
+
promptText: (localTitle, question) => ui.ask({
|
|
100
|
+
localTitle,
|
|
101
|
+
question,
|
|
102
|
+
// No recommendation and no options: both surfaces fall back to
|
|
103
|
+
// their plain text input, which is exactly what "type your
|
|
104
|
+
// question" needs. Skip = changed my mind.
|
|
105
|
+
allowSkip: true
|
|
106
|
+
}),
|
|
107
|
+
showAnswer: async (question, answer) => {
|
|
108
|
+
const text = `You asked:\n${question}\n\n${answer}\n`;
|
|
109
|
+
publishViewer(ASK_TITLE, text);
|
|
110
|
+
await ctx.ui.editor(ASK_TITLE, text);
|
|
111
|
+
},
|
|
112
|
+
onEntries: async (entries) => {
|
|
113
|
+
await persistEntries(cwd, planId, entries);
|
|
114
|
+
},
|
|
115
|
+
renderMarkdown: (s) => renderInlineMarkdown(s, theme),
|
|
116
|
+
setStatus: line => {
|
|
117
|
+
status = line;
|
|
118
|
+
},
|
|
119
|
+
yolo: isYoloMode(),
|
|
120
|
+
...(logDebug && { logDebug })
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Write the transcript into the plan file. The decisions and the model's answers
|
|
125
|
+
* to the user's questions live in separate sections: only the decisions are
|
|
126
|
+
* authoritative for the implementation, and the file must not blur that.
|
|
127
|
+
*/
|
|
128
|
+
export async function persistEntries(cwd, planId, entries) {
|
|
129
|
+
const decisions = entries.filter(e => e.kind !== 'note');
|
|
130
|
+
const notes = entries.filter(e => e.kind === 'note');
|
|
131
|
+
await setTaskSection(cwd, planId, 'decisions', formatPlanDecisions(decisions));
|
|
132
|
+
if (notes.length > 0) {
|
|
133
|
+
await setTaskSection(cwd, planId, 'notes', notes
|
|
134
|
+
.map(n => (n.kind === 'note' ? `Q: ${n.question}\nA: ${n.answer}` : ''))
|
|
135
|
+
.join('\n\n'));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The default handoff: EXACTLY what `/task <prompt>` does with a typed prompt —
|
|
140
|
+
* gated when `verify work` / `enforce guidelines` is on, fire-and-forget
|
|
141
|
+
* otherwise. A planned task must not be a second kind of task.
|
|
142
|
+
*/
|
|
143
|
+
async function defaultHandoff(ctx, cwd, prompt) {
|
|
144
|
+
const cfg = getConfig();
|
|
145
|
+
if (cfg.verifyWork || cfg.enforceGuidelines) {
|
|
146
|
+
// runGatedTask owns the whole gated sequence and reports its own outcome;
|
|
147
|
+
// it does not surface the inner task id, so the plan file records the
|
|
148
|
+
// handoff without one.
|
|
149
|
+
await runGatedTask(ctx, cwd, prompt);
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const { taskId, sessionCancelled } = await runSingleTask(ctx, cwd, prompt, { notifyFinish: true });
|
|
153
|
+
if (sessionCancelled) {
|
|
154
|
+
ctx.ui.notify('Could not start a fresh session for /task-plan.', 'warning');
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
return taskId || undefined;
|
|
158
|
+
}
|
|
159
|
+
const DEFAULT_COMMAND_DEPS = {
|
|
160
|
+
session: buildPlanDeps,
|
|
161
|
+
run: runPlanSession,
|
|
162
|
+
handoff: defaultHandoff
|
|
163
|
+
};
|
|
164
|
+
export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DEPS) {
|
|
165
|
+
await ctx.waitForIdle();
|
|
166
|
+
const cwd = ctx.cwd;
|
|
167
|
+
const raw = args.trim();
|
|
168
|
+
if (raw.length === 0) {
|
|
169
|
+
ctx.ui.setEditorText('/task-plan ');
|
|
170
|
+
ctx.ui.notify('Describe the task after /task-plan (use @ for file completion).', 'info');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Inline any @file the user referenced, exactly as /task-auto's planner does:
|
|
174
|
+
// a one-line "Implement @spec.md" reads as trivial to a model that cannot see
|
|
175
|
+
// the file, and the first question comes out useless.
|
|
176
|
+
const task = await expandFeatureMentions(cwd, raw);
|
|
177
|
+
const planId = await allocatePlanId(cwd);
|
|
178
|
+
const now = new Date().toISOString();
|
|
179
|
+
const fm = {
|
|
180
|
+
id: planId,
|
|
181
|
+
state: 'in_progress',
|
|
182
|
+
// Plan files have no phase pipeline of their own — same as TASK_AUTO_*,
|
|
183
|
+
// which parks at 'done' so the phase progress bar never claims otherwise.
|
|
184
|
+
phase: 'done',
|
|
185
|
+
created_at: now,
|
|
186
|
+
updated_at: now,
|
|
187
|
+
title: deriveTitle(raw)
|
|
188
|
+
};
|
|
189
|
+
await writeTaskFile(cwd, fm, buildPlanBody(task));
|
|
190
|
+
const abort = new AbortController();
|
|
191
|
+
let outcome;
|
|
192
|
+
try {
|
|
193
|
+
outcome = await commandDeps.run(commandDeps.session(ctx, cwd, planId, task, abort.signal));
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
197
|
+
await updateTaskFrontMatter(cwd, planId, {
|
|
198
|
+
state: msg === USER_CANCELLED ? 'cancelled' : 'failed',
|
|
199
|
+
reason: msg.slice(0, 200)
|
|
200
|
+
}).catch(() => { });
|
|
201
|
+
const line = `${planId} stopped — ${msg.slice(0, 160)}`;
|
|
202
|
+
ctx.ui.notify(line, 'error');
|
|
203
|
+
publishLifecycleNotice(line, 'error');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (outcome.kind === 'cancelled') {
|
|
207
|
+
// Nothing is handed to /task, but whatever WAS decided stays on disk —
|
|
208
|
+
// a cancelled plan is a record, not a rollback.
|
|
209
|
+
await updateTaskFrontMatter(cwd, planId, { state: 'cancelled' }).catch(() => { });
|
|
210
|
+
const line = outcome.entries.length === 0 ?
|
|
211
|
+
`${planId} cancelled — nothing planned.`
|
|
212
|
+
: `${planId} cancelled — ${outcome.entries.length} entr${outcome.entries.length === 1 ? 'y' : 'ies'} kept in .pi-tasks/${planId}.md`;
|
|
213
|
+
ctx.ui.notify(line, 'warning');
|
|
214
|
+
publishNotify(line, 'warning');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const prompt = buildHandoffPrompt(task, outcome.entries);
|
|
218
|
+
const decisions = outcome.entries.filter(e => e.kind !== 'note').length;
|
|
219
|
+
await setTaskSection(cwd, planId, 'handoff', `handoff_at: ${new Date().toISOString()}\ndecisions: ${decisions}`).catch(() => { });
|
|
220
|
+
await updateTaskFrontMatter(cwd, planId, { state: 'completed' }).catch(() => { });
|
|
221
|
+
const taskId = await commandDeps.handoff(ctx, cwd, prompt);
|
|
222
|
+
if (taskId) {
|
|
223
|
+
// Stamped after the fact: this is the one link from the plan to the task
|
|
224
|
+
// it produced, and /task allocates the id only once its own run starts.
|
|
225
|
+
await setTaskSection(cwd, planId, 'handoff', `handoff_at: ${new Date().toISOString()}\ndecisions: ${decisions}\ntask: ${taskId}`).catch(() => { });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
export function registerTaskPlan(pi) {
|
|
229
|
+
registerBridgeCommand(pi, 'task-plan', {
|
|
230
|
+
description: 'Plan one task with the model — it asks, you answer (or ask it something, or '
|
|
231
|
+
+ 'proceed) — then hand the decisions to /task. Usage: /task-plan <prompt>',
|
|
232
|
+
handler: handleTaskPlan
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts for /task-plan's two planning children.
|
|
3
|
+
*
|
|
4
|
+
* /task-plan plans ONE task interactively before any spec work happens. Its
|
|
5
|
+
* question generator is deliberately the SAME SHAPE as /task-auto's clarify head
|
|
6
|
+
* (`AUTO_CLARIFY_PROMPT` in auto-prompts.ts): one numbered question, a required
|
|
7
|
+
* `SUGGESTED:` line, an optional `ALT:` line for a binary fork, the literal token
|
|
8
|
+
* NONE when nothing remains. That is not a coincidence — it means
|
|
9
|
+
* {@link parseClarifyList} parses this output UNCHANGED, and the boxed picker,
|
|
10
|
+
* the duplicate backstop and the YOLO picker all work here with no new code.
|
|
11
|
+
*
|
|
12
|
+
* What differs is the JOB. /task-auto's clarify asks what changes how a feature is
|
|
13
|
+
* SPLIT INTO TASKS; every question it asks is about plan shape, ordering, and which
|
|
14
|
+
* subsystems are in or out. /task-plan is planning a single unit of work that /task
|
|
15
|
+
* will implement in one run, so a "how do we split this" question is off-topic here
|
|
16
|
+
* and the prompt below rules it out explicitly.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Ask the SINGLE most important remaining question about ONE task.
|
|
20
|
+
*
|
|
21
|
+
* `priorQA` carries every decision made so far — model questions the user
|
|
22
|
+
* answered, answers the user volunteered, and the Q&A from questions the user
|
|
23
|
+
* asked the model — so each next question adapts to them. Output MUST match
|
|
24
|
+
* parseClarifyList.
|
|
25
|
+
*/
|
|
26
|
+
export declare const PLAN_QUESTION_PROMPT: (task: string, priorQA: string) => string;
|
|
27
|
+
/**
|
|
28
|
+
* Answer a question the USER asked the model during planning.
|
|
29
|
+
*
|
|
30
|
+
* This is the one channel /task-plan adds that no existing phase has: everywhere
|
|
31
|
+
* else in pi-task the model asks and the user answers. Here the user asks. The
|
|
32
|
+
* answer is advisory — it is recorded in the plan file as a note, and it does NOT
|
|
33
|
+
* by itself decide anything; the user still answers the model's own questions.
|
|
34
|
+
*
|
|
35
|
+
* The abstention rule matters more here than anywhere else in the pipeline: a
|
|
36
|
+
* planning answer that invents a file, a flag, or an API reads exactly like a
|
|
37
|
+
* grounded one, and the user is asking BECAUSE they do not know. Saying "I could
|
|
38
|
+
* not confirm this" is a correct answer; a confident guess is not.
|
|
39
|
+
*/
|
|
40
|
+
export declare const PLAN_ANSWER_PROMPT: (task: string, priorQA: string, question: string) => string;
|