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