From 70b1ebcc196c1d14cf601a4c8d29f3fac4aa5773 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 16:11:13 +0200 Subject: [PATCH] fix(story-mode): name the files, say when a repository is new, and when a push stayed on this computer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review round 9 (a newcomer to the project): - One or two files are named, not counted: "Claude writes .gitignore and saves the work", "Claude changes rename_by_date.sh.", "Claude writes the new files mathx.py and test_mathx.py." Three or more are counted. - The first save in a folder Claude turned into a repository says so: "Claude starts a git repository and saves the work: …". A `git init --bare` (the push target) does not count, nor a folder that already had commits. - A push to a repository in a folder on this computer (`To ./origin.git`) is a copy, not an upload: "then copies it to origin.git, a folder on this computer", and the final chip reads "copied to origin.git". - A tool lookup for plan mode reads "Claude gets ready to plan." Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 57 +++++++++++++++---- src/web/public/story-mode.js | 14 ++++- .../background-helpers-reports.expected.json | 2 +- ...ash-script-background-helper.expected.json | 6 +- .../compact-then-plan-mode.expected.json | 8 +-- .../errors-push-append.expected.json | 12 ++-- .../failing-test-on-purpose.expected.json | 2 +- .../long-turn-prompt-added.expected.json | 6 +- .../python-cli-fix-loop.expected.json | 2 +- .../question-tests-first-push.expected.json | 2 +- .../teammates-then-interrupt.expected.json | 4 +- .../text-plan-dry-run-helper.expected.json | 2 +- .../web-page-node-test.expected.json | 2 +- test/story-corpus.test.ts | 14 +++-- test/story-mode-ui.test.ts | 42 +++++++------- test/story-mode.test.ts | 40 +++++++++++-- 16 files changed, 148 insertions(+), 67 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index 95f52f52..7b27b381 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -2289,6 +2289,10 @@ function checkName(e: StoryEvent | undefined): string { return 'another check'; } +/** 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); + /** You asked for a failing run first (test first), or Claude said the failure was expected. */ const PLANNED_IN_PROMPT = /\b(test[- ]first|tdd|fail(s|ing)? (first|on purpose)|failing tests? first|on purpose|red first|before (the )?(code|module|function|implementation) exists)\b/i; @@ -2374,7 +2378,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde const checking = turn.origin === 'task' || turn.origin === 'peer'; const named = shown.map((f) => basename(f)); story = evs.every((e) => e.tool === 'ToolSearch') - ? 'Claude gets the tools it needs ready.' + ? evs.some((e) => /PlanMode/.test(e.label)) + ? 'Claude gets ready to plan.' + : 'Claude gets the tools it needs ready.' : shown.length && shown.length <= 2 ? `Claude ${checking ? 'checks' : 'reads'} ${named.join(' and ')}.` : shown.length @@ -2452,11 +2458,13 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde edits.length > 0 && edits.every((d) => d.created || d.hunks.every((h) => !h.oldStart && !h.oldLines)); story = created && created === files.length - ? `Claude writes ${plural(created, 'new file')}.` + ? files.length <= 2 + ? `Claude writes the new ${files.length === 1 ? 'file' : 'files'} ${fileWords(files)}.` + : `Claude writes ${plural(created, 'new file')}.` : whole && files.length - ? `Claude writes ${plural(files.length, 'file')}.` + ? `Claude writes ${fileWords(files)}.` : files.length - ? `Claude changes ${plural(files.length, 'file')}.` + ? `Claude changes ${fileWords(files)}.` : 'Claude changes a file.'; const perFile = files.slice(0, 3).map((f) => { const mine = edits.filter((e) => e.file === f); @@ -2571,8 +2579,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde tests.errors ? `, ${plural(tests.errors, 'error')}` : '' }`; if (edits.length) director += ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}`; - const written = unique(edits.map((d) => d.file)).length; - if (written) story = `Claude writes ${plural(written, 'file')}; ${story[0].toLowerCase()}${story.slice(1)}`; + const written = unique(edits.map((d) => d.file)); + if (written.length) story = `Claude writes ${fileWords(written)}; ${story[0].toLowerCase()}${story.slice(1)}`; const saved = checks.reduce((n, e) => n + (e.commits ?? 0), 0); if (saved) { director += ` · ${plural(saved, 'commit')}`; @@ -2627,6 +2635,13 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde const inCommits = commits > 1 ? ` in ${commits} commits` : ''; // A push of commits made earlier: nothing is saved here, only uploaded. const uploadOnly = ships === 'push' && !commits; + // A push to a folder on this computer (a repository beside the project, `To ./origin.git`) is a + // copy, not an upload: nothing left the machine. + const target = /^To (\S+)/m.exec(evs.map((e) => e.output ?? '').join('\n'))?.[1]; + const localTarget = + target && (/^file:\/\//.test(target) || !/:/.test(target)) ? basename(target.replace(/\/+$/, '')) : ''; + const uploads = (what: string) => + localTarget ? `copies ${what} to ${localTarget}, a folder on this computer` : `uploads ${what}`; // One commit: its message says what was saved. const subject = commits === 1 ? evs.find((e) => e.subject)?.subject : undefined; // Its conventional prefix (`feat:`, `chore(deps):`) is for git, not for the reader. @@ -2634,9 +2649,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde const said = plainSubject ? `: "${clip(plainSubject, 60).replace(/[.]$/, '')}"` : ''; story = savedAnyway ? uploadOnly - ? 'Claude uploads the work, but a step after that fails.' + ? `Claude ${uploads('the work')}, but a step after that fails.` : ships === 'push' - ? `Claude saves the work${inCommits} and uploads it, but a step after that fails.` + ? `Claude saves the work${inCommits} and ${uploads('it')}, but a step after that fails.` : `Claude saves the work${inCommits}, but a step after that fails.` : panel.ok === false ? ships === 'pr' @@ -2647,12 +2662,30 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : ships === 'pr' ? 'Claude opens a pull request.' : uploadOnly - ? 'Claude uploads the work.' + ? `Claude ${uploads('the work')}.` : ships === 'push' - ? `Claude saves the work${inCommits} and uploads it${said}.` + ? localTarget && said + ? `Claude saves the work${said}, then ${uploads('it')}.` + : `Claude saves the work${inCommits} and ${uploads('it')}${said}.` : `Claude saves the work${inCommits}${said}.`; - const wrote = unique(edits.map((d) => d.file)).length; - if (wrote && story.startsWith('Claude ')) story = `Claude writes ${plural(wrote, 'file')} and ${story.slice(7)}`; + const wrote = unique(edits.map((d) => d.file)); + const firstAt = Math.min(...draft.events); + const earlier = events.slice(0, firstAt); + const startsRepo = + !earlier.some((e) => (e.commits ?? 0) > 0) && + [...earlier, ...evs].some((e) => e.tool === 'Bash' && /\bgit init\b(?!\s+--bare)/.test(e.label)); + const lead = [ + startsRepo ? 'starts a git repository' : '', + wrote.length ? `writes ${fileWords(wrote)}` : '', + ].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 + ? `Claude ${lead.join(', ')}, and ${rest}` + : `Claude ${lead[0]}${rest.split('"')[0].includes(' and ') ? ',' : ' and'} ${rest}`; + } director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${ edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : '' }`; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 370c9dcc..73f09d31 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2544,9 +2544,21 @@ Object.assign(CodemanApp.prototype, { const shipped = page.events.some( (e) => e.ships === 'push' && (e.ok !== false || /\[new branch\]|\S+\.\.\S+\s+\S+\s+->/.test(e.output || '')) ); + // A push to a folder on this computer (`To ./origin.git`) is a copy: nothing left the machine. + const target = /^To (\S+)/m.exec( + page.events + .filter((e) => e.ships === 'push') + .map((e) => e.output || '') + .join('\n') + )?.[1]; + const copiedTo = + target && (/^file:\/\//.test(target) || !target.includes(':')) ? target.split('/').filter(Boolean).pop() : ''; if (commits || shipped) items.push({ - text: [commits ? `${commits} commit${commits === 1 ? '' : 's'}` : '', shipped ? 'uploaded' : ''] + text: [ + commits ? `${commits} commit${commits === 1 ? '' : 's'}` : '', + shipped ? (copiedTo ? `copied to ${copiedTo}` : 'uploaded') : '', + ] .filter(Boolean) .join(', '), }); diff --git a/test/fixtures/story-corpus/background-helpers-reports.expected.json b/test/fixtures/story-corpus/background-helpers-reports.expected.json index d7df322d..f4bc4811 100644 --- a/test/fixtures/story-corpus/background-helpers-reports.expected.json +++ b/test/fixtures/story-corpus/background-helpers-reports.expected.json @@ -40,7 +40,7 @@ { "kind": "fix", "ok": null, - "story": "Claude writes 2 new files.", + "story": "Claude writes the new files wordcount.py and test_basic.py.", "director": "Write ×2 · wordcount.py +23 · test_basic.py +21", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/bash-script-background-helper.expected.json b/test/fixtures/story-corpus/bash-script-background-helper.expected.json index fab40182..4d536665 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -62,7 +62,7 @@ { "kind": "fix", "ok": null, - "story": "Claude changes 2 files.", + "story": "Claude changes rename_by_date.sh and -dash.jpg.", "director": "Write · Edit ×3 · Bash · rename_by_date.sh +95 −3 · -dash.jpg +1", "balloon": "Now I'll create the samples and run the dry run.", "tests": null, @@ -205,7 +205,7 @@ { "kind": "fix", "ok": null, - "story": "Claude changes 1 file.", + "story": "Claude changes rename_by_date.sh.", "director": "Edit ×3 · rename_by_date.sh +21 −6", "balloon": null, "tests": null, @@ -231,7 +231,7 @@ { "kind": "fix", "ok": null, - "story": "Claude changes 1 file.", + "story": "Claude changes README.md.", "director": "Edit ×3 · README.md +18 −2", "balloon": "All passing.", "tests": null, diff --git a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json index 7319b208..0850d5ca 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -40,7 +40,7 @@ { "kind": "fix", "ok": null, - "story": "Claude writes 2 new files.", + "story": "Claude writes the new files mathx.py and test_mathx.py.", "director": "Write ×2 · mathx.py +11 · test_mathx.py +26 · after 1 second opinion", "balloon": null, "tests": null, @@ -65,7 +65,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work: \"add clamp() with pytest coverage\".", + "story": "Claude starts a git repository and saves the work: \"add clamp() with pytest coverage\".", "director": "git commit -q -F -", "balloon": null, "tests": null, @@ -167,7 +167,7 @@ { "kind": "investigate", "ok": null, - "story": "Claude gets the tools it needs ready.", + "story": "Claude gets ready to plan.", "director": "ToolSearch", "balloon": null, "tests": null, @@ -200,7 +200,7 @@ { "kind": "fix", "ok": null, - "story": "Claude changes 2 files.", + "story": "Claude changes mathx.py and test_mathx.py.", "director": "Edit ×3 · mathx.py +11 · test_mathx.py +20 −1", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/errors-push-append.expected.json b/test/fixtures/story-corpus/errors-push-append.expected.json index 890d66ec..ce3dbff8 100644 --- a/test/fixtures/story-corpus/errors-push-append.expected.json +++ b/test/fixtures/story-corpus/errors-push-append.expected.json @@ -51,7 +51,7 @@ { "kind": "fix", "ok": null, - "story": "Claude writes 1 new file.", + "story": "Claude writes the new file test_calc_stats.py.", "director": "Write · test_calc_stats.py +55", "balloon": null, "tests": null, @@ -77,7 +77,7 @@ { "kind": "save", "ok": true, - "story": "Claude writes 1 file and saves the work: \"add failing tests for calc_stats mean/median\".", + "story": "Claude writes .gitignore and saves the work: \"add failing tests for calc_stats mean/median\".", "director": "git commit -q -F - · writes 1 file", "balloon": "Red as expected: collection error, exit code 2.", "tests": null, @@ -88,7 +88,7 @@ { "kind": "fix", "ok": null, - "story": "Claude writes 1 new file.", + "story": "Claude writes the new file calc_stats.py.", "director": "Write · calc_stats.py +24", "balloon": null, "tests": null, @@ -181,7 +181,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work and uploads it: \"add calc_stats.mode with smallest-value tie-breaking\".", + "story": "Claude saves the work: \"add calc_stats.mode with smallest-value tie-breaking\", then copies it to origin.git, a folder on this computer.", "director": "git commit -q -F - · git push -u origin HEAD", "balloon": null, "tests": null, @@ -243,7 +243,7 @@ { "kind": "fix", "ok": null, - "story": "Claude writes 1 file.", + "story": "Claude writes NOTES.md.", "director": "Bash · NOTES.md +2", "balloon": null, "tests": null, @@ -268,7 +268,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work and uploads it, but a step after that fails.", + "story": "Claude saves the work and copies it to origin.git, a folder on this computer, but a step after that fails.", "director": "git commit -q -F - · git push · 1 error", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json index 74795ebf..e936eb93 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -71,7 +71,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work: \"add slugify module with node:test suite\".", + "story": "Claude starts a git repository and saves the work: \"add slugify module with node:test suite\".", "director": "git commit -q -F -", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json index d0beaf59..bf7e4b2e 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -97,7 +97,7 @@ { "kind": "fix", "ok": null, - "story": "Claude writes 1 new file.", + "story": "Claude writes the new file README.md.", "director": "Write · README.md +79", "balloon": null, "tests": null, @@ -123,7 +123,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work in 6 commits.", + "story": "Claude starts a git repository and saves the work in 6 commits.", "director": "git commit -q -m \"chore: scaffold inventory package\" -m \"Ad…", "balloon": "Commits are in.", "tests": null, @@ -243,7 +243,7 @@ { "kind": "fix", "ok": null, - "story": "Claude changes 1 file.", + "story": "Claude changes CHANGELOG.md.", "director": "Edit · CHANGELOG.md +2", "balloon": "Step 12:", "tests": null, diff --git a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json index 1d6c9e26..9ec39595 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -58,7 +58,7 @@ { "kind": "save", "ok": true, - "story": "Claude writes 1 file and saves the work: \"ignore Python and pytest caches\".", + "story": "Claude writes .gitignore and saves the work: \"ignore Python and pytest caches\".", "director": "git commit -q -m \"chore: ignore Python and pytest caches · writes 1 file", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/question-tests-first-push.expected.json b/test/fixtures/story-corpus/question-tests-first-push.expected.json index 0b55ee14..db3e92dd 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -94,7 +94,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work and uploads it, but a step after that fails.", + "story": "Claude starts a git repository, saves the work and copies it to remote.git, a folder on this computer, but a step after that fails.", "director": "git commit -q -F - · git push -u origin main · 1 error", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index beaa4eaf..eab757ba 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -40,7 +40,7 @@ { "kind": "save", "ok": true, - "story": "Claude writes 2 files and saves the work: \"add package.json and index.js with add and slugify\".", + "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\".", "director": "git commit -q -m \"feat: add package.json and index.js with… · writes 2 files · after 1 second opinion", "balloon": null, "tests": null, @@ -62,7 +62,7 @@ { "kind": "check", "ok": true, - "story": "Claude writes 1 file; the tests pass.", + "story": "Claude writes index.test.mjs; the tests pass.", "director": "node --test index.test.mjs → ok · 8 passed · writes 1 file", "balloon": null, "tests": { diff --git a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json index 55e960fb..b50254a9 100644 --- a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json +++ b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json @@ -62,7 +62,7 @@ { "kind": "check", "ok": true, - "story": "Claude writes 2 files; the tests pass, and Claude saves the work.", + "story": "Claude writes test_csvstat.py and pyproject.toml; the tests pass, and Claude saves the work.", "director": "python3 -m pytest -q 2>&1 → ok ×2 · 17 passed · writes 2 files · 1 commit", "balloon": "Code and tests are committed.", "tests": { diff --git a/test/fixtures/story-corpus/web-page-node-test.expected.json b/test/fixtures/story-corpus/web-page-node-test.expected.json index 8723f268..f8eb8634 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -79,7 +79,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work: \"add pomodoro timer with tests\".", + "story": "Claude starts a git repository and saves the work: \"add pomodoro timer with tests\".", "director": "git commit -q -m \"feat: add pomodoro timer with tests\" -m \"… · then 1 second opinion", "balloon": null, "tests": null, diff --git a/test/story-corpus.test.ts b/test/story-corpus.test.ts index 78d96ace..eec0a710 100644 --- a/test/story-corpus.test.ts +++ b/test/story-corpus.test.ts @@ -107,7 +107,7 @@ describe('Story Mode on real sessions', () => { it('a commit at the end of a test one-liner is kept, and a background helper reports back in 9 s', () => { expect(panel('text-plan-dry-run-helper', 0, 'check')?.story).toBe( - 'Claude writes 2 files; the tests pass, and Claude saves the work.' + 'Claude writes test_csvstat.py and pyproject.toml; the tests pass, and Claude saves the work.' ); expect(panel('text-plan-dry-run-helper', 0, 'plan')?.story).toBe('Claude writes a plan: 4 steps.'); expect(pagesOf('text-plan-dry-run-helper')[1]).toMatchObject({ origin: 'task', durationMs: 9008 }); @@ -125,7 +125,9 @@ describe('Story Mode on real sessions', () => { expect(panel('question-tests-first-push', 0, 'save')).toMatchObject({ ok: true, // One commit, then a push in a command of its own: one commit, not two. - story: 'Claude saves the work and uploads it, but a step after that fails.', + // The folder had no repository: the first save starts one, and the reader is told. + story: + 'Claude starts a git repository, saves the work and copies it to remote.git, a folder on this computer, but a step after that fails.', }); }); @@ -137,10 +139,10 @@ describe('Story Mode on real sessions', () => { roundsOk: [false, true], }); expect(second.panels.find((p) => p.kind === 'save')?.story).toBe( - 'Claude saves the work and uploads it: "add calc_stats.mode with smallest-value tie-breaking".' + 'Claude saves the work: "add calc_stats.mode with smallest-value tie-breaking", then copies it to origin.git, a folder on this computer.' ); expect(third.panels.find((p) => p.kind === 'save')?.story).toBe( - 'Claude saves the work and uploads it, but a step after that fails.' + 'Claude saves the work and copies it to origin.git, a folder on this computer, but a step after that fails.' ); }); @@ -184,10 +186,10 @@ describe('Story Mode on real sessions', () => { expect(pagesOf('background-helpers-reports')[0].finishedLater).toBe(2); }); - it('a lookup of tools reads as getting them ready, without their names', () => { + it('a lookup of tools reads as getting them ready, without their names; for plan mode, as getting ready to plan', () => { const panel = pagesOf('compact-then-plan-mode') .flatMap((p) => p.panels) - .find((p) => p.story === 'Claude gets the tools it needs ready.'); + .find((p) => p.story === 'Claude gets ready to plan.'); expect(panel).toBeTruthy(); }); diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 8005ecbb..53221768 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -887,25 +887,29 @@ describe('story-mode.js overlay', () => { }); it('the final panel counts the commits of the whole turn, and says when the work was uploaded', async () => { - boot( - storyOf([ - prompt('ship it'), - ...edit('e1', '/work/demo-shop/a.py', ['+a = 1']), - ...bash('b1', 'pytest -q && git commit -qam "fix: a"', 'Test and commit', '3 passed in 0.1s'), - ...bash( - 'b2', - 'git commit -qam "docs: b" && git push', - 'Commit and push', - 'To ./origin.git\n 1a2b3c4..5d6e7f8 main -> main' - ), - end(1000), - ]) - ); - app.openStoryMode(SID); - await settle(); - expect($$('.story-panel[data-kind="complete"] .story-chips li').map((li) => li.textContent)).toContain( - '2 commits, uploaded' - ); + const chipsFor = async (to: string) => { + boot( + storyOf([ + prompt('ship it'), + ...edit('e1', '/work/demo-shop/a.py', ['+a = 1']), + ...bash('b1', 'pytest -q && git commit -qam "fix: a"', 'Test and commit', '3 passed in 0.1s'), + ...bash( + 'b2', + 'git commit -qam "docs: b" && git push', + 'Commit and push', + `To ${to}\n 1a2b3c4..5d6e7f8 main -> main` + ), + end(1000), + ]) + ); + app.openStoryMode(SID); + await settle(); + return $$('.story-panel[data-kind="complete"] .story-chips li').map((li) => li.textContent); + }; + expect(await chipsFor('git@github.com:me/shop.git')).toContain('2 commits, uploaded'); + app.closeStoryMode?.(); + // A folder on this computer: a copy, named. + expect(await chipsFor('./origin.git')).toContain('2 commits, copied to origin.git'); }); it('a chip that opened the drawer takes focus back, even when the page was redrawn meanwhile', async () => { diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 5c33f1a2..0024f187 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -929,7 +929,7 @@ describe('buildStoryPage', () => { const check = page.panels.find((p) => p.kind === 'check')!; expect(check.caption.director).toBe('node --test → ok · 3 passed · writes 1 file · 1 commit'); // The commit at the end of the test one-liner is not lost, nor the file written: the check says both. - expect(check.caption.story).toBe('Claude writes 1 file; the tests pass, and Claude saves the work.'); + expect(check.caption.story).toBe('Claude writes index.js; the tests pass, and Claude saves the work.'); }); it('one-line printf/echo writes and the files Claude Code says a command changed are edits', () => { @@ -962,20 +962,50 @@ describe('buildStoryPage', () => { it('a commit then a push in its own command is one commit, named; a push alone only uploads', () => { const save = (lines: unknown[]) => pageOf(lines).panels.find((p) => p.kind === 'save')!.caption.story; - expect( + const ship = (to: string) => save([ prompt('ship it'), ...bash('b1', 'git commit -qm "feat: add mode()"', 'Commit', ''), - ...bash('b2', 'git push origin main', 'Push', 'To ./origin.git\n f80bb31..3e0fb80 main -> main'), + ...bash('b2', 'git push origin main', 'Push', `To ${to}\n f80bb31..3e0fb80 main -> main`), end(1000), - ]) - ).toBe('Claude saves the work and uploads it: "add mode()".'); + ]); + expect(ship('github.com:me/shop.git')).toBe('Claude saves the work and uploads it: "add mode()".'); + expect(ship('https://example.com/me/shop.git')).toBe('Claude saves the work and uploads it: "add mode()".'); + // A repository in a folder on this computer: a copy, and nothing left the machine. + expect(ship('./origin.git')).toBe( + 'Claude saves the work: "add mode()", then copies it to origin.git, a folder on this computer.' + ); + expect(ship('/srv/git/shop.git/')).toBe( + 'Claude saves the work: "add mode()", then copies it to shop.git, a folder on this computer.' + ); expect(save([prompt('push'), ...bash('b1', 'git push', 'Push', 'Everything up-to-date'), end(1000)])).toBe( 'Claude uploads the work.' ); expect(pageOf([prompt('c'), ...bash('b1', 'git commit -qm x', 'Commit', ''), end(1000)]).events[1].commits).toBe(1); }); + it('the first save in a folder Claude made a repository says so; a bare copy target does not count', () => { + const save = (lines: unknown[]) => pageOf(lines).panels.find((p) => p.kind === 'save')!.caption.story; + expect( + save([ + prompt('start'), + ...bash('b1', 'git init -q', 'Init', ''), + ...edit('e1', '/w/a.py', ['+a = 1']), + ...bash('b2', 'git add -A && git commit -qm "add a"', 'Commit', ''), + end(1000), + ]) + ).toBe('Claude starts a git repository and saves the work: "add a".'); + // `git init --bare` makes the push target, not the project's repository. + expect( + save([ + prompt('start'), + ...bash('b1', 'git init --bare ../origin.git', 'Remote', ''), + ...bash('b2', 'git commit -qm "add a"', 'Commit', ''), + end(1000), + ]) + ).toBe('Claude saves the work: "add a".'); + }); + it('crafted agent text never takes seconds: every helper stays linear on its worst shapes', () => { // Each shape took one to several seconds before (a lazy regex rescanning per opener, or a // backtracking one over a long one-liner); a single call blocks the whole server.