From 2fffd9cbdc02264c986878694cce7de1239740d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Fri, 30 Oct 2026 03:18:56 -0500 Subject: [PATCH] Name commands that may have changed unlisted files, and cut opening titles at a word break (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CF-7 close-out's live runs left three observations. This fixes all three. - A Claude shell redirect rewrote a file the map listed only as "read", and a Codex `perl -pi` edit, parsed as an unknown action, left the note with no files section. The context map now lists such commands, once, under "These commands may have changed files that neither the CLI nor Modex named". It uses the rule that already makes a check stale and guesses no file. The footer and Task view read "N, maybe more" or "not listed". Older saved records reopen unchanged. - A thread named after its opening message is cut at a word break, by grapheme, with "…". A Retry still renames a first turn that carries the older 60-character cut. Unit, runner, title and e2e regressions cover each; docs/task-context.md, the close-out receipt and docs/status.md are updated. Val's human review on #280 stays open. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/e2e/task-to-notes.spec.ts | 38 ++++++++++++ apps/desktop/src/main/engine/runner.ts | 10 ++- apps/desktop/src/main/engine/titles.ts | 18 ++++++ .../components/transcript/TaskPlanCard.tsx | 23 +++++-- .../src/renderer/space/TaskNoteView.tsx | 9 +-- apps/desktop/src/shared/task-context.ts | 49 ++++++++++++--- apps/desktop/test/runner.test.ts | 25 ++++++++ apps/desktop/test/task-context.test.ts | 62 ++++++++++++++++++- apps/desktop/test/titles.test.ts | 17 ++++- .../2026-10-29-cf7-closeout/checkpoint.md | 8 +++ docs/status.md | 7 +++ docs/task-context.md | 16 ++++- 12 files changed, 258 insertions(+), 24 deletions(-) diff --git a/apps/desktop/e2e/task-to-notes.spec.ts b/apps/desktop/e2e/task-to-notes.spec.ts index 35a05b52..457865d2 100644 --- a/apps/desktop/e2e/task-to-notes.spec.ts +++ b/apps/desktop/e2e/task-to-notes.spec.ts @@ -131,6 +131,44 @@ test("a turn's footer lists the files it touched, on demand, as the CLI reported await expect(context).toContainText("As the CLI reported them"); }); +test("a footer whose files a shell command may have changed says so and lists the command, never a guessed file", async () => { + // CF-7 close-out observations 1 and 2: Claude rewrote a file it had read through a redirect, and Codex + // edited one with `perl -pi`, which it parsed as an unknown action. Neither CLI named the file written. + const cwd = repo; + const origin = (turn: string, item: string, backend: string) => ({ thread: threadId, turn, attempt: `${turn}:A1`, backend, native: { runtime: "R", item } }); + const tool = (turn: string, backend: string, id: string, extra: Record) => ({ type: "item", threadId, item: { id, kind: "tool", args: {}, status: "done", outcome: "succeeded", ok: true, origin: origin(turn, id, backend), at: at(), ...extra } }); + const redirect = "printf 'hello, live\\n' > greeting.txt && cat greeting.txt"; + const perl = "/bin/zsh -lc 'perl -pi -e s/hello/hi/ greeting.txt'"; + const before = await tid(page, "task-context-toggle").count(); + await app.evaluate(({ BrowserWindow }, list) => { + for (const event of list) BrowserWindow.getAllWindows()[0]!.webContents.send("thread:event", event); + }, [ + { type: "item", threadId, item: { id: "u-redirect", kind: "user", text: "Say hello, live", at: at() } }, + tool("u-redirect", "claude", "rr1", { name: "Read", title: "read greeting.txt", args: { file_path: `${cwd}/greeting.txt` } }), + tool("u-redirect", "claude", "rb1", { name: "Bash", title: `$ ${redirect}`, args: { command: redirect }, exec: { command: redirect } }), + { type: "item", threadId, item: { id: "a-redirect", kind: "assistant", text: "Done.", phase: "final", outcome: "succeeded", origin: origin("u-redirect", "a-redirect", "claude"), at: at() } }, + { type: "item", threadId, item: { id: "u-perl", kind: "user", text: "Say hi", at: at() } }, + tool("u-perl", "codex", "pp1", { name: "shell", title: `$ ${perl}`, exec: { command: perl, actions: [] }, exitCode: 0 }), + tool("u-perl", "codex", "pp2", { name: "shell", title: `$ ${perl}`, exec: { command: perl, actions: [] }, exitCode: 0 }), + { type: "item", threadId, item: { id: "a-perl", kind: "assistant", text: "Done.", phase: "final", outcome: "succeeded", origin: origin("u-perl", "a-perl", "codex"), at: at() } }, + { type: "status", threadId, status: "idle" }, + ]); + await expect(tid(page, "task-context-toggle")).toHaveCount(before + 2); + const claude = tid(page, "task-context").filter({ hasText: "Files touched · 1, maybe more" }); + await expect(claude).toHaveCount(1); + await tid(claude, "task-context-toggle").click(); + await expect(tid(claude, "task-context-file")).toHaveText(["greeting.txt read"]); + await expect(claude).toContainText("These commands may have changed files that neither the CLI nor Modex named:"); + await expect(tid(claude, "task-context-unlisted")).toHaveText([redirect]); + const codex = tid(page, "task-context").filter({ hasText: "Files touched · not listed" }); + await expect(codex).toHaveCount(1); + await tid(codex, "task-context-toggle").click(); + await expect(tid(codex, "task-context-file")).toHaveCount(0); + // Shown as the script its shell ran, with the exact command on hover, and once although it ran twice. + await expect(tid(codex, "task-context-unlisted")).toHaveText(["perl -pi -e s/hello/hi/ greeting.txt"]); + await expect(tid(codex, "task-context-unlisted").locator("code")).toHaveAttribute("title", perl); +}); + test("a successful shell receipt cannot verify masked failures or writes after a check", async () => { const commands = ["pnpm test || true", "pnpm test && printf broken > app.ts", "pnpm test | cat", "pnpm build && pnpm test"]; const before = await tid(page, "task-verification").count(); diff --git a/apps/desktop/src/main/engine/runner.ts b/apps/desktop/src/main/engine/runner.ts index 6b10c9bf..3afba15a 100644 --- a/apps/desktop/src/main/engine/runner.ts +++ b/apps/desktop/src/main/engine/runner.ts @@ -33,6 +33,7 @@ import { parseRecoveryJSON, recoveryJSON } from "./attachment-recovery-file.js"; import type { DraftRemovalScope } from "../../shared/native-drafts.js"; import { boundOutput } from "../../shared/evidence.js"; import { taskMarkdown, taskRecord, type TaskNoteView } from "../../shared/task-context.js"; +import { openingTitle } from "./titles.js"; import { actionDigest, emptyRequests, publicAnswers, register as registerRequest, resolve as resolveRequest, settle as settleRequest, validateAnswers, withdraw as withdrawRequests, type Question, type RequestBinding, type RequestsState, type Resolution } from "../../shared/human-requests.js"; import { CONVERSATION_SCHEMA, applyConversationEvent, initialConversation, okFor, toolItemId, type AttemptMark, type ConversationEvent, type ConversationState, type Observation, type SettledOutcome, type TurnRef } from "../../shared/conversation-events.js"; @@ -711,10 +712,13 @@ export class ThreadRunner { let files: Attachment[]; try { files = this.attachments.claim(threadId, attachments, draft ? { draft } : {}); } finally { if (draft) this.attachments.revokeDraft(draft); } - const derivedTitle = (text.replace(/\s+/g, " ").trim() || files.map((f) => f.name).join(", ")).slice(0, 60) || "New thread"; - // Name the thread after its first completed turn, including a retried first turn that still carries the opening-message title. + const opening = text.replace(/\s+/g, " ").trim() || files.map((f) => f.name).join(", "); + const derivedTitle = openingTitle(opening) || "New thread"; + // Name the thread after its first completed turn, including a retried first turn that still carries the opening-message title + // (or the plain 60-character cut it had before titles were cut at a word break). const firstTurn = !l.items.some((item) => item.kind === "user" && item.id !== reuse); - const shouldName = firstTurn && thread.titleSource !== "manual" && (thread.title === "New thread" || (retry && thread.title === derivedTitle)); + const openingTitled = thread.title === derivedTitle || thread.title === opening.slice(0, 60); + const shouldName = firstTurn && thread.titleSource !== "manual" && (thread.title === "New thread" || (retry && openingTitled)); l.followUp?.abort.abort(); l.followUp = undefined; // The Modex turn is the user message it answers: Retry runs the same turn again as a new attempt. diff --git a/apps/desktop/src/main/engine/titles.ts b/apps/desktop/src/main/engine/titles.ts index 84590ba4..8a4a2f4f 100644 --- a/apps/desktop/src/main/engine/titles.ts +++ b/apps/desktop/src/main/engine/titles.ts @@ -18,6 +18,24 @@ function normalizeTitle(output: string): string | null { return /[\p{L}\p{N}]/u.test(title) && [...characters.segment(title)].length <= 60 ? title : null; } +/** The most characters (graphemes) a thread title shows. */ +export const TITLE_LENGTH = 60; + +/** + * A thread's first name, from its opening message, until a better one arrives. Whitespace collapses. A + * longer message is cut at the last word break that keeps at least half the length, else at a grapheme, + * and ends in "…", so neither a word nor a character is cut in two. + */ +export function openingTitle(text: string): string { + const flat = text.replace(/\s+/g, " ").trim(); + const graphemes = [...characters.segment(flat)].map((part) => part.segment); + if (graphemes.length <= TITLE_LENGTH) return flat; + const head = graphemes.slice(0, TITLE_LENGTH - 1); + const space = head.lastIndexOf(" "); + const kept = (graphemes[TITLE_LENGTH - 1] === " " ? head : space >= TITLE_LENGTH / 2 ? head.slice(0, space) : head).join("").trimEnd(); + return `${kept.replace(/[\s,;:.\-–—(\[{]+$/u, "")}…`; +} + /** A separate, non-resumed CLI conversation; its output never enters the coding transcript. */ export async function generateTitle(backend: Backend, opts: TitleOptions, text: string, signal: AbortSignal): Promise { if (signal.aborted) return null; diff --git a/apps/desktop/src/renderer/components/transcript/TaskPlanCard.tsx b/apps/desktop/src/renderer/components/transcript/TaskPlanCard.tsx index 4428387a..4d2144b5 100644 --- a/apps/desktop/src/renderer/components/transcript/TaskPlanCard.tsx +++ b/apps/desktop/src/renderer/components/transcript/TaskPlanCard.tsx @@ -3,7 +3,7 @@ import type { ThreadItem } from "../../../shared/types"; import type { PlanStep } from "../../../shared/conversation-events"; import { bridge } from "../../bridge"; import { ErrorNotice } from "../ui/ErrorNotice"; -import { CONTEXT_SHOWN, displayCommand, noPlan, noPlanText, taskContext, verification, type TaskCheck, type TaskVerification } from "../../../shared/task-context"; +import { CONTEXT_SHOWN, UNLISTED_WORDS, contextCount, displayCommand, noPlan, noPlanText, taskContext, verification, type TaskCheck, type TaskVerification } from "../../../shared/task-context"; export const PLAN_STATUS: Record = { pending: "Not started", "in-progress": "In progress", completed: "Reported done", unknown: "Status unknown" }; export const PLAN_FROM = { "codex-plan": "Codex's plan", "claude-todo": "Claude's todo list", "claude-tasks": "Claude's task list", "acp-plan": "The agent's plan" } as const; @@ -99,17 +99,20 @@ export function VerificationText({ v }: { v: TaskVerification }) { /** * The project files a finished turn touched (docs/task-context.md#context-map), shown when asked: what * its tools changed, read and searched, as the CLI reported them. Nothing is indexed or inferred. - * At most 20 are listed, then how many more; paths outside the project are only counted. + * At most 20 are listed, then how many more; paths outside the project are only counted. Commands that + * may have changed files no CLI named are listed after them, so the list never reads as complete when it + * might not be. */ export function TaskContextLine({ items, cwd }: { items: readonly ThreadItem[]; cwd: string }) { const context = useMemo(() => taskContext(items, cwd), [items, cwd]); const [open, setOpen] = useState(false); const listId = useId(); - if (!context.files.length && !context.outside) return null; + const unlisted = context.unlisted ?? []; + if (!context.files.length && !context.outside && !unlisted.length) return null; return (
{open &&

As the CLI reported them. Modex did not index the project.

@@ -120,7 +123,19 @@ export function TaskContextLine({ items, cwd }: { items: readonly ThreadItem[]; {context.files.length > CONTEXT_SHOWN &&
  • and {context.files.length - CONTEXT_SHOWN} more
  • } {context.outside > 0 &&
  • {context.outside} outside the project, not named
  • } + {unlisted.length > 0 && }
    }
    ); } + +/** Commands that may have changed files no CLI named, shown as their scripts with the exact command on hover. */ +export function UnlistedCommands({ commands, testId }: { commands: readonly string[]; testId: string }) { + return <> +

    {UNLISTED_WORDS}

    +
      + {commands.slice(0, CONTEXT_SHOWN).map((command) =>
    • {displayCommand(command)}
    • )} + {commands.length > CONTEXT_SHOWN &&
    • and {commands.length - CONTEXT_SHOWN} more
    • } +
    + ; +} diff --git a/apps/desktop/src/renderer/space/TaskNoteView.tsx b/apps/desktop/src/renderer/space/TaskNoteView.tsx index 3ee630a3..000a2c0a 100644 --- a/apps/desktop/src/renderer/space/TaskNoteView.tsx +++ b/apps/desktop/src/renderer/space/TaskNoteView.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; import type { SpacePage } from "../../shared/space"; -import { CONTEXT_SHOWN, PROPOSED_WORDS, clarificationLine, type TaskNoteView as View } from "../../shared/task-context"; +import { CONTEXT_SHOWN, PROPOSED_WORDS, clarificationLine, contextCount, type TaskNoteView as View } from "../../shared/task-context"; import { bridge } from "../bridge"; -import { PLAN_FROM, PLAN_STATUS, VerificationText } from "../components/transcript/TaskPlanCard"; +import { PLAN_FROM, PLAN_STATUS, UnlistedCommands, VerificationText } from "../components/transcript/TaskPlanCard"; const EDITED_BY: Record["by"], string> = { you: "You", outside: "Outside Modex", agent: "an agent", other: "an unknown change" }; @@ -63,13 +63,14 @@ export function TaskNoteView({ view, onOpenThread, onClose }: { view: View; onOp {!record.plan && record.proposedPlan && <>

    Plan

    {PROPOSED_WORDS}

    {record.proposedPlan}
    } - {context && (context.files.length > 0 || context.outside > 0) && <>

    Files touched · {context.files.length}

    + {context && (context.files.length > 0 || context.outside > 0 || (context.unlisted?.length ?? 0) > 0) && <>

    Files touched · {contextCount(context)}

    As the CLI reported them. Modex did not index the project.

      {context.files.slice(0, CONTEXT_SHOWN).map((file) =>
    • {file.path} {file.relations.join(", ")}
    • )} {context.files.length > CONTEXT_SHOWN &&
    • and {context.files.length - CONTEXT_SHOWN} more
    • } {context.outside > 0 &&
    • {context.outside} outside the project, not named
    • } -
    } + + {context.unlisted?.length ? : null}} {verification && (verification.checks.length > 0 || verification.edits > 0) && <>

    Verification

    } {record.answer && <>

    Answer

    {record.answer}

    } diff --git a/apps/desktop/src/shared/task-context.ts b/apps/desktop/src/shared/task-context.ts index 6db0d73f..3b1f394e 100644 --- a/apps/desktop/src/shared/task-context.ts +++ b/apps/desktop/src/shared/task-context.ts @@ -105,7 +105,8 @@ export function isTaskSnapshot(value: unknown): value is TaskSnapshot { && Array.isArray(r.clarifications) && r.clarifications.every((c) => typeof c?.question === "string" && (c.answer === null || strings(c.answer))) && (r.plan === undefined || (Array.isArray(r.plan.steps) && r.plan.steps.every((s) => typeof s?.text === "string" && typeof s.status === "string"))) && (r.verification === undefined || (typeof r.verification.state === "string" && Array.isArray(r.verification.checks) && r.verification.checks.every((c) => typeof c?.command === "string"))) - && (r.context === undefined || (Array.isArray(r.context.files) && r.context.files.every((f) => typeof f?.path === "string" && strings(f.relations)) && Number.isInteger(r.context.outside))) + && (r.context === undefined || (Array.isArray(r.context.files) && r.context.files.every((f) => typeof f?.path === "string" && strings(f.relations)) && Number.isInteger(r.context.outside) + && (r.context.unlisted === undefined || strings(r.context.unlisted)))) && (r.answer === undefined || typeof r.answer === "string"); } @@ -214,12 +215,19 @@ export function taskMarkdown(record: TaskRecord): string { lines.push(""); } const context = record.context; - if (context && (context.files.length || context.outside)) { + const unlisted = context?.unlisted ?? []; + if (context && (context.files.length || context.outside || unlisted.length)) { lines.push("## Files the turn touched", "", "As the CLI reported them. Modex did not index the project.", ""); for (const file of context.files.slice(0, CONTEXT_SHOWN)) lines.push(`- \`${file.path}\` — ${file.relations.join(", ")}`); if (context.files.length > CONTEXT_SHOWN) lines.push(`- and ${context.files.length - CONTEXT_SHOWN} more`); if (context.outside) lines.push(`- ${context.outside} outside the project (not named)`); - lines.push(""); + if (context.files.length || context.outside) lines.push(""); + if (unlisted.length) { + lines.push(UNLISTED_WORDS, ""); + for (const command of unlisted.slice(0, CONTEXT_SHOWN)) lines.push(`- \`${command.replace(/`/g, "'")}\``); + if (unlisted.length > CONTEXT_SHOWN) lines.push(`- and ${unlisted.length - CONTEXT_SHOWN} more`); + lines.push(""); + } } if (record.commands.length) { lines.push("## Commands", ""); @@ -348,7 +356,7 @@ export function verification(items: readonly ThreadItem[]): TaskVerification { if (isCheckCommand(command)) { const passed = outcome === "succeeded" && (item.exitCode === 0 || item.exitCode === undefined); checks.push({ command, outcome, ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), passed, stale: false }); - } else if (outcome !== "denied" && !isReadOnlyCommand(command)) { + } else if (outcome !== "denied" && mayWrite(command)) { staleAll(); } } @@ -497,8 +505,13 @@ function segments(command: string, options: { noWrites?: boolean; checks?: boole export const CONTEXT_SHOWN = 20; /** A project file the turn touched, and how, as the CLI reported it. */ export interface ContextFile { path: string; relations: ("read" | "changed" | "searched")[] } -/** The files a turn touched inside the project, and how many outside it (counted, never named). */ -export interface TaskContext { files: ContextFile[]; outside: number } +/** + * The files a turn touched inside the project, and how many outside it (counted, never named). + * `unlisted`: commands that may have changed files no CLI named (a shell redirect, `perl -pi`, `sed -i`), + * by the rule that makes a check stale. They are listed as commands; Modex does not guess their files. + * Absent in records saved before it existed. + */ +export interface TaskContext { files: ContextFile[]; outside: number; unlisted?: string[] } /** Tools that read one file, and the argument that names it. */ const READ_ARGS: Record = { Read: "file_path", read_file: "path", NotebookRead: "notebook_path" }; @@ -510,11 +523,14 @@ const SEARCH_ARGS: Record = { Grep: "path", Glob: "path", LS: "p * its patch tools changed, files it read (Claude's Read, Codex's own parse of a command), and paths it * searched or listed. A denied or failed tool touched nothing. Nothing is inferred: a `cat` through a * shell that the CLI did not parse is not counted, and no dependency is guessed. Changed files come - * first, then read, then searched, each in the order the turn first touched them. + * first, then read, then searched, each in the order the turn first touched them. A command that may + * have written files without naming them is listed in `unlisted`, once, so the map never reads as complete + * when it might not be. */ export function taskContext(items: readonly ThreadItem[], cwd: string): TaskContext { const files = new Map(); const outsidePaths = new Set(); + const unlisted: string[] = []; const add = (raw: unknown, relation: ContextFile["relations"][number], commandCwd = cwd) => { if (typeof raw !== "string" || !raw.trim()) return; const path = relative(raw.trim(), cwd, commandCwd); @@ -539,8 +555,25 @@ export function taskContext(items: readonly ThreadItem[], cwd: string): TaskCont if (READ_ARGS[item.name]) add(item.args[READ_ARGS[item.name]!], "read"); if (SEARCH_ARGS[item.name]) add(item.args[SEARCH_ARGS[item.name]!], "searched"); for (const action of item.exec?.actions ?? []) add(action.path, action.kind === "read" ? "read" : "searched", item.exec?.cwd ?? cwd); + const command = item.exec?.command; + if (command && !EDIT_TOOLS.has(item.name) && mayWrite(command) && !unlisted.includes(command)) unlisted.push(command); } const rank = (file: ContextFile) => (file.relations.includes("changed") ? 0 : file.relations.includes("read") ? 1 : 2); const ordered = [...files.values()].map((file, i) => ({ file, i })).sort((a, b) => rank(a.file) - rank(b.file) || a.i - b.i).map(({ file }) => file); - return { files: ordered, outside: outsidePaths.size }; + return { files: ordered, outside: outsidePaths.size, ...(unlisted.length ? { unlisted } : {}) }; } + +/** Whether a command may have written files: neither a check nor known to be read-only (the rule that makes a check stale). */ +function mayWrite(command: string): boolean { + return !isCheckCommand(command) && !isReadOnlyCommand(command); +} + +/** How a context map's files read in a label: "3", "3, maybe more" when a command may have changed others, "not listed". */ +export function contextCount(context: TaskContext): string { + const more = (context.unlisted?.length ?? 0) > 0; + if (!context.files.length && more) return "not listed"; + return `${context.files.length}${more ? ", maybe more" : ""}`; +} + +/** The words before the commands a context map could not resolve to files. */ +export const UNLISTED_WORDS = "These commands may have changed files that neither the CLI nor Modex named:"; diff --git a/apps/desktop/test/runner.test.ts b/apps/desktop/test/runner.test.ts index 73c123f0..185742d2 100644 --- a/apps/desktop/test/runner.test.ts +++ b/apps/desktop/test/runner.test.ts @@ -1085,6 +1085,31 @@ test("autonaming uses the successful attempt's last assistant reply, excluding e } finally { await runner.dispose(); } }); +test("a long opening message names the thread at a word break, and a Retry still renames a thread with the older 60-character cut", async () => { + // CF-7 close-out observation 3: the saved note was titled "…tool available (for". + const request = "If you have a task-list or todo planning tool available (for this turn), use it to plan the fix."; + const h = harness(); + let titles = 0; + const backend: Backend = { + id: "mock", listModels: async () => [], dispose: async () => {}, + runTurn: async () => ({ status: "failed", error: "try again" }), + generateTitle: async () => { titles++; return "Plan the fix"; }, + }; + const runner = new ThreadRunner({ ...h, backends: { mock: backend } }); + const thread = await runner.createThread(h.store.addProject(gitRepo()).id); + try { + await runner.send(thread.id, request); + assert.equal(h.store.thread(thread.id)?.title, "If you have a task-list or todo planning tool available…"); + // A thread named before this change carries the plain cut; its retried first turn is still named. + runner.updateThread(thread.id, { title: request.slice(0, 60) }, { automaticTitle: true }); + backend.runTurn = async () => ({ status: "completed" }); + await runner.retry(thread.id); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(titles, 1); + assert.equal(h.store.thread(thread.id)?.title, "Plan the fix"); + } finally { await runner.dispose(); } +}); + test("manual ownership survives relaunch and a retry even when its text matches the fallback", async () => { const h = harness(); let titles = 0; diff --git a/apps/desktop/test/task-context.test.ts b/apps/desktop/test/task-context.test.ts index 63bf6f0d..c9e2c039 100644 --- a/apps/desktop/test/task-context.test.ts +++ b/apps/desktop/test/task-context.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { CONVERSATION_SCHEMA, projectConversation, type ConversationEvent, type Observation, type AttemptMark, type TurnRef } from "../src/shared/conversation-events.js"; -import { CONTEXT_SHOWN, displayCommand, isCheckCommand, noPlan, taskContext, taskRecord, taskMarkdown, verification } from "../src/shared/task-context.js"; +import { CONTEXT_SHOWN, UNLISTED_WORDS, contextCount, displayCommand, isCheckCommand, isTaskSnapshot, noPlan, taskContext, taskRecord, taskMarkdown, verification } from "../src/shared/task-context.js"; import { CodexBackend } from "../src/main/engine/backends/codex.js"; import { ClaudeBackend } from "../src/main/engine/backends/claude.js"; import type { ThreadItem } from "../src/shared/types.js"; @@ -557,6 +557,66 @@ test("the note lists at most the first files the turn touched, says how many mor }); +// CF-7 close-out observations 1 and 2 (docs/reviews/2026-10-29-cf7-closeout): a file a shell command wrote +// without any CLI naming it must not leave the map looking complete. +const bash = (command: string, extra: Record = {}): ThreadItem => + ({ id: `t${++seqId}`, kind: "tool", name: "Bash", title: `$ ${command}`, args: { command }, status: "done", outcome: "succeeded", ok: true, exec: { command }, at: at(seqId), ...extra }) as ThreadItem; + +test("a Claude shell redirect that rewrote a file read earlier is listed as a command that may have changed files", () => { + const redirect = "printf 'hello, live\\n' > greeting.txt && cat greeting.txt"; + const items = [user, bash("npm test", { outcome: "failed", ok: false }), tool("Read", { file_path: "/repo/greeting.txt" }), bash(redirect), bash("npm test")]; + const context = taskContext(items, "/repo"); + assert.deepEqual(context.files, [{ path: "greeting.txt", relations: ["read"] }], "Claude reported only the Read; Modex does not guess the redirect's file"); + assert.deepEqual(context.unlisted, [redirect]); + assert.equal(contextCount(context), "1, maybe more"); + const note = taskMarkdown(taskRecord(items, { threadId: "T", title: "Say hello, live", cwd: "/repo" })); + assert.ok(note.includes(`- \`greeting.txt\` — read\n\n${UNLISTED_WORDS}\n\n- \`${redirect}\`\n`), note); +}); + +test("a Codex in-place edit it parsed as unknown still gives the note a files section, listing the command once", () => { + const perl = "/bin/zsh -lc 'perl -pi -e '\"'\"'s/\\bhello\\b/hello, live/g'\"'\"' greeting.txt'"; + const items = [user, codexRun("/bin/zsh -lc 'npm test'", []), codexRun(perl, []), codexRun(perl, []), codexRun("/bin/zsh -lc 'npm test'", [])]; + const context = taskContext(items, "/repo"); + assert.deepEqual(context.files, []); + assert.deepEqual(context.unlisted, [perl], "the same command run twice is listed once"); + assert.equal(contextCount(context), "not listed"); + const note = taskMarkdown(taskRecord(items, { threadId: "T", title: "Say hello, live", cwd: "/repo" })); + assert.match(note, /## Files the turn touched\n\nAs the CLI reported them\. Modex did not index the project\.\n\nThese commands may have changed files/); + assert.ok(note.includes(`- \`${perl.replace(/`/g, "'")}\`\n`)); +}); + +test("checks, read-only commands, and denied or failed commands are not listed as possible writers", () => { + const context = taskContext([ + user, + bash("pnpm test"), + bash("cat a.txt && sed -n 1,5p b.txt | wc -l"), + bash("git status --short"), + bash("rm -rf build", { outcome: "denied", ok: false }), + bash("touch x", { outcome: "failed", ok: false }), + tool("Edit", { file_path: "/repo/a.ts", old_string: "a", new_string: "b" }), + ], "/repo"); + assert.equal(context.unlisted, undefined); + assert.equal(contextCount(context), "1"); + // An interrupted or unreported command may still have written something. + assert.deepEqual(taskContext([bash("git checkout -- a.ts", { outcome: "interrupted" }), bash("echo x > out.txt", { outcome: "unknown", ok: undefined })], "/repo").unlisted, + ["git checkout -- a.ts", "echo x > out.txt"]); +}); + +test("the note lists at most the first possible-writer commands and says how many more", () => { + const items = [user, ...Array.from({ length: CONTEXT_SHOWN + 2 }, (_, i) => bash(`touch f${i}`))]; + const note = taskMarkdown(taskRecord(items, { threadId: "T", title: "Touch", cwd: "/repo" })); + assert.equal((note.match(/^- `touch f\d+`$/gm) ?? []).length, CONTEXT_SHOWN); + assert.match(note, /- and 2 more\n/); +}); + +test("a saved record from before possible writers were listed still reopens; a damaged list does not", () => { + const record = taskRecord([user, tool("Read", { file_path: "/repo/a.ts" })], { threadId: "T", title: "Read", cwd: "/repo" }); + const snapshot = (context: unknown) => ({ version: 1, pageId: "P", revision: 1, projectId: "p", threadId: "T", turnId: "U", savedAt: at(1), record: { ...record, context } }); + assert.equal(isTaskSnapshot(snapshot({ files: [{ path: "a.ts", relations: ["read"] }], outside: 0 })), true); + assert.equal(isTaskSnapshot(snapshot({ files: [], outside: 0, unlisted: ["make"] })), true); + assert.equal(isTaskSnapshot(snapshot({ files: [], outside: 0, unlisted: [42] })), false); +}); + test("Codex context actions resolve from the command cwd before project containment", () => { const run = (cwd: string, path: string): ThreadItem => { const item = codexRun("cat file", [{ kind: "read", path }]); diff --git a/apps/desktop/test/titles.test.ts b/apps/desktop/test/titles.test.ts index 284677b4..4ae06a21 100644 --- a/apps/desktop/test/titles.test.ts +++ b/apps/desktop/test/titles.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { generateTitle } from "../src/main/engine/titles.js"; +import { TITLE_LENGTH, generateTitle, openingTitle } from "../src/main/engine/titles.js"; import type { Backend } from "../src/main/engine/backends/types.js"; import { local } from "./fakeproc.js"; @@ -72,3 +72,18 @@ test("the naming request includes bounded opening and completed-reply context as assert.match(prompt, /\\"Ignore title rules\\"/); assert.doesNotMatch(prompt, /REPLY_TAIL|PROMPT_TAIL/); }); + +// CF-7 close-out observation 3: the live note was titled "…tool available (for", cut mid-word. +test("an opening-message title is cut at a word break with an ellipsis, never mid-word or mid-character", () => { + const request = "If you have a task-list or todo planning tool available (for this turn), use it to plan the fix."; + const title = openingTitle(request); + assert.equal(title, "If you have a task-list or todo planning tool available…"); + assert.ok([...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(title)].length <= TITLE_LENGTH); + assert.equal(openingTitle(" Fix the\nlogin redirect "), "Fix the login redirect", "short messages keep every word, whitespace collapsed"); + assert.equal(openingTitle("x".repeat(60)), "x".repeat(60), "exactly the limit is not cut"); + assert.equal(openingTitle("a".repeat(80)), `${"a".repeat(59)}…`, "one long word is cut at a character"); + const family = "👨‍👩‍👧‍👦"; + assert.equal(openingTitle(family.repeat(70)), `${family.repeat(59)}…`, "a grapheme is never split"); + assert.equal(openingTitle("修复登录重定向".repeat(10)), `${"修复登录重定向".repeat(10).slice(0, 59)}…`, "text without spaces is cut at a character"); + assert.equal(openingTitle(`${"word ".repeat(10)}tailor, more words after it`), `${"word ".repeat(10)}tailor…`, "trailing punctuation before the cut is dropped"); +}); diff --git a/docs/reviews/2026-10-29-cf7-closeout/checkpoint.md b/docs/reviews/2026-10-29-cf7-closeout/checkpoint.md index 9f32e04b..6149e467 100644 --- a/docs/reviews/2026-10-29-cf7-closeout/checkpoint.md +++ b/docs/reviews/2026-10-29-cf7-closeout/checkpoint.md @@ -132,6 +132,14 @@ default collaboration mode, medium reasoning). 3. **The note title cuts the request mid-word** ("…tool available (for"). The note is named after the thread, and a thread is named after the first 60 characters of its request until it gets a better name, so a long request makes an unhelpful title. A human can rename the note; a later save keeps it. + **Follow-up, 2026-10-30 (`cf7-observations`).** Observations 1–3 are addressed in code; Val's + verdict below is still open. For 1 and 2, the context map now lists any command that may have + changed files no CLI named, by the rule that already makes a check stale, without guessing the + file. The Claude turn reads "Files touched · 1, maybe more" and lists the `printf … > greeting.txt` + command. The Codex turn reads "Files touched · not listed", lists the `perl -pi` command once, and + its note gains the files section. For 3, an opening-message title is cut at a word break with "…" + ("If you have a task-list or todo planning tool available…"). Notes saved earlier keep their titles + and records; they reopen unchanged. 4. **No live `turn/plan/updated` step plan, still.** Six default-mode Codex turns on this machine (0.163.0 and 0.164.0, `gpt-6.1-sol` and `gpt-6-astra`) have not been offered the plan tool. The path stays tested with schema-shaped fixtures, as `task-context.md#not-done-yet` says. diff --git a/docs/status.md b/docs/status.md index b0583e26..665c8acd 100644 --- a/docs/status.md +++ b/docs/status.md @@ -72,6 +72,13 @@ PR #422's CR-6 review found two blockers: unconfirmed child exit was reported as The published application source is `2eda138` (v0.4.0). Later source deliveries belong here; the release inventory and its incomplete acceptance gates are recorded separately below. +- **CF-7 close-out follow-ups (#280, 2026-10-30).** The three observations from the live close-out + runs are fixed. A turn's files list now names any command that may have changed files no CLI + reported, such as a shell redirect or `perl -pi`, and says "maybe more" or "not listed" instead of + looking complete. It never guesses the file. A thread named after its opening message is cut at a + word break with "…" rather than mid-word. Val's human review of the saved Notes is still open on + #280. See the [close-out receipt](reviews/2026-10-29-cf7-closeout/checkpoint.md). + ## v0.4.0 source delivery and remaining acceptance - **v0.4.0 published.** Signed tag `v0.4.0` at `2eda138` is public and confirmed by GitHub's latest-release endpoint. Protected Release run `40985927886`, independent downloaded-asset checks and downloaded-app E2E passed. The source deliveries below are included in this release; feature exposure and acceptance limits remain as stated. See the [publication record](reviews/2026-10-24-v0.4.0-release-review.md#published--2026-10-30-utc). diff --git a/docs/task-context.md b/docs/task-context.md index 1a5ddab8..8092e657 100644 --- a/docs/task-context.md +++ b/docs/task-context.md @@ -121,14 +121,24 @@ as the CLI reported them. Nothing is indexed, searched for or inferred: no depen - **What counts.** A denied or failed tool touched nothing. A command the CLI did not parse (a `cat` through Claude's Bash, an `unknown` Codex action) is not counted as a read. +- **Commands that may have changed files no CLI named.** A shell redirect through Claude's Bash or a + Codex `perl -pi` edit can write a file that neither CLI reports. Such a command is listed as a + command, once, under "These commands may have changed files that neither the CLI nor Modex named". + Modex does not guess which files it wrote. The rule is the one that makes a check stale: a command + that is neither a check nor known to be read-only, and was not denied or failed. Records saved before + this list existed reopen without it. Added after the CF-7 close-out's live runs, where a redirect left + a rewritten file listed only as `read` and a `perl -pi` edit left the note with no files at all. - **Paths.** They are relative to the thread's working directory. Paths outside it are counted, never named. - **Order.** Changed files come first, then read, then searched, each in the order the turn first touched them. A file lists each relation once. -A finished turn's footer has **Files touched · N** when there is anything to show. Opening it lists at -most 20, then "and N more", then how many were outside the project. The note gets a **Files the turn -touched** section with the same limit. The record keeps every file. +A finished turn's footer has **Files touched · N** when there is anything to show. It reads +**N, maybe more** when a command may have changed other files, and **not listed** when only such +commands ran. Opening it lists at most 20 files, then "and N more", then how many were outside the +project, then those commands, shown as the script their shell ran with the exact command on hover. +The note and the read-only Task view get the same sections with the same limit. The record keeps +every file and command. ## Clarifications