Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ Workflow 在清理隔离 checkout 前原子保存有界 Handoff Manifest:track

| 能力 | 使用方式 | 它负责什么 |
| ------------- | --------------------------------------- | ------------------------------------------------------------------- |
| Tasks | `tasks_add` / `tasks_update` / `/tasks` | 跨 Agent Run 与用户回合记录当前批次工作意图;不执行工作 |
| Tasks | `tasks_add` / `tasks_update` / `/tasks` | 逐项同步当前批次工作意图并刷新完整快照;不推断完成、不执行工作 |
| Goal | `/goal <目标>` | 驱动一个持续到终态的自主目标;完成前要求证据审计 |
| Plan Mode | `/plan [目标]` | 只读调研;`plan_ready` 后才准备可编辑的实施 Prompt,不自动执行 |
| Context Pivot | `/context-pivot <下一阶段>` | Context 超过约 30K Tokens 且任务换阶段时,用自包含 Brief 替换旧噪音 |
Expand Down
2 changes: 1 addition & 1 deletion SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Model callers use `get_goal`, `create_goal`, and `update_goal`. `create_goal` is

There are no normal user-facing Turn, no-progress, or wall-clock caps; a hidden 1000-continuation circuit breaker exists only to stop runaway automation. An optional `token_budget` must only be positive. Goal non-cached Assistant input-plus-output Token and elapsed-time usage are persisted; crossing the budget marks `budget_limited` and queues one wrap-up Turn. Active goals continue after reload/resume. Fork and tree navigation defer inherited active continuation until the first explicit user input; paused, blocked, and usage-limited goals remain stopped and can prompt for Resume. A v1 active/waiting goal migrates once to paused. Assistant aborts pause an active goal and Assistant errors block it. Print/json automation is inert. Footer text mirrors Codex (`Pursuing goal (…)`, resume hints, `Goal unmet`, `Goal achieved`) without showing the objective or legacy Turn counts. An achieved Footer remains visible until the next explicit interactive/RPC input, then a branch-persisted acknowledgement hides only the Footer while `/goal` retains the completed record.

Session Tasks remain advisory multi-item work intent and do not determine Goal completion. They are scoped to the current request batch: once every item is done or dropped, the batch closes and the next `tasks_add` starts again at T1. Active items persist in a polished Claude Code-style panel above the editor; `Ctrl+Shift+T` or `/tasks hide|show|toggle` controls visibility, while `/tasks` opens the full list. No `/openpi-setup` setting or secondary judge model is required.
Session Tasks remain advisory multi-item work intent and do not determine Goal completion. They are scoped to the current request batch: once every item is done or dropped, the batch closes and the next `tasks_add` starts again at T1. The model marks a tracked item `in_progress` before starting it, records `done`, `blocked`, or `dropped` immediately after that item reaches a real outcome, and reconciles touched items before its final answer. Every add/update result returns the complete bounded current snapshot so the next item is explicit and the panel refreshes on each persisted transition. Commit, test, and authorization signals are only task-scoped evidence candidates; OpenPI never infers completion or mutates a task from those signals. Active items persist in a polished Claude Code-style panel above the editor; `Ctrl+Shift+T` or `/tasks hide|show|toggle` controls visibility, while `/tasks` opens the full list. No `/openpi-setup` setting or secondary judge model is required.

## Other commands added by this fork

Expand Down
100 changes: 100 additions & 0 deletions extensions/tasks/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import test from "node:test";
import type {
ExtensionAPI,
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import type { Component } from "@earendil-works/pi-tui";
import sessionTasks, {
findTaskConflict,
injectTaskProjection,
Expand All @@ -17,6 +19,21 @@ const sourceInfo = (path: string) => ({
origin: "top-level" as const,
});

const plainTheme = {
fg: (_name: string, text: string) => text,
bold: (text: string) => text,
strikethrough: (text: string) => text,
} as unknown as Theme;

function widgetLines(widget: unknown) {
if (typeof widget !== "function") return [];
const component = (widget as (tui: unknown, theme: Theme) => Component)(
undefined,
plainTheme,
);
return component.render(120);
}

function widgetHarness(
initialBranch: unknown[] = [],
initialTools: unknown[] = [],
Expand Down Expand Up @@ -164,6 +181,89 @@ test("persistent task widget restores, updates, and expands all tasks", async ()
assert.equal(h.widgets.at(-1), undefined);
});

test("four tracked tasks refresh after every explicit progress transition", async () => {
const h = widgetHarness();
await h.emit("session_start");
const add = h.tools.get("tasks_add");
const update = h.tools.get("tasks_update");

const added = await add.execute(
"add-four",
{
items: Array.from({ length: 4 }, (_, index) => ({
subject: `Task ${index + 1}`,
})),
},
undefined,
undefined,
h.ctx,
);
assert.match(
added.content[0]?.text ?? "",
/Current task snapshot \(4 items\)/,
);
assert.equal(added.details.items.length, 4);

for (let id = 1; id <= 4; id++) {
const widgetWritesBeforeStart = h.widgets.length;
const started = await update.execute(
`start-${id}`,
{ id, status: "in_progress" },
undefined,
undefined,
h.ctx,
);
assert.equal(h.widgets.length, widgetWritesBeforeStart + 1);
assert.match(
started.content[0]?.text ?? "",
new RegExp(`T${id} \\[in_progress\\] Task ${id}`),
);
assert.match(
widgetLines(h.widgets.at(-1)).join("\n"),
new RegExp(`T${id} Task ${id}`),
);

const widgetWritesBeforeDone = h.widgets.length;
const completed = await update.execute(
`done-${id}`,
{ id, status: "done", note: `evidence-${id}` },
undefined,
undefined,
h.ctx,
);
assert.equal(h.widgets.length, widgetWritesBeforeDone + 1);
if (id < 4) {
assert.match(
completed.content[0]?.text ?? "",
new RegExp(`T${id} \\[done\\] Task ${id}`),
);
assert.equal(completed.details.items.length, 4);
assert.match(
widgetLines(h.widgets.at(-1))[0] ?? "",
new RegExp(`${id} done`),
);
} else {
assert.match(completed.content[0]?.text ?? "", /Task batch closed/);
assert.equal(h.widgets.at(-1), undefined);
}
}
});

test("task tools teach immediate reconciliation without inferring completion signals", async () => {
const h = widgetHarness();
await h.emit("session_start");
const addGuidance = h.tools.get("tasks_add").promptGuidelines.join("\n");
const updateGuidance = h.tools
.get("tasks_update")
.promptGuidelines.join("\n");

assert.match(addGuidance, /before starting each tracked item/i);
assert.match(updateGuidance, /immediately after each tracked item/i);
assert.match(updateGuidance, /before (?:sending )?(?:a )?final answer/i);
assert.match(updateGuidance, /commit.*passing test.*authorization/i);
assert.match(updateGuidance, /does not by itself prove/i);
});

test("task panel commands report actual visibility and conflicts block the shortcut", async () => {
const empty = widgetHarness();
await empty.emit("session_start");
Expand Down
33 changes: 21 additions & 12 deletions extensions/tasks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,15 @@ export default function sessionTasks(pi: ExtensionAPI) {
items,
total: snapshot().items.length,
revision: snapshot().revision,
// From the live snapshot, not `items`: a tools_update carries only the one
// row it touched, and a header counted from that would claim the batch is
// a single task.
counts: taskCounts(snapshot().items),
...(batchClosed ? { batchClosed: true } : {}),
});

const mutationResultText = (summary: string) => {
const current = snapshot();
return `${summary}\nCurrent task snapshot (${current.items.length} ${current.items.length === 1 ? "item" : "items"}):\n${tasks.render()}`;
};

const registerTools = () => {
if (toolsRegistered || conflict) return;
toolsRegistered = true;
Expand All @@ -218,6 +220,7 @@ export default function sessionTasks(pi: ExtensionAPI) {
"Add stable work-intent items to the current session tasks",
promptGuidelines: [
"Use tasks_add only for work spanning multiple agent runs or user turns, or when the user explicitly provides a task list; do not use it as a per-step scratchpad within one run.",
"Before starting each tracked item, call tasks_update to mark it in_progress; concurrent work may have multiple in_progress items.",
"Task tools record advisory intent only; Subagents and Workflows execute work, while files, git, tests, tool results, artifacts, and user confirmation remain truth.",
],
parameters: Type.Object({
Expand All @@ -243,10 +246,12 @@ export default function sessionTasks(pi: ExtensionAPI) {
content: [
{
type: "text" as const,
text: `Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
text: mutationResultText(
`Added ${mutation.items.map((item) => `T${item.id}`).join(", ")}.`,
),
},
],
details: toolDetails("add", mutation.items),
details: toolDetails("add", snapshot().items),
});
},
renderCall(args, theme) {
Expand All @@ -273,7 +278,9 @@ export default function sessionTasks(pi: ExtensionAPI) {
description: `${TOOL_PURPOSE} Patch one task item by numeric ID. blocked, done, and dropped status changes require a fresh note explaining the blocker, observable evidence, or drop reason.`,
promptSnippet: "Update one session task item by stable ID",
promptGuidelines: [
"Keep tasks_update status current when tracked work materially changes, but avoid ceremonial status churn.",
"Immediately after each tracked item reaches a real outcome, call tasks_update to set done, blocked, or dropped before moving to the next tracked item.",
"Before sending a final answer, reconcile every task touched in the current request; do not leave completed work pending or in_progress.",
"A commit, passing test, or authorization is task-scoped evidence only; it does not by itself prove a task is done or identify which task to update.",
"Before setting a task item to done, include a note citing an observable check, artifact, commit, tool result, or user confirmation; Tasks record this claim but do not verify it.",
],
parameters: Type.Object({
Expand Down Expand Up @@ -309,14 +316,16 @@ export default function sessionTasks(pi: ExtensionAPI) {
content: [
{
type: "text" as const,
text: changed
? closesBatch
? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.`
: `Updated T${params.id}.`
: `T${params.id} already has that state; no update recorded.`,
text: mutationResultText(
changed
? closesBatch
? `${params.status === "dropped" ? "Dropped" : "Completed"} T${params.id}. Task batch closed; the next tasks_add starts again at T1.`
: `Updated T${params.id}.`
: `T${params.id} already has that state; no update recorded.`,
),
},
],
details: toolDetails("update", mutation.items, closesBatch),
details: toolDetails("update", snapshot().items, closesBatch),
});
},
renderCall(args, theme) {
Expand Down
Loading