@mjasnikovs/pi-task 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -111,7 +111,9 @@ It works like the clarify step you already know — **one question at a time, ea
|
|
|
111
111
|
|
|
112
112
|
When the model runs out of questions it says so and offers the same three moves rather than proceeding behind your back. Answering something new re-opens it: a decision you volunteer can make a fresh question worth asking.
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
**Planning is read-only.** Nothing in your project is created, edited, or deleted while you plan — the planning model runs with a one-tool allowlist (`read`), which also excludes write tools contributed by any extension you have whitelisted for helper sessions. That is verified as well as prevented: the working tree is compared before and after every step, and if anything outside `.pi-tasks/` ever changes, the run says so loudly and records it in the plan file rather than carrying on quietly. Read-only ends the moment you choose **Proceed to execution** — from there it is a normal `/task` run and it writes code.
|
|
115
|
+
|
|
116
|
+
The one thing written during planning is the plan file itself, `.pi-tasks/TASK_PLAN_NNNN.md` — the task prompt, the `## decisions` transcript, and a separate `## notes` section for what you asked (a session you abandon before deciding anything deletes its own file, so an aborted plan leaves nothing at all). Only the decisions are handed to `/task`, as an authoritative block ahead of your original prompt; the notes stay behind, because an answer you read is not a decision you made. From there it is an ordinary `/task` run — same pipeline, same gates.
|
|
115
117
|
|
|
116
118
|
It works from the browser too (see [Remote](#remote--drive-a-task-from-your-phone)): the prompt card grows an **Ask the model** and a **Proceed to execution** button alongside the usual Accept / Manual answer.
|
|
117
119
|
|
|
@@ -48,5 +48,20 @@ export interface PlanCommandDeps {
|
|
|
48
48
|
/** Hand the composed prompt to /task. Returns the produced task id, if any. */
|
|
49
49
|
handoff(ctx: ExtensionCommandContext, cwd: string, prompt: string): Promise<string | undefined>;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Remove the plan file (and its debug log) when the session ended with nothing
|
|
53
|
+
* in it — the user opened /task-plan and backed out at the first question.
|
|
54
|
+
*
|
|
55
|
+
* The file is allocated up front so a crash mid-session still leaves the record,
|
|
56
|
+
* which means an abandoned session would otherwise leave a stub whose whole
|
|
57
|
+
* content is "(none yet)". That is exactly the artifact this command is not
|
|
58
|
+
* supposed to produce.
|
|
59
|
+
*
|
|
60
|
+
* It refuses to delete anything that carries a read-only violation: that section
|
|
61
|
+
* is the report of a file we could not account for, and it must outlive the
|
|
62
|
+
* session that found it. Best-effort — a failure here leaves the stub, which is
|
|
63
|
+
* harmless.
|
|
64
|
+
*/
|
|
65
|
+
export declare function discardEmptyPlanFile(cwd: string, planId: string): Promise<void>;
|
|
51
66
|
export declare function handleTaskPlan(args: string, ctx: ExtensionCommandContext, commandDeps?: PlanCommandDeps): Promise<void>;
|
|
52
67
|
export declare function registerTaskPlan(pi: ExtensionAPI): void;
|
|
@@ -24,7 +24,10 @@ import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
|
24
24
|
import { PLAN_QUESTION_PROMPT, PLAN_ANSWER_PROMPT } from './plan-prompts.js';
|
|
25
25
|
import { runPlanSession, ASK_TITLE } from './plan-session.js';
|
|
26
26
|
import { allocatePlanId, buildPlanBody, buildHandoffPrompt, formatPlanDecisions } from './plan-io.js';
|
|
27
|
-
import { writeTaskFile, setTaskSection, updateTaskFrontMatter, tasksDir } from './task-io.js';
|
|
27
|
+
import { writeTaskFile, readTaskFile, setTaskSection, readSection, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
|
|
28
|
+
import { extractSection } from './task-parsers.js';
|
|
29
|
+
import { collectTreeChanges } from './gate-deps.js';
|
|
30
|
+
import { PLAN_TOOLS, newTreeChanges, isEmptyChange, formatReadOnlyViolation } from './plan-readonly.js';
|
|
28
31
|
import { deriveTitle } from './parsers.js';
|
|
29
32
|
import { renderInlineMarkdown } from './inline-markdown.js';
|
|
30
33
|
import { expandFeatureMentions } from './auto-orchestrator.js';
|
|
@@ -70,7 +73,14 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
|
70
73
|
},
|
|
71
74
|
...(logDebug && { logDebug })
|
|
72
75
|
};
|
|
73
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Run a planning child under the shared /task-auto-style loader, with the
|
|
78
|
+
* read-only contract enforced around it: the child gets exactly
|
|
79
|
+
* {@link PLAN_TOOLS}, and the working tree is compared before and after so a
|
|
80
|
+
* hole in that prevention is reported instead of shipped silently. The
|
|
81
|
+
* comparison excludes `.pi-tasks/` (collectTreeChanges does), which is where
|
|
82
|
+
* the plan file itself is written.
|
|
83
|
+
*/
|
|
74
84
|
const child = async (name, prompt) => {
|
|
75
85
|
lastLine = undefined;
|
|
76
86
|
contextUsage = undefined;
|
|
@@ -85,11 +95,21 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
|
85
95
|
lastLine,
|
|
86
96
|
contextUsage
|
|
87
97
|
}));
|
|
98
|
+
const before = await collectTreeChanges(cwd, signal).catch(() => null);
|
|
88
99
|
try {
|
|
89
|
-
return await runPhaseChild(phaseDeps, name,
|
|
100
|
+
return await runPhaseChild(phaseDeps, name, PLAN_TOOLS, prompt);
|
|
90
101
|
}
|
|
91
102
|
finally {
|
|
92
103
|
stopLoader();
|
|
104
|
+
// Outside a git repo `before` is null and there is nothing to compare
|
|
105
|
+
// against — the same degrade every other tree-reading guard here takes.
|
|
106
|
+
if (before) {
|
|
107
|
+
const after = await collectTreeChanges(cwd, signal).catch(() => null);
|
|
108
|
+
const touched = after ? newTreeChanges(before, after) : null;
|
|
109
|
+
if (touched && !isEmptyChange(touched)) {
|
|
110
|
+
await reportReadOnlyViolation(ctx, cwd, planId, name, touched, logDebug);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
93
113
|
}
|
|
94
114
|
};
|
|
95
115
|
return {
|
|
@@ -120,6 +140,32 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
|
120
140
|
...(logDebug && { logDebug })
|
|
121
141
|
};
|
|
122
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* A planning step touched the project: say so on every surface and write it into
|
|
145
|
+
* the plan file, which is the one record that outlives the session.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately NOT a rollback. If this ever fires, something wrote a file we
|
|
148
|
+
* cannot account for; deleting it unseen would turn a reporting bug into data
|
|
149
|
+
* loss. The user gets the path and decides.
|
|
150
|
+
*/
|
|
151
|
+
async function reportReadOnlyViolation(ctx, cwd, planId, step, touched, logDebug) {
|
|
152
|
+
const line = formatReadOnlyViolation(step, touched);
|
|
153
|
+
logDebug?.(line);
|
|
154
|
+
try {
|
|
155
|
+
ctx.ui.notify(line, 'error');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* stale ctx — the record below is what matters */
|
|
159
|
+
}
|
|
160
|
+
publishLifecycleNotice(line, 'error');
|
|
161
|
+
try {
|
|
162
|
+
const existing = (await readSection(cwd, planId, 'read-only violations')) ?? '';
|
|
163
|
+
await setTaskSection(cwd, planId, 'read-only violations', existing ? `${existing}\n- ${line}` : `- ${line}`);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* best-effort */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
123
169
|
/**
|
|
124
170
|
* Write the transcript into the plan file. The decisions and the model's answers
|
|
125
171
|
* to the user's questions live in separate sections: only the decisions are
|
|
@@ -161,6 +207,37 @@ const DEFAULT_COMMAND_DEPS = {
|
|
|
161
207
|
run: runPlanSession,
|
|
162
208
|
handoff: defaultHandoff
|
|
163
209
|
};
|
|
210
|
+
/**
|
|
211
|
+
* Remove the plan file (and its debug log) when the session ended with nothing
|
|
212
|
+
* in it — the user opened /task-plan and backed out at the first question.
|
|
213
|
+
*
|
|
214
|
+
* The file is allocated up front so a crash mid-session still leaves the record,
|
|
215
|
+
* which means an abandoned session would otherwise leave a stub whose whole
|
|
216
|
+
* content is "(none yet)". That is exactly the artifact this command is not
|
|
217
|
+
* supposed to produce.
|
|
218
|
+
*
|
|
219
|
+
* It refuses to delete anything that carries a read-only violation: that section
|
|
220
|
+
* is the report of a file we could not account for, and it must outlive the
|
|
221
|
+
* session that found it. Best-effort — a failure here leaves the stub, which is
|
|
222
|
+
* harmless.
|
|
223
|
+
*/
|
|
224
|
+
export async function discardEmptyPlanFile(cwd, planId) {
|
|
225
|
+
try {
|
|
226
|
+
const { body } = await readTaskFile(cwd, planId);
|
|
227
|
+
if (extractSection(body, 'read-only violations') !== null)
|
|
228
|
+
return;
|
|
229
|
+
const decisions = extractSection(body, 'decisions');
|
|
230
|
+
if (decisions !== null && decisions.trim() !== '(none yet)')
|
|
231
|
+
return;
|
|
232
|
+
if (extractSection(body, 'notes') !== null)
|
|
233
|
+
return;
|
|
234
|
+
await fsp.rm(taskFilePath(cwd, planId), { force: true });
|
|
235
|
+
await fsp.rm(path.join(tasksDir(cwd), `${planId}-debug.log`), { force: true });
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* best-effort: an unreadable file is left exactly where it is */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
164
241
|
export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DEPS) {
|
|
165
242
|
await ctx.waitForIdle();
|
|
166
243
|
const cwd = ctx.cwd;
|
|
@@ -204,9 +281,11 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
|
|
|
204
281
|
return;
|
|
205
282
|
}
|
|
206
283
|
if (outcome.kind === 'cancelled') {
|
|
207
|
-
//
|
|
208
|
-
// a
|
|
284
|
+
// Whatever WAS decided stays on disk — a cancelled plan is a record, not
|
|
285
|
+
// a rollback. A session that decided NOTHING leaves nothing at all.
|
|
209
286
|
await updateTaskFrontMatter(cwd, planId, { state: 'cancelled' }).catch(() => { });
|
|
287
|
+
if (outcome.entries.length === 0)
|
|
288
|
+
await discardEmptyPlanFile(cwd, planId);
|
|
210
289
|
const line = outcome.entries.length === 0 ?
|
|
211
290
|
`${planId} cancelled — nothing planned.`
|
|
212
291
|
: `${planId} cancelled — ${outcome.entries.length} entr${outcome.entries.length === 1 ? 'y' : 'ies'} kept in .pi-tasks/${planId}.md`;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only contract for /task-plan.
|
|
3
|
+
*
|
|
4
|
+
* Planning happens BEFORE the work is agreed, so a plan session must leave the
|
|
5
|
+
* project exactly as it found it: no file created, edited, or deleted. Nothing is
|
|
6
|
+
* built yet, so any artifact a plan produced would be an artifact nobody approved
|
|
7
|
+
* — and it would sit in the tree looking like part of the change the user is
|
|
8
|
+
* still deciding whether to make.
|
|
9
|
+
*
|
|
10
|
+
* Two layers, because one of them is somebody else's flag:
|
|
11
|
+
*
|
|
12
|
+
* PREVENTION — the planning children run with {@link PLAN_TOOLS}, an allowlist
|
|
13
|
+
* of exactly one tool. pi applies `--tools` to built-in, extension AND custom
|
|
14
|
+
* tools, so a write tool contributed by a whitelisted extension is excluded too
|
|
15
|
+
* (proven live — scripts/live-task-plan-readonly.ts). This is what actually
|
|
16
|
+
* makes the session read-only.
|
|
17
|
+
*
|
|
18
|
+
* VERIFICATION — after every child, the working tree is compared against the
|
|
19
|
+
* snapshot taken before it. `.pi-tasks/` is excluded (that is where the plan
|
|
20
|
+
* file itself lives — see collectTreeChanges), so what remains is precisely
|
|
21
|
+
* "did planning touch the project". If it ever fires, prevention has a hole:
|
|
22
|
+
* it is reported loudly and recorded in the plan file rather than silently
|
|
23
|
+
* tolerated. It never deletes anything — an unexplained file is a thing to show
|
|
24
|
+
* the user, not a thing to quietly destroy.
|
|
25
|
+
*/
|
|
26
|
+
import type { TreeChangeSummary } from './write-guard.js';
|
|
27
|
+
/**
|
|
28
|
+
* The tool allowlist every /task-plan child runs under. One tool: `read`.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately a named constant with a test pinning it (plan-readonly.test.ts):
|
|
31
|
+
* widening this string is the single edit that would end the read-only guarantee,
|
|
32
|
+
* and it should never happen by accident. The same value grill and clarify use
|
|
33
|
+
* for their generation children — planning has never needed more.
|
|
34
|
+
*/
|
|
35
|
+
export declare const PLAN_TOOLS = "read";
|
|
36
|
+
/**
|
|
37
|
+
* What appeared in `after` that was not already in `before`.
|
|
38
|
+
*
|
|
39
|
+
* A set difference, not an emptiness check: /task-plan runs against whatever the
|
|
40
|
+
* user already has in progress, and a tree that was dirty when planning started
|
|
41
|
+
* is normal — only what planning ADDED is a violation.
|
|
42
|
+
*/
|
|
43
|
+
export declare function newTreeChanges(before: TreeChangeSummary, after: TreeChangeSummary): TreeChangeSummary;
|
|
44
|
+
/** True when the summary names nothing at all. */
|
|
45
|
+
export declare function isEmptyChange(c: TreeChangeSummary): boolean;
|
|
46
|
+
/** One line naming what a planning step touched, for the notify and the record. */
|
|
47
|
+
export declare function formatReadOnlyViolation(step: string, c: TreeChangeSummary): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only contract for /task-plan.
|
|
3
|
+
*
|
|
4
|
+
* Planning happens BEFORE the work is agreed, so a plan session must leave the
|
|
5
|
+
* project exactly as it found it: no file created, edited, or deleted. Nothing is
|
|
6
|
+
* built yet, so any artifact a plan produced would be an artifact nobody approved
|
|
7
|
+
* — and it would sit in the tree looking like part of the change the user is
|
|
8
|
+
* still deciding whether to make.
|
|
9
|
+
*
|
|
10
|
+
* Two layers, because one of them is somebody else's flag:
|
|
11
|
+
*
|
|
12
|
+
* PREVENTION — the planning children run with {@link PLAN_TOOLS}, an allowlist
|
|
13
|
+
* of exactly one tool. pi applies `--tools` to built-in, extension AND custom
|
|
14
|
+
* tools, so a write tool contributed by a whitelisted extension is excluded too
|
|
15
|
+
* (proven live — scripts/live-task-plan-readonly.ts). This is what actually
|
|
16
|
+
* makes the session read-only.
|
|
17
|
+
*
|
|
18
|
+
* VERIFICATION — after every child, the working tree is compared against the
|
|
19
|
+
* snapshot taken before it. `.pi-tasks/` is excluded (that is where the plan
|
|
20
|
+
* file itself lives — see collectTreeChanges), so what remains is precisely
|
|
21
|
+
* "did planning touch the project". If it ever fires, prevention has a hole:
|
|
22
|
+
* it is reported loudly and recorded in the plan file rather than silently
|
|
23
|
+
* tolerated. It never deletes anything — an unexplained file is a thing to show
|
|
24
|
+
* the user, not a thing to quietly destroy.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The tool allowlist every /task-plan child runs under. One tool: `read`.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately a named constant with a test pinning it (plan-readonly.test.ts):
|
|
30
|
+
* widening this string is the single edit that would end the read-only guarantee,
|
|
31
|
+
* and it should never happen by accident. The same value grill and clarify use
|
|
32
|
+
* for their generation children — planning has never needed more.
|
|
33
|
+
*/
|
|
34
|
+
export const PLAN_TOOLS = 'read';
|
|
35
|
+
/**
|
|
36
|
+
* What appeared in `after` that was not already in `before`.
|
|
37
|
+
*
|
|
38
|
+
* A set difference, not an emptiness check: /task-plan runs against whatever the
|
|
39
|
+
* user already has in progress, and a tree that was dirty when planning started
|
|
40
|
+
* is normal — only what planning ADDED is a violation.
|
|
41
|
+
*/
|
|
42
|
+
export function newTreeChanges(before, after) {
|
|
43
|
+
const diff = (a, b) => {
|
|
44
|
+
const seen = new Set(b);
|
|
45
|
+
return a.filter(p => !seen.has(p));
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
modified: diff(after.modified, before.modified),
|
|
49
|
+
added: diff(after.added, before.added),
|
|
50
|
+
deleted: diff(after.deleted, before.deleted)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** True when the summary names nothing at all. */
|
|
54
|
+
export function isEmptyChange(c) {
|
|
55
|
+
return c.modified.length + c.added.length + c.deleted.length === 0;
|
|
56
|
+
}
|
|
57
|
+
/** One line naming what a planning step touched, for the notify and the record. */
|
|
58
|
+
export function formatReadOnlyViolation(step, c) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (c.added.length > 0)
|
|
61
|
+
parts.push(`created ${c.added.join(', ')}`);
|
|
62
|
+
if (c.modified.length > 0)
|
|
63
|
+
parts.push(`modified ${c.modified.join(', ')}`);
|
|
64
|
+
if (c.deleted.length > 0)
|
|
65
|
+
parts.push(`deleted ${c.deleted.join(', ')}`);
|
|
66
|
+
return `/task-plan is read-only, but the ${step} step ${parts.join('; ')}`;
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|