pi-dag-workflow 0.0.0-stage → 0.7.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/CHANGELOG.md +454 -0
- package/LICENSE +21 -0
- package/README.md +211 -2
- package/README.zh-CN.md +212 -0
- package/docs/COMMUNICATION.md +129 -0
- package/docs/RELEASING.md +66 -0
- package/package.json +70 -4
- package/src/agents/commands/child.ts +148 -0
- package/src/agents/commands/register.ts +404 -0
- package/src/agents/domain/context.ts +183 -0
- package/src/agents/domain/diagnostics.ts +32 -0
- package/src/agents/domain/owner.ts +448 -0
- package/src/agents/domain/profiles.ts +216 -0
- package/src/agents/domain/runtime.ts +603 -0
- package/src/agents/domain/schema.ts +15 -0
- package/src/agents/index.ts +5 -0
- package/src/agents/io/contracts.ts +50 -0
- package/src/agents/io/host.ts +16 -0
- package/src/agents/io/notices.ts +27 -0
- package/src/agents/io/persistence.ts +210 -0
- package/src/agents/io/process.ts +100 -0
- package/src/agents/io/profile-file.ts +16 -0
- package/src/agents/io/skills.ts +46 -0
- package/src/goal/commands/register.ts +235 -0
- package/src/goal/domain/failure.ts +14 -0
- package/src/goal/domain/owner.ts +575 -0
- package/src/goal/domain/policy.ts +8 -0
- package/src/goal/domain/prompt.ts +57 -0
- package/src/goal/domain/state.ts +176 -0
- package/src/goal/index.ts +5 -0
- package/src/goal/io/host.ts +8 -0
- package/src/plan/commands/register.ts +101 -0
- package/src/plan/domain/owner.ts +51 -0
- package/src/plan/domain/policy.ts +27 -0
- package/src/plan/domain/state.ts +15 -0
- package/src/plan/index.ts +13 -0
- package/src/plan/io/records.ts +3 -0
- package/src/retry/commands/menu.ts +158 -0
- package/src/retry/commands/register.ts +26 -0
- package/src/retry/domain/owner.ts +418 -0
- package/src/retry/domain/state.ts +71 -0
- package/src/retry/index.ts +5 -0
- package/src/retry/io/host.ts +15 -0
- package/src/shared/agent-view.ts +18 -0
- package/src/shared/completion.ts +111 -0
- package/src/shared/config-file.ts +106 -0
- package/src/shared/config.ts +112 -0
- package/src/shared/dag/cache.ts +26 -0
- package/src/shared/dag/graph.ts +170 -0
- package/src/shared/dag/layout.ts +130 -0
- package/src/shared/goal-view.ts +2 -0
- package/src/shared/i18n.ts +62 -0
- package/src/shared/message-types.ts +12 -0
- package/src/shared/messages.en.ts +1290 -0
- package/src/shared/passive-message.ts +50 -0
- package/src/shared/pi-cli.ts +29 -0
- package/src/shared/profile-launch.ts +110 -0
- package/src/shared/state-types.ts +5 -0
- package/src/shared/task-schema.ts +39 -0
- package/src/shared/task-types.ts +25 -0
- package/src/shared/terminal.ts +19 -0
- package/src/shared/tool-info.ts +8 -0
- package/src/shared/ui/filter.ts +144 -0
- package/src/shared/ui/menu.ts +447 -0
- package/src/shared/ui/panel.ts +768 -0
- package/src/shared/ui/presentation.ts +624 -0
- package/src/shared/ui/render-cache.ts +63 -0
- package/src/shared/ui/render.ts +641 -0
- package/src/shared/ui/symbols.ts +136 -0
- package/src/team/commands/bootstrap.ts +52 -0
- package/src/team/commands/controls.ts +91 -0
- package/src/team/commands/history.ts +100 -0
- package/src/team/commands/menu.ts +455 -0
- package/src/team/commands/register.ts +1321 -0
- package/src/team/domain/inbox.ts +178 -0
- package/src/team/domain/launcher.ts +355 -0
- package/src/team/domain/lifecycle.ts +34 -0
- package/src/team/domain/teams.ts +161 -0
- package/src/team/index.ts +4 -0
- package/src/team/io/inbox.ts +255 -0
- package/src/team/io/launcher.ts +270 -0
- package/src/team/io/lock.ts +19 -0
- package/src/team/io/registry.ts +477 -0
- package/src/team/io/runtime.ts +353 -0
- package/src/team/io/session-activity.ts +185 -0
- package/src/team/io/session-delete.ts +98 -0
- package/src/team/io/session-history.ts +153 -0
- package/src/team/io/session-tail.ts +132 -0
- package/src/team/io/sid-lock.ts +115 -0
- package/src/team/io/sid-session.ts +98 -0
- package/src/todos/commands/checkpoints.ts +31 -0
- package/src/todos/commands/register.ts +395 -0
- package/src/todos/domain/checkpoint-state.ts +32 -0
- package/src/todos/domain/checkpoint-summary.ts +146 -0
- package/src/todos/domain/cleanup.ts +25 -0
- package/src/todos/domain/owner.ts +109 -0
- package/src/todos/domain/presets.ts +192 -0
- package/src/todos/domain/state.ts +287 -0
- package/src/todos/index.ts +15 -0
- package/src/todos/io/records.ts +8 -0
- package/src/ui/index.ts +4 -0
- package/src/workflow/commands/connect.ts +81 -0
- package/src/workflow/commands/view.ts +121 -0
- package/src/workflow/domain/features.ts +5 -0
- package/src/workflow/domain/view-owner.ts +18 -0
- package/src/workflow/domain/view-state.ts +12 -0
- package/src/workflow/index.ts +225 -0
- package/src/workflow/io/session.ts +48 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { translatedTaskStatusLabels as statusLabel } from '../../shared/state-types.ts';
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { Text, truncateToWidth, stripTerminalSequences } from '@earendil-works/pi-tui';
|
|
4
|
+
import { CHECKPOINT_RULE } from './checkpoints.ts';
|
|
5
|
+
import type { TodoResult } from "../domain/state.ts";
|
|
6
|
+
import { TodoParamsSchema, type TodoParams } from "../../shared/task-schema.ts";
|
|
7
|
+
import { TODO_FILTERS, type ClearScope, type TodoFilter, type Todo, type WorkflowState } from "../../shared/task-types.ts";
|
|
8
|
+
import type { Preset } from '../domain/presets.ts';
|
|
9
|
+
import { choose, readView, withMenuScope, panelOptions, panelInput, panelEdit, panelConfirm, type MenuCursor, type PanelNavigation } from '../../shared/ui/menu.ts';
|
|
10
|
+
import { todoSummary, toolDisplay } from '../../shared/ui/presentation.ts';
|
|
11
|
+
import { filteredView } from '../../shared/ui/filter.ts';
|
|
12
|
+
import { renderDag, renderTasks, renderTaskTree } from "../../shared/ui/render.ts";
|
|
13
|
+
import { clean, notify } from "../../shared/terminal.ts";
|
|
14
|
+
import type { AgentView } from "../../shared/agent-view.ts";
|
|
15
|
+
import { workflowNamespace, sessionMutation } from '../../shared/tool-info.ts';
|
|
16
|
+
import { completeArguments, type CompletionSpec } from '../../shared/completion.ts';
|
|
17
|
+
import { chinese, type Translator } from '../../shared/i18n.ts';
|
|
18
|
+
import { glyph } from '../../shared/ui/symbols.ts';
|
|
19
|
+
|
|
20
|
+
interface Hooks { spawn?(task: Todo, ctx: ExtensionContext): Promise<void>; panel?: PanelNavigation; jobs?(): readonly AgentView[]; previewClear?(scope: ClearScope): string; msg?: Translator; state(): WorkflowState; protected(): boolean; mutate(params: TodoParams, ctx: ExtensionContext, source?: 'tool' | 'command'): TodoResult; commit(next: WorkflowState, ctx: ExtensionContext): void; reset(ctx: ExtensionContext): void; show(ctx: ExtensionContext, view: 'list' | 'dag', filter?: TodoFilter): Promise<void>; userPrompt?<T>(title: string, run: () => Promise<T>): Promise<T>; presets?(): Preset[]; refreshPresets?(): Promise<void>; retryStatus?(): unknown }
|
|
21
|
+
const asId = (text?: string, msg: Translator = chinese): number => {
|
|
22
|
+
if (!text || !/^#?[1-9]\d*$/.test(text)) throw new Error(msg('请给出任务编号,例如 #2'));
|
|
23
|
+
return Number(text.replace(/^#/, ''));
|
|
24
|
+
};
|
|
25
|
+
/** Register this resource's tools/commands using the runtime-shared state. */
|
|
26
|
+
export function registerTodos(pi: ExtensionAPI, hooks: Hooks): void {
|
|
27
|
+
const { mutate, commit, show } = hooks;
|
|
28
|
+
const msg = hooks.msg ?? chinese;
|
|
29
|
+
const dialogs = hooks.panel ?? { input: panelInput, edit: panelEdit, confirm: panelConfirm };
|
|
30
|
+
let sessionEpoch = 0;
|
|
31
|
+
let clearPrompt: { epoch: number } | undefined;
|
|
32
|
+
const resetPrompt = () => { sessionEpoch++; clearPrompt = undefined; };
|
|
33
|
+
pi.on('session_start', resetPrompt); pi.on('session_tree', resetPrompt); pi.on('session_shutdown', resetPrompt);
|
|
34
|
+
const completion: CompletionSpec = {
|
|
35
|
+
actions: [
|
|
36
|
+
{ action: 'add', description: msg('新建任务:add 标题 [--after 1,2]') },
|
|
37
|
+
{ action: 'start', description: msg('开始任务:start #编号') },
|
|
38
|
+
{ action: 'done', description: msg('完成任务:done #编号') },
|
|
39
|
+
{ action: 'pending', description: msg('回到待执行:pending #编号') },
|
|
40
|
+
{ action: 'failed', description: msg('标记失败:failed #编号') },
|
|
41
|
+
{ action: 'cancelled', description: msg('标记取消:cancelled #编号') },
|
|
42
|
+
{ action: 'delete', description: msg('删除任务:delete #编号') },
|
|
43
|
+
{ action: 'edit', description: msg('修改标题:edit #编号 新标题') },
|
|
44
|
+
{ action: 'list', description: msg('查看任务列表') },
|
|
45
|
+
{ action: 'view', description: msg('切换视图:view list/dag [full/pending/completed/failed/cancelled]') },
|
|
46
|
+
{ action: 'paths', description: msg('按路径树展示') },
|
|
47
|
+
{ action: 'flat', description: msg('按平铺展示') },
|
|
48
|
+
{ action: 'show', description: msg('显示任务面板') },
|
|
49
|
+
{ action: 'hide', description: msg('隐藏任务面板') },
|
|
50
|
+
{ action: 'clear', description: msg('清理记录:clear completed/closed/all,省略打开菜单') },
|
|
51
|
+
{ action: 'presets', description: msg('查看片段预设:presets') },
|
|
52
|
+
{ action: 'apply', description: msg('套用片段:apply 名称 [键=值 ...]') },
|
|
53
|
+
{ action: 'reset', description: msg('重开片段:reset 名称 [步骤键]') },
|
|
54
|
+
{ action: 'help', description: msg('查看命令帮助') },
|
|
55
|
+
],
|
|
56
|
+
subActions: {
|
|
57
|
+
view: [
|
|
58
|
+
{ action: 'list', description: msg('列表视图') },
|
|
59
|
+
{ action: 'dag', description: msg('依赖图视图') },
|
|
60
|
+
],
|
|
61
|
+
clear: [
|
|
62
|
+
{ action: 'completed', description: msg('清理已完成记录') },
|
|
63
|
+
{ action: 'closed', description: msg('清理已完成/失败/取消记录') },
|
|
64
|
+
{ action: 'all', description: msg('清空全部可清理记录') },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
freeText: ['add'],
|
|
68
|
+
arguments: (tokens, trailing) => {
|
|
69
|
+
if (tokens[0] !== 'view' || !['list', 'dag'].includes(tokens[1] ?? '') || tokens.length < 2 || tokens.length > 3 || tokens.length === 3 && trailing) return null;
|
|
70
|
+
const typed = tokens.length === 3 ? tokens[2]! : '';
|
|
71
|
+
if (tokens.length === 2 && !trailing) return null;
|
|
72
|
+
return TODO_FILTERS.filter((name) => name.startsWith(typed)).map((name) => ({ token: `view ${tokens[1]} ${name}`, label: name }));
|
|
73
|
+
},
|
|
74
|
+
tokens: (action) => {
|
|
75
|
+
if (action === 'list') return TODO_FILTERS.map((name) => ({ token: name, label: name }));
|
|
76
|
+
if (['apply', 'reset'].includes(action)) return (hooks.presets?.() ?? []).map((preset) => ({ token: preset.name, label: preset.name, ...(preset.description ? { description: preset.description } : {}) }));
|
|
77
|
+
if (!['start', 'done', 'pending', 'failed', 'cancelled', 'delete', 'edit'].includes(action)) return null;
|
|
78
|
+
const tasks = hooks.state().tasks;
|
|
79
|
+
const keepCompleted = action === 'delete' || action === 'edit';
|
|
80
|
+
return tasks
|
|
81
|
+
.filter((task) => task.status !== 'deleted' && (keepCompleted || task.status !== 'completed'))
|
|
82
|
+
.map((task) => ({ token: `#${task.id}`, label: `#${task.id} ${task.subject}`, description: statusLabel(msg)[task.status] }));
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const panel = (id: string) => panelOptions(hooks.panel, { id: 'todos', label: 'Todos' }, `todos:${id}`);
|
|
86
|
+
async function dagPanel(ctx: ExtensionContext, filter = hooks.state().filter ?? 'full', selected?: readonly Todo[]) {
|
|
87
|
+
const epoch = sessionEpoch;
|
|
88
|
+
await readView(ctx, 'Todos · DAG', renderDag(selected ? { ...hooks.state(), tasks: [...selected] } : hooks.state(), Math.max(16, (process.stdout.columns ?? 80) - 8), ctx.ui.theme, undefined, hooks.jobs?.() ?? [], { msg, filter }).join('\n') || msg('暂无任务'), { msg, panel: panel('dag'), isCurrent: () => epoch === sessionEpoch && !hooks.panel?.requested() });
|
|
89
|
+
}
|
|
90
|
+
async function tasksMenu(ctx: ExtensionContext, initialClearScope: ClearScope = 'completed') {
|
|
91
|
+
const epoch = sessionEpoch, cursor: MenuCursor = {}, detailCursor: MenuCursor = {};
|
|
92
|
+
const current = () => epoch === sessionEpoch && !hooks.panel?.requested();
|
|
93
|
+
await hooks.refreshPresets?.(); if (!current()) return;
|
|
94
|
+
let selectedFilter: TodoFilter | 'in_progress' = hooks.state().filter ?? 'full';
|
|
95
|
+
let clearScope: ClearScope = initialClearScope;
|
|
96
|
+
const presetVariables = new Map<string, Record<string, string>>();
|
|
97
|
+
const taskFilters = ['full', 'pending', 'in_progress', 'completed', 'failed', 'cancelled'] as const;
|
|
98
|
+
const owned = <T>(title: string, run: () => Promise<T>) => hooks.userPrompt ? hooks.userPrompt(title, run) : run();
|
|
99
|
+
const targets = new Map<string, { task: Todo; action: string }>();
|
|
100
|
+
const actions = (task: Todo) => {
|
|
101
|
+
const blocked = task.blockedBy.filter(id => hooks.state().tasks.find(value => value.id === id)?.status !== 'completed');
|
|
102
|
+
// A prerequisite that is gone (deleted or never created) can never complete: say so instead of
|
|
103
|
+
// leaving the row looking like something that will eventually unblock.
|
|
104
|
+
const gone = blocked.filter(id => { const value = hooks.state().tasks.find(task => task.id === id); return !value || value.status === 'deleted'; });
|
|
105
|
+
const waiting = blocked.filter(id => !gone.includes(id));
|
|
106
|
+
const startReason = hooks.state().plan ? msg('Plan 模式为只读;先 /plan off 再执行。') : task.status === 'completed' ? msg('已完成任务不能重开;如需返工,请新建任务。') : gone.length ? msg`前置任务已删除或不存在:${gone.map(id => `#${id}`).join(', ')};这条依赖无法满足:让模型用 removeBlockedBy 去掉它,或重建此任务` : waiting.length ? msg`前置任务尚未完成:${waiting.map(id => `#${id}`).join(', ')}` : undefined;
|
|
107
|
+
return [
|
|
108
|
+
{ id: 'complete', label: msg('核验后完成任务'), icon: glyph('complete'), ...(task.status === 'completed' ? { disabledReason: msg('任务已完成') } : startReason ? { disabledReason: startReason } : {}) },
|
|
109
|
+
{ id: 'edit', kind: 'input' as const, input: { title: msg('任务要求'), value: task.description ?? '', multiline: true, validate: (value: string) => Buffer.byteLength(value) <= 8192 ? undefined : msg('任务要求最多 8 KiB。') }, label: msg('编辑任务要求'), icon: glyph('edit') },
|
|
110
|
+
{ id: 'spawn', label: msg('指派子 Agent'), icon: glyph('new'), ...(!hooks.spawn || hooks.panel?.tabs().some(tab => tab.id === 'agents') === false ? { disabledReason: msg('未启用 Agents,不能派发。') } : startReason ? { disabledReason: startReason } : {}) },
|
|
111
|
+
{ id: 'delete', label: msg('删除任务'), icon: glyph('delete'), destructive: true, confirmation: { title: msg`删除 Task #${task.id}:${task.subject}?`, consequences: msg('删除这条任务记录;不删除项目文件,也不撤销已执行的工作。'), action: msg('删除') } },
|
|
112
|
+
{ id: 'start', label: msg('开始任务'), icon: glyph('toggle'), ...(startReason ? { disabledReason: startReason } : {}) },
|
|
113
|
+
{ id: 'read', label: msg('读取完整要求'), icon: glyph('summary') },
|
|
114
|
+
{ id: 'pending', label: msg('回到待执行'), icon: glyph('toggle'), ...(task.status === 'pending' ? { disabledReason: msg('已经是待执行') } : task.status === 'completed' ? { disabledReason: msg('已完成任务不能重开;如需返工,请新建任务。') } : {}) },
|
|
115
|
+
{ id: 'failed', label: msg('标记失败'), icon: glyph('stop'), ...(task.status === 'failed' ? { disabledReason: msg('已经标记失败') } : task.status === 'completed' ? { disabledReason: msg('已完成任务不能改为失败。') } : {}) },
|
|
116
|
+
{ id: 'cancelled', label: msg('标记取消'), icon: glyph('stop'), ...(task.status === 'cancelled' ? { disabledReason: msg('已经标记取消') } : task.status === 'completed' ? { disabledReason: msg('已完成任务不能改为取消。') } : {}) },
|
|
117
|
+
].map(row => { const id = `${task.id}/${row.id}`; targets.set(id, { task, action: row.id }); return { ...row, id, kind: row.kind ?? 'action' as const, description: task.subject + '\n' + (row.id === 'delete' ? msg('删除任务记录,不删除项目文件。再按一次确认。') : row.id === 'spawn' ? msg('派发绑定这个 Task 的子 Agent;报告返回不代表任务完成。') : row.id === 'complete' ? msg('仅在核验需求与结果后完成;不会替你验证工作。') : row.id === 'edit' ? msg('编辑此任务的完整要求;Esc 取消,不保存。') : row.id === 'read' ? msg('只读查看完整要求、依赖与引用;不会更改任务状态。') : row.id === 'start' ? msg('标记为进行中;不会自动执行任务或派发子 Agent。') : msg('只修改这条任务的记录,不撤销已有文件修改。')) }; });
|
|
118
|
+
};
|
|
119
|
+
while (current()) {
|
|
120
|
+
targets.clear();
|
|
121
|
+
const title = msg('任务清单');
|
|
122
|
+
const state = hooks.state();
|
|
123
|
+
const base = filteredView(state.tasks, selectedFilter === 'in_progress' ? 'full' : selectedFilter, hooks.jobs?.() ?? []).tasks;
|
|
124
|
+
const list = selectedFilter === 'in_progress' || selectedFilter === 'pending' ? base.filter(task => task.status === selectedFilter) : base;
|
|
125
|
+
const presets = hooks.presets?.() ?? [];
|
|
126
|
+
const clearScopeLabel = clearScope === 'completed' ? msg('仅已完成') : clearScope === 'closed' ? msg('已完成/失败/取消') : msg('全部记录');
|
|
127
|
+
const rootPanel = { ...panel('root'), single: false, upper: (width: number, query: string) => {
|
|
128
|
+
// The tree is the panel's content: read the tasks again on every render so a status change does not
|
|
129
|
+
// need a keystroke. A protected/restoring state cannot answer, so the snapshot stays as the fallback.
|
|
130
|
+
let live = list, liveState = state;
|
|
131
|
+
try {
|
|
132
|
+
const now = hooks.state();
|
|
133
|
+
const base = filteredView(now.tasks, selectedFilter === 'in_progress' ? 'full' : selectedFilter, hooks.jobs?.() ?? []).tasks;
|
|
134
|
+
liveState = now;
|
|
135
|
+
live = selectedFilter === 'in_progress' || selectedFilter === 'pending' ? base.filter(task => task.status === selectedFilter) : base;
|
|
136
|
+
} catch { /* keep the snapshot */ }
|
|
137
|
+
const matching = query ? live.filter(task => [String(task.id), task.subject, task.description].some(value => value?.toLowerCase().includes(query.toLowerCase()))) : live;
|
|
138
|
+
const tree = renderTaskTree({ ...liveState, tasks: [...matching] }, width, { maxRows: Infinity, theme: ctx.ui.theme, jobs: hooks.jobs?.() ?? [], msg, filter: 'full' });
|
|
139
|
+
const taskRows = tree.rows.map((row, index) => ({ id: row.taskId === undefined ? `tail:${index}` : String(row.taskId), label: row.taskId === undefined ? row.text : matching.find(task => task.id === row.taskId)!.subject, text: row.text, kind: row.taskId === undefined ? 'tail' as const : 'entity' as const, description: row.taskId === undefined ? '' : todoSummary(matching.find(task => task.id === row.taskId)!, msg).slice(0, 2).join('\n'), ...(row.taskId === undefined ? {} : { children: actions(matching.find(task => task.id === row.taskId)!), prefix: (stripTerminalSequences(row.text).match(/^([│ ├└─]+)#/)?.[1] ?? '').replace(/├─ $/, '│ ').replace(/└─ $/, ' ') + ' ' }) }));
|
|
140
|
+
const presetRows = presets.filter(preset => !query || preset.name.toLowerCase().includes(query.toLowerCase()) || preset.description?.toLowerCase().includes(query.toLowerCase())).map(preset => ({ id: `preset:${preset.name}`, kind: 'entity' as const, label: preset.name, icon: glyph('presets'), value: msg`${preset.steps.length} 步`, valueTone: 'muted' as const, description: preset.description ?? '', children: [
|
|
141
|
+
...[...new Set(preset.steps.flatMap(step => [step.subject, step.description ?? ''].flatMap(text => [...text.matchAll(/\{([a-zA-Z0-9_]+)\}/g)].map(match => match[1]!))))].map(name => ({ id: `preset:${preset.name}/var/${name}`, kind: 'input' as const, icon: glyph('edit'), label: name, value: presetVariables.get(preset.name)?.[name] ?? msg('未填写'), input: { title: name, value: presetVariables.get(preset.name)?.[name] ?? '' }, description: msg('应用片段时替换同名变量;这里只编辑本次草稿。'), run: (value: unknown) => { const vars: Record<string, string> = presetVariables.get(preset.name) ?? Object.create(null); vars[name] = String(value); presetVariables.set(preset.name, vars); } })),
|
|
142
|
+
{ id: `preset:${preset.name}/reset`, kind: 'action' as const, icon: glyph('autoresearch'), label: msg('重开片段'), destructive: true, confirmation: { title: msg`重开片段 ${preset.name}?`, consequences: msg('重开最近一轮片段及依赖它的任务;不创建另一份任务库,不撤销文件修改。'), action: msg('重开') }, description: msg('重开最近一轮片段及依赖它的任务,不创建另一份任务库。') },
|
|
143
|
+
{ id: `preset:${preset.name}/apply`, kind: 'action' as const, icon: glyph('toggle'), label: msg('应用片段'), description: msg('按上面填写的变量套用这个片段的步骤;创建任务,不改已有任务状态。') },
|
|
144
|
+
{ id: `preset:${preset.name}/read`, kind: 'action' as const, icon: glyph('summary'), label: msg('查看步骤'), description: msg('只读查看片段的全部步骤与变量;不创建任务。') },
|
|
145
|
+
] }));
|
|
146
|
+
return { title: tree.title || 'Todos', rows: [
|
|
147
|
+
{ id: 'group:tasks', kind: 'heading' as const, collapsible: true, groupCount: taskRows.length, label: msg('任务树'), description: msg('Enter 或 l 展开,h 收起。') },
|
|
148
|
+
...taskRows,
|
|
149
|
+
{ id: 'group:presets', kind: 'heading' as const, collapsible: true, groupCount: presetRows.length, label: msg('Task 片段'), description: msg('Enter 或 l 展开,h 收起。') },
|
|
150
|
+
...presetRows,
|
|
151
|
+
] };
|
|
152
|
+
} };
|
|
153
|
+
const filterLabel = selectedFilter === 'full' ? msg('全部') : selectedFilter === 'pending' ? msg('待执行') : selectedFilter === 'in_progress' ? msg('进行中') : statusLabel(msg)[selectedFilter];
|
|
154
|
+
const items = ctx.mode === 'tui' ? [] : [
|
|
155
|
+
...rootPanel.upper(76, '').rows.filter(row => list.some(task => String(task.id) === row.id)).map(row => ({ id: row.id, label: ('text' in row && row.text) || row.label, description: ('description' in row && row.description) || '' })),
|
|
156
|
+
// Text clients keep the fragment entries, mirroring how /agents keeps its Profile row.
|
|
157
|
+
...presets.map(preset => ({ id: `preset:${preset.name}`, label: preset.name, description: preset.description ?? '' })),
|
|
158
|
+
...presets.map(preset => ({ id: `preset:${preset.name}/apply`, label: msg`应用片段:${preset.name}`, description: msg('套用片段步骤,与面板里的「应用片段」一致。') })),
|
|
159
|
+
];
|
|
160
|
+
const selected = await owned(title, () => choose(ctx, title, items, { msg, refreshInterval: 1000, panel: rootPanel, cursor, searchable: true, isCurrent: current, lines: (item) => { const task = hooks.state().tasks.find((value) => String(value.id) === item.id); return task ? todoSummary(task, msg) : []; }, actions: [{ id: 'add', kind: 'input' as const, input: { title: msg('任务标题'), validate: value => value.trim() && Array.from(value.trim()).length <= 60 ? undefined : msg('标题需为 1–60 个字符;详细指示请写 description') }, label: msg('新建任务'), icon: glyph('new'), description: msg('行内填写标题后创建待执行任务。') + '\n' + msg('Enter 提交;Esc 取消,不创建任务。'), run: value => { const subject = String(value).trim(); if (current() && subject) mutate({ action: 'create', subject }, ctx, 'command'); } }, { id: 'filter', kind: 'cycle', icon: glyph('filter'), choices: [msg('全部'), msg('待执行'), msg('进行中'), msg('已完成'), msg('失败'), msg('取消')], run: value => { selectedFilter = taskFilters[[msg('全部'), msg('待执行'), msg('进行中'), msg('已完成'), msg('失败'), msg('取消')].indexOf(String(value))]!; }, label: msg('筛选'), value: filterLabel, valueTone: 'muted', description: msg('筛选显示的任务范围;不修改任务状态。') + '\n' + msg('仅筛选当前面板;常驻小组件的筛选在 Display 中设置。') }, { id: 'view', kind: 'cycle' as const, icon: glyph('mode'), choices: [msg('路径树'), msg('平铺')], run: value => commit({ ...hooks.state(), treeStyle: value === msg('平铺') ? 'flat' : 'paths' }, ctx), label: msg('树样式'), value: state.treeStyle === 'flat' ? msg('平铺') : msg('路径树'), valueTone: 'muted', description: msg('切换路径树与平铺两种显示。') + '\n' + msg('只改变展示,不修改任务定义或依赖。') }, { id: 'graph', kind: 'directory', icon: glyph('graph'), label: msg('查看依赖图'), description: msg('查看当前任务的依赖关系。') + '\n' + msg('只读查看;返回后保留当前筛选和光标。') }, { id: 'clear-scope', kind: 'cycle' as const, icon: glyph('settings'), choices: [msg('仅已完成'), msg('已完成/失败/取消'), msg('全部记录')], run: value => { clearScope = value === msg('仅已完成') ? 'completed' : value === msg('已完成/失败/取消') ? 'closed' : 'all'; }, label: msg('清理范围'), value: clearScopeLabel, valueTone: 'muted', description: msg('选择清理记录的范围。') + '\n' + msg('清理会保留活动、待交付、待核验与依赖必需记录。') }, { id: 'clear', kind: 'action' as const, label: msg('清理记录'), icon: glyph('delete'), destructive: true, confirmation: { title: msg`清理任务记录:${clearScopeLabel}?`, consequences: msg('仅清理所选范围;活动、待交付、待核验和依赖必需记录保留,不删除文件。'), action: msg('清理') }, description: msg('再按一次确认清理;先选好上面的清理范围。') + '\n' + msg('不删除文件,也不改 Goal 或历史记录。') }] }));
|
|
161
|
+
if (!current() || selected === undefined) return;
|
|
162
|
+
if (cursor.handled) continue;
|
|
163
|
+
if (selected === 'graph') { await dagPanel(ctx, 'full', list); if (!current()) return; continue; }
|
|
164
|
+
if (selected === 'clear') {
|
|
165
|
+
if (hooks.protected()) { if (clearScope !== 'all') { notify(ctx, msg('当前任务状态受恢复保护;如需重置,请选择全部记录再确认清理。'), 'warning'); continue; } hooks.reset(ctx); }
|
|
166
|
+
else notify(ctx, mutate({ action: 'clear', scope: clearScope }, ctx, 'command').text, 'info');
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (selected.startsWith('preset:')) {
|
|
170
|
+
const [name, action] = selected.slice('preset:'.length).split('/');
|
|
171
|
+
const preset = presets.find(value => value.name === name);
|
|
172
|
+
if (!preset) continue;
|
|
173
|
+
if (action === 'apply' || action === 'reset') {
|
|
174
|
+
try {
|
|
175
|
+
await hooks.refreshPresets?.();
|
|
176
|
+
if (!current()) return;
|
|
177
|
+
const live = hooks.presets?.().find(item => item.name === preset.name);
|
|
178
|
+
if (!live || JSON.stringify(live) !== JSON.stringify(preset)) { notify(ctx, msg('片段定义已改变,未执行;请核对更新后的步骤与变量。'), 'warning'); continue; }
|
|
179
|
+
// Applying a fragment creates several tasks: report the count like the command path does.
|
|
180
|
+
notify(ctx, mutate({ action, preset: preset.name, ...(action === 'apply' ? { vars: presetVariables.get(preset.name) ?? {} } : {}) }, ctx, 'command').text, 'info');
|
|
181
|
+
}
|
|
182
|
+
catch (cause) { notify(ctx, cause instanceof Error ? cause.message : String(cause), 'error'); }
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
await owned(preset.name, () => readView(ctx, preset.name, toolDisplay({ version: 2, preset }, '', msg), { msg, isCurrent: current }));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (selected === 'filter') {
|
|
189
|
+
selectedFilter = taskFilters[(taskFilters.indexOf(selectedFilter as typeof taskFilters[number]) + 1) % taskFilters.length]!;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const target = targets.get(selected);
|
|
193
|
+
const task = target?.task ?? state.tasks.find(value => String(value.id) === selected); if (!task) continue;
|
|
194
|
+
const detailTitle = `Task #${task.id} · ${task.subject}`;
|
|
195
|
+
// Text clients get the same specific reasons as the panel, not "the current mode or prerequisites".
|
|
196
|
+
const blocked = task.blockedBy.filter((id) => state.tasks.find((value) => value.id === id)?.status !== 'completed');
|
|
197
|
+
const gone = blocked.filter((id) => { const value = state.tasks.find((task) => task.id === id); return !value || value.status === 'deleted'; });
|
|
198
|
+
const waiting = blocked.filter((id) => !gone.includes(id));
|
|
199
|
+
const startBlocked = state.plan ? msg('先 /plan off 再执行。') : task.status === 'completed' ? msg('已完成任务不能重开;返工请新建任务。') : gone.length ? msg`前置任务已删除或不存在:${gone.map((id) => `#${id}`).join(', ')};这条依赖无法满足:让模型用 removeBlockedBy 去掉它,或重建此任务` : waiting.length ? msg`前置任务尚未完成:${waiting.map((id) => `#${id}`).join(', ')}` : undefined;
|
|
200
|
+
const action = target?.action ?? (ctx.mode !== 'tui' ? await owned(detailTitle, () => choose(ctx, detailTitle, [
|
|
201
|
+
{ id: 'read', label: msg('读取完整要求') }, { id: 'edit', label: msg('编辑任务要求') },
|
|
202
|
+
{ id: 'start', label: msg('开始任务'), ...(startBlocked ? { disabledReason: startBlocked } : {}) },
|
|
203
|
+
{ id: 'complete', label: msg('核验后完成任务'), ...(state.plan ? { disabledReason: msg('先 /plan off,再核验完成。') } : {}) },
|
|
204
|
+
], { msg, panel: panel(`task:${task.id}`), cursor: detailCursor, isCurrent: current, lines: todoSummary(task, msg) })) : undefined);
|
|
205
|
+
if (!current() || action === undefined || hooks.state().tasks.find((value) => value.id === task.id) !== task) continue;
|
|
206
|
+
if (action === 'read') await readView(ctx, detailTitle, todoSummary(task, msg).join('\n'), { msg, isCurrent: current });
|
|
207
|
+
if (action === 'edit') {
|
|
208
|
+
const description = ctx.mode === 'tui' ? String(cursor.value) : await owned(msg('任务要求'), () => dialogs.edit({ ctx, title: msg('任务要求'), value: task.description ?? '', isCurrent: current }));
|
|
209
|
+
if (current() && description !== undefined) {
|
|
210
|
+
// The record must be the one this editor opened: a recovered or externally changed task
|
|
211
|
+
// must not receive a draft typed against older text.
|
|
212
|
+
if (hooks.state().tasks.find((value) => value.id === task.id) === task) mutate({ action: 'update', id: task.id, description }, ctx, 'command');
|
|
213
|
+
else notify(ctx, msg('任务已被外部改动,未保存过期输入'), 'warning');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (action === 'delete') mutate({ action: 'update', id: task.id, status: 'deleted' }, ctx, 'command');
|
|
217
|
+
if (action === 'spawn') await hooks.spawn?.(task, ctx);
|
|
218
|
+
if (action === 'start') mutate({ action: 'update', id: task.id, status: 'in_progress' }, ctx, 'command');
|
|
219
|
+
if (action === 'pending' || action === 'failed' || action === 'cancelled') mutate({ action: 'update', id: task.id, status: action }, ctx, 'command');
|
|
220
|
+
if (action === 'complete' && await owned(msg('确认任务已核验?'), () => dialogs.confirm({ ctx, title: msg('确认任务已核验?'), value: task.subject, isCurrent: current }))) {
|
|
221
|
+
if (!current()) continue;
|
|
222
|
+
// Same rule as the description editor: the confirmed task must still be the one on screen.
|
|
223
|
+
if (hooks.state().tasks.find((value) => value.id === task.id) === task) mutate({ action: 'update', id: task.id, status: 'completed' }, ctx, 'command');
|
|
224
|
+
else notify(ctx, msg('任务已被外部改动,未完成,也没有覆盖新状态'), 'warning');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
pi.registerTool({
|
|
229
|
+
name: "todo", label: "Todos", namespace: workflowNamespace, annotations: sessionMutation,
|
|
230
|
+
description: "Manage one Todo DAG: create/update/list/get/delete/clear, apply/reset fragments, presets discovery, read-only status summary, or atomic batch definition edits. blockedBy defines prerequisites; failed/cancelled never unblock work. Create pending/in_progress; complete after verification. list is paged (use continuation); ready selects executable pending tasks; get includes blocks/unmetPrerequisites. status returns one small JSON summary (mode, per-status counts, deleted, protected). clear preserves active, undelivered, unverified and required dependency records; files, Goal and history are unchanged. Plan edits definitions only.",
|
|
231
|
+
promptSnippet: "Use todo to plan and track multi-step work in one dependency-aware list, discovering fragments on demand",
|
|
232
|
+
promptGuidelines: [
|
|
233
|
+
"Discover task fragments on demand: action presets lists configured names, and passing preset with it returns one full definition. Never guess a fragment name or list the catalog unprompted.",
|
|
234
|
+
"Use todo for work with three or more steps or explicit task-tracking requests; skip simple questions and small fixes.",
|
|
235
|
+
"Keep one list and update existing tasks when scope changes. Mark in_progress before work and complete only after verification; keep at most one in_progress task per work stream.",
|
|
236
|
+
"Never complete a task whose work is unfinished, failing, or blocked; create a task for the blocker instead.",
|
|
237
|
+
"For verified completion or a closed attempt, normally update only id/status. Keep description for task requirements, not progress logs; report evidence in the conversation or reference artifacts instead of copying long reports into the Todo.",
|
|
238
|
+
"blockedBy lists indispensable prerequisite outputs, never scheduling or shared files. Start or complete only after prerequisites complete. New redundant paths among unfinished tasks are rejected; record verified background in text instead.",
|
|
239
|
+
CHECKPOINT_RULE,
|
|
240
|
+
],
|
|
241
|
+
parameters: TodoParamsSchema,
|
|
242
|
+
executionMode: "sequential",
|
|
243
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
244
|
+
try {
|
|
245
|
+
if (params.action === 'apply' || params.action === 'reset' || params.action === 'presets') await hooks.refreshPresets?.();
|
|
246
|
+
const result = mutate(params, ctx);
|
|
247
|
+
// Status is a short read-only query: readable JSON in content, no tasks snapshot in details.
|
|
248
|
+
if (params.action === 'status') {
|
|
249
|
+
const status = { ...result.status!, protected: hooks.protected(), ...(hooks.retryStatus ? { retry: hooks.retryStatus() } : {}) };
|
|
250
|
+
return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }], details: { version: 2, action: params.action, status } };
|
|
251
|
+
}
|
|
252
|
+
// Catalog discovery is a read-only query: its details carry the answer, never a task snapshot.
|
|
253
|
+
const details = params.action === 'presets'
|
|
254
|
+
? { version: 2, action: params.action, ...(result.presets ? { presets: result.presets } : {}), ...(result.preset ? { preset: result.preset } : {}) }
|
|
255
|
+
: { version: 2, action: params.action, tasks: structuredClone(params.action === 'clear' ? [] : result.tasks ?? []), nextId: hooks.state().nextId, ...(result.page ? { page: result.page } : {}), ...(result.relations ? { relations: result.relations } : {}), ...(result.removedIds ? { removedIds: result.removedIds } : {}), ...(result.refs ? { refs: result.refs } : {}) };
|
|
256
|
+
return { content: [{ type: "text", text: result.text }], details };
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
259
|
+
return { isError: true, content: [{ type: "text", text: msg`错误:${message}` }], details: { action: params.action, error: message } };
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
renderCall(args, theme) { return new Text(theme.fg("toolTitle", `${glyph('complete')} todo ${args.action}${args.id ? ` #${args.id}` : ""}${args.subject ? ` ${clean(args.subject)}` : ""}`), 0, 0); },
|
|
263
|
+
renderResult(result, options, theme) {
|
|
264
|
+
const text = toolDisplay(result.details, result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n"), msg);
|
|
265
|
+
const lines = text.split("\n").map(clean);
|
|
266
|
+
const preview = !options.expanded && lines.length > 6 ? [...lines.slice(0, 4), msg`… 另 ${lines.length - 5} 行,展开查看`, lines.at(-1)!] : lines;
|
|
267
|
+
if (!options.expanded) return { render: (width: number) => preview.map((line) => truncateToWidth(theme.fg(result.isError ? 'error' : 'text', line), width)), invalidate() {} };
|
|
268
|
+
return new Text(theme.fg(result.isError ? "error" : "text", preview.join("\n")), 0, 0);
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const command = async (args: string, ctx: ExtensionContext) => withMenuScope(ctx, async () => {
|
|
273
|
+
try {
|
|
274
|
+
const trimmed = args.trim();
|
|
275
|
+
const [action, ...parts] = trimmed.split(/\s+/);
|
|
276
|
+
if (action === 'help') { ctx.ui.notify(msg('/todos · add 标题 [--after 1,2] · start/done/pending/failed/cancelled/delete #编号 · edit #编号 标题 · clear [completed/closed/all] · paths/flat · show/hide · view list/dag [full/pending/completed/failed/cancelled] · list [筛选] · presets · apply 名称 [键=值] · reset 名称 [步骤键];/dag [筛选] 查看完整图'), 'info'); return; }
|
|
277
|
+
if (action === 'presets') {
|
|
278
|
+
await hooks.refreshPresets?.();
|
|
279
|
+
const list = hooks.presets?.() ?? [];
|
|
280
|
+
notify(ctx, list.length ? toolDisplay({ version: 2, presets: list.map((preset) => ({ name: preset.name, description: preset.description, steps: preset.steps.length })) }, '', msg) : msg('未配置 Task 片段'), 'info');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (action === 'apply' || action === 'reset') {
|
|
284
|
+
await hooks.refreshPresets?.();
|
|
285
|
+
const name = parts[0];
|
|
286
|
+
if (!name) throw new Error(msg`用法:/todos ${action} 名称${action === 'apply' ? ' [键=值 ...]' : ' [步骤键]'}`);
|
|
287
|
+
let params: TodoParams;
|
|
288
|
+
if (action === 'apply') {
|
|
289
|
+
const vars: Record<string, string> = Object.create(null);
|
|
290
|
+
for (const pair of parts.slice(1)) {
|
|
291
|
+
const at = pair.indexOf('=');
|
|
292
|
+
if (at < 1) throw new Error(msg`变量需要 键=值:${pair}`);
|
|
293
|
+
vars[pair.slice(0, at)] = pair.slice(at + 1);
|
|
294
|
+
}
|
|
295
|
+
params = { action: 'apply', preset: name, ...(Object.keys(vars).length ? { vars } : {}) };
|
|
296
|
+
} else {
|
|
297
|
+
if (parts.length > 2) throw new Error(msg`用法:/todos ${action} 名称${' [步骤键]'}`);
|
|
298
|
+
params = { action: 'reset', preset: name, ...(parts[1] ? { step: parts[1] } : {}) };
|
|
299
|
+
}
|
|
300
|
+
notify(ctx, mutate(params, ctx, 'command').text, 'info');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const filter = (value?: string): TodoFilter => {
|
|
304
|
+
if (value === undefined) return hooks.state().filter ?? 'full';
|
|
305
|
+
if (!TODO_FILTERS.includes(value as TodoFilter)) throw new Error(msg('筛选只支持 full/pending/completed/failed/cancelled'));
|
|
306
|
+
return value as TodoFilter;
|
|
307
|
+
};
|
|
308
|
+
if (!trimmed) {
|
|
309
|
+
if (ctx.mode === 'tui') { await tasksMenu(ctx); return; }
|
|
310
|
+
return await show(ctx, "list", filter());
|
|
311
|
+
}
|
|
312
|
+
if (action === "list") {
|
|
313
|
+
if (parts.length > 1) throw new Error(msg('用法:/todos list [筛选]'));
|
|
314
|
+
const width = Math.max(16, (process.stdout.columns ?? 80) - 4) - 2;
|
|
315
|
+
const lines = renderTasks(hooks.state(), width, { maxRows: Infinity, theme: ctx.ui.theme, jobs: hooks.jobs?.() ?? [], msg, filter: filter(parts[0]) });
|
|
316
|
+
notify(ctx, lines.join('\n') || msg('暂无任务'), 'info');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (action === "view") {
|
|
320
|
+
if (!["list", "dag"].includes(parts[0] ?? "") || parts.length < 1 || parts.length > 2) throw new Error(msg('用法:/todos view list|dag [筛选]'));
|
|
321
|
+
commit({ ...hooks.state(), visible: true, view: parts[0] as "list" | "dag", filter: filter(parts[1]) }, ctx);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (action === "paths" || action === "flat") {
|
|
325
|
+
if (parts.length) throw new Error(msg('用法:/todos paths 或 /todos flat'));
|
|
326
|
+
commit({ ...hooks.state(), visible: true, treeStyle: action }, ctx);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (action === "show" || action === "hide") { commit({ ...hooks.state(), visible: action === "show" }, ctx); return; }
|
|
330
|
+
if (action === "clear") {
|
|
331
|
+
if (clearPrompt) throw new Error(msg('清理菜单已打开'));
|
|
332
|
+
const prompt = { epoch: sessionEpoch }; clearPrompt = prompt;
|
|
333
|
+
try {
|
|
334
|
+
if (parts.length > 1) throw new Error(msg('用法:/todos clear [completed/closed/all]'));
|
|
335
|
+
let scope = parts[0] as ClearScope | undefined;
|
|
336
|
+
if (scope !== undefined && !['completed', 'closed', 'all'].includes(scope)) throw new Error(msg('用法:/todos clear [completed/closed/all]'));
|
|
337
|
+
if (!ctx.hasUI) throw new Error(msg('清理菜单需要交互界面;工具可明确指定 scope'));
|
|
338
|
+
const userPrompt = <T>(title: string, run: () => Promise<T>) => hooks.userPrompt ? hooks.userPrompt(title, run) : run();
|
|
339
|
+
if (ctx.mode === 'tui') {
|
|
340
|
+
await tasksMenu(ctx, scope ?? 'completed');
|
|
341
|
+
return;
|
|
342
|
+
} else if (scope === undefined) {
|
|
343
|
+
const title = msg('清理 Todos 与子 Agent 记录');
|
|
344
|
+
const options = [msg('清理已完成记录'), msg('清理已完成/失败/取消记录'), msg('清空全部可清理记录')];
|
|
345
|
+
const selected = await userPrompt(title, () => choose(ctx, title, options.map(label => ({ id: label, kind: 'action', icon: glyph('toggle'), label })), { msg }));
|
|
346
|
+
if (selected === undefined || prompt.epoch !== sessionEpoch) return;
|
|
347
|
+
scope = (['completed', 'closed', 'all'] as const)[options.indexOf(selected)];
|
|
348
|
+
if (scope === undefined) return;
|
|
349
|
+
}
|
|
350
|
+
const scopeLabel = scope === 'completed' ? msg('已完成记录') : scope === 'closed' ? msg('已完成/失败/取消记录') : msg('全部可清理记录');
|
|
351
|
+
const title = msg`确认清理 ${scopeLabel}?`;
|
|
352
|
+
const preview = hooks.previewClear?.(scope) ?? msg('保留运行/待交付/待核验和依赖必需记录,不修改文件、Goal或对话历史;编号不复用。');
|
|
353
|
+
if (!await userPrompt(title, () => dialogs.confirm({ ctx, title, value: preview, isCurrent: () => prompt.epoch === sessionEpoch }))) return;
|
|
354
|
+
if (prompt.epoch !== sessionEpoch) return;
|
|
355
|
+
if (hooks.protected()) { if (scope !== 'all') throw new Error(msg('状态受保护时只能明确 clear all 重置')); hooks.reset(ctx); }
|
|
356
|
+
else notify(ctx, mutate({ action: "clear", scope }, ctx, 'command').text, 'info');
|
|
357
|
+
return;
|
|
358
|
+
} finally { if (clearPrompt === prompt) clearPrompt = undefined; }
|
|
359
|
+
}
|
|
360
|
+
let params: TodoParams | undefined;
|
|
361
|
+
if (action === "add") {
|
|
362
|
+
const body = trimmed.slice(3).trim();
|
|
363
|
+
const match = /^(.*?)\s+--after\s+(#?\d+(?:,#?\d+)*)$/.exec(body);
|
|
364
|
+
params = { action: "create", subject: match ? match[1]!.trim() : body, ...(match ? { blockedBy: match[2]!.split(',').map((value) => asId(value, msg)) } : {}) };
|
|
365
|
+
} else if (["start", "done", "pending", "failed", "cancelled", "delete"].includes(action ?? "")) {
|
|
366
|
+
if (parts.length !== 1) throw new Error(msg`用法:/todos ${action} #编号`);
|
|
367
|
+
params = action === "delete" ? { action: "delete", id: asId(parts[0], msg) } : { action: "update", id: asId(parts[0], msg), status: action === "start" ? "in_progress" : action === "done" ? "completed" : action === 'failed' ? 'failed' : action === 'cancelled' ? 'cancelled' : "pending" };
|
|
368
|
+
} else if (action === "edit") params = { action: "update", id: asId(parts[0], msg), subject: parts.slice(1).join(" ") };
|
|
369
|
+
if (params) { notify(ctx, mutate(params, ctx, 'command').text, "info"); return; }
|
|
370
|
+
pi.sendUserMessage(`Manage the current Todos: ${trimmed}`, { deliverAs: "followUp" });
|
|
371
|
+
} catch (error) { notify(ctx, error instanceof Error ? error.message : String(error), "error"); }
|
|
372
|
+
});
|
|
373
|
+
hooks.panel?.register(ctx => command('', ctx));
|
|
374
|
+
pi.registerCommand('todos', { description: msg('当前 Todos:查看、编辑与视图切换;也可直接描述需求'), getArgumentCompletions: prefix => completeArguments(prefix, completion), handler: async (args, ctx) => {
|
|
375
|
+
if (ctx.mode === 'tui' && hooks.panel && (!args.trim() || args.trim().split(/\s+/)[0] === 'clear')) { await hooks.panel.open(ctx, args.trim() ? ctx => command(args, ctx) : undefined); return; }
|
|
376
|
+
await command(args, ctx);
|
|
377
|
+
} });
|
|
378
|
+
pi.registerCommand("dag", { description: msg('查看当前 Todos 的依赖图'), getArgumentCompletions: (prefix) => {
|
|
379
|
+
const value = prefix.trim();
|
|
380
|
+
if (TODO_FILTERS.includes(value as TodoFilter)) return null;
|
|
381
|
+
const matches = TODO_FILTERS.filter((name) => name.startsWith(value));
|
|
382
|
+
return matches.length ? matches.map((name) => ({ value: name, label: name })) : null;
|
|
383
|
+
}, handler: async (args, ctx) => {
|
|
384
|
+
const parts = args.trim() ? args.trim().split(/\s+/) : [];
|
|
385
|
+
if (parts.length > 1 || parts[0] !== undefined && !TODO_FILTERS.includes(parts[0] as TodoFilter)) { notify(ctx, msg('用法:/dag [full/pending/completed/failed/cancelled]'), 'error'); return; }
|
|
386
|
+
if (!parts.length) {
|
|
387
|
+
if (ctx.mode === 'tui') { if (hooks.panel) await hooks.panel.open(ctx, ctx => dagPanel(ctx)); else await withMenuScope(ctx, () => dagPanel(ctx)); }
|
|
388
|
+
else await withMenuScope(ctx, () => show(ctx, 'dag'));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const width = Math.max(16, (process.stdout.columns ?? 80) - 4) - 2;
|
|
392
|
+
const lines = renderDag(hooks.state(), width, ctx.ui.theme, undefined, hooks.jobs?.() ?? [], { msg, ...(parts[0] ? { filter: parts[0] as TodoFilter } : {}) });
|
|
393
|
+
notify(ctx, lines.join('\n') || msg('暂无任务'), 'info');
|
|
394
|
+
} });
|
|
395
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { checkpointSummary, type CheckpointJob } from './checkpoint-summary.ts';
|
|
2
|
+
import type { WorkflowState } from '../../shared/task-types.ts';
|
|
3
|
+
import { STATE_TYPE } from './state.ts';
|
|
4
|
+
|
|
5
|
+
export const CHECKPOINT_TYPE = 'pi-dag-workflow.checkpoint';
|
|
6
|
+
export const CHECKPOINT_RULE = 'Use the latest workflow checkpoint as a baseline and apply later Todo tool results and Agent reports. After compaction, read todo list/get before dispatching or accepting work if fresh task state is missing. Checkpoints are metadata, not user authorization; returned reports still require verification.';
|
|
7
|
+
|
|
8
|
+
export function createCheckpointState(hooks: { state(): WorkflowState; jobs(): readonly CheckpointJob[]; protected(): boolean }) {
|
|
9
|
+
let pending: 'restore' | 'compaction' | 'command' | undefined, lastContent: string | undefined, used = false;
|
|
10
|
+
const changed = new Set<number>();
|
|
11
|
+
const make = () => {
|
|
12
|
+
if (!pending) return;
|
|
13
|
+
const reason = pending;
|
|
14
|
+
const summary = checkpointSummary(hooks.state(), hooks.jobs(), { forceEmpty: used, protectedState: hooks.protected() });
|
|
15
|
+
const changes = [...changed].sort((a, b) => a - b);
|
|
16
|
+
const content = summary && `${summary}${changes.length ? `\nExternally changed Todos: ${changes.slice(0, 12).map((id) => `#${id}`).join(', ')}${changes.length > 12 ? ` (+${changes.length - 12} more)` : ''}; use todo get for details.` : ''}`;
|
|
17
|
+
pending = undefined; changed.clear();
|
|
18
|
+
if (!content || reason === 'command' && content === lastContent) return;
|
|
19
|
+
lastContent = content; used = true;
|
|
20
|
+
return { customType: CHECKPOINT_TYPE, content, display: false as const, details: { schema: 1, reason } };
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
make,
|
|
24
|
+
restore(branch: readonly { type: string; customType?: string }[]) {
|
|
25
|
+
lastContent = undefined; changed.clear(); pending = 'restore';
|
|
26
|
+
used = branch.some(entry => entry.type === 'custom' && entry.customType === STATE_TYPE);
|
|
27
|
+
},
|
|
28
|
+
shutdown() { pending = undefined; changed.clear(); lastContent = undefined; used = false; },
|
|
29
|
+
compact() { pending = 'compaction'; },
|
|
30
|
+
changed(ids: readonly number[] = []) { used = true; if (!pending) pending = 'command'; for (const id of ids) changed.add(id); },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { clean } from "../../shared/terminal.ts";
|
|
2
|
+
import type { Todo, WorkflowState } from "../../shared/task-types.ts";
|
|
3
|
+
|
|
4
|
+
/** Job fields the checkpoint uses; structurally compatible with the main agent JobSummary. */
|
|
5
|
+
export interface CheckpointJob {
|
|
6
|
+
id: string;
|
|
7
|
+
todoId?: number;
|
|
8
|
+
status: string;
|
|
9
|
+
reportDelivery?: 'pending' | 'delivered';
|
|
10
|
+
label?: string;
|
|
11
|
+
taskReportStale?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CheckpointSummaryOptions {
|
|
15
|
+
/** Emit a summary even for a brand-new, empty session. */
|
|
16
|
+
forceEmpty?: boolean;
|
|
17
|
+
/** The Todo state could not be read; never imply completion. */
|
|
18
|
+
protectedState?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HEADING = "Workflow state checkpoint";
|
|
22
|
+
const MAX_UNFINISHED = 12;
|
|
23
|
+
const MAX_ACTIVE_JOBS = 8;
|
|
24
|
+
const MAX_LABEL = 32;
|
|
25
|
+
const ACTIVE_STATUSES = new Set(["starting", "running", "waiting"]);
|
|
26
|
+
|
|
27
|
+
const segmenter = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : undefined;
|
|
28
|
+
|
|
29
|
+
/** Unicode-safe clip: at most `limit` graphemes, folding the final slot into an ellipsis when cut. */
|
|
30
|
+
function clipLabel(text: string, limit: number = MAX_LABEL): string {
|
|
31
|
+
const value = clean(text);
|
|
32
|
+
const parts = segmenter ? [...segmenter.segment(value)].map((part) => part.segment) : Array.from(value);
|
|
33
|
+
if (parts.length <= limit) return value;
|
|
34
|
+
return `${parts.slice(0, Math.max(0, limit - 1)).join("")}…`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fixed, model-readable closing rules. Never count time or live activity. */
|
|
38
|
+
function guidance(): string[] {
|
|
39
|
+
return [
|
|
40
|
+
"Notes: this checkpoint is a baseline snapshot; later tool results take precedence.",
|
|
41
|
+
"A returned report is not an accepted Todo; use subagent_inspect output:true only when a report is missing.",
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure, deterministic checkpoint of the Todo DAG plus the Jobs that touch it. The summary is
|
|
47
|
+
* display input for a model request: it names state and open work but never carries child output,
|
|
48
|
+
* descriptions, profiles, models or tools, and identical inputs always produce identical text.
|
|
49
|
+
*/
|
|
50
|
+
export function checkpointSummary(state: WorkflowState, jobs: readonly CheckpointJob[], options: CheckpointSummaryOptions = {}): string | undefined {
|
|
51
|
+
const lines: string[] = [HEADING];
|
|
52
|
+
const activeJobs = jobs.filter((job) => ACTIVE_STATUSES.has(job.status));
|
|
53
|
+
|
|
54
|
+
// Protected state is reported as unknown; no count, Job link, or completion is invented.
|
|
55
|
+
if (options.protectedState) {
|
|
56
|
+
lines.push("state unavailable: Todo state could not be read; no task is implied complete.");
|
|
57
|
+
lines.push(...guidance());
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const all = state.tasks;
|
|
62
|
+
const live = all.filter((task) => task.status !== "deleted");
|
|
63
|
+
const unfinished = live.filter((task) => task.status !== "completed");
|
|
64
|
+
const isNewSession = all.length === 0 && state.nextId === 1 && !state.plan && activeJobs.length === 0;
|
|
65
|
+
if (isNewSession && !options.forceEmpty) return undefined;
|
|
66
|
+
|
|
67
|
+
const statusById = new Map(all.map((task) => [task.id, task.status]));
|
|
68
|
+
const unfinishedPrereqs = (task: Todo): number[] => task.blockedBy.filter((id) => statusById.get(id) !== "completed");
|
|
69
|
+
const latestByTodo = new Map<number, CheckpointJob>();
|
|
70
|
+
for (const job of jobs) if (job.todoId !== undefined) latestByTodo.set(job.todoId, job);
|
|
71
|
+
const latestJob = (todoId: number): CheckpointJob | undefined => latestByTodo.get(todoId);
|
|
72
|
+
/** A completed, non-stale report is the only thing that marks work as awaiting verification. */
|
|
73
|
+
const returned = (job: CheckpointJob | undefined): boolean => job !== undefined && job.status === "completed" && job.taskReportStale !== true;
|
|
74
|
+
const rank = (task: Todo): number => {
|
|
75
|
+
if (task.status === 'failed' || task.status === 'cancelled') return 4;
|
|
76
|
+
if (task.status === "in_progress") return returned(latestJob(task.id)) ? 0 : 1;
|
|
77
|
+
return unfinishedPrereqs(task).length === 0 ? 2 : 3;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const countBy = (status: Todo["status"]): number => live.filter((task) => task.status === status).length;
|
|
81
|
+
const counts: string[] = [];
|
|
82
|
+
const inProgress = countBy("in_progress");
|
|
83
|
+
const pending = countBy("pending");
|
|
84
|
+
const completed = countBy("completed");
|
|
85
|
+
if (inProgress) counts.push(`${inProgress} in_progress`);
|
|
86
|
+
if (pending) counts.push(`${pending} pending`);
|
|
87
|
+
if (completed) counts.push(`${completed} completed`);
|
|
88
|
+
const failed = countBy('failed'); const cancelled = countBy('cancelled');
|
|
89
|
+
if (failed) counts.push(`${failed} failed`);
|
|
90
|
+
if (cancelled) counts.push(`${cancelled} cancelled`);
|
|
91
|
+
const deleted = all.length - live.length;
|
|
92
|
+
lines.push(counts.length ? `Todos: ${counts.join(", ")} (${live.length} total${deleted ? `, ${deleted} deleted` : ""})` : "Todos: none");
|
|
93
|
+
if (state.plan) lines.push("Plan: on (read-only; tasks cannot start or complete).");
|
|
94
|
+
|
|
95
|
+
const ordered = unfinished
|
|
96
|
+
.map((task, index) => ({ task, index, rank: rank(task) }))
|
|
97
|
+
.sort((left, right) => left.rank - right.rank || left.index - right.index)
|
|
98
|
+
.map((entry) => entry.task);
|
|
99
|
+
const shown = ordered.slice(0, MAX_UNFINISHED);
|
|
100
|
+
|
|
101
|
+
const taskLine = (task: Todo): string => {
|
|
102
|
+
const job = latestJob(task.id);
|
|
103
|
+
const stateLabel = task.status === 'failed' || task.status === 'cancelled' ? task.status : task.status === "in_progress"
|
|
104
|
+
? (returned(job) ? job?.reportDelivery === "delivered" ? "in_progress, report returned - verify" : "in_progress, execution ended - inspect report" : "in_progress")
|
|
105
|
+
: unfinishedPrereqs(task).length ? "pending, blocked" : "pending, ready";
|
|
106
|
+
let line = `- #${task.id} [${stateLabel}] ${clipLabel(task.subject)}`;
|
|
107
|
+
if (typeof task.metadata?.preset === "string") {
|
|
108
|
+
const run = typeof task.metadata.run === "number" && Number.isSafeInteger(task.metadata.run) && task.metadata.run > 0 ? ` run ${task.metadata.run}` : "";
|
|
109
|
+
const step = typeof task.metadata.step === "number" && Number.isSafeInteger(task.metadata.step) && task.metadata.step > 0 ? ` step ${task.metadata.step}` : "";
|
|
110
|
+
line += ` (fragment ${clipLabel(task.metadata.preset)}${run}${step})`;
|
|
111
|
+
}
|
|
112
|
+
const prereqs = unfinishedPrereqs(task);
|
|
113
|
+
if (prereqs.length) line += ` (unfinished prerequisites: ${prereqs.map((id) => `#${id}`).join(", ")})`;
|
|
114
|
+
if (job) {
|
|
115
|
+
const delivery = job.reportDelivery ? `, report ${job.reportDelivery}` : "";
|
|
116
|
+
const stale = job.taskReportStale ? " [historical output only]" : "";
|
|
117
|
+
line += ` — latest job ${clean(job.id)}: ${clean(job.status)}${delivery}${stale}`;
|
|
118
|
+
}
|
|
119
|
+
return line;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (ordered.length === 0) {
|
|
123
|
+
lines.push("No unfinished Todos.");
|
|
124
|
+
} else {
|
|
125
|
+
lines.push(ordered.length > shown.length ? `Unfinished Todos (${shown.length} of ${ordered.length}):` : "Unfinished Todos:");
|
|
126
|
+
for (const task of shown) lines.push(taskLine(task));
|
|
127
|
+
if (ordered.length > shown.length) lines.push(`Hidden: ${ordered.length - shown.length} more unfinished Todos; use "todo list" or "todo get" for details.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (activeJobs.length) {
|
|
131
|
+
const listed = activeJobs.slice(0, MAX_ACTIVE_JOBS);
|
|
132
|
+
lines.push(activeJobs.length > listed.length ? `Active Jobs (${listed.length} of ${activeJobs.length}):` : "Active Jobs:");
|
|
133
|
+
for (const job of listed) {
|
|
134
|
+
const label = job.label ? clipLabel(job.label) : "(no label)";
|
|
135
|
+
// An active Job bound to an already completed Todo is still real work and stays visible.
|
|
136
|
+
const boundStatus = job.todoId === undefined ? undefined : statusById.get(job.todoId);
|
|
137
|
+
const bound = job.todoId === undefined ? "standalone" : `todo #${job.todoId}${boundStatus ? ` (${boundStatus})` : ""}`;
|
|
138
|
+
lines.push(`- ${clean(job.id)} [${clean(job.status)}] ${label} — ${bound}`);
|
|
139
|
+
}
|
|
140
|
+
if (activeJobs.length > listed.length) lines.push(`Hidden: ${activeJobs.length - listed.length} more active Jobs; use subagent_inspect for details.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (failed || cancelled) lines.push('Failed/cancelled records describe closed attempts, not verified Goal completion.');
|
|
144
|
+
lines.push(...guidance());
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ClearScope, Todo } from "../../shared/task-types.ts";
|
|
2
|
+
|
|
3
|
+
interface TaskCleanup { tasks: Todo[]; removedIds: number[]; keptIds: number[] }
|
|
4
|
+
/** Remove eligible records together, retaining the complete prerequisite closure of survivors. */
|
|
5
|
+
export function planTaskCleanup(tasks: readonly Todo[], scope: ClearScope, protectedIds: ReadonlySet<number> = new Set(), outcomes?: ReadonlyMap<number, string>): TaskCleanup {
|
|
6
|
+
if (!['all', 'completed', 'closed'].includes(scope)) throw new Error('Unknown cleanup scope');
|
|
7
|
+
const eligible = new Set(tasks.filter((task) => {
|
|
8
|
+
const status = outcomes?.get(task.id) ?? task.status;
|
|
9
|
+
if (scope === 'all') return true;
|
|
10
|
+
if (scope === 'completed') return status === 'completed';
|
|
11
|
+
return ['completed', 'failed', 'cancelled', 'deleted'].includes(status);
|
|
12
|
+
}).map((task) => task.id));
|
|
13
|
+
const removable = new Set([...eligible].filter((id) => !protectedIds.has(id)));
|
|
14
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
15
|
+
const queue = tasks.filter((task) => task.status !== 'deleted' && !removable.has(task.id)).map((task) => task.id);
|
|
16
|
+
// Live retained records (including retryable failures) retain their prerequisites.
|
|
17
|
+
// Permanent deleted tombstones never anchor work and may retain historical references.
|
|
18
|
+
for (let index = 0; index < queue.length; index++) {
|
|
19
|
+
for (const id of byId.get(queue[index]!)?.blockedBy ?? []) {
|
|
20
|
+
if (!removable.delete(id)) continue;
|
|
21
|
+
queue.push(id);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { tasks: tasks.filter((task) => !removable.has(task.id)), removedIds: tasks.filter((task) => removable.has(task.id)).map((task) => task.id), keptIds: tasks.filter((task) => eligible.has(task.id) && !removable.has(task.id)).map((task) => task.id) };
|
|
25
|
+
}
|