@dreki-gg/pi-plan-mode 0.19.0 → 0.20.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.20.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Hide the `technical-options` skill from model auto-invocation (`disable-model-invocation: true`). It carried always-on system-prompt token cost but never auto-triggered in practice. It remains available on demand via `/skill:technical-options`. `planning-context` and `visual-prototype` are unchanged.
8
+
9
+ ## 0.20.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 37d0f37: Add `update_tasks` batch tool: mark several plan tasks done/skipped in a single
14
+ call with one coalesced `tasks.jsonl` write (avoids the file-write contention
15
+ from repeated `update_task` calls). Each item is `{ task_id, status, notes? }`;
16
+ blocking stays single-task via `update_task`.
17
+
3
18
  ## 0.19.0
4
19
 
5
20
  ### Minor Changes
package/README.md CHANGED
@@ -27,6 +27,7 @@ pi install npm:@dreki-gg/pi-questionnaire
27
27
  | Command | `/todos` | Show current plan progress |
28
28
  | Shortcut | `Ctrl+Alt+P` | Toggle plan mode |
29
29
  | Tool | `update_task` | Mark a task done / skipped / blocked |
30
+ | Tool | `update_tasks` | Mark several tasks done / skipped in one call |
30
31
  | Tool | `add_task` | Capture a discovered follow-up (deferred) |
31
32
  | Tool | `plan_status` | Read-only snapshot; progress table when many plans are active |
32
33
  | Tool | `update_plan` | Close/reopen a plan: done, superseded, abandoned, in-progress |
@@ -0,0 +1,130 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { registerUpdateTasksTool } from '../tools/update-tasks.js';
3
+ import type { PlanData, TaskRecord, TaskStatus } from '../types.js';
4
+
5
+ const now = '2026-05-27T12:00:00.000Z';
6
+
7
+ interface CapturedTool {
8
+ execute: (
9
+ id: string,
10
+ params: {
11
+ updates: Array<{ task_id: string; status: 'done' | 'skipped'; notes?: string }>;
12
+ plan?: string;
13
+ },
14
+ ) => Promise<{ content?: Array<{ text: string }>; details?: unknown; terminate?: boolean }>;
15
+ }
16
+
17
+ function setup(plan: PlanData | undefined, candidates: string[] = []) {
18
+ let tool: CapturedTool | undefined;
19
+ const updates: Array<{ taskId: string; status: string; notes?: string }> = [];
20
+ // Count how many times the coalesced write callback fires — must be ≤ 1 per call.
21
+ let writeCount = 0;
22
+ const pi = {
23
+ registerTool: (config: CapturedTool) => {
24
+ tool = config;
25
+ },
26
+ } as unknown as Parameters<typeof registerUpdateTasksTool>[0];
27
+
28
+ registerUpdateTasksTool(pi, {
29
+ resolvePlan: async () => ({ plan, candidates }),
30
+ onTasksUpdated: (batch) => {
31
+ writeCount += 1;
32
+ for (const { taskId, status, notes } of batch) {
33
+ const target = plan?.tasks.find((candidate) => candidate.id === taskId);
34
+ if (target) target.status = status;
35
+ updates.push({ taskId, status, notes });
36
+ }
37
+ },
38
+ });
39
+
40
+ return { tool: tool!, updates, getWriteCount: () => writeCount };
41
+ }
42
+
43
+ const basePlan = (tasks: TaskRecord[]): PlanData => ({
44
+ title: 'Plan',
45
+ planName: 'plan',
46
+ handoff: '',
47
+ tasks,
48
+ });
49
+ const planTask = (id: string, status: TaskStatus = 'pending'): TaskRecord => ({
50
+ _type: 'task',
51
+ id,
52
+ description: `task ${id}`,
53
+ status,
54
+ origin: 'plan',
55
+ created_at: now,
56
+ updated_at: now,
57
+ });
58
+
59
+ describe('update_tasks tool', () => {
60
+ test('marks multiple pending tasks (mixed statuses) in a SINGLE write and reports progress', async () => {
61
+ const { tool, updates, getWriteCount } = setup(
62
+ basePlan([planTask('t-001'), planTask('t-002'), planTask('t-003')]),
63
+ );
64
+ const result = await tool.execute('c', {
65
+ updates: [
66
+ { task_id: 't-001', status: 'done', notes: 'a' },
67
+ { task_id: 't-002', status: 'skipped', notes: 'b' },
68
+ ],
69
+ });
70
+ expect(updates).toEqual([
71
+ { taskId: 't-001', status: 'done', notes: 'a' },
72
+ { taskId: 't-002', status: 'skipped', notes: 'b' },
73
+ ]);
74
+ // The whole point of the batch tool: one coalesced write, not one per task.
75
+ expect(getWriteCount()).toBe(1);
76
+ expect(result.content?.[0]?.text).toMatch(/Progress: 2\/3/);
77
+ expect(result.terminate).toBeUndefined();
78
+ });
79
+
80
+ test('soft-skips (no throw) when there is no active plan', async () => {
81
+ const { tool, updates } = setup(undefined, ['alpha', 'beta']);
82
+ const result = await tool.execute('c', { updates: [{ task_id: 't-001', status: 'done' }] });
83
+ expect((result.details as { skipped?: boolean }).skipped).toBe(true);
84
+ expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
85
+ expect(updates).toHaveLength(0);
86
+ });
87
+
88
+ test('no write when every item is a no-op or not_found', async () => {
89
+ const { tool, getWriteCount } = setup(basePlan([planTask('t-001', 'done')]));
90
+ await tool.execute('c', {
91
+ updates: [
92
+ { task_id: 't-001', status: 'done' },
93
+ { task_id: 't-999', status: 'done' },
94
+ ],
95
+ });
96
+ expect(getWriteCount()).toBe(0);
97
+ });
98
+
99
+ test('mixed batch: valid applied, unknown id reported as not_found', async () => {
100
+ const { tool, updates } = setup(basePlan([planTask('t-001'), planTask('t-002')]));
101
+ const result = await tool.execute('c', {
102
+ updates: [
103
+ { task_id: 't-001', status: 'done' },
104
+ { task_id: 't-999', status: 'done' },
105
+ ],
106
+ });
107
+ expect(updates).toEqual([{ taskId: 't-001', status: 'done', notes: undefined }]);
108
+ const details = result.details as { results?: Array<{ task_id: string; outcome: string }> };
109
+ expect(details.results?.find((r) => r.task_id === 't-999')?.outcome).toBe('not_found');
110
+ });
111
+
112
+ test('idempotent: re-marking the same status is a per-item no-op', async () => {
113
+ const { tool, updates } = setup(basePlan([planTask('t-001', 'done'), planTask('t-002')]));
114
+ const result = await tool.execute('c', {
115
+ updates: [
116
+ { task_id: 't-001', status: 'done' },
117
+ { task_id: 't-002', status: 'done' },
118
+ ],
119
+ });
120
+ expect(updates).toEqual([{ taskId: 't-002', status: 'done', notes: undefined }]);
121
+ const details = result.details as { results?: Array<{ task_id: string; outcome: string }> };
122
+ expect(details.results?.find((r) => r.task_id === 't-001')?.outcome).toBe('noop');
123
+ });
124
+
125
+ test('corrects an already-resolved task (done → skipped)', async () => {
126
+ const { tool, updates } = setup(basePlan([planTask('t-001', 'done')]));
127
+ await tool.execute('c', { updates: [{ task_id: 't-001', status: 'skipped' }] });
128
+ expect(updates).toEqual([{ taskId: 't-001', status: 'skipped', notes: undefined }]);
129
+ });
130
+ });
@@ -26,6 +26,7 @@ export const EXEC_TOOLS = [
26
26
  'edit',
27
27
  'write',
28
28
  'update_task',
29
+ 'update_tasks',
29
30
  'add_task',
30
31
  'plan_status',
31
32
  'update_plan',
@@ -26,7 +26,7 @@ import {
26
26
  EXEC_MODEL,
27
27
  EXEC_THINKING,
28
28
  } from './constants.js';
29
- import type { ThinkingLevel } from './types.js';
29
+ import type { ThinkingLevel, TaskStatus } from './types.js';
30
30
  import { PlanModeState } from './state.js';
31
31
  import { makePlanRuntime } from './effects/runtime.js';
32
32
  import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
@@ -43,6 +43,7 @@ import { collectPlanDrift } from './reconcile.js';
43
43
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
44
44
  import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
45
45
  import { registerUpdateTaskTool } from './tools/update-task.js';
46
+ import { registerUpdateTasksTool } from './tools/update-tasks.js';
46
47
  import { registerAddTaskTool } from './tools/add-task.js';
47
48
  import { registerPlanStatusTool } from './tools/plan-status.js';
48
49
  import { registerUpdatePlanTool } from './tools/update-plan.js';
@@ -72,15 +73,64 @@ export default function planMode(pi: ExtensionAPI): void {
72
73
 
73
74
  registerPreviewPrototypeTool(pi, runPlanIO);
74
75
 
76
+ // Shared task-write closure: mutate the in-memory task, persist tasks.jsonl,
77
+ // and re-derive registry status. Used by both update_task and update_tasks.
78
+ const onTaskUpdated = async (
79
+ taskId: string,
80
+ status: Exclude<TaskStatus, 'pending'>,
81
+ notes?: string,
82
+ ) => {
83
+ if (!state.plan || !state.planDir) return;
84
+ const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
85
+ if (!task) return;
86
+ task.status = status;
87
+ task.updated_at = new Date().toISOString();
88
+ if (notes) task.notes = notes;
89
+ await runPlanIO(
90
+ writeTasksJsonl(
91
+ state.planDir,
92
+ {
93
+ _type: 'meta',
94
+ title: state.plan.title,
95
+ plan_name: state.plan.planName,
96
+ created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
97
+ },
98
+ state.plan.tasks,
99
+ ),
100
+ );
101
+ // FEEDBACK #1: registry status is a projection of task state. Re-derive it
102
+ // on every task write so completion is decoupled from in-session execution
103
+ // — cross-session / disk-tracked task updates now close the plan too.
104
+ await runPlanIO(
105
+ reconcilePlanStatus(
106
+ state.plan.planName,
107
+ isPlanFinalizable(state.plan.tasks),
108
+ state.plan.title,
109
+ ),
110
+ );
111
+ state.persist(pi);
112
+ };
113
+
75
114
  registerUpdateTaskTool(pi, {
76
115
  resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
77
- onTaskUpdated: async (taskId, status, notes) => {
116
+ onTaskUpdated,
117
+ });
118
+
119
+ registerUpdateTasksTool(pi, {
120
+ resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
121
+ // Coalesced batch write: mutate every task in memory first, then perform a
122
+ // SINGLE tasks.jsonl write + ONE registry reconcile. This is the whole point
123
+ // of update_tasks — repeated single-task writes caused file-write contention.
124
+ onTasksUpdated: async (updates) => {
78
125
  if (!state.plan || !state.planDir) return;
79
- const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
80
- if (!task) return;
81
- task.status = status;
82
- task.updated_at = new Date().toISOString();
83
- if (notes) task.notes = notes;
126
+ const now = new Date().toISOString();
127
+ for (const { taskId, status, notes } of updates) {
128
+ const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
129
+ if (!task) continue;
130
+ task.status = status;
131
+ task.updated_at = now;
132
+ if (notes) task.notes = notes;
133
+ }
84
134
  await runPlanIO(
85
135
  writeTasksJsonl(
86
136
  state.planDir,
@@ -88,14 +138,11 @@ export default function planMode(pi: ExtensionAPI): void {
88
138
  _type: 'meta',
89
139
  title: state.plan.title,
90
140
  plan_name: state.plan.planName,
91
- created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
141
+ created_at: state.plan.tasks[0]?.created_at ?? now,
92
142
  },
93
143
  state.plan.tasks,
94
144
  ),
95
145
  );
96
- // FEEDBACK #1: registry status is a projection of task state. Re-derive it
97
- // on every task write so completion is decoupled from in-session execution
98
- // — cross-session / disk-tracked `update_task` now closes the plan too.
99
146
  await runPlanIO(
100
147
  reconcilePlanStatus(
101
148
  state.plan.planName,
@@ -0,0 +1,172 @@
1
+ /**
2
+ * update_tasks tool — batch sibling of update_task.
3
+ *
4
+ * Marks several tasks done/skipped in a single call. Unlike update_task it
5
+ * never blocks (blocking pauses the turn, which is ambiguous mid-batch) and
6
+ * therefore never terminates. Each item mirrors update_task's per-task
7
+ * semantics: idempotent no-ops, corrections, and soft not_found handling.
8
+ */
9
+
10
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
11
+ import { StringEnum } from '@earendil-works/pi-ai';
12
+ import { Text } from '@earendil-works/pi-tui';
13
+ import { Type } from 'typebox';
14
+ import type { TaskStatus } from '../types.js';
15
+ import type { ResolvedPlan } from '../resolve-plan.js';
16
+
17
+ export interface UpdateTasksCallbacks {
18
+ /** Resolve the active plan, attaching from disk when none is in memory. */
19
+ resolvePlan: (opts?: { name?: string }) => Promise<ResolvedPlan>;
20
+ /**
21
+ * Apply ALL accepted updates in one shot. The implementation mutates the
22
+ * in-memory tasks, then performs a SINGLE coalesced tasks.jsonl write and
23
+ * registry reconcile — avoiding the per-task write storm that caused file
24
+ * write contention with repeated update_task calls.
25
+ */
26
+ onTasksUpdated: (
27
+ updates: Array<{
28
+ taskId: string;
29
+ status: Exclude<TaskStatus, 'pending' | 'blocked' | 'deferred'>;
30
+ notes?: string;
31
+ }>,
32
+ ) => void | Promise<void>;
33
+ }
34
+
35
+ /** A non-terminating tool result — task tracking must never derail real work. */
36
+ function soft(text: string, details: Record<string, unknown>) {
37
+ return { content: [{ type: 'text' as const, text }], details };
38
+ }
39
+
40
+ type ItemOutcome = 'updated' | 'noop' | 'not_found';
41
+
42
+ interface ItemResult {
43
+ task_id: string;
44
+ outcome: ItemOutcome;
45
+ status?: 'done' | 'skipped';
46
+ prior?: string;
47
+ }
48
+
49
+ export function registerUpdateTasksTool(pi: ExtensionAPI, callbacks: UpdateTasksCallbacks): void {
50
+ pi.registerTool({
51
+ name: 'update_tasks',
52
+ label: 'Update Tasks',
53
+ description:
54
+ 'Mark several plan tasks done or skipped in one call. Use when you finished multiple tasks in a turn. Cannot block — use update_task for that.',
55
+ promptSnippet: 'Mark multiple plan tasks done or skipped in one call',
56
+ promptGuidelines: [
57
+ 'Use update_tasks to resolve several finished tasks at once instead of repeated update_task calls.',
58
+ 'Each item is { task_id, status: done|skipped, notes? }. To block a task, use update_task instead.',
59
+ 'Always include notes summarizing what was done or why skipped.',
60
+ ],
61
+ parameters: Type.Object({
62
+ updates: Type.Array(
63
+ Type.Object({
64
+ task_id: Type.String({ description: 'Task ID (for example, t-001)' }),
65
+ status: StringEnum(['done', 'skipped'] as const),
66
+ notes: Type.Optional(
67
+ Type.String({ description: 'What was done or why skipped' }),
68
+ ),
69
+ }),
70
+ { description: 'Tasks to mark, each with its own status and notes', minItems: 1 },
71
+ ),
72
+ plan: Type.Optional(
73
+ Type.String({
74
+ description:
75
+ 'Plan name (or .plans/<name>) to target. Only needed to disambiguate when multiple plans are in-progress; otherwise the active / sole in-progress plan is used.',
76
+ }),
77
+ ),
78
+ }),
79
+
80
+ async execute(_toolCallId, params) {
81
+ const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
82
+ // No active plan is a tracking miss, not an error: return a soft result
83
+ // (non-terminating) so the agent keeps doing the real work.
84
+ if (!plan) {
85
+ const hint =
86
+ candidates.length > 1
87
+ ? ` Multiple in-progress plans (${candidates.join(', ')}) — pass { plan: "<name>" } to choose.`
88
+ : ' No in-progress plan found in .plans/plans.jsonl.';
89
+ return soft(`Skipped task tracking — no active plan.${hint}`, {
90
+ skipped: true,
91
+ candidates,
92
+ });
93
+ }
94
+
95
+ const results: ItemResult[] = [];
96
+ const accepted: Array<{ taskId: string; status: 'done' | 'skipped'; notes?: string }> = [];
97
+ for (const update of params.updates) {
98
+ const task = plan.tasks.find((candidate) => candidate.id === update.task_id);
99
+ if (!task) {
100
+ results.push({ task_id: update.task_id, outcome: 'not_found' });
101
+ continue;
102
+ }
103
+ // Idempotent: re-marking the same status is a no-op (safe to retry).
104
+ if (task.status === update.status) {
105
+ results.push({ task_id: update.task_id, outcome: 'noop', status: update.status });
106
+ continue;
107
+ }
108
+ // A different status on an already-resolved task is a CORRECTION — apply
109
+ // it. The plan queue recomputes from the new status.
110
+ results.push({
111
+ task_id: update.task_id,
112
+ outcome: 'updated',
113
+ status: update.status,
114
+ prior: task.status,
115
+ });
116
+ accepted.push({ taskId: update.task_id, status: update.status, notes: update.notes });
117
+ }
118
+
119
+ // SINGLE coalesced write: apply every accepted change in one callback,
120
+ // which performs exactly one tasks.jsonl write + one registry reconcile.
121
+ if (accepted.length > 0) await callbacks.onTasksUpdated(accepted);
122
+
123
+ const updated = results.filter((r) => r.outcome === 'updated');
124
+ const noop = results.filter((r) => r.outcome === 'noop');
125
+ const notFound = results.filter((r) => r.outcome === 'not_found');
126
+
127
+ const done = plan.tasks.filter((candidate) => candidate.status === 'done').length;
128
+ const skipped = plan.tasks.filter((candidate) => candidate.status === 'skipped').length;
129
+ const resolved = done + skipped;
130
+ const next = plan.tasks.find((candidate) => candidate.status === 'pending');
131
+
132
+ const parts = [`Updated ${updated.length} task(s).`];
133
+ if (noop.length) parts.push(`${noop.length} no-op.`);
134
+ if (notFound.length) {
135
+ const ids = plan.tasks.map((candidate) => candidate.id).join(', ');
136
+ parts.push(`Not found: ${notFound.map((r) => r.task_id).join(', ')} (valid: ${ids}).`);
137
+ }
138
+ let text = parts.join(' ');
139
+ text += ` Progress: ${resolved}/${plan.tasks.length}`;
140
+ text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
141
+
142
+ // Never terminate: update_tasks has no blocked path, and completion is
143
+ // handled out-of-band by the agent_end handler in index.ts.
144
+ return { content: [{ type: 'text' as const, text }], details: { results } };
145
+ },
146
+
147
+ renderCall(args, theme) {
148
+ const updates = (args as { updates?: Array<{ task_id?: string }> }).updates ?? [];
149
+ let content = theme.fg('toolTitle', theme.bold('update_tasks '));
150
+ content += theme.fg('muted', updates.map((u) => u.task_id ?? '?').join(', '));
151
+ return new Text(content, 0, 0);
152
+ },
153
+
154
+ renderResult(result, _options, theme) {
155
+ const details = result.details as { results?: ItemResult[] } | undefined;
156
+ const results = details?.results ?? [];
157
+ if (results.length === 0) return new Text(theme.fg('dim', 'Updated'), 0, 0);
158
+ const icon: Record<string, string> = {
159
+ done: theme.fg('success', '✓'),
160
+ skipped: theme.fg('warning', '⊘'),
161
+ };
162
+ const line = results
163
+ .map((r) => {
164
+ if (r.outcome === 'not_found') return theme.fg('error', `✗ ${r.task_id}`);
165
+ if (r.outcome === 'noop') return theme.fg('dim', `= ${r.task_id}`);
166
+ return `${icon[r.status ?? ''] ?? ''} ${r.task_id}`;
167
+ })
168
+ .join(' ');
169
+ return new Text(line, 0, 0);
170
+ },
171
+ });
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: technical-options
3
3
  description: Generate and rank competing options for a technical decision using parallel evaluators. Use when the user wants a structured comparison of implementation approaches, architecture alternatives, or engineering tradeoffs before choosing one. Not for binary yes/no or pure preference decisions.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Technical Options