@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 +15 -0
- package/README.md +1 -0
- package/extensions/plan-mode/__tests__/update-tasks.test.ts +130 -0
- package/extensions/plan-mode/constants.ts +1 -0
- package/extensions/plan-mode/index.ts +58 -11
- package/extensions/plan-mode/tools/update-tasks.ts +172 -0
- package/package.json +1 -1
- package/skills/technical-options/SKILL.md +1 -0
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,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
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 ??
|
|
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,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
|