fix(story-mode): plans you approved, project rules, tag lookups and dry runs (review round 12)

From a reader who has never written code:
- "a save your prompt here did not ask for" on a save that was step 3 of
  the plan you approved ("Verify and commit"): a commit step in an
  approved plan asks for the save. When Claude says a project rule asks
  for its commits (read on its whole message), the line says "…; your
  project's rules do".
- "a check after that fails" read as a failed test where it was `git
  describe` finding no tags: "the version-tag lookup after that fails
  (there are no tags yet)".
- The dry run you asked for never showed: the terminal card took the
  last command's edge-case errors. A dry run in the panel is now what the
  card shows, its "a -> b" lines, under "Claude does the dry run you
  asked for: it shows what would happen, changing nothing."

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 18:19:01 +02:00
1 parent bcfef8af5b
commit f6c540276c
12 files changed
+120 -31

No files matched your search

+49 -7
View File
@@ -127,7 +127,7 @@ export type StoryEntry =
/** Teammates (helpers started as a team) reporting back, matched to their helper by name. */
teammates?: StoryTeammateReport[];
}
| { k: 'text'; at: string; text: string; keyPoints?: string[] }
| { k: 'text'; at: string; text: string; keyPoints?: string[]; citesSaveRule?: boolean }
| { k: 'tool'; at: string; id: string; name: string; input: ReducedInput }
| {
k: 'result';
@@ -1493,7 +1493,15 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
const content = message.content;
const textEntry = (text: string): StoryEntry => {
const keyPoints = keyPointsOf(text);
return { k: 'text', at, text: clip(text, STORY_LIMITS.textChars), ...(keyPoints.length ? { keyPoints } : {}) };
// Read on the whole message: the label keeps only its start.
const rule = RULE_SAVE.test(text.slice(0, 8000));
return {
k: 'text',
at,
text: clip(text, STORY_LIMITS.textChars),
...(keyPoints.length ? { keyPoints } : {}),
...(rule ? { citesSaveRule: true } : {}),
};
};
if (typeof content === 'string') return content.trim() ? [textEntry(content)] : [];
if (!Array.isArray(content)) return [];
@@ -1933,6 +1941,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
label: e.text,
intent: 'say',
...(e.keyPoints ? { keyPoints: e.keyPoints } : {}),
...(e.citesSaveRule ? { citesSaveRule: true } : {}),
});
break;
case 'tool': {
@@ -2411,6 +2420,10 @@ function emptyAnswerWhat(e: StoryEvent): string {
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;
/** Claude saying a project rule asks for the commits ("CLAUDE.md says to commit after every change"). */
const RULE_SAVE =
/\b(CLAUDE\.md|project('s)? (rules|notes|instructions)|house rules)\b[^.\n]{0,120}\bcommit|\bcommit[^.\n]{0,120}\b(CLAUDE\.md|project('s)? (rules|notes|instructions)|house rules)\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);
@@ -2455,6 +2468,9 @@ function touchedBefore(file: string, before: StoryEvent[]): boolean {
const GIT_LOOK =
/^(?:(?:git (?:-C \S+ |--git-dir=\S+ )?(?:log|status|show|ls-remote|branch|remote|describe|rev-parse|diff)\b[^;&|\n]*|(?:echo|head|tail|wc|sort)\b[^;&|\n]*)\s*(?:&&|\|\||;|\||\n|$)\s*)+$/;
/** A dry run: a command that shows what it would do without doing it. */
const DRY_RUN = /\bdry[- ]?run\b|--dry-run\b|--what-if\b/i;
/** 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);
@@ -2831,13 +2847,19 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
case 'run': {
panel.notes = notesOf(evs);
// What it printed, so a dry run or a listing you asked to see is on the page, not only in the drawer.
const lastOut = [...evs].reverse().find((e) => e.output?.trim() && !e.running);
// A dry run in the panel is what the card shows, whatever ran after it (edge cases, a clean-up).
const dryRun = evs.find(
(e) => e.output?.trim() && !e.running && e.ok !== false && DRY_RUN.test(`${e.description ?? ''} ${e.label}`)
);
const lastOut = dryRun ?? [...evs].reverse().find((e) => e.output?.trim() && !e.running);
if (lastOut?.output) {
const lines = lastOut.output
.split('\n')
.map(printableLine)
.filter((l): l is string => !!l);
const picked = lastOut.ok === false ? lines.slice(-2) : lines.slice(0, 3);
// Of a dry run, the lines that show what it would do ("a.jpg -> holiday_a.jpg").
const shows = dryRun ? lines.filter((l) => /->|→|\bwould\b|dry[- ]?run\]/i.test(l)) : [];
const picked = shows.length ? shows.slice(0, 3) : lastOut.ok === false ? lines.slice(-2) : lines.slice(0, 3);
if (picked.length) panel.printed = picked;
}
story = evs.every((e) => e.running)
@@ -2854,7 +2876,11 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
}.`
: evs.every((e) => e.tool !== 'Bash')
? 'Claude uses a tool.'
: 'Claude works in the terminal.';
: dryRun
? events.some((e) => e.intent === 'prompt' && DRY_RUN.test(e.label))
? 'Claude does the dry run you asked for: it shows what would happen, changing nothing.'
: 'Claude does a dry run: it shows what would happen, changing nothing.'
: 'Claude works in the terminal.';
director = `${tools} · ${shortCommand(evs[0])}${errors ? ` · ${plural(errors, 'error')}` : ''}`;
break;
}
@@ -2923,7 +2949,15 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
/\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' : ''}`;
// `git describe` with no tags: a lookup of a version name, not a check of the work.
const noTags = /No names found, cannot describe anything|No tags can describe/.test(out);
const stepAfter = `${
noTags
? 'the version-tag lookup after that fails (there are no tags yet)'
: looksOnly
? 'a check after that fails'
: 'a step after that fails'
}${allowed ? ', as you said it might' : ''}`;
story =
savedAnyway || hiddenFail
? uploadOnly
@@ -3009,13 +3043,21 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
break;
}
const savesHere = draft.events.some((i) => (events[i].commits ?? 0) > 0);
// A plan you approved that has a commit step asked for the save too ("Verify and commit").
const planAsked = events.some(
(e) =>
e.tool === 'ExitPlanMode' &&
e.question?.answer === 'Approved' &&
(ASKS_TO_SAVE.test(e.label) || !!e.todos?.some((t) => ASKS_TO_SAVE.test(t.text)))
);
if (
savesHere &&
turn.origin === 'human' &&
!planAsked &&
!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;
panel.unaskedSave = events.some((e) => e.intent === 'say' && e.citesSaveRule) ? 'rule' : 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');
+7 -2
View File
@@ -137,6 +137,8 @@ export interface StoryEvent {
* not taken, what you asked for. From the whole message, past the clip of `label`.
*/
keyPoints?: string[];
/** Claude says a project rule asks for its commits ("CLAUDE.md's commit-after-each-change rule"). */
citesSaveRule?: boolean;
/** A save that ships the work: `gh pr create` (`pr`) or `git push` (`push`). */
ships?: 'pr' | 'push';
/** How many commits a Bash call makes (`git commit` segments). */
@@ -174,8 +176,11 @@ 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;
/**
* The turn's first save, when no prompt of the turn (nor a plan you approved) asked for one: `rule`
* when Claude said a project rule asks for it, `true` otherwise.
*/
unaskedSave?: true | 'rule';
/** 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. */
+5 -1
View File
@@ -2514,7 +2514,11 @@ Object.assign(CodemanApp.prototype, {
const unanswered = asked.length > 0 && asked.every((i) => page.events[i].ok === false);
const ask = unanswered ? 'tries to ask' : 'asks';
const notes = [
p.unaskedSave ? 'a save your prompt here did not ask for' : '',
p.unaskedSave === 'rule'
? "a save your prompt here did not ask for; your project's rules do"
: p.unaskedSave
? 'a save your prompt here did not ask for'
: '',
p.secondOpinion
? `${
before