Skip to content

Commit f6976df

Browse files
authored
Show the plan each CLI reports and save a turn's task record to Notes (CF-7, first part) (#289)
* Read the plan each CLI reports as typed plan updates Codex's turn/plan/updated (pending, inProgress, completed, and an optional explanation) and Claude Code's TodoWrite todos (pending, in_progress, completed) become plan.update observations; a status Modex does not know is unknown. Each update is a revision of one plan item per turn, which never folds away and takes the turn's outcome when the turn ends. "completed" stays what the CLI reported; nothing is marked verified. The phone receives a plan as steps plus a text summary ("Plan · 1 of 2 reported done") that older builds show as is. Refs #280 Coven-Harness: Claude Code / claude-opus-5-5 * Build a turn's task record and save it to the project's notes on request taskRecord/taskMarkdown summarise one turn: request, latest plan, changed files relative to the project (files outside it are counted, not named), commands with outcome and exit code, the answer and what is not settled, with a backlink to the thread. No reasoning, output or outside paths. task:save-note (ThreadRunner.saveTaskNote) writes it into the thread's project notes as a human change; saving again updates the same note, unless it was edited since, when the edit stays and this version becomes a new note. The thread remembers each turn's note. Refs #280 Coven-Harness: Claude Code / claude-opus-5-5 * Show the reported plan and a Save to Notes action under each finished turn TaskPlanCard lists each step as Not started, In progress, Reported done or Status unknown, with where the plan came from and its revision. Under each finished turn, Save to Notes (then Update note) saves the turn's record and offers Open. e2e/task-to-notes.spec.ts failed on both tests before this and passes now. Refs #280 Coven-Harness: Claude Code / claude-opus-5-5 * Document task plans and records and record them in status Refs #280 Coven-Harness: Claude Code / claude-opus-5-5 * Refuse saving a task note in the store demo The offline store demo has no Notes, so task:save-note is unavailable there like the other knowledge commands. CI's full build caught the missing handler. Coven-Harness: Claude Code / claude-opus-5-5
1 parent d3711c6 commit f6976df

20 files changed

Lines changed: 587 additions & 11 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { currentRow, items, launch, seedHome, tid } from "./support";
5+
6+
/**
7+
* A task's plan and record (docs/task-context.md): the plan the CLI reported, in its statuses, and an
8+
* explicit Save to Notes that writes the turn's record into the project's notes and updates it when
9+
* saved again. Plan items are sent to the window as main would; the saved turn ran on the mock engine.
10+
*/
11+
let app: ElectronApplication;
12+
let page: Page;
13+
let home: string;
14+
let repo: string;
15+
let threadId: string;
16+
17+
const at = () => new Date().toISOString();
18+
19+
test.beforeAll(async () => {
20+
({ home, repo } = seedHome());
21+
({ app, page } = await launch(home));
22+
const script = path.join(home, "task-script.json");
23+
fs.writeFileSync(script, JSON.stringify([{ content: "Fixed the import." }]));
24+
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
25+
await page.keyboard.press("Meta+n");
26+
await expect(tid(page, "composer-input")).toBeFocused();
27+
await tid(page, "composer-input").fill("Fix the failing import");
28+
await page.keyboard.press("Meta+Enter");
29+
await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 });
30+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
31+
threadId = (await currentRow(page).getAttribute("data-thread-id"))!;
32+
});
33+
34+
test.afterAll(async () => {
35+
await app?.close();
36+
fs.rmSync(home, { recursive: true, force: true });
37+
fs.rmSync(repo, { recursive: true, force: true });
38+
});
39+
40+
test("a plan shows each step as the CLI reported it, never as verified", async () => {
41+
const origin = { thread: threadId, turn: "u-plan", attempt: "P1", backend: "codex", native: { runtime: "R", item: "plan" } };
42+
await app.evaluate(({ BrowserWindow }, events) => {
43+
for (const event of events) BrowserWindow.getAllWindows()[0]!.webContents.send("thread:event", event);
44+
}, [
45+
{ type: "item", threadId, item: { id: "u-plan", kind: "user", text: "plan turn", at: at() } },
46+
{ type: "item", threadId, item: { id: "p", kind: "plan", from: "codex-plan", revision: 2, explanation: "Small fix", outcome: "succeeded", origin, at: at(),
47+
steps: [{ text: "Read the config", status: "completed" }, { text: "Fix the import", status: "in-progress" }, { text: "Run the tests", status: "pending" }, { text: "Ship", status: "unknown" }] } },
48+
]);
49+
const plan = items(page, "plan");
50+
await expect(tid(plan, "plan-meta")).toHaveText("From Codex's plan · revision 2");
51+
await expect(tid(plan, "plan-step")).toHaveText(["Reported done Read the config", "In progress Fix the import", "Not started Run the tests", "Status unknown Ship"]);
52+
await expect(plan).toContainText("Small fix");
53+
await expect(plan).not.toContainText(/verified/i);
54+
});
55+
56+
test("Save to Notes writes the turn's record into the project's notes, and saving again updates the same note", async () => {
57+
const save = tid(page, "save-task-note").first();
58+
await save.click();
59+
await expect(tid(page, "task-note-saved").first()).toContainText("Saved to Notes · Task:");
60+
const pages = async () => (await page.evaluate(() => window.modex!.invoke("space:list", undefined))) as { id: string; title: string; markdown: string }[];
61+
const saved = (await pages()).filter((p) => p.title.startsWith("Task:"));
62+
expect(saved).toHaveLength(1);
63+
expect(saved[0]!.markdown).toContain("> Fix the failing import");
64+
expect(saved[0]!.markdown).toContain("Fixed the import.");
65+
expect(saved[0]!.markdown).toContain(`From modex thread ${threadId}.`);
66+
expect(saved[0]!.markdown).not.toContain(repo);
67+
await expect(save).toHaveText("Update note");
68+
await save.click();
69+
await expect(tid(page, "task-note-saved").first()).toContainText("Updated in Notes · Task:");
70+
expect((await pages()).filter((p) => p.title.startsWith("Task:"))).toHaveLength(1);
71+
});

‎apps/desktop/src/main/commands.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export function registerCommands(handle: RegisterCommand, {
9090
});
9191
handle("thread:create", ({ projectId, worktree, mode, model, backend, auto }) => runner.createThread(projectId, { worktree, mode, model, backend, auto }));
9292
handle("thread:items", ({ threadId }) => runner.items(threadId));
93+
handle("task:save-note", ({ threadId, turnId }) => runner.saveTaskNote(threadId, turnId));
9394
handle("thread:followup", ({ threadId }) => runner.followUp(threadId));
9495
handle("thread:send", ({ threadId, text, attachments }) => startTurn(threadId, runner.send(threadId, text, attachments)));
9596
registerAttachmentCommands(handle, runner.attachments, { bytesOnly: true, reveal: revealAttachment ?? (() => { throw new Error("Attachment reveal is unavailable."); }) });

‎apps/desktop/src/main/engine/backends/claude.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { LineBuffer, shortJson, stderrTail } from "./types.js";
1111
import { claudeCall, UsageMeter, withUsage } from "./usage.js";
1212
import { cliEnvironment, cliSpawn, health, installation, probe } from "./health.js";
1313
import type { BackendHealth } from "../../../shared/types.js";
14-
import type { NativeRef, Observation } from "../../../shared/conversation-events.js";
14+
import type { NativeRef, Observation, PlanStepStatus } from "../../../shared/conversation-events.js";
1515
import { isModelOutOfCredits } from "../../../shared/failures.js";
1616

1717
/**
@@ -407,6 +407,11 @@ export class ClaudeBackend implements Backend {
407407
continue;
408408
}
409409
ctx.started.set(block.id, Date.now());
410+
// TodoWrite carries the whole todo list: Claude's plan, revised by each call.
411+
if (block.name === "TodoWrite" && Array.isArray(block.input?.todos)) {
412+
const steps = (block.input.todos as { content?: unknown; status?: unknown }[]).map((t) => ({ text: String(t.content ?? ""), status: claudeStepStatus(t.status) }));
413+
ctx.observe({ type: "plan.update", native: ctx.native("todos"), from: "claude-todo", steps });
414+
}
410415
// Bash's command is what ran; its working directory is the CLI's own and persists between calls, so none is claimed.
411416
const command = block.name === "Bash" && typeof block.input?.command === "string" ? block.input.command : undefined;
412417
ctx.observe({ type: "tool.start", native: ctx.native(block.id), tool: { name: block.name ?? "tool", title: toolTitle(block.name ?? "tool", block.input ?? {}), args: block.input ?? {}, ...(command ? { exec: { command } } : {}) } });
@@ -582,6 +587,10 @@ export function thinkingRepresentation(display: string | undefined): "summary" |
582587
return display === "summarized" ? "summary" : "provider-text";
583588
}
584589

590+
function claudeStepStatus(status: unknown): PlanStepStatus {
591+
return status === "pending" ? "pending" : status === "in_progress" ? "in-progress" : status === "completed" ? "completed" : "unknown";
592+
}
593+
585594
/** A `result` message as a turn outcome. */
586595
function resultOf(msg: ClaudeMessage): TurnResult {
587596
if (msg.is_error) return { status: "failed", error: typeof msg.result === "string" ? msg.result : msg.subtype ?? "error", detail: { resultSubtype: msg.subtype, errors: msg.errors, sessionId: msg.session_id, numTurns: msg.num_turns, durationMs: msg.duration_ms } };

‎apps/desktop/src/main/engine/backends/codex.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { LineBuffer, stderrTail } from "./types.js";
1111
import { codexCall, UsageMeter, withUsage } from "./usage.js";
1212
import { cliEnvironment, cliSpawn, health, installation } from "./health.js";
1313
import type { AgentActivity, BackendHealth } from "../../../shared/types.js";
14-
import type { MessagePhase, NativeRef, SettledOutcome } from "../../../shared/conversation-events.js";
14+
import type { MessagePhase, NativeRef, PlanStepStatus, SettledOutcome } from "../../../shared/conversation-events.js";
1515
import { boundOutput, byteLength, memoryEvidence } from "../../../shared/evidence.js";
1616

1717
/**
@@ -425,6 +425,12 @@ export class CodexBackend implements Backend {
425425
case "item/reasoning/textDelta":
426426
sink.observe({ type: "reasoning.delta", native: native(p.itemId), representation: "provider-text", index: partIndex(p.contentIndex), text: String(p.delta ?? "") });
427427
break;
428+
// The plan Codex reports for the turn (codex-cli 0.163.0: pending, inProgress, completed), whole each time.
429+
case "turn/plan/updated": {
430+
const steps = Array.isArray(p.plan) ? (p.plan as { step?: unknown; status?: unknown }[]).map((s) => ({ text: String(s.step ?? ""), status: codexStepStatus(s.status) })) : [];
431+
sink.observe({ type: "plan.update", native: native("plan"), from: "codex-plan", steps, ...(typeof p.explanation === "string" && p.explanation ? { explanation: p.explanation } : {}) });
432+
break;
433+
}
428434
case "item/agentMessage/delta":
429435
sink.observe({ type: "message.delta", native: native(p.itemId), text: String(p.delta ?? "") });
430436
break;
@@ -700,6 +706,10 @@ export function codexOutcome(item: { type: string; status?: string; exitCode?: n
700706
return item.error ? "failed" : "unknown";
701707
}
702708

709+
function codexStepStatus(status: unknown): PlanStepStatus {
710+
return status === "pending" ? "pending" : status === "inProgress" ? "in-progress" : status === "completed" ? "completed" : "unknown";
711+
}
712+
703713
function partIndex(value: unknown): number {
704714
const n = Number(value);
705715
return Number.isSafeInteger(n) && n >= 0 ? n : 0;

‎apps/desktop/src/main/engine/companion.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ export function mobileItem(item: ThreadItem): Record<string, unknown> {
389389
case "tool": return { id: item.id, kind: item.kind, title: item.status === "done" && (item.outcome ? item.outcome !== "succeeded" : item.ok === false) ? `${item.title} · ${toolStatusLabel(item)}` : item.title,
390390
status: item.status, ok: item.ok, ...(item.outcome ? { outcome: item.outcome } : {}), at: item.at };
391391
case "route": return { id: item.id, kind: item.kind, text: `${item.backend} · ${item.model || "default"}`, at: item.at };
392+
// A phone that predates plans shows a kind it does not know as its text.
393+
case "plan": return { id: item.id, kind: item.kind, text: `Plan · ${item.steps.filter((s) => s.status === "completed").length} of ${item.steps.length} reported done`, steps: item.steps, revision: item.revision, ...(item.outcome ? { outcome: item.outcome } : {}), at: item.at };
392394
}
393395
}
394396

‎apps/desktop/src/main/engine/runner.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { copyComposerRecovery } from "./update-recovery.js";
2929
import { parseRecoveryJSON, recoveryJSON } from "./attachment-recovery-file.js";
3030
import type { DraftRemovalScope } from "../../shared/native-drafts.js";
3131
import { boundOutput } from "../../shared/evidence.js";
32+
import { taskMarkdown, taskRecord } from "../../shared/task-context.js";
3233
import { CONVERSATION_SCHEMA, applyConversationEvent, initialConversation, okFor, type AttemptMark, type ConversationEvent, type ConversationState, type Observation, type SettledOutcome, type TurnRef } from "../../shared/conversation-events.js";
3334

3435
/** Refused approvals in a routine run: the routine is read-only, and nobody is there to answer. */
@@ -301,6 +302,38 @@ export class ThreadRunner {
301302
} finally { this.unattended.delete(threadId); }
302303
}
303304

305+
/**
306+
* Saves a turn's task record (shared/task-context.ts) to the thread's project notes, at a person's
307+
* request. Saving again updates the same note, unless it was edited since: then the edit stays and
308+
* this version becomes a note of its own. Writes only through the notes store; reads only the
309+
* transcript.
310+
*/
311+
saveTaskNote(threadId: string, turnId: string): { pageId: string; title: string; revision: number; created: boolean; preserved: boolean } {
312+
this.assertNotRestarting();
313+
const thread = this.o.store.thread(threadId);
314+
if (!thread) throw new Error(`unknown thread ${threadId}`);
315+
const items = this.items(threadId);
316+
const start = items.findIndex((item) => item.kind === "user" && item.id === turnId);
317+
if (start < 0) throw new Error("That message is no longer in this thread.");
318+
const next = items.findIndex((item, i) => i > start && item.kind === "user");
319+
const turn = items.slice(start, next < 0 ? undefined : next);
320+
const record = taskRecord(turn, { threadId, title: `Task: ${thread.title}`, cwd: thread.cwd });
321+
const markdown = taskMarkdown(record);
322+
const notes = this.o.store.space;
323+
const project = notes.tree().nodes.find((node) => node.kind === "project" && node.modexProjectId === thread.projectId);
324+
if (!project) throw new Error("This project has no notes yet. Open Notes for it, then save again.");
325+
const saved = thread.taskNotes?.[turnId];
326+
const existing = saved ? notes.list().find((page) => page.id === saved.pageId && !page.trashedAt) : undefined;
327+
const change = { actor: "human", summary: "Saved a task record" };
328+
const untouched = existing && existing.revision === saved!.revision;
329+
const page = untouched
330+
? notes.save({ ...existing, title: record.title, markdown }, change)
331+
: notes.create({ title: existing ? `${record.title} (saved again)` : record.title, markdown, containerId: project.id }, change);
332+
const updated = this.o.store.updateThread(threadId, { taskNotes: { ...thread.taskNotes, [turnId]: { pageId: page.id, revision: page.revision } } });
333+
this.o.emit({ threadId, type: "thread", thread: updated });
334+
return { pageId: page.id, title: page.title, revision: page.revision, created: !untouched, preserved: Boolean(existing && !untouched) };
335+
}
336+
304337
/** A receipt in the transcript for something Modex did on the thread's behalf. */
305338
addNotice(threadId: string, level: "info" | "warn" | "error", text: string): void {
306339
this.addItem(threadId, { id: newId(), kind: "notice", level, text, at: new Date().toISOString() });

‎apps/desktop/src/main/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ handle("project:remove", async ({ projectId }) => {
778778
});
779779
handle("thread:create", ({ projectId, worktree, mode, model, backend, auto }) => runner.createThread(projectId, { worktree, mode, model, backend, auto }));
780780
handle("thread:items", ({ threadId }) => runner.items(threadId));
781+
handle("task:save-note", ({ threadId, turnId }) => runner.saveTaskNote(threadId, turnId));
781782
handle("thread:followup", ({ threadId }) => runner.followUp(threadId));
782783
/**
783784
* Starts a turn without waiting for it. A rejection the runner can detect up front (busy thread,

‎apps/desktop/src/renderer/components/ThreadView.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ErrorNotice } from "./ui/ErrorNotice";
1616
import { TurnContent, groupTurns } from "./transcript/TurnContent";
1717
import { ReasoningItem } from "./transcript/ReasoningItem";
1818
import { EvidenceCard } from "./transcript/EvidenceCard";
19+
import { SaveTaskNote, TaskPlanCard } from "./transcript/TaskPlanCard";
1920
import { OUTPUT_PREVIEW_BYTES } from "../../shared/evidence";
2021

2122
export { formatElapsed } from "./transcript/TurnContent";
@@ -170,6 +171,7 @@ export function ThreadView({ thread, project, items, text, onText, attachments,
170171
)}
171172
{turns.map((turn, i) => (
172173
<TurnContent key={turn.user?.id ?? `pre-${i}`} turn={turn} status={i === lastUser && busy ? thread.status : undefined}
174+
footer={(t) => <SaveTaskNote threadId={thread.id} turnId={t.user!.id} saved={Boolean(thread.taskNotes?.[t.user!.id])} />}
173175
render={(item, live) => <Item key={item.id} item={item} live={live} onAnswer={onAnswer} onPolicy={(approvals) => onUpdate({ approvals })} onFix={onFix} onRetry={!thread.retired && !busy && item.id === retryId ? onRetry : undefined} />} />
174176
))}
175177
</div>
@@ -247,6 +249,8 @@ function Item({ item, live, onAnswer, onPolicy, onFix, onRetry }: { item: Thread
247249
return <ReasoningItem item={item} />;
248250
case "route":
249251
return <RouteItem item={item} />;
252+
case "plan":
253+
return <TaskPlanCard item={item} />;
250254
}
251255
}
252256

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useState } from "react";
2+
import type { ThreadItem } from "../../../shared/types";
3+
import type { PlanStep } from "../../../shared/conversation-events";
4+
import { bridge } from "../../bridge";
5+
import { ErrorNotice } from "../ui/ErrorNotice";
6+
7+
const STATUS: Record<PlanStep["status"], string> = { pending: "Not started", "in-progress": "In progress", completed: "Reported done", unknown: "Status unknown" };
8+
const FROM = { "codex-plan": "Codex's plan", "claude-todo": "Claude's todo list" } as const;
9+
10+
/**
11+
* The plan a CLI reported for a turn (docs/task-context.md), in its own statuses. A step the CLI
12+
* marked completed reads "Reported done": what it said, not a check.
13+
*/
14+
export function TaskPlanCard({ item }: { item: Extract<ThreadItem, { kind: "plan" }> }) {
15+
return (
16+
<div className="plan-card" data-testid="item" data-item-kind="plan">
17+
<div className="plan-head">
18+
<span className="plan-title">Plan</span>
19+
<span className="plan-meta" data-testid="plan-meta">From {FROM[item.from]} · revision {item.revision}</span>
20+
</div>
21+
{item.explanation && <p className="plan-explanation">{item.explanation}</p>}
22+
<ol className="plan-steps">
23+
{item.steps.map((step, i) => (
24+
<li key={i} className={`plan-step ${step.status}`} data-testid="plan-step" data-status={step.status}>
25+
<span className="plan-status">{STATUS[step.status]}</span> {step.text}
26+
</li>
27+
))}
28+
</ol>
29+
</div>
30+
);
31+
}
32+
33+
/**
34+
* Saves one turn's task record to its project's notes, only when asked. Saving again updates the
35+
* same note, unless it was edited since; then the edit stays and this version becomes a new note.
36+
*/
37+
export function SaveTaskNote({ threadId, turnId, saved }: { threadId: string; turnId: string; saved: boolean }) {
38+
const [state, setState] = useState<{ busy: boolean; result?: { pageId: string; title: string; created: boolean; preserved: boolean }; error?: string }>({ busy: false });
39+
const save = async () => {
40+
setState((s) => ({ ...s, busy: true, error: undefined }));
41+
try { setState({ busy: false, result: await bridge.invoke("task:save-note", { threadId, turnId }) }); }
42+
catch (err) { setState({ busy: false, error: (err as Error).message }); }
43+
};
44+
const result = state.result;
45+
return (
46+
<div className="task-note">
47+
<button type="button" className="task-note-save" data-testid="save-task-note" disabled={state.busy} onClick={() => void save()}>{saved || result ? "Update note" : "Save to Notes"}</button>
48+
{result && <span className="task-note-saved" data-testid="task-note-saved">
49+
{result.created ? (result.preserved ? "The note was edited since; saved as a new note · " : "Saved to Notes · ") : "Updated in Notes · "}{result.title}
50+
<button type="button" className="btn small" onClick={() => window.dispatchEvent(new CustomEvent("modex:open-page", { detail: result.pageId }))}>Open</button>
51+
</span>}
52+
{state.error && <ErrorNotice testId="task-note-error" where="Notes · save task" message={state.error} onRetry={state.busy ? undefined : () => void save()} context={{ threadId, turnId }} />}
53+
</div>
54+
);
55+
}

‎apps/desktop/src/renderer/components/transcript/TurnContent.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function groupTurns(items: ThreadItem[]): Turn[] {
2323
* Keep the final answer and actionable items visible; completed work stays inspectable. Which item
2424
* answers, which are live and which may fold comes from `layoutTurn` (shared/turn-layout.ts).
2525
*/
26-
export function TurnContent({ turn, status, render }: { turn: Turn; status?: Thread["status"]; render: (item: ThreadItem, live: boolean) => ReactNode }) {
26+
export function TurnContent({ turn, status, render, footer }: { turn: Turn; status?: Thread["status"]; render: (item: ThreadItem, live: boolean) => ReactNode; footer?: (turn: Turn) => ReactNode }) {
2727
const [expanded, setExpanded] = useState(false);
2828
const historyId = useId();
2929
const live = status !== undefined;
@@ -41,6 +41,7 @@ export function TurnContent({ turn, status, render }: { turn: Turn; status?: Thr
4141
<div key={item.id} hidden={layout.work[index] && !showWork} data-final={index === layout.final ? "true" : undefined} data-live={layout.live[index] ? "true" : undefined}>{render(item, layout.live[index]!)}</div>,
4242
])}
4343
</div>
44+
{!live && turn.user && turn.rest.length > 0 && footer?.(turn)}
4445
</>;
4546
}
4647

0 commit comments

Comments
 (0)