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.
Files changed (108) hide show
  1. package/CHANGELOG.md +454 -0
  2. package/LICENSE +21 -0
  3. package/README.md +211 -2
  4. package/README.zh-CN.md +212 -0
  5. package/docs/COMMUNICATION.md +129 -0
  6. package/docs/RELEASING.md +66 -0
  7. package/package.json +70 -4
  8. package/src/agents/commands/child.ts +148 -0
  9. package/src/agents/commands/register.ts +404 -0
  10. package/src/agents/domain/context.ts +183 -0
  11. package/src/agents/domain/diagnostics.ts +32 -0
  12. package/src/agents/domain/owner.ts +448 -0
  13. package/src/agents/domain/profiles.ts +216 -0
  14. package/src/agents/domain/runtime.ts +603 -0
  15. package/src/agents/domain/schema.ts +15 -0
  16. package/src/agents/index.ts +5 -0
  17. package/src/agents/io/contracts.ts +50 -0
  18. package/src/agents/io/host.ts +16 -0
  19. package/src/agents/io/notices.ts +27 -0
  20. package/src/agents/io/persistence.ts +210 -0
  21. package/src/agents/io/process.ts +100 -0
  22. package/src/agents/io/profile-file.ts +16 -0
  23. package/src/agents/io/skills.ts +46 -0
  24. package/src/goal/commands/register.ts +235 -0
  25. package/src/goal/domain/failure.ts +14 -0
  26. package/src/goal/domain/owner.ts +575 -0
  27. package/src/goal/domain/policy.ts +8 -0
  28. package/src/goal/domain/prompt.ts +57 -0
  29. package/src/goal/domain/state.ts +176 -0
  30. package/src/goal/index.ts +5 -0
  31. package/src/goal/io/host.ts +8 -0
  32. package/src/plan/commands/register.ts +101 -0
  33. package/src/plan/domain/owner.ts +51 -0
  34. package/src/plan/domain/policy.ts +27 -0
  35. package/src/plan/domain/state.ts +15 -0
  36. package/src/plan/index.ts +13 -0
  37. package/src/plan/io/records.ts +3 -0
  38. package/src/retry/commands/menu.ts +158 -0
  39. package/src/retry/commands/register.ts +26 -0
  40. package/src/retry/domain/owner.ts +418 -0
  41. package/src/retry/domain/state.ts +71 -0
  42. package/src/retry/index.ts +5 -0
  43. package/src/retry/io/host.ts +15 -0
  44. package/src/shared/agent-view.ts +18 -0
  45. package/src/shared/completion.ts +111 -0
  46. package/src/shared/config-file.ts +106 -0
  47. package/src/shared/config.ts +112 -0
  48. package/src/shared/dag/cache.ts +26 -0
  49. package/src/shared/dag/graph.ts +170 -0
  50. package/src/shared/dag/layout.ts +130 -0
  51. package/src/shared/goal-view.ts +2 -0
  52. package/src/shared/i18n.ts +62 -0
  53. package/src/shared/message-types.ts +12 -0
  54. package/src/shared/messages.en.ts +1290 -0
  55. package/src/shared/passive-message.ts +50 -0
  56. package/src/shared/pi-cli.ts +29 -0
  57. package/src/shared/profile-launch.ts +110 -0
  58. package/src/shared/state-types.ts +5 -0
  59. package/src/shared/task-schema.ts +39 -0
  60. package/src/shared/task-types.ts +25 -0
  61. package/src/shared/terminal.ts +19 -0
  62. package/src/shared/tool-info.ts +8 -0
  63. package/src/shared/ui/filter.ts +144 -0
  64. package/src/shared/ui/menu.ts +447 -0
  65. package/src/shared/ui/panel.ts +768 -0
  66. package/src/shared/ui/presentation.ts +624 -0
  67. package/src/shared/ui/render-cache.ts +63 -0
  68. package/src/shared/ui/render.ts +641 -0
  69. package/src/shared/ui/symbols.ts +136 -0
  70. package/src/team/commands/bootstrap.ts +52 -0
  71. package/src/team/commands/controls.ts +91 -0
  72. package/src/team/commands/history.ts +100 -0
  73. package/src/team/commands/menu.ts +455 -0
  74. package/src/team/commands/register.ts +1321 -0
  75. package/src/team/domain/inbox.ts +178 -0
  76. package/src/team/domain/launcher.ts +355 -0
  77. package/src/team/domain/lifecycle.ts +34 -0
  78. package/src/team/domain/teams.ts +161 -0
  79. package/src/team/index.ts +4 -0
  80. package/src/team/io/inbox.ts +255 -0
  81. package/src/team/io/launcher.ts +270 -0
  82. package/src/team/io/lock.ts +19 -0
  83. package/src/team/io/registry.ts +477 -0
  84. package/src/team/io/runtime.ts +353 -0
  85. package/src/team/io/session-activity.ts +185 -0
  86. package/src/team/io/session-delete.ts +98 -0
  87. package/src/team/io/session-history.ts +153 -0
  88. package/src/team/io/session-tail.ts +132 -0
  89. package/src/team/io/sid-lock.ts +115 -0
  90. package/src/team/io/sid-session.ts +98 -0
  91. package/src/todos/commands/checkpoints.ts +31 -0
  92. package/src/todos/commands/register.ts +395 -0
  93. package/src/todos/domain/checkpoint-state.ts +32 -0
  94. package/src/todos/domain/checkpoint-summary.ts +146 -0
  95. package/src/todos/domain/cleanup.ts +25 -0
  96. package/src/todos/domain/owner.ts +109 -0
  97. package/src/todos/domain/presets.ts +192 -0
  98. package/src/todos/domain/state.ts +287 -0
  99. package/src/todos/index.ts +15 -0
  100. package/src/todos/io/records.ts +8 -0
  101. package/src/ui/index.ts +4 -0
  102. package/src/workflow/commands/connect.ts +81 -0
  103. package/src/workflow/commands/view.ts +121 -0
  104. package/src/workflow/domain/features.ts +5 -0
  105. package/src/workflow/domain/view-owner.ts +18 -0
  106. package/src/workflow/domain/view-state.ts +12 -0
  107. package/src/workflow/index.ts +225 -0
  108. package/src/workflow/io/session.ts +48 -0
@@ -0,0 +1,109 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { truncateToWidth } from '@earendil-works/pi-tui';
3
+ import { chinese, type Translator } from '../../shared/i18n.ts';
4
+ import { clean, notify } from '../../shared/terminal.ts';
5
+ import type { AgentView } from '../../shared/agent-view.ts';
6
+ import type { ClearScope, Todo, WorkflowState } from '../../shared/task-types.ts';
7
+ import { latestBoundJobs, taskFilterStatus } from '../../shared/ui/filter.ts';
8
+ import { configPaths } from '../../shared/config.ts';
9
+ import { PresetStore, resetClosure } from './presets.ts';
10
+ import { appendTodoRecord } from '../io/records.ts';
11
+ import { STATE_TYPE, applyTodo, clearTodoRecords, emptyState, newlyReady, restoreState, validateState, validateTodoFields } from "./state.ts";
12
+ import type { TodoParams } from "../../shared/task-schema.ts";
13
+
14
+ interface Hooks {
15
+ msg?: Translator;
16
+ api(): ExtensionAPI;
17
+ branch(ctx: ExtensionContext): ReturnType<ExtensionContext['sessionManager']['getBranch']>;
18
+ plan(): { enabled: boolean; tools: string[] };
19
+ jobs(): readonly AgentView[];
20
+ paint(ctx: ExtensionContext): void;
21
+ saved(ctx: ExtensionContext): void;
22
+ changed(ids?: readonly number[]): void;
23
+ assertMutation(params: TodoParams): void;
24
+ afterMutation(params: TodoParams, before: WorkflowState): void;
25
+ cleanupProtection(tasks: readonly Todo[]): ReadonlySet<number>;
26
+ cleanupRecords(scope: ClearScope, before: readonly Todo[], after: readonly Todo[]): number;
27
+ }
28
+
29
+ export function createTodosOwner(hooks: Hooks) {
30
+ const msg = hooks.msg ?? chinese;
31
+ let tasks = emptyState(), error: string | undefined, epoch = 0;
32
+ let legacyPlanFields: { plan?: unknown; planTools?: unknown } = {};
33
+ const presets = new PresetStore({ path: configPaths().preset });
34
+ let presetList = presets.list();
35
+ const state = (): WorkflowState => ({ ...tasks, tasks: error ? [] : tasks.tasks, plan: hooks.plan().enabled, planTools: hooks.plan().tools });
36
+ function commit(next: WorkflowState, ctx: ExtensionContext, source: 'tool' | 'command' = 'command') {
37
+ if (error) throw new Error(msg`状态恢复失败,不能修改:${error}`);
38
+ if (next.tasks === tasks.tasks && next.nextId === tasks.nextId && next.visible === tasks.visible && next.view === tasks.view && next.treeStyle === tasks.treeStyle && next.filter === tasks.filter) return;
39
+ const changed = next.tasks !== tasks.tasks, before = tasks;
40
+ appendTodoRecord(hooks.api(), STATE_TYPE, { ...next, plan: legacyPlanFields.plan ?? false, planTools: legacyPlanFields.planTools ?? [] });
41
+ tasks = { ...next, plan: false, planTools: [] };
42
+ if (source === 'command' && changed) {
43
+ const previous = new Map(before.tasks.map(task => [task.id, task]));
44
+ hooks.changed([...new Set([...next.tasks.filter(task => previous.get(task.id) !== task).map(task => task.id), ...before.tasks.filter(task => !next.tasks.some(item => item.id === task.id)).map(task => task.id)])]);
45
+ }
46
+ hooks.paint(ctx); hooks.saved(ctx);
47
+ }
48
+ function restore(ctx: ExtensionContext) {
49
+ epoch++; legacyPlanFields = {};
50
+ try {
51
+ const branch = hooks.branch(ctx);
52
+ const saved = branch.findLast(item => item.type === 'custom' && item.customType === STATE_TYPE);
53
+ const legacy = saved?.type === 'custom' ? structuredClone(saved.data as WorkflowState) : restoreState(branch, msg);
54
+ legacyPlanFields = { plan: legacy?.plan, planTools: legacy?.planTools };
55
+ const value = { ...legacy, plan: false, planTools: [] };
56
+ validateState(value, msg); tasks = value; error = undefined;
57
+ } catch (cause) { tasks = emptyState(); error = String(cause); notify(ctx, msg`工作流状态恢复失败:${error}。保留原记录,修改已禁用;/todos clear 可明确重置。`, 'error'); }
58
+ }
59
+ async function refreshPresets() { await presets.load(); presetList = presets.list(); }
60
+ async function restorePresets(ctx: ExtensionContext) {
61
+ const own = epoch;
62
+ try { await refreshPresets(); if (own === epoch) hooks.paint(ctx); }
63
+ catch (cause) { if (own === epoch) notify(ctx, msg`Task 片段(preset)配置无效:${String(cause)}`, 'error'); }
64
+ }
65
+ function mutate(params: TodoParams, ctx: ExtensionContext, source: 'tool' | 'command' = 'tool') {
66
+ if (error && !['list', 'get', 'status', 'presets'].includes(params.action)) throw new Error(msg`状态恢复失败,不能修改:${error}`);
67
+ if (params.action === 'clear') {
68
+ validateTodoFields(params, msg);
69
+ const before = state(), scope = params.scope ?? 'all', latest = latestBoundJobs(hooks.jobs());
70
+ const outcomes = new Map(before.tasks.filter(task => task.status !== 'deleted').map(task => [task.id, taskFilterStatus(task, latest.get(task.id))]));
71
+ const result = clearTodoRecords(before, scope, hooks.cleanupProtection(before.tasks), outcomes, msg);
72
+ commit(result.state, ctx, source);
73
+ const removed = hooks.cleanupRecords(scope, before.tasks, tasks.tasks);
74
+ return { ...result, text: `${result.text}\n${msg`已清理 ${removed} 个已结束子 Agent 记录;运行及待交付/核验记录保留`}` };
75
+ }
76
+ if (params.action !== 'batch') hooks.assertMutation(params);
77
+ const before = state(), result = applyTodo(before, params, msg, presets);
78
+ if (['list', 'get', 'status', 'presets'].includes(params.action)) return result;
79
+ if (result.operations) for (const operation of result.operations) hooks.assertMutation(operation);
80
+ const ready = newlyReady(before, result.state); commit(result.state, ctx, source);
81
+ if (result.operations) {
82
+ const updates = new Map<number, TodoParams>();
83
+ for (const operation of result.operations) if (operation.action === 'update' && operation.id !== undefined) { const previous = updates.get(operation.id); updates.set(operation.id, { ...previous, ...operation, addBlockedBy: [...previous?.addBlockedBy ?? [], ...operation.addBlockedBy ?? []], removeBlockedBy: [...previous?.removeBlockedBy ?? [], ...operation.removeBlockedBy ?? []] }); }
84
+ for (const operation of updates.values()) hooks.afterMutation(operation, before);
85
+ } else hooks.afterMutation(params, before);
86
+ if (ready.length) {
87
+ const names = ready.slice(0, 5).map(task => `#${task.id} ${truncateToWidth(clean(task.subject), 24)}`).join('、');
88
+ result.text += `\n${msg`前置已完成,可开始:${names}${ready.length > 5 ? msg` 等 ${ready.length} 项` : ''}`}`;
89
+ }
90
+ return result;
91
+ }
92
+ return {
93
+ state, commit, restore, mutate, refreshPresets, restorePresets,
94
+ validateDispatch(before: WorkflowState, id: number) { applyTodo(before, { action: 'update', id, status: 'in_progress' }, msg); },
95
+ resetTasks(params: TodoParams) { return resetClosure(state().tasks, params.preset ?? '', params.step, params.run); },
96
+ presets: () => presetList,
97
+ protected: () => !!error,
98
+ error: () => error,
99
+ reset(ctx: ExtensionContext) { error = undefined; commit(emptyState(), ctx); },
100
+ invalidate() { epoch++; },
101
+ shutdown() { epoch++; tasks = emptyState(); error = undefined; },
102
+ previewClear(scope: ClearScope) {
103
+ const current = state(), latest = latestBoundJobs(hooks.jobs());
104
+ const outcomes = new Map(current.tasks.filter(task => task.status !== 'deleted').map(task => [task.id, taskFilterStatus(task, latest.get(task.id))]));
105
+ const preview = clearTodoRecords(current, scope, hooks.cleanupProtection(current.tasks), outcomes, msg);
106
+ return `${msg`预计清理 ${preview.removedIds.length} 项,保留 ${preview.keptIds.length} 项`}\n${msg('运行、待交付、待核验和依赖必需记录保留;不修改文件、Goal或对话历史。')}`;
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,192 @@
1
+ import { readPresetData } from '../io/records.ts';
2
+ import type { Todo } from "../../shared/task-types.ts";
3
+
4
+ /**
5
+ * A reusable task fragment for workflows that repeat. Definitions live in one user-level file;
6
+ * instantiating one appends real Todos tagged with `metadata.preset`/`run`/`key`, so a fragment
7
+ * can be reopened later without touching the rest of the list.
8
+ */
9
+ interface PresetStep {
10
+ key: string;
11
+ subject: string;
12
+ description?: string;
13
+ activeForm?: string;
14
+ owner?: string;
15
+ after?: string[];
16
+ }
17
+ export interface Preset { name: string; description?: string; skill?: string; steps: PresetStep[] }
18
+ interface PresetOptions { path?: string }
19
+ interface ExpandedPreset { name: string; skill?: string; run: number; tasks: Todo[]; ids: Map<string, number> }
20
+
21
+ const MAX_PRESETS = 64;
22
+ const MAX_STEPS = 32;
23
+ const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$/;
24
+ const KEY = /^[a-z0-9][a-z0-9_-]{0,31}$/;
25
+
26
+ function object(value: unknown): Record<string, unknown> {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a preset configuration object");
28
+ return value as Record<string, unknown>;
29
+ }
30
+ function exactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
31
+ const unknown = Object.keys(value).find((key) => !keys.includes(key));
32
+ if (unknown) throw new Error(`Unknown preset field: ${unknown}`);
33
+ }
34
+ function text(value: unknown, field: string, max: number, pattern?: RegExp): string {
35
+ if (typeof value !== "string") throw new Error(`Invalid preset ${field}`);
36
+ const clean = value.replace(/[\r\n\t]/g, " ").replace(/[\p{Cc}\p{Cf}]/gu, "").trim();
37
+ if (!clean || clean.length > max || (pattern && !pattern.test(clean))) throw new Error(`Invalid preset ${field}`);
38
+ return clean;
39
+ }
40
+
41
+ /** In-memory presets, reloaded from disk before every use so edits apply without a restart. */
42
+ export class PresetStore {
43
+ private presets = new Map<string, Preset>();
44
+ private readonly options: PresetOptions;
45
+ constructor(options: PresetOptions = {}) { this.options = options; }
46
+ private step(value: unknown, seen: Set<string>): PresetStep {
47
+ const record = object(value);
48
+ exactKeys(record, ["key", "subject", "description", "activeForm", "owner", "after"]);
49
+ const key = text(record.key, "step key", 32, KEY);
50
+ if (seen.has(key)) throw new Error(`Duplicate preset step key: ${key}`);
51
+ seen.add(key);
52
+ const after = record.after === undefined ? undefined : (Array.isArray(record.after) ? record.after.map((item) => text(item, "step dependency", 32, KEY)) : (() => { throw new Error("Invalid preset step dependencies"); })());
53
+ return {
54
+ key,
55
+ subject: text(record.subject, "step subject", 200),
56
+ ...(record.description === undefined ? {} : { description: text(record.description, "step description", 400) }),
57
+ ...(record.activeForm === undefined ? {} : { activeForm: text(record.activeForm, "step activeForm", 200) }),
58
+ ...(record.owner === undefined ? {} : { owner: text(record.owner, "step owner", 48, NAME) }),
59
+ ...(after && after.length ? { after } : {}),
60
+ };
61
+ }
62
+ private preset(value: unknown): Preset {
63
+ const record = object(value);
64
+ exactKeys(record, ["name", "description", "skill", "steps"]);
65
+ const name = text(record.name, "name", 48, NAME);
66
+ if (!Array.isArray(record.steps) || !record.steps.length || record.steps.length > MAX_STEPS) throw new Error(`Preset ${name} needs 1-${MAX_STEPS} steps`);
67
+ const seen = new Set<string>();
68
+ const steps = record.steps.map((item) => this.step(item, seen));
69
+ for (const step of steps) for (const dependency of step.after ?? []) {
70
+ if (dependency === step.key) throw new Error(`Preset ${name}: step ${step.key} depends on itself`);
71
+ if (!seen.has(dependency)) throw new Error(`Preset ${name}: step ${step.key} depends on unknown key ${dependency}`);
72
+ }
73
+ // Any order the definition uses must be resolvable: reject cycles before they reach a session.
74
+ const order = new Map(steps.map((step) => [step.key, step.after ?? []]));
75
+ const done = new Set<string>();
76
+ while (done.size < order.size) {
77
+ const ready = [...order.keys()].filter((key) => !done.has(key) && (order.get(key) ?? []).every((dependency) => done.has(dependency)));
78
+ if (!ready.length) throw new Error(`Preset ${name} has a dependency cycle`);
79
+ for (const key of ready) done.add(key);
80
+ }
81
+ return {
82
+ name,
83
+ ...(record.description === undefined ? {} : { description: text(record.description, "description", 200) }),
84
+ ...(record.skill === undefined ? {} : { skill: text(record.skill, "skill", 64, NAME) }),
85
+ steps,
86
+ };
87
+ }
88
+ get(name: string): Preset | undefined { const preset = this.presets.get(name); return preset ? structuredClone(preset) : undefined; }
89
+ list(): Preset[] { return structuredClone([...this.presets.values()]); }
90
+ /** Replace the loaded set from the configuration file; a missing file means no presets. */
91
+ async load(): Promise<void> {
92
+ if (!this.options.path) { this.presets.clear(); return; }
93
+ let data: unknown;
94
+ try { data = await readPresetData(this.options.path); }
95
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { this.presets.clear(); return; } throw error; }
96
+ const config = object(data);
97
+ exactKeys(config, ["presets"]);
98
+ if (!Array.isArray(config.presets) || config.presets.length > MAX_PRESETS) throw new Error(`presets must contain at most ${MAX_PRESETS} presets`);
99
+ const validated = config.presets.map((item) => this.preset(item));
100
+ if (new Set(validated.map((preset) => preset.name)).size !== validated.length) throw new Error("Duplicate preset name");
101
+ this.presets = new Map(validated.map((preset) => [preset.name, preset]));
102
+ }
103
+ }
104
+
105
+ const PLACEHOLDER = /\{([a-zA-Z0-9_]+)\}/g;
106
+
107
+ /** Fill `{name}` placeholders from the caller's variables; a missing value is an error, never a guess. */
108
+ export function fill(text: string, vars: Readonly<Record<string, string>> | undefined, where: string): string {
109
+ return text.replace(PLACEHOLDER, (_match, name: string) => {
110
+ const value = vars && Object.hasOwn(vars, name) ? vars[name] : undefined;
111
+ if (typeof value !== "string") throw new Error(`${where} needs a value for {${name}}`);
112
+ return value;
113
+ });
114
+ }
115
+
116
+ /** The next run number for a preset: instances never reuse ids, so runs only ever grow. */
117
+ function validRun(task: Todo, name: string): number | undefined {
118
+ const run = task.metadata?.run;
119
+ return task.metadata?.preset === name && typeof run === "number" && Number.isSafeInteger(run) && run > 0 ? run : undefined;
120
+ }
121
+ export function nextRun(tasks: readonly Todo[], name: string): number {
122
+ const next = tasks.reduce((max, task) => Math.max(max, validRun(task, name) ?? 0), 0) + 1;
123
+ if (!Number.isSafeInteger(next)) throw new Error("Preset run counter exhausted");
124
+ return next;
125
+ }
126
+
127
+ /** Turn a definition into concrete Todos; dependencies resolve to the ids this run creates. */
128
+ export function expand(preset: Preset, run: number, startId: number, vars?: Readonly<Record<string, string>>): ExpandedPreset {
129
+ const ids = new Map<string, number>();
130
+ const created: Todo[] = [];
131
+ if (!Number.isSafeInteger(startId + preset.steps.length)) throw new Error("Todo id counter exhausted");
132
+ // Resolve the complete run first: acyclic definitions may refer to a later-listed step.
133
+ for (const [index, step] of preset.steps.entries()) ids.set(step.key, startId + index);
134
+ for (const step of preset.steps) {
135
+ const id = ids.get(step.key)!;
136
+ created.push({
137
+ id,
138
+ subject: fill(step.subject, vars, `preset ${preset.name} step ${step.key}`),
139
+ status: "pending",
140
+ blockedBy: (step.after ?? []).map((key) => {
141
+ const dependency = ids.get(key);
142
+ if (dependency === undefined) throw new Error(`preset ${preset.name} step ${step.key} depends on unknown key ${key}`);
143
+ return dependency;
144
+ }),
145
+ ...(step.description === undefined ? {} : { description: fill(step.description, vars, `preset ${preset.name} step ${step.key}`) }),
146
+ ...(step.activeForm === undefined ? {} : { activeForm: step.activeForm }),
147
+ ...(step.owner === undefined ? {} : { owner: step.owner }),
148
+ metadata: { preset: preset.name, run, key: step.key, step: created.length + 1 },
149
+ });
150
+ }
151
+ return { name: preset.name, ...(preset.skill === undefined ? {} : { skill: preset.skill }), run, tasks: created, ids };
152
+ }
153
+
154
+ /** One instantiated fragment on the current list, newest run first. */
155
+ export function instances(tasks: readonly Todo[], name: string, run?: number): number[] {
156
+ const newest = tasks.reduce((max, task) => Math.max(max, validRun(task, name) ?? 0), 0);
157
+ if (!newest) return [];
158
+ const wanted = run ?? newest;
159
+ return tasks.filter((task) => task.status !== "deleted" && validRun(task, name) === wanted).map((task) => task.id);
160
+ }
161
+
162
+ /**
163
+ * Ids a reset has to reopen: the requested step (or the whole instance) plus every task that
164
+ * depends on it, so a reopened step can never leave a finished successor behind.
165
+ */
166
+ export function resetClosure(tasks: readonly Todo[], name: string, step?: string, run?: number): number[] {
167
+ const target = new Set(instances(tasks, name, run));
168
+ if (!target.size) return [];
169
+ if (step !== undefined) {
170
+ const start = tasks.find((task) => target.has(task.id) && task.metadata?.key === step);
171
+ if (!start) return [];
172
+ target.clear(); target.add(start.id);
173
+ }
174
+ // Both whole-run and single-step resets include live external successors. Deleted rows stay
175
+ // deleted. A successor adjacency map makes the closure linear even for reverse-listed chains.
176
+ const successors = new Map<number, number[]>();
177
+ for (const task of tasks) {
178
+ if (task.status === "deleted") continue;
179
+ for (const id of task.blockedBy) {
180
+ const children = successors.get(id) ?? [];
181
+ children.push(task.id); successors.set(id, children);
182
+ }
183
+ }
184
+ const queue = [...target];
185
+ for (let index = 0; index < queue.length; index++) {
186
+ for (const id of successors.get(queue[index]!) ?? []) {
187
+ if (target.has(id)) continue;
188
+ target.add(id); queue.push(id);
189
+ }
190
+ }
191
+ return tasks.filter((task) => target.has(task.id)).map((task) => task.id);
192
+ }
@@ -0,0 +1,287 @@
1
+ import type { TodoParams, TodoBatchOperation } from '../../shared/task-schema.ts';
2
+ import { TODO_FILTERS, type TodoStatus, type ClearScope, type Todo, type WorkflowState } from "../../shared/task-types.ts";
3
+ import { translatedTaskStatusLabels as translatedStatusLabel, WORKFLOW_STATE_TYPE } from '../../shared/state-types.ts';
4
+ import { dagStructure, reuseDagStructure } from "../../shared/dag/cache.ts";
5
+ import { newRedundantEdges } from "../../shared/dag/graph.ts";
6
+ import { chinese, type Translator } from "../../shared/i18n.ts";
7
+ import { expand, nextRun, resetClosure, type Preset, type PresetStore } from "./presets.ts";
8
+ import { planTaskCleanup } from './cleanup.ts';
9
+
10
+ export const STATE_TYPE = WORKFLOW_STATE_TYPE;
11
+ interface TodoPage { total: number; count: number; limit: number; afterId: number; nextAfterId?: number; next?: TodoParams }
12
+ interface TodoRelations { blocks: number[]; unmetPrerequisites: number[] }
13
+ /** One bounded read-only answer: current mode plus per-status counts. Deleted is listed separately. */
14
+ interface TodoStatusSummary { mode: 'normal' | 'plan'; total: number; counts: Record<Exclude<TodoStatus, 'deleted'>, number>; deleted: number }
15
+ export interface TodoResult { state: WorkflowState; text: string; tasks?: Todo[]; page?: TodoPage; relations?: TodoRelations; removedIds?: number[]; refs?: Record<string, number>; operations?: TodoParams[]; presets?: { name: string; description?: string; steps: number }[]; preset?: Preset; status?: TodoStatusSummary }
16
+ export const emptyState = (): WorkflowState => ({ version: 1, tasks: [], nextId: 1, plan: false, planTools: [], visible: true, view: "list", treeStyle: "paths" });
17
+
18
+ const fields: Record<string, readonly string[]> = {
19
+ create: ["subject", "description", "activeForm", "owner", "metadata", "blockedBy", "status"],
20
+ update: ["id", "subject", "description", "activeForm", "owner", "metadata", "status", "addBlockedBy", "removeBlockedBy"],
21
+ list: ["status", "includeDeleted", "limit", "afterId", "ready"], get: ["id"], delete: ["id"], clear: ['scope'],
22
+ apply: ["preset", "vars"], reset: ["preset", "run", "step"], presets: ["preset"], status: [],
23
+ batch: ['operations'],
24
+ };
25
+ const transitions: Record<TodoStatus, readonly TodoStatus[]> = {
26
+ pending: ["pending", "in_progress", "completed", "failed", "cancelled", "deleted"],
27
+ in_progress: ["pending", "in_progress", "completed", "failed", "cancelled", "deleted"],
28
+ failed: ["pending", "in_progress", "completed", "failed", "cancelled", "deleted"],
29
+ cancelled: ["pending", "in_progress", "completed", "failed", "cancelled", "deleted"],
30
+ completed: ["completed", "deleted"], deleted: ["deleted"],
31
+ };
32
+ export function todoRef(todo: Todo): string {
33
+ return `#${todo.id}${todo.blockedBy.length ? `<-${todo.blockedBy.map((id) => `#${id}`).join(",")}` : ""}`;
34
+ }
35
+ export const statusLabel: Record<TodoStatus, string> = translatedStatusLabel(chinese);
36
+ const textFields = ["subject", "description", "activeForm", "owner"] as const;
37
+
38
+ function validateTask(task: Todo, msg: Translator = chinese): void {
39
+ if (!task || typeof task.subject !== "string" || !task.subject.trim() || Array.from(task.subject).length > 60) throw new Error(msg("标题需为 1–60 个字符;详细指示请写 description"));
40
+ for (const field of textFields) {
41
+ const value = task[field];
42
+ if (value !== undefined && (typeof value !== "string" || Buffer.byteLength(value) > 8192)) throw new Error(msg`${field} 需为不超过 8 KiB 的文本`);
43
+ }
44
+ if (!Object.hasOwn(transitions, task.status)) throw new Error(msg("未知任务状态"));
45
+ if (!Array.isArray(task.blockedBy) || task.blockedBy.some((id) => !Number.isSafeInteger(id) || id < 1)) throw new Error(msg("依赖需为有效任务编号"));
46
+ if (task.metadata !== undefined) {
47
+ if (!task.metadata || typeof task.metadata !== "object" || Array.isArray(task.metadata) || Buffer.byteLength(JSON.stringify(task.metadata)) > 8192) throw new Error(msg("metadata 需为不超过 8 KiB 的 JSON 对象"));
48
+ }
49
+ }
50
+ export function validateState(state: WorkflowState, msg: Translator = chinese): void {
51
+ if (!state || state.version !== 1 || !Array.isArray(state.tasks) || !Number.isSafeInteger(state.nextId) || state.nextId < 1) throw new Error(msg("不支持或损坏的工作流状态"));
52
+ if (typeof state.plan !== "boolean" || typeof state.visible !== "boolean" || !["list", "dag"].includes(state.view)) throw new Error(msg("损坏的模式/视图状态"));
53
+ if (state.treeStyle !== undefined && !["paths", "flat"].includes(state.treeStyle)) throw new Error(msg("损坏的任务树样式"));
54
+ if (state.filter !== undefined && !TODO_FILTERS.includes(state.filter)) throw new Error(msg('损坏的任务过滤器'));
55
+ if (!Array.isArray(state.planTools) || state.planTools.some((name) => typeof name !== "string" || !/^[\w-]+$/.test(name))) throw new Error(msg("损坏的 Plan 工具列表"));
56
+ for (const task of state.tasks) {
57
+ validateTask(task, msg);
58
+ if (task.id >= state.nextId) throw new Error(msg("任务计数器不能复用已有编号"));
59
+ }
60
+ dagStructure(state.tasks);
61
+ }
62
+
63
+ /** The scope as the user named it; the enum key never reaches a message. */
64
+ const scopeText = (scope: ClearScope, msg: Translator): string => scope === 'completed' ? msg('已完成记录') : scope === 'closed' ? msg('已完成/失败/取消记录') : msg('全部可清理记录');
65
+
66
+ /** Pure task cleanup. The runtime coordinator supplies job protections/outcomes and prunes jobs. */
67
+ export function clearTodoRecords(state: WorkflowState, scope: ClearScope = 'all', protectedIds: ReadonlySet<number> = new Set(), outcomes?: ReadonlyMap<number, string>, msg: Translator = chinese) {
68
+ const result = planTaskCleanup(state.tasks, scope, protectedIds, outcomes);
69
+ if (!result.removedIds.length) return { state, text: msg`没有可清理任务;保留 ${result.keptIds.length} 项(运行/待核验/依赖必需)`, ...result };
70
+ const next = { ...state, tasks: result.tasks };
71
+ validateState(next, msg);
72
+ return { state: next, text: msg`已清理 ${result.removedIds.length} 项(${scopeText(scope, msg)});保留 ${result.keptIds.length} 项,编号不复用`, ...result };
73
+ }
74
+
75
+ /** Read-only summary from the canonical list; it never adds persisted state or renumbers tasks. */
76
+ function todoStatusSummary(state: WorkflowState): TodoStatusSummary {
77
+ const counts: Record<Exclude<TodoStatus, 'deleted'>, number> = { pending: 0, in_progress: 0, completed: 0, failed: 0, cancelled: 0 };
78
+ let deleted = 0;
79
+ for (const task of state.tasks) if (task.status === 'deleted') deleted++; else counts[task.status]++;
80
+ return { mode: state.plan ? 'plan' : 'normal', total: state.tasks.length - deleted, counts, deleted };
81
+ }
82
+
83
+ export function validateTodoFields(params: TodoParams, msg: Translator = chinese): void {
84
+ const allowed = fields[params.action];
85
+ if (!allowed) throw new Error(msg("未知 Task 操作"));
86
+ for (const key of Object.keys(params)) if (key !== "action" && !allowed.includes(key)) throw new Error(msg`${params.action} 不接受字段 ${key}`);
87
+ }
88
+
89
+ /** A definition change must not add an edge that an unfinished path already implies. */
90
+ function guardRedundancy(before: WorkflowState, after: WorkflowState, msg: Translator = chinese): void {
91
+ if (before.tasks === after.tasks) return;
92
+ const redundant = newRedundantEdges(before.tasks, after.tasks);
93
+ if (!redundant.length) return;
94
+ const shown = redundant.slice(0, 3).map((edge) => `#${edge.to}<-#${edge.from}(已有 #${[edge.from, ...edge.via, edge.to].join(' -> #')})`).join(';');
95
+ throw new Error(msg`冗余依赖:${shown};blockedBy 只放不可缺少的前置产物(排期、同文件、祖先履历不算),本次写入已拒绝`);
96
+ }
97
+
98
+ /** Atomic pure mutation: errors never modify the current state. */
99
+ export function applyTodo(state: WorkflowState, params: TodoParams, msg: Translator = chinese, presets?: PresetStore): TodoResult {
100
+ validateTodoFields(params, msg);
101
+ // Batch judges the whole final definition once: an intermediate triangle is not a write.
102
+ if (params.action === 'batch') return applyBatch(state, params.operations, msg);
103
+ const result = applyDefinition(state, params, msg, presets);
104
+ guardRedundancy(state, result.state, msg);
105
+ return result;
106
+ }
107
+
108
+ function applyDefinition(state: WorkflowState, params: TodoParams, msg: Translator = chinese, presets?: PresetStore): TodoResult {
109
+ if (params.action === 'status') { const status = todoStatusSummary(state); return { state, status, text: JSON.stringify(status, null, 2) }; }
110
+ if (params.action === 'presets') {
111
+ // Read-only discovery: compact directory summaries, or one full definition.
112
+ if (params.preset !== undefined) {
113
+ const preset = presets?.get(params.preset);
114
+ if (!preset) throw new Error(msg`未找到片段 ${params.preset}`);
115
+ return { state, preset, text: JSON.stringify(preset, null, 2) };
116
+ }
117
+ const directory = presets?.list().map((item) => ({ name: item.name, ...(item.description ? { description: item.description } : {}), steps: item.steps.length })) ?? [];
118
+ return { state, presets: directory, text: directory.length ? JSON.stringify(directory, null, 2) : msg('未配置 Task 片段:在 ~/.pi/agent/pi-dag-workflow/pi-dag-workflow-preset.json 里定义') };
119
+ }
120
+ if (params.action === "apply" || params.action === "reset") {
121
+ const name = params.preset ?? "";
122
+ if (!presets || !name) throw new Error(msg("apply/reset 需要 preset,且需配置片段文件"));
123
+ if (params.action === "apply") {
124
+ const preset = presets.get(name);
125
+ if (!preset) throw new Error(msg`未找到片段 ${name}`);
126
+ const { tasks: created, ids } = expand(preset, nextRun(state.tasks, name), state.nextId, params.vars);
127
+ for (const task of created) validateTask(task, msg);
128
+ const tasks = [...state.tasks, ...created];
129
+ if (!reuseDagStructure(state.tasks, tasks)) dagStructure(tasks);
130
+ const map = [...ids].map(([key, id]) => `${key}=#${id}`).join(", ");
131
+ const hint = preset.skill ? msg`;执行前建议加载 skill:${preset.skill}` : "";
132
+ return { state: { ...state, tasks, nextId: state.nextId + created.length }, tasks: created, text: msg`已创建片段 ${name} 第 ${nextRun(state.tasks, name)} 轮(${created.length} 项):${map}${hint}` };
133
+ }
134
+ const ids = resetClosure(state.tasks, name, params.step, params.run);
135
+ if (!ids.length) throw new Error(params.step ? msg`片段 ${name} 中没有步骤 ${params.step}` : msg`没有可重置的片段 ${name}`);
136
+ const reopened = new Set(ids);
137
+ const tasks = state.tasks.map((task) => {
138
+ if (!reopened.has(task.id)) return task;
139
+ const { activeForm: _dropped, ...rest } = task;
140
+ return { ...rest, status: "pending" as const };
141
+ });
142
+ if (!reuseDagStructure(state.tasks, tasks)) dagStructure(tasks);
143
+ return { state: { ...state, tasks }, tasks: tasks.filter((task) => reopened.has(task.id)), text: msg`已重置片段 ${name} 的 ${ids.length} 项为待执行:${ids.map((id) => `#${id}`).join(", ")}` };
144
+ }
145
+ if (params.action === "list") {
146
+ const limit = params.limit ?? 50; const afterId = params.afterId ?? 0;
147
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200 || !Number.isSafeInteger(afterId) || afterId < 0) throw new Error(msg('分页参数无效:limit 为 1–200,afterId 为非负整数'));
148
+ if (params.ready && params.status !== undefined && params.status !== 'pending') throw new Error(msg('ready 只支持 pending 任务'));
149
+ const statuses = params.ready ? new Map(state.tasks.map((task) => [task.id, task.status])) : undefined;
150
+ const matched = state.tasks.filter((task) => (params.includeDeleted || task.status !== "deleted") && (!params.status || task.status === params.status) && (!statuses || task.status === 'pending' && task.blockedBy.every((id) => statuses.get(id) === 'completed'))).sort((a, b) => a.id - b.id);
151
+ const remaining = matched.filter((task) => task.id > afterId);
152
+ const tasks = remaining.slice(0, limit);
153
+ const page: TodoPage = { total: matched.length, count: tasks.length, limit, afterId, ...(remaining.length > limit ? { nextAfterId: tasks.at(-1)!.id } : {}) };
154
+ if (page.nextAfterId !== undefined) page.next = { action: 'list', afterId: page.nextAfterId, limit, ...(params.status ? { status: params.status } : {}), ...(params.includeDeleted ? { includeDeleted: true } : {}), ...(params.ready ? { ready: true } : {}) };
155
+ const header = msg`本页 ${tasks.length} / 共 ${matched.length} 项`;
156
+ const text = tasks.length ? `${header}\n${tasks.map((task) => `${todoRef(task)} [${translatedStatusLabel(msg)[task.status]}] ${task.subject}`).join("\n")}` : `${header}\n${msg('暂无任务')}`;
157
+ return { state, tasks, page, text: page.next ? `${text}\n${msg`继续读取:${JSON.stringify(page.next)}`}` : text };
158
+ }
159
+ const current = params.id === undefined ? undefined : state.tasks.find((task) => task.id === params.id);
160
+ if (["get", "update", "delete"].includes(params.action) && !current) throw new Error(msg`找不到任务 #${params.id ?? "?"}`);
161
+ if (params.action === "get") {
162
+ const status = new Map(state.tasks.map((task) => [task.id, task.status]));
163
+ const relations = { blocks: state.tasks.filter((task) => task.status !== 'deleted' && task.blockedBy.includes(current!.id)).map((task) => task.id).sort((a, b) => a - b), unmetPrerequisites: current!.blockedBy.filter((id) => status.get(id) !== 'completed') };
164
+ return { state, tasks: [current!], relations, text: JSON.stringify({ ...current, ...relations }, null, 2) };
165
+ }
166
+ if (state.plan && params.status && ["in_progress", "completed"].includes(params.status)) throw new Error(msg("Plan 只允许整理任务,不允许开始或完成;先 /plan off"));
167
+ if (params.action === "clear") return clearTodoRecords(state, params.scope ?? 'all', new Set(), undefined, msg);
168
+ let task: Todo;
169
+ if (params.action === "create") {
170
+ if (!params.subject?.trim()) throw new Error(msg("create 需要 subject"));
171
+ if (!Number.isSafeInteger(state.nextId + 1)) throw new Error(msg("任务计数器不能复用已有编号"));
172
+ if (params.status !== undefined && !['pending', 'in_progress'].includes(params.status)) throw new Error(msg('create 的 status 只支持 pending 或 in_progress;核验完成后用 update 设置 completed'));
173
+ task = { id: state.nextId, subject: params.subject.trim(), status: params.status ?? "pending", blockedBy: [...new Set(params.blockedBy ?? [])] };
174
+ } else {
175
+ task = { ...current!, blockedBy: [...current!.blockedBy] };
176
+ if (task.status === "deleted") throw new Error(msg`任务 #${task.id} 已删除`);
177
+ if (params.action === "delete") task.status = "deleted";
178
+ if (params.action === "update") {
179
+ if (Object.keys(params).every((key) => key === "action" || key === "id")) throw new Error(msg("update 需要至少一个修改字段"));
180
+ if (params.status) {
181
+ if (!transitions[task.status].includes(params.status)) throw new Error(msg`不允许 ${task.status} → ${params.status};返工请新建任务`);
182
+ task.status = params.status;
183
+ }
184
+ const removed = new Set(params.removeBlockedBy ?? []);
185
+ task.blockedBy = [...new Set([...task.blockedBy, ...params.addBlockedBy ?? []])].filter((id) => !removed.has(id));
186
+ }
187
+ }
188
+ for (const field of textFields) if (params[field] !== undefined) task[field] = params[field]!;
189
+ if (params.metadata !== undefined) {
190
+ const merged = new Map(Object.entries(task.metadata ?? {}));
191
+ for (const [key, value] of Object.entries(params.metadata)) value === null ? merged.delete(key) : merged.set(key, value);
192
+ if (merged.size) task.metadata = structuredClone(Object.fromEntries(merged)); else delete task.metadata;
193
+ }
194
+ validateTask(task, msg);
195
+ const tasks = params.action === "create" ? [...state.tasks, task] : state.tasks.map((item) => item.id === task.id ? task : item);
196
+ const next = { ...state, tasks, nextId: state.nextId + (params.action === "create" ? 1 : 0) };
197
+ if (!reuseDagStructure(state.tasks, tasks)) dagStructure(tasks); // delete safety, dangling dependencies, cycles
198
+ if (["in_progress", "completed"].includes(task.status)) {
199
+ const order = dagStructure(tasks).order;
200
+ const unfinished = task.blockedBy.filter((id) => tasks[order.get(id)!]?.status !== "completed");
201
+ // A prerequisite that no longer exists can never complete: name the fix instead of listing it as pending.
202
+ const gone = unfinished.filter((id) => { const value = tasks[order.get(id)!]; return !value || value.status === "deleted"; });
203
+ if (gone.length) throw new Error(msg`前置任务已删除或不存在:${gone.map((id) => `#${id}`).join(",")};这条依赖无法满足,用 removeBlockedBy 去掉它,或重建 #${task.id}`);
204
+ if (unfinished.length) throw new Error(msg`前置未完成:${unfinished.map((id) => `#${id}`).join(",")};不能开始或完成 #${task.id}`);
205
+ }
206
+ if (JSON.stringify(task) === JSON.stringify(current)) return { state, tasks: [current!], text: msg`#${task.id} 无变化` };
207
+ return { state: next, tasks: [task], text: msg`${params.action === "create" ? msg("已创建") : msg("已更新")} #${task.id}:${task.subject} [${translatedStatusLabel(msg)[task.status]}]` };
208
+ }
209
+
210
+ /** Resolve a small sequential transaction in memory; the coordinator commits only its final state. */
211
+ function applyBatch(state: WorkflowState, operations: TodoBatchOperation[] | undefined, msg: Translator): TodoResult {
212
+ if (!Array.isArray(operations) || !operations.length || operations.length > 50) throw new Error(msg('batch 需要 1–50 个定义操作'));
213
+ let draft = state;
214
+ const refs = new Map<string, number>(); const resolved: TodoParams[] = []; const touched = new Set<number>();
215
+ const resolve = (value: number | string): number => {
216
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value;
217
+ if (typeof value !== 'string' || !/^@[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(value) || !refs.has(value.slice(1))) throw new Error(msg`未知 batch 引用:${String(value)}`);
218
+ return refs.get(value.slice(1))!;
219
+ };
220
+ for (const [index, operation] of operations.entries()) {
221
+ try {
222
+ if (!operation || !['create', 'update'].includes(operation.action)) throw new Error(msg('batch 仅支持 create pending 和 update 定义'));
223
+ const allowed = operation.action === 'create' ? [...fields.create!, 'ref'] : fields.update!.filter((key) => key !== 'status' && key !== 'owner');
224
+ for (const key of Object.keys(operation)) if (key !== 'action' && !allowed.includes(key)) throw new Error(msg`batch ${operation.action} 不接受字段 ${key}`);
225
+ if (operation.status !== undefined && operation.status !== 'pending') throw new Error(msg('batch 仅支持 create pending 和 update 定义'));
226
+ if (operation.ref !== undefined && (!/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(operation.ref) || refs.has(operation.ref))) throw new Error(msg`无效或重复 batch ref:${operation.ref}`);
227
+ const { ref, ...input } = operation;
228
+ const concrete: TodoParams = { ...input, ...(input.id === undefined ? {} : { id: resolve(input.id) }), ...Object.fromEntries(['blockedBy', 'addBlockedBy', 'removeBlockedBy'].filter((key) => input[key as keyof typeof input] !== undefined).map((key) => {
229
+ const values = input[key as 'blockedBy' | 'addBlockedBy' | 'removeBlockedBy'];
230
+ if (!Array.isArray(values)) throw new Error(msg('依赖需为有效任务编号'));
231
+ return [key, values.map(resolve)];
232
+ })) } as TodoParams;
233
+ const result = applyDefinition(draft, concrete, msg);
234
+ const id = concrete.action === 'create' ? result.tasks![0]!.id : concrete.id!;
235
+ if (ref !== undefined) refs.set(ref, id);
236
+ touched.add(id); resolved.push(concrete); draft = result.state;
237
+ } catch (cause) { throw new Error(msg`batch 第 ${index + 1} 项失败:${cause instanceof Error ? cause.message : String(cause)}`); }
238
+ }
239
+ // Reversible definition edits that return to the original values are a real no-op.
240
+ const before = new Map(state.tasks.map((task) => [task.id, task]));
241
+ const tasks = draft.tasks.map((task) => touched.has(task.id) && JSON.stringify(task) === JSON.stringify(before.get(task.id)) ? before.get(task.id)! : task);
242
+ if (draft.nextId === state.nextId && tasks.every((task, index) => task === state.tasks[index])) draft = state;
243
+ else if (tasks.some((task, index) => task !== draft.tasks[index])) draft = { ...draft, tasks };
244
+ guardRedundancy(state, draft, msg);
245
+ return { state: draft, tasks: draft.tasks.filter((task) => touched.has(task.id)), refs: Object.fromEntries(refs), operations: resolved, text: msg`已原子处理 ${operations.length} 个定义操作:${[...touched].map((id) => `#${id}`).join(', ')}${refs.size ? `\n${JSON.stringify(Object.fromEntries(refs))}` : ''}` };
246
+ }
247
+
248
+ /**
249
+ * Only walk the active branch; never replay abandoned branches or model summaries.
250
+ * A list left by rpiv-todo (same tool name, same result shape) is read only when this
251
+ * plugin has no snapshot of its own on the branch, so an existing session keeps its tasks.
252
+ */
253
+ /**
254
+ * Pending tasks that were blocked before the change and are ready after it. Used for the
255
+ * "prerequisites are done" hint; it never starts anything on its own.
256
+ */
257
+ export function newlyReady(before: WorkflowState, after: WorkflowState): Todo[] {
258
+ if (before.tasks === after.tasks) return [];
259
+ const was = new Map(before.tasks.map((task) => [task.id, task.status]));
260
+ if (!after.tasks.some((task) => task.status === "completed" && was.get(task.id) !== "completed")) return [];
261
+ const now = new Map(after.tasks.map((task) => [task.id, task.status]));
262
+ return after.tasks.filter((task) => task.status === "pending" && task.blockedBy.length > 0 && task.blockedBy.every((id) => now.get(id) === "completed") && !task.blockedBy.every((id) => was.get(id) === "completed"));
263
+ }
264
+
265
+ export function restoreState(branch: readonly { type: string; customType?: string; data?: unknown; message?: unknown }[], msg: Translator = chinese): WorkflowState {
266
+ let rpiv: unknown;
267
+ for (let index = branch.length - 1; index >= 0; index--) {
268
+ const entry = branch[index]!;
269
+ if (entry.type === "custom" && entry.customType === STATE_TYPE) {
270
+ const state = structuredClone(entry.data) as WorkflowState;
271
+ validateState(state, msg);
272
+ return state;
273
+ }
274
+ if (rpiv === undefined && entry.type === "message" && entry.message && typeof entry.message === "object") {
275
+ const message = entry.message as { role?: string; toolName?: string; details?: { version?: number; tasks?: unknown; nextId?: unknown } };
276
+ const details = message.details;
277
+ if (message.role === "toolResult" && message.toolName === "todo" && details && (details.version === undefined || details.version === 1) && Array.isArray(details.tasks) && Number.isSafeInteger(details.nextId)) rpiv = details;
278
+ }
279
+ }
280
+ if (rpiv) {
281
+ const data = structuredClone(rpiv) as { tasks: Todo[]; nextId: number };
282
+ const state = { ...emptyState(), tasks: data.tasks.map((task) => ({ ...task, blockedBy: task.blockedBy ?? [] })), nextId: data.nextId };
283
+ // Another plugin's data must never break session restore: keep it only when it validates.
284
+ try { validateState(state, msg); return state; } catch { return emptyState(); }
285
+ }
286
+ return emptyState();
287
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { connectFeature } from '../workflow/commands/connect.ts';
3
+ import type { createTodosOwner } from './domain/owner.ts';
4
+ import type { registerTodos } from './commands/register.ts';
5
+
6
+ export default async function (pi: ExtensionAPI): Promise<void> { await connectFeature(pi, 'todos'); }
7
+
8
+ export async function attachTodos(pi: ExtensionAPI, hooks: Omit<Parameters<typeof createTodosOwner>[0], 'changed'> & Pick<Parameters<typeof registerTodos>[1], 'show' | 'userPrompt' | 'retryStatus' | 'panel' | 'spawn'>) {
9
+ const [{ createTodosOwner }, { registerCheckpoints }, { registerTodos }] = await Promise.all([import('./domain/owner.ts'), import('./commands/checkpoints.ts'), import('./commands/register.ts')]);
10
+ let checkpoints: ReturnType<typeof registerCheckpoints>;
11
+ const owner = createTodosOwner({ ...hooks, changed: ids => checkpoints.changed(ids) });
12
+ checkpoints = registerCheckpoints(pi, { state: owner.state, jobs: hooks.jobs, protected: owner.protected, branch: hooks.branch });
13
+ registerTodos(pi, { ...hooks, state: owner.state, mutate: owner.mutate, commit: owner.commit, reset: owner.reset, protected: owner.protected, presets: owner.presets, refreshPresets: owner.refreshPresets, previewClear: owner.previewClear });
14
+ return { ...owner, checkpoints };
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { readConfigText } from '../../shared/config-file.ts';
3
+
4
+ export function sendCheckpoint(pi: ExtensionAPI, message: Parameters<ExtensionAPI['sendMessage']>[0]): void { pi.sendMessage(message, { triggerTurn: false }); }
5
+ export function appendTodoRecord(pi: ExtensionAPI, type: string, data: unknown): void { pi.appendEntry(type, data); }
6
+ export async function readPresetData(path: string): Promise<unknown> {
7
+ return JSON.parse(await readConfigText(path, 65536, 'Preset configuration exceeds 64 KiB')) as unknown;
8
+ }
@@ -0,0 +1,4 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+
3
+ /** Compatibility for saved Pi path filters. UI no longer activates a feature or controller. */
4
+ export default function (_pi: ExtensionAPI): void {}