mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(story-mode): what the captions blurred for a reader who skims (review round 11)
From an engineering manager skimming titles and captions:
- "but a check after that fails" under "Mission complete!" where the
prompt said that step may fail now adds ", as you said it might".
- Saves no prompt of the turn asked for (a project rule, or Claude's own
idea) were indistinguishable from requested ones: the turn's first such
save carries a small line, "a save your prompt here did not ask for".
"save its log with tee" no longer counts as asking to save the work.
- The first save on a branch Claude created says which: "saves the work
on a new branch (feat/csvstat)".
- "Claude changes 3 files" hid an edit to your CLAUDE.md: a panel that
wrote some files and patched others says both ("Claude writes
README.md and .gitignore and changes CLAUDE.md."); a file created and
then edited in the same panel is written.
- A turn with a problem left and work still out read "Done for now";
the catch now wins the title.
- A helper's report that is only a path reads "reports back with the
file it wrote: README.md."
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
175017f604
commit
d3c55e04a3
18 files changed
+345
-40
No files matched your search
+81
-14
@@ -2390,6 +2390,25 @@ function emptyAnswerWhat(e: StoryEvent): string {
|
||||
: 'the last search finds no match';
|
||||
}
|
||||
|
||||
/** Words in a prompt that ask for (or mention) saving the work: then a save is no surprise. */
|
||||
const ASKS_TO_SAVE =
|
||||
/\b(commit|commits|committed|git|push|pushed|pull request|check(ed)? in|ship|release|merge|version control)\b|\bpr\b|\b(save|saving|saved) (the |your |all |this |my |our )?(work|changes|progress|code|project)\b/i;
|
||||
|
||||
/** The branch Claude created for this save (`git checkout -b x`, `git switch -c x`), when it is the first save on it. */
|
||||
function newBranchAt(indices: number[], events: StoryEvent[]): string | undefined {
|
||||
const firstAt = Math.min(...indices);
|
||||
const lastAt = Math.max(...indices);
|
||||
for (let i = lastAt; i >= 0; i--) {
|
||||
const m =
|
||||
events[i].tool === 'Bash' &&
|
||||
/\bgit (?:checkout|switch)(?:\s+-q)?\s+(?:-b|-c|--create)\s+([\w./-]+)/.exec(events[i].label);
|
||||
if (!m) continue;
|
||||
// Nothing committed between the branch and this panel: this is its first save.
|
||||
return events.slice(i + 1, firstAt).some((e) => (e.commits ?? 0) > 0) ? undefined : m[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One or two files by name ("calc.py and test_calc.py"), more by count ("3 files"). */
|
||||
const fileWords = (files: string[], noun = 'file'): string =>
|
||||
files.length && files.length <= 2 ? files.map((f) => basename(f)).join(' and ') : plural(files.length, noun);
|
||||
@@ -2461,9 +2480,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
const report = origin === 'task' && evs[0]?.helper?.result?.trim();
|
||||
if (panel.balloon && report) {
|
||||
// A report that is only a path says which file it wrote: its name is enough.
|
||||
const said = (
|
||||
/^\S+$/.test(report) && report.includes('/') ? basename(report) : closingText(report, keyPointsOf(report))
|
||||
)
|
||||
const pathOnly = /^\S+$/.test(report) && report.includes('/');
|
||||
const said = (pathOnly ? basename(report) : closingText(report, keyPointsOf(report)))
|
||||
// An absolute path is mostly room: its last two parts ("tests/test_extra.py") say which file.
|
||||
// Run on the clipped balloon (560 characters at most), so the scan stays short.
|
||||
.replace(/(?<![\w.~-])\/(?:[^\s/]+\/)+([^\s/]+\/[^\s/]+)/g, '$1');
|
||||
@@ -2471,9 +2489,12 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
if (said)
|
||||
panel.balloon = {
|
||||
who: 'note',
|
||||
text: name
|
||||
? `"${name}" reports: ${said}`
|
||||
: `${panel.balloon.text.replace(/[.]?$/, '.')} Its report: ${said}`,
|
||||
text:
|
||||
name && pathOnly
|
||||
? `"${name}" reports back with the file it wrote: ${said}.`
|
||||
: name
|
||||
? `"${name}" reports: ${said}`
|
||||
: `${panel.balloon.text.replace(/[.]?$/, '.')} Its report: ${said}`,
|
||||
};
|
||||
}
|
||||
director = command
|
||||
@@ -2576,6 +2597,20 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
panel.removed = edits.reduce((n, e) => n + e.removed, 0);
|
||||
if (draft.kind === 'fix') {
|
||||
const created = unique(edits.filter((e) => e.created).map((e) => e.file)).length;
|
||||
// Some files written whole (new ones), others patched: "writes README.md and changes CLAUDE.md".
|
||||
// A file created here is written, even when Claude then edited it in the same panel.
|
||||
const wholeOf = (f: string) => {
|
||||
const mine = edits.filter((d) => d.file === f);
|
||||
return (
|
||||
mine.some((d) => d.created) ||
|
||||
mine.every((d) => d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines))
|
||||
);
|
||||
};
|
||||
const wholeList = files.filter(wholeOf);
|
||||
const mixed =
|
||||
wholeList.length && wholeList.length < files.length
|
||||
? { whole: wholeList, patched: files.filter((f) => !wholeList.includes(f)) }
|
||||
: undefined;
|
||||
const whole =
|
||||
edits.length > 0 &&
|
||||
edits.every((d) => d.created || (d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines)));
|
||||
@@ -2586,9 +2621,11 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
: `Claude writes ${plural(created, 'new file')}.`
|
||||
: whole && files.length
|
||||
? `Claude writes ${fileWords(files)}.`
|
||||
: files.length
|
||||
? `Claude changes ${fileWords(files)}.`
|
||||
: 'Claude changes a file.';
|
||||
: mixed
|
||||
? `Claude writes ${fileWords(mixed.whole)} and changes ${fileWords(mixed.patched)}.`
|
||||
: files.length
|
||||
? `Claude changes ${fileWords(files)}.`
|
||||
: 'Claude changes a file.';
|
||||
const perFile = files.slice(0, 3).map((f) => {
|
||||
const mine = edits.filter((e) => e.file === f);
|
||||
return `${basename(f)} ${fmtCounts(
|
||||
@@ -2728,8 +2765,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
director += ` · ${plural(saved, 'commit')}`;
|
||||
if (ok)
|
||||
story = `${story.slice(0, -1)}, ${startsRepoAt(draft.events, events) ? 'then Claude starts a git repository and' : 'and Claude'} saves the work${
|
||||
checks.some((e) => e.ships === 'push') ? ' and sends it up' : ''
|
||||
}.`;
|
||||
newBranchAt(draft.events, events) ? ` on a new branch (${newBranchAt(draft.events, events)})` : ''
|
||||
}${checks.some((e) => e.ships === 'push') ? ' and sends it up' : ''}.`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -2815,7 +2852,20 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
s
|
||||
)
|
||||
);
|
||||
const stepAfter = looksOnly ? 'a check after that fails' : 'a step after that fails';
|
||||
// The prompt named that step and said it may fail ("git describe may fail, that is fine").
|
||||
const asked = events
|
||||
.filter((e) => e.intent === 'prompt')
|
||||
.map((e) => e.label)
|
||||
.join('\n');
|
||||
const allowed =
|
||||
after.some((s) => {
|
||||
const head = s.split(/\s+/).slice(0, 2).join(' ');
|
||||
return head.length > 3 && asked.includes(head);
|
||||
}) &&
|
||||
/\b(may|might|can|could|will) (fail|error)\b|\bexpected to fail\b|\b(fine|ok|okay) if it fails\b|\b(that is|that's) fine\b/i.test(
|
||||
asked
|
||||
);
|
||||
const stepAfter = `${looksOnly ? 'a check after that fails' : 'a step after that fails'}${allowed ? ', as you said it might' : ''}`;
|
||||
story =
|
||||
savedAnyway || hiddenFail
|
||||
? uploadOnly
|
||||
@@ -2840,18 +2890,27 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
: `Claude saves the work${inCommits}${said}.`;
|
||||
const wrote = unique(edits.map((d) => d.file));
|
||||
const startsRepo = startsRepoAt(draft.events, events);
|
||||
const wroteWhole = wrote.filter((f) =>
|
||||
edits
|
||||
.filter((d) => d.file === f)
|
||||
.every((d) => d.created || (d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines)))
|
||||
);
|
||||
const wrotePatched = wrote.filter((f) => !wroteWhole.includes(f));
|
||||
const lead = [
|
||||
startsRepo ? 'starts a git repository' : '',
|
||||
wrote.length ? `writes ${fileWords(wrote)}` : '',
|
||||
wroteWhole.length ? `writes ${fileWords(wroteWhole)}` : '',
|
||||
wrotePatched.length ? `changes ${fileWords(wrotePatched)}` : '',
|
||||
].filter(Boolean);
|
||||
if (lead.length && story.startsWith('Claude ')) {
|
||||
// "starts a git repository, saves the work and uploads it": never "and … and".
|
||||
const rest = story.slice(7);
|
||||
story =
|
||||
lead.length === 2
|
||||
lead.length >= 2
|
||||
? `Claude ${lead.join(', ')}, and ${rest}`
|
||||
: `Claude ${lead[0]}${rest.replace(/"[^"]*"/g, '').includes(' and ') ? ',' : ' and'} ${rest}`;
|
||||
}
|
||||
const branch = newBranchAt(draft.events, events);
|
||||
if (branch && commits) story = story.replace('saves the work', `saves the work on a new branch (${branch})`);
|
||||
director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${
|
||||
edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : ''
|
||||
}`;
|
||||
@@ -2888,6 +2947,14 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
case 'complete':
|
||||
break;
|
||||
}
|
||||
const savesHere = draft.events.some((i) => (events[i].commits ?? 0) > 0);
|
||||
if (
|
||||
savesHere &&
|
||||
turn.origin === 'human' &&
|
||||
!events.slice(0, Math.min(...draft.events)).some((e) => (e.commits ?? 0) > 0) &&
|
||||
!events.some((e) => e.intent === 'prompt' && ASKS_TO_SAVE.test(e.label))
|
||||
)
|
||||
panel.unaskedSave = true;
|
||||
if (panel.secondOpinion) {
|
||||
// Asked before the step, after it (nothing following), or in the middle of a step's calls.
|
||||
const work = draft.events.filter((i) => events[i].tool !== 'Advisor');
|
||||
|
||||
@@ -174,6 +174,8 @@ export interface StoryPanel {
|
||||
removed?: number;
|
||||
ok?: boolean;
|
||||
rounds?: number;
|
||||
/** The turn's first save, when no prompt of the turn asked for one (a project rule, or Claude's own idea). */
|
||||
unaskedSave?: boolean;
|
||||
/** Second opinions (the advisor) Claude asked for right before this step: not a step of their own. */
|
||||
secondOpinion?: number;
|
||||
/** A loop's test runs in order: whether each passed, `null` for one still running. */
|
||||
|
||||
@@ -2505,16 +2505,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A call that came back with an error brought no opinion: say so, never that one was given.
|
||||
const unanswered = asked.length > 0 && asked.every((i) => page.events[i].ok === false);
|
||||
const ask = unanswered ? 'tries to ask' : 'asks';
|
||||
rt.capNote.hidden = !p.secondOpinion;
|
||||
rt.capNote.textContent = !p.secondOpinion
|
||||
? ''
|
||||
: `${
|
||||
before
|
||||
? `after ${unanswered ? 'trying to ask' : 'asking'}`
|
||||
: trailing
|
||||
? `then ${ask}`
|
||||
: `along the way, ${ask}`
|
||||
} a stronger AI model for a second opinion${unanswered ? ' (no answer came)' : ''}`;
|
||||
const notes = [
|
||||
p.unaskedSave ? 'a save your prompt here did not ask for' : '',
|
||||
p.secondOpinion
|
||||
? `${
|
||||
before
|
||||
? `after ${unanswered ? 'trying to ask' : 'asking'}`
|
||||
: trailing
|
||||
? `then ${ask}`
|
||||
: `along the way, ${ask}`
|
||||
} a stronger AI model for a second opinion${unanswered ? ' (no answer came)' : ''}`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
rt.capNote.hidden = !notes.length;
|
||||
rt.capNote.textContent = notes.join('; ');
|
||||
}
|
||||
rt.el.classList.toggle('is-live', !!p.live);
|
||||
// Named by its place only: the caption inside is read as the panel's content, once.
|
||||
@@ -2537,11 +2541,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
? 'To be continued…'
|
||||
: page.noneWorked
|
||||
? 'Not done'
|
||||
: page.helpersRunning || page.finishedLater || page.events.some((e) => e.running && !e.helper)
|
||||
? // Work was still out when the turn ended (and maybe still is): not a clean finish.
|
||||
'Done for now'
|
||||
: page.openNote === 'catch'
|
||||
? 'Done, with a catch'
|
||||
: page.openNote === 'catch'
|
||||
? // A problem left beats work still out: it is what the reader must not miss.
|
||||
'Done, with a catch'
|
||||
: page.helpersRunning || page.finishedLater || page.events.some((e) => e.running && !e.helper)
|
||||
? // Work was still out when the turn ended (and maybe still is): not a clean finish.
|
||||
'Done for now'
|
||||
: page.openNote
|
||||
? 'Done, with a note'
|
||||
: page.asks
|
||||
|
||||
Reference in new issue
Block a user