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

+1 -1
View File
@@ -1082,7 +1082,7 @@ A Bash call sent to the background (its result is only `Command running in backg
A `page` carries the turn's summary fields plus:
- `events`: one per prompt, sentence Claude wrote and tool call. Each has `intent`, `tool`, `label` and, when the call returned, `ok` (`false` when the result was flagged as an error, or reported failed tests or lint problems a pipe kept from the exit code). Depending on the call: `edit` (an Edit/Write, hunks in Claude Code's own `structuredPatch` shape), `edits` (files a Bash call wrote through here-documents or `printf`/`echo` redirects, that a python or node script fed by a here-document opens for writing, or that Claude Code says it changed; the last two have no lines, since what changed is not in the command; an append's hunk has `atEnd: true` and no line numbers, since where it lands is not in the command), the tail of `output`, `tests` (`{ passed, failed, errors? }`, counted on the whole output before it is cut), `helper`, `files` (files a Bash call printed with `cat`/`head`/`tail`, resolved against the command's folder), `headline` (the part of a one-liner that decided its intent), `commits`, `subject` (the first commit's message, when the command spells it out with `-m` or a `git commit -F -` here-document), `ships` (`push` / `pr`), `running`, `background` and `finishedLater` (see Background work), `keyPoints` (for a sentence Claude wrote: up to two sentences from further into a long message that a reader must not lose, such as an open problem, something not yet done or checked, or a word on what you asked for; read on the whole message, past the `label`'s clip, and used by the final panel's balloon), `question` and, for prompts, `origin`. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript, so it is never a panel of its own: the panel of the step after it (or the last one, when nothing follows) holds its events and counts them in `secondOpinion` (an advisor call that returned an error is drawn as a second opinion that got no answer). `unaskedSave` marks the turn's first save when no prompt of the turn asked to commit, push or save the work (a project rule, or Claude's own idea); the page says so in a small line, never as a fault. In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), and `ExitPlanMode`'s result carries the plan (`output`) and your answer (`Approved` / `Sent back`).
- `events`: one per prompt, sentence Claude wrote and tool call. Each has `intent`, `tool`, `label` and, when the call returned, `ok` (`false` when the result was flagged as an error, or reported failed tests or lint problems a pipe kept from the exit code). Depending on the call: `edit` (an Edit/Write, hunks in Claude Code's own `structuredPatch` shape), `edits` (files a Bash call wrote through here-documents or `printf`/`echo` redirects, that a python or node script fed by a here-document opens for writing, or that Claude Code says it changed; the last two have no lines, since what changed is not in the command; an append's hunk has `atEnd: true` and no line numbers, since where it lands is not in the command), the tail of `output`, `tests` (`{ passed, failed, errors? }`, counted on the whole output before it is cut), `helper`, `files` (files a Bash call printed with `cat`/`head`/`tail`, resolved against the command's folder), `headline` (the part of a one-liner that decided its intent), `commits`, `subject` (the first commit's message, when the command spells it out with `-m` or a `git commit -F -` here-document), `ships` (`push` / `pr`), `running`, `background` and `finishedLater` (see Background work), `keyPoints` (for a sentence Claude wrote: up to two sentences from further into a long message that a reader must not lose, such as an open problem, something not yet done or checked, or a word on what you asked for; read on the whole message, past the `label`'s clip, and used by the final panel's balloon), `question` and, for prompts, `origin`. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript, so it is never a panel of its own: the panel of the step after it (or the last one, when nothing follows) holds its events and counts them in `secondOpinion` (an advisor call that returned an error is drawn as a second opinion that got no answer). `unaskedSave` marks the turn's first save when no prompt of the turn (nor a plan you approved) asked to commit, push or save the work: `rule` when Claude said a project rule asks for it, else `true`; the page says so in a small line, never as a fault. In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), and `ExitPlanMode`'s result carries the plan (`output`) and your answer (`Approved` / `Sent back`).
- `panels`: at most 12, each `{ kind, events, caption: { story, director }, balloon?, files?, notes?, helpers?, todos?, tests?, ok?, rounds?, roundsOk?, question?, live? }`, with `events` as indices into `events`. A run panel's `printed` holds the first lines its last command printed (its last lines when it failed), for the terminal card: the card is drawn on the canvas and goes into a saved image, so lines that look like secrets are left out and addresses and long token-like strings are masked. `balloon.who` is `you`, `claude` or `note` (words that start a turn but are not yours: a background job's report, another session's message); a helper's report keeps its own words there (`"<helper>" reports: …`, its first sentence and key points, absolute paths shortened to their last two parts). A loop's `roundsOk` lists every test run in order, `true` where it passed and `null` for one still running, so a failure inside a round that passed in the end still shows, and a live loop shows the runs that finished. Kinds: `mission`, `investigate`, `research`, `plan`, `backup`, `fix`, `check`, `loop` (fix and check rounds folded), `run`, `save`, `ask`, `compact`, `montage` (quieter panels folded to fit) and `complete`.
- `filesChanged`, `added`, `removed` (summed per edit), `helpers` (the turn's own helper calls; a report turn's prompt carries the helper it answers, which is not counted), `helpersRunning`, `noneWorked` (every tool call of the turn failed, so the last panel is no celebration; a command whose last check only found nothing, exit code 1 after its answer, does not count as failed), `durationMs`, `partial` (the turn began before the part that was read) and `truncated` (older outputs, hunks and helper texts were dropped to keep the page small: a page carries at most 120,000 characters of them, newest first; an edit that lost its lines keeps its counts and an empty `hunks`).
+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
@@ -78,16 +78,16 @@
{
"kind": "run",
"ok": null,
"story": "Claude works in the terminal.",
"story": "Claude does the dry run you asked for: it shows what would happen, changing nothing.",
"unaskedSave": false,
"director": "Bash ×5 · rm -- ./-- && S=/tmp/claude/-work-cases-story-review-c/740e…",
"balloon": "My mk -- ... helper call left a stray file named --. I'll remove it, then run the dry run and save a listing to confirm nothing changed.",
"tests": null,
"roundsOk": null,
"printed": [
"--bogus exit=2",
"two dirs exit=2",
"-- two dirs exit=2"
"[dry-run] 2022-07-04_120000.jpg -> 2020-01-01_000000.jpg",
"[dry-run] a.jpg -> 2024-05-01_093000.jpg",
"[dry-run] b.jpg -> 2024-05-01_093000_1.jpg"
],
"events": 5
},
@@ -230,16 +230,16 @@
{
"kind": "run",
"ok": null,
"story": "Claude works in the terminal.",
"story": "Claude does the dry run you asked for: it shows what would happen, changing nothing.",
"unaskedSave": false,
"director": "Bash ×2 · bash -n \"$P/rename_by_date.sh\" && echo \"syntax ok\"",
"balloon": "Here's the dry run you asked for on the samples, plus a check that nothing on disk changed:",
"tests": null,
"roundsOk": null,
"printed": [
"--- missing value",
"exit=2 error: --prefix needs a value",
"--- slash"
"[dry-run] 2020-01-01_000000.jpg -> holiday_2020-01-01_000000.jpg",
"[dry-run] 2021-02-03_040506.jpg -> holiday_2021-02-03_040506.jpg",
"[dry-run] 2022-07-04_120000.jpg -> holiday_2022-07-04_120000.jpg"
],
"events": 2
},
@@ -70,7 +70,7 @@
"kind": "save",
"ok": true,
"story": "Claude starts a git repository and saves the work: \"add clamp() with pytest coverage\".",
"unaskedSave": true,
"unaskedSave": "rule",
"director": "git commit -q -F -",
"balloon": null,
"tests": null,
@@ -241,7 +241,7 @@
"kind": "save",
"ok": true,
"story": "Claude saves the work: \"add wrap() for modular wrapping into a range\".",
"unaskedSave": true,
"unaskedSave": false,
"director": "git commit -q -F -",
"balloon": null,
"tests": null,
@@ -193,7 +193,7 @@
{
"kind": "save",
"ok": true,
"story": "Claude saves the work: \"add calc_stats.mode with smallest-value tie-breaking\" and copies it to origin.git, a folder on this computer, but a check after that fails, as you said it might.",
"story": "Claude saves the work: \"add calc_stats.mode with smallest-value tie-breaking\" and copies it to origin.git, a folder on this computer, but the version-tag lookup after that fails (there are no tags yet), as you said it might.",
"unaskedSave": false,
"director": "git commit -q -F - · git push -u origin HEAD",
"balloon": null,
@@ -286,7 +286,7 @@
{
"kind": "save",
"ok": true,
"story": "Claude saves the work: \"add NOTES.md describing mode behavior\" and copies it to origin.git, a folder on this computer, but a check after that fails, as you said it might.",
"story": "Claude saves the work: \"add NOTES.md describing mode behavior\" and copies it to origin.git, a folder on this computer, but the version-tag lookup after that fails (there are no tags yet), as you said it might.",
"unaskedSave": false,
"director": "git commit -q -F - · git push · 1 error",
"balloon": null,
@@ -43,7 +43,7 @@
"kind": "save",
"ok": true,
"story": "Claude starts a git repository, writes package.json and index.js, and saves the work: \"add package.json and index.js with add and slugify\".",
"unaskedSave": true,
"unaskedSave": "rule",
"director": "git commit -q -m \"feat: add package.json and index.js with… · writes 2 files · after 1 second opinion",
"balloon": null,
"tests": null,
@@ -93,7 +93,7 @@
{
"kind": "run",
"ok": null,
"story": "Claude works in the terminal.",
"story": "Claude does the dry run you asked for: it shows what would happen, changing nothing.",
"unaskedSave": false,
"director": "Bash · set -o pipefail && python3 csvstat.py sample.csv | tee /tmp…",
"balloon": null,
+6 -3
View File
@@ -143,10 +143,10 @@ describe('Story Mode on real sessions', () => {
});
expect(second.panels.find((p) => p.kind === 'save')?.story).toBe(
// `git describe --tags` failed after the push; `; echo "exit=$?"` hid it from the exit code, not from the output.
'Claude saves the work: "add calc_stats.mode with smallest-value tie-breaking" and copies it to origin.git, a folder on this computer, but a check after that fails, as you said it might.'
'Claude saves the work: "add calc_stats.mode with smallest-value tie-breaking" and copies it to origin.git, a folder on this computer, but the version-tag lookup after that fails (there are no tags yet), as you said it might.'
);
expect(third.panels.find((p) => p.kind === 'save')?.story).toBe(
'Claude saves the work: "add NOTES.md describing mode behavior" and copies it to origin.git, a folder on this computer, but a check after that fails, as you said it might.'
'Claude saves the work: "add NOTES.md describing mode behavior" and copies it to origin.git, a folder on this computer, but the version-tag lookup after that fails (there are no tags yet), as you said it might.'
);
});
@@ -206,7 +206,10 @@ describe('Story Mode on real sessions', () => {
it('the saves no prompt asked for are marked on their first panel (round 11)', () => {
const marked = (name: string) =>
pagesOf(name).flatMap((p, t) => p.panels.filter((x) => x.unaskedSave).map((x) => `T${t + 1} ${x.kind}`));
expect(marked('compact-then-plan-mode')).toEqual(['T1 save', 'T3 save']);
// T3's save was a step of the plan you approved ("Verify and commit"): asked for.
expect(marked('compact-then-plan-mode')).toEqual(['T1 save']);
// Claude said why: the project's rules ask for a commit after each change.
expect(pagesOf('teammates-then-interrupt')[0].panels.find((x) => x.unaskedSave)?.unaskedSave).toBe('rule');
expect(marked('python-cli-fix-loop')).toEqual(['T1 loop']);
expect(marked('text-plan-dry-run-helper')).toEqual(['T1 check']);
// The prompt asked to commit and push: nothing to mark.
+16
View File
@@ -948,6 +948,22 @@ describe('story-mode.js overlay', () => {
expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('Done, with a catch');
});
it("an unasked save that Claude says the project's rules ask for says so", async () => {
boot(
storyOf([
prompt('Add a.'),
...bash('b1', 'git commit -qm "add a"', 'Commit', ''),
said("I added a. I committed it to follow CLAUDE.md's commit-after-each-change rule."),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
expect($('.story-panel[data-kind="save"] .story-cap-note')!.textContent).toBe(
"a save your prompt here did not ask for; your project's rules do"
);
});
it('a second opinion that came back with an error says no answer came', async () => {
const failed = {
type: 'assistant',
+22 -3
View File
@@ -1127,6 +1127,25 @@ describe('buildStoryPage', () => {
);
});
it('the dry run you asked for is what the terminal card shows, whatever ran after it', () => {
const page = pageOf([
prompt('add --prefix and show me a dry run on the samples'),
...bash(
'b1',
'./rename.sh --dry-run --prefix x_',
'Dry run',
'syntax ok\n[dry-run] a.jpg -> x_a.jpg\n[dry-run] b.jpg -> x_b.jpg'
),
...bash('b2', './rename.sh --prefix', 'Edge cases', '--- missing value\nexit=2 error: --prefix needs a value'),
end(1000),
]);
const run = page.panels.find((p) => p.kind === 'run')!;
expect(run.printed).toEqual(['[dry-run] a.jpg -> x_a.jpg', '[dry-run] b.jpg -> x_b.jpg']);
expect(run.caption.story).toBe(
'Claude does the dry run you asked for: it shows what would happen, changing nothing.'
);
});
it('a short ending keeps the sentence that leaves something open', () => {
const page = pageOf([
prompt('run them'),
@@ -1331,7 +1350,7 @@ describe('buildStoryPage', () => {
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'save')!.caption.story).toBe(
'Claude saves the work: "add a" and copies it to origin.git, a folder on this computer, but a check after that fails.'
'Claude saves the work: "add a" and copies it to origin.git, a folder on this computer, but the version-tag lookup after that fails (there are no tags yet).'
);
expect(page.toolCalls).toBe(2);
});
@@ -1384,8 +1403,8 @@ describe('buildStoryPage', () => {
prompt('dry run please'),
...bash(
'b1',
'./rename.sh --dry-run samples/',
'Dry run',
'./rename.sh --list samples/',
'List the renames',
'a.jpg -> holiday_2024-05-01.jpg\nAPI_TOKEN=abc123\nauthor: me@example.com\nb.jpg -> holiday_2024-05-02.jpg'
),
end(1000),