From 98f31e924d34c634c20493448e5fc1b0f01e89a0 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 18:49:59 +0000 Subject: [PATCH 1/5] fix(autoplan): guard tolerates Claude Code's cwd-reset footer and recovers after an unreadable init (#3651) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/bin/phase-publication-hook.ts | 22 ++++++++---- changelog.d/gst-566-autoplan-cwd-footer.md | 2 ++ test/autoplan-publication-guard.test.ts | 40 ++++++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 changelog.d/gst-566-autoplan-cwd-footer.md diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index d8cf0c214..ead9d9d7c 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -220,6 +220,11 @@ export function boundAutoplanPhaseConsumption(events: Event[], use: Use, cwd: st return consumption(use, cwd, root, invocation(events.filter(e => e.order < use.order), root)); } +/** Claude Code appends exactly this one line to a Bash result when the command's `cd` left the session cwd (#3651). */ +const SHELL_CWD_RESET = /\nShell cwd was reset to [^\n]+$/; +/** A Bash result's JSON, without that one host footer; anything else must still be strict JSON. */ +function bashJson(text: string): any { return JSON.parse(text.replace(SHELL_CWD_RESET, '')); } + /** Authenticate the existing direct-create result; this does not prove its shell command's origin. */ function checkpointResult(result: Event, tools: Tools, init: Invocation): { phase: Phase; path: string } | undefined { if (result.kind !== 'result') return; @@ -227,7 +232,7 @@ function checkpointResult(result: Event, tools: Tools, init: Invocation): { phas if (use?.name !== 'Bash' || use.order >= result.order) return; const text = textResult(result); if (text === undefined) return; - const output = JSON.parse(text); + const output = bashJson(text); if (!object(output) || !['ceo', 'design', 'dx', 'eng'].includes(output.phase) || !ownPath(output.snapshotPath) || typeof output.nativePrompt !== 'string' || !object(output.baselineEdits)) return; const { nativePrompt, baselineEdits, ...identity } = output; @@ -260,6 +265,7 @@ function initArguments(command: unknown, root: string): string[] | undefined { const INIT_MISMATCH_CAUSE = 'Snapshot init returned paths that do not match the canonical form of its own command arguments'; const INIT_FAILED_CAUSE = 'The most recent snapshot init call failed; re-run the same init command with the same three paths'; +const INIT_UNPARSEABLE_CAUSE = 'The most recent snapshot init printed something other than its JSON result; re-run the same init command with the same three paths, without a leading cd'; /** Diagnostic only, never binds: a Bash call that ran snapshot init in a shape the guard cannot bind (#3045). */ const UNBINDABLE_INIT = /(?:gstack-autoplan-snapshot|SNAPSHOT_TOOL|\$\{?[A-Z_]+\}?"?)\S*\s+init\s/; @@ -268,8 +274,8 @@ function invocation(events: Event[], root: string): Invocation { let bound: Invocation | undefined; let chosen: Record | undefined; let unbindable = false; - // True while the most recent bindable init use errored (a later success clears it). - let lastErrored = false; + // The cause while the most recent bindable init use failed (a later success clears it). + let lastFailure: string | undefined; const tools = byTool(events); for (const use of events) { if (use.kind !== 'use' || use.name !== 'Bash') continue; @@ -278,10 +284,12 @@ function invocation(events: Event[], root: string): Invocation { const results = tools.results(use.toolUseId).filter(x => x.order > use.order); if (results.length !== 1) fail('init_mismatch'); // An errored init published nothing; a later correct init may still bind. - if (results[0]!.isError === true) { lastErrored = true; continue; } + if (results[0]!.isError === true) { lastFailure = INIT_FAILED_CAUSE; continue; } const text = textResult(results[0]!); if (text === undefined) fail('init_failed'); - const result = JSON.parse(text); + let result: any; + // Output that is not the init's JSON bound nothing; a later correct init may still bind. + try { result = bashJson(text); } catch { lastFailure = INIT_UNPARSEABLE_CAUSE; continue; } // init reports canonical destinations (macOS /tmp is /private/tmp); canonicalize the argv the same way. let active: string, restore: string; try { active = canonicalDestination(args[1]!); restore = canonicalDestination(args[2]!); } @@ -289,13 +297,13 @@ function invocation(events: Event[], root: string): Invocation { if (!object(result) || !samePath(result.sourcePlan, fs.realpathSync(args[0]!)) || !samePath(result.activePlan, active) || !samePath(result.restorePath, restore) || typeof result.reused !== 'boolean' || !positive(result.originalBytes) || !/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE }); - lastErrored = false; + lastFailure = undefined; if (result.reused && bound && bound.activePlan === result.activePlan && bound.restorePath === result.restorePath) continue; chosen = result; bound = { activePlan: result.activePlan, restorePath: result.restorePath, originalSha256: result.originalSha256, start: results[0]!.order }; } - if (lastErrored) fail('init_failed', { cause: INIT_FAILED_CAUSE }); + if (lastFailure) fail('init_failed', { cause: lastFailure }); if ((!chosen || !bound) && unbindable) fail('init_unbindable'); if (!chosen || !bound) fail('init_required'); const restore = read(bound.restorePath, true), active = read(bound.activePlan); diff --git a/changelog.d/gst-566-autoplan-cwd-footer.md b/changelog.d/gst-566-autoplan-cwd-footer.md new file mode 100644 index 000000000..5ad986f07 --- /dev/null +++ b/changelog.d/gst-566-autoplan-cwd-footer.md @@ -0,0 +1,2 @@ +### Fixed +- **`/autoplan` no longer locks a Claude Code session after a snapshot init run as `cd && …` (#3651).** When that `cd` leaves the session's folder, Claude Code adds a `Shell cwd was reset to …` line to the command's output. The guard now removes exactly that one line before reading the init or checkpoint JSON; any other extra text still fails. An init whose output still can't be read now counts as a failed init with a "re-run" hint, so a later clean init in the same session works again. Before, every later phase read was denied with code `evidence` until a new session. diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index fb1199a7a..9f84e961f 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -206,6 +206,16 @@ describe('Autoplan parent publication guard', () => { expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('earlier checkpoint') }); }); + test('#3651: a rerun checkpoint whose create result carries the cwd-reset footer still retires the old close', () => { + const { f, tasks } = completedCycle(), create = checkpointWriter(f); + const oldClose = f.events.find(e => e.kind === 'use' && e.toolUseId === 'eng-close')!; + create('eng', 'new-generation'); + (f.events.at(-1) as any).content += '\nShell cwd was reset to /home/user/project'; + f.read('old-close-replayed', oldClose.input!.file_path as string); f.message('Phase 3 complete.'); + f.input.tool_input = { file_path: tasks }; f.current(); + expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('earlier checkpoint') }); + }); + for (const status of ['partial', 'failed', 'pending'] as const) test(`a ${status} rerun close cannot borrow the first cycle's completed report`, () => { const { f, create, tasks } = completedCycle(), rerun = create('eng', 'rerun'); @@ -1596,3 +1606,33 @@ describe('PAX-3638: symlinked plan directory and superseded failed inits', () => expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code mutation_pending,') }); }); }); + +describe('#3651: Claude Code cwd-reset footer on a Bash result', () => { + const FOOTER = '\nShell cwd was reset to /home/user/project'; + function withInitTail(tail: string) { + const f = fixture(); + (f.events.find(e => e.kind === 'result' && e.toolUseId === 'init') as any).content += tail; + return f; + } + const ready = (f: ReturnType) => { f.message(); f.current(); f.reorder(); return f.evaluate(); }; + + test('an init result carrying the footer still binds', () => { + expect(ready(withInitTail(FOOTER))).toEqual({ allow: true }); + }); + + for (const [name, tail] of [['other trailing text', '\nunexpected trailing output'], ['two footers', `${FOOTER}${FOOTER}`], ['a footer that is not the last line', `${FOOTER}\n`]] as const) + test(`${name} after an init result is init_failed with the re-run hint, not the generic evidence denial`, () => { + const decision = ready(withInitTail(tail)) as any; + expect(decision.allow).toBe(false); + expect(decision.reason).toContain('(code init_failed,'); + expect(decision.reason).toContain('re-run'); + }); + + test('a later clean init recovers the session after an init whose output did not parse', () => { + const f = withInitTail('\nunexpected trailing output'); + f.events.splice(2, 0, { ...(structuredClone(f.events[0]!) as any), toolUseId: 'again' }, + { ...(structuredClone(f.events[1]!) as any), toolUseId: 'again', content: JSON.stringify({ ...f.init, reused: true }) }); + expect(ready(f)).toEqual({ allow: true }); + }); +}); + From bdac971bc6688678cf50c2bad139d3967a7cbfc3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:05:11 +0000 Subject: [PATCH 2/5] fix: pre-landing review fixes (journal reader strips the cwd-reset footer; an init with no text block no longer locks the session) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/bin/guard-journal.ts | 6 +++++- autoplan/bin/phase-publication-hook.ts | 9 ++------- test/autoplan-publication-guard.test.ts | 22 +++++++++++++++++++++- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/autoplan/bin/guard-journal.ts b/autoplan/bin/guard-journal.ts index 418b879ac..41a4aafdb 100644 --- a/autoplan/bin/guard-journal.ts +++ b/autoplan/bin/guard-journal.ts @@ -17,6 +17,10 @@ type Event = ClaudeParentPublicEvent; type Use = NativePublicToolEvent & { order: number }; /** Claude Code's reply to a Read of an unchanged file it already returned in full. */ export const DEDUP_REPLY = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.'; +/** Claude Code appends exactly this one line to a Bash result when the command's `cd` left the session cwd (#3651). */ +const SHELL_CWD_RESET = /\nShell cwd was reset to [^\n]+$/; +/** A Bash result's JSON, without that one host footer; anything else must still be strict JSON. */ +export function bashJson(text: string): any { return JSON.parse(text.replace(SHELL_CWD_RESET, '')); } /** The longest Read input or Bash command the index keeps; a literal init is far shorter. */ const INDEX_INPUT_MAX = 4096; @@ -69,7 +73,7 @@ function select(tests: GuardJournalTests, currentToolUseId: string) { const replies = (resultsOf.get(init.use.toolUseId) ?? []).flatMap(r => r.events) .filter(e => e.kind === 'result' && e.toolUseId === init.use.toolUseId && e.order > init.use.order); let reply: unknown; - try { reply = replies.length === 1 ? JSON.parse(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; } + try { reply = replies.length === 1 ? bashJson(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; } // A reused:true init keeps the earlier binding, as invocation() does. if (object(reply) && reply.reused === true && bound && bound.activePlan === reply.activePlan && bound.restorePath === reply.restorePath) continue; diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index ead9d9d7c..1cb6b8347 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -10,7 +10,7 @@ import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks, ca import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication'; import { nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../../lib/claude-journal-records'; import { ownedRecordLimit, ownedRetainedLimit, type JournalPrefix, type OwnedRead, type OwnedReadMeasure } from '../../lib/claude-owned-journal'; -import { readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal'; +import { bashJson, readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal'; import { REASONS, reasonCode, reasonText, type Detail, type ReasonCode } from './guard-reasons'; import { logGuardDecision } from './guard-log'; import { approveOwnedRead, renderSectionBase, userRenderRoot } from './owned-read'; @@ -220,11 +220,6 @@ export function boundAutoplanPhaseConsumption(events: Event[], use: Use, cwd: st return consumption(use, cwd, root, invocation(events.filter(e => e.order < use.order), root)); } -/** Claude Code appends exactly this one line to a Bash result when the command's `cd` left the session cwd (#3651). */ -const SHELL_CWD_RESET = /\nShell cwd was reset to [^\n]+$/; -/** A Bash result's JSON, without that one host footer; anything else must still be strict JSON. */ -function bashJson(text: string): any { return JSON.parse(text.replace(SHELL_CWD_RESET, '')); } - /** Authenticate the existing direct-create result; this does not prove its shell command's origin. */ function checkpointResult(result: Event, tools: Tools, init: Invocation): { phase: Phase; path: string } | undefined { if (result.kind !== 'result') return; @@ -286,7 +281,7 @@ function invocation(events: Event[], root: string): Invocation { // An errored init published nothing; a later correct init may still bind. if (results[0]!.isError === true) { lastFailure = INIT_FAILED_CAUSE; continue; } const text = textResult(results[0]!); - if (text === undefined) fail('init_failed'); + if (text === undefined) { lastFailure = INIT_FAILED_CAUSE; continue; } let result: any; // Output that is not the init's JSON bound nothing; a later correct init may still bind. try { result = bashJson(text); } catch { lastFailure = INIT_UNPARSEABLE_CAUSE; continue; } diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index 9f84e961f..b476a82d3 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -1628,8 +1628,28 @@ describe('#3651: Claude Code cwd-reset footer on a Bash result', () => { expect(decision.reason).toContain('re-run'); }); - test('a later clean init recovers the session after an init whose output did not parse', () => { +test('#3651: the hook path keeps the first binding when a cwd-reset init is re-run after a typed user message', async () => { + const footer = '\nShell cwd was reset to /home/user/project'; + const run = async (tail: string) => { + const f = fixture(); (f.events[1] as any).content += tail; f.message(); + f.use('re-init', 'Bash', (f.events[0] as any).input); f.result('re-init', { content: JSON.stringify({ ...f.init, reused: true }) + tail }); + f.current(); + const { rows, record } = f.journal(); + const at = rows.findIndex(r => Array.isArray(r.message.content) && r.message.content.some((b: any) => b.type === 'tool_use' && b.id === 're-init')); + rows[at - 1]!.message.stop_reason = 'end_turn'; + rows.splice(at, 0, record('user', 'Looks good, keep going.', { origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID() })); + rows.forEach((r, i) => { r.parentUuid = i ? rows[i - 1]!.uuid : null; }); + fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n'); + return withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT)); + }; + expect(await run('')).toEqual(OWNED_READ_APPROVAL); + expect(await run(footer)).toEqual(OWNED_READ_APPROVAL); + }); + + for (const [name, content] of [['did not parse', undefined], ['had no single text block', [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }]]] as const) + test(`a later clean init recovers the session after an init whose output ${name}`, () => { const f = withInitTail('\nunexpected trailing output'); + if (content) (f.events[1] as any).content = content; f.events.splice(2, 0, { ...(structuredClone(f.events[0]!) as any), toolUseId: 'again' }, { ...(structuredClone(f.events[1]!) as any), toolUseId: 'again', content: JSON.stringify({ ...f.init, reused: true }) }); expect(ready(f)).toEqual({ allow: true }); From 532c0f374c1b0bbb201a8856af466e909abda9e3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:09:57 +0000 Subject: [PATCH 3/5] fix: journal reader skips a failed or unreadable init, as invocation() does Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/bin/guard-journal.ts | 4 +++- test/autoplan-publication-guard.test.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/autoplan/bin/guard-journal.ts b/autoplan/bin/guard-journal.ts index 41a4aafdb..76df6191c 100644 --- a/autoplan/bin/guard-journal.ts +++ b/autoplan/bin/guard-journal.ts @@ -74,10 +74,12 @@ function select(tests: GuardJournalTests, currentToolUseId: string) { .filter(e => e.kind === 'result' && e.toolUseId === init.use.toolUseId && e.order > init.use.order); let reply: unknown; try { reply = replies.length === 1 ? bashJson(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; } + // A failed or unreadable init binds nothing and is skipped, as invocation() does. + if (!object(reply)) continue; // A reused:true init keeps the earlier binding, as invocation() does. if (object(reply) && reply.reused === true && bound && bound.activePlan === reply.activePlan && bound.restorePath === reply.restorePath) continue; - chainStart = init.record; bound = object(reply) ? reply : undefined; + chainStart = init.record; bound = reply; } let start = 0; for (let i = chainStart ?? records.length - 1; i >= 0; i--) diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index b476a82d3..7294d10ce 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -1628,14 +1628,15 @@ describe('#3651: Claude Code cwd-reset footer on a Bash result', () => { expect(decision.reason).toContain('re-run'); }); -test('#3651: the hook path keeps the first binding when a cwd-reset init is re-run after a typed user message', async () => { + test('#3651: the hook path keeps the first binding when an init is re-run after a typed user message', async () => { const footer = '\nShell cwd was reset to /home/user/project'; - const run = async (tail: string) => { + const run = async (tail: string, failed?: unknown) => { const f = fixture(); (f.events[1] as any).content += tail; f.message(); + if (failed !== undefined) { f.use('bad-init', 'Bash', (f.events[0] as any).input); f.result('bad-init', { content: failed }); } f.use('re-init', 'Bash', (f.events[0] as any).input); f.result('re-init', { content: JSON.stringify({ ...f.init, reused: true }) + tail }); f.current(); const { rows, record } = f.journal(); - const at = rows.findIndex(r => Array.isArray(r.message.content) && r.message.content.some((b: any) => b.type === 'tool_use' && b.id === 're-init')); + const at = rows.findIndex(r => Array.isArray(r.message.content) && r.message.content.some((b: any) => b.type === 'tool_use' && ['bad-init', 're-init'].includes(b.id))); rows[at - 1]!.message.stop_reason = 'end_turn'; rows.splice(at, 0, record('user', 'Looks good, keep going.', { origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID() })); rows.forEach((r, i) => { r.parentUuid = i ? rows[i - 1]!.uuid : null; }); @@ -1644,6 +1645,8 @@ test('#3651: the hook path keeps the first binding when a cwd-reset init is re-r }; expect(await run('')).toEqual(OWNED_READ_APPROVAL); expect(await run(footer)).toEqual(OWNED_READ_APPROVAL); + expect(await run('', 'unexpected output')).toEqual(OWNED_READ_APPROVAL); + expect(await run('', [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toEqual(OWNED_READ_APPROVAL); }); for (const [name, content] of [['did not parse', undefined], ['had no single text block', [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }]]] as const) From ffa0f976cf1ee8455f214d968908e866d7d6e249 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 20:26:58 +0000 Subject: [PATCH 4/5] docs(autoplan): init_failed covers unreadable init output and the cwd-reset line (#3651) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/autoplan-guard-troubleshooting.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/autoplan-guard-troubleshooting.md b/docs/autoplan-guard-troubleshooting.md index ff7970f5b..4e7feaaa7 100644 --- a/docs/autoplan-guard-troubleshooting.md +++ b/docs/autoplan-guard-troubleshooting.md @@ -257,6 +257,13 @@ the same three paths. An errored init is skipped, so a later successful init still binds. This denial applies when no init ever succeeded, or when an errored init follows a good one. +An init whose output is not its JSON result (extra text, or no single text +block) also counts as failed, and the message adds "without a leading cd" to +the re-run hint. The one exception is the `Shell cwd was reset to ` line +Claude Code adds as the last line of a Bash result when the command's `cd` +left the session's folder: the guard removes exactly that line before reading +an init or checkpoint result (#3651). + ### `init_unbindable` Init ran through a shell variable, substitution, chaining, a pipe or a From 0d53a5f041bc73454dae9dd89adef5407eb1e8b7 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 21:38:01 +0000 Subject: [PATCH 5/5] test(autoplan): #3651 tests pass the test-code type ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/autoplan-publication-guard.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index 7294d10ce..44a375db4 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -211,7 +211,7 @@ describe('Autoplan parent publication guard', () => { const oldClose = f.events.find(e => e.kind === 'use' && e.toolUseId === 'eng-close')!; create('eng', 'new-generation'); (f.events.at(-1) as any).content += '\nShell cwd was reset to /home/user/project'; - f.read('old-close-replayed', oldClose.input!.file_path as string); f.message('Phase 3 complete.'); + f.read('old-close-replayed', (oldClose as any).input.file_path as string); f.message('Phase 3 complete.'); f.input.tool_input = { file_path: tasks }; f.current(); expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('earlier checkpoint') }); }); @@ -1637,7 +1637,7 @@ describe('#3651: Claude Code cwd-reset footer on a Bash result', () => { f.current(); const { rows, record } = f.journal(); const at = rows.findIndex(r => Array.isArray(r.message.content) && r.message.content.some((b: any) => b.type === 'tool_use' && ['bad-init', 're-init'].includes(b.id))); - rows[at - 1]!.message.stop_reason = 'end_turn'; + (rows[at - 1]!.message as any).stop_reason = 'end_turn'; rows.splice(at, 0, record('user', 'Looks good, keep going.', { origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID() })); rows.forEach((r, i) => { r.parentUuid = i ? rows[i - 1]!.uuid : null; }); fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n');