From 52df4963b9d28d6dc24d0d799a91aa6a77aa8aac Mon Sep 17 00:00:00 2001 From: ozzy-3 Date: Tue, 26 May 2026 01:16:18 +0900 Subject: [PATCH] fix(routine): left-align --emit-bootstrap-prompt output (#377) The bootstrap prompt body (cli.routine.bootstrapPromptLine1-4) carried a baked-in 7-space leading indent meant for the Web UI paste view, but it leaked into --emit-bootstrap-prompt, whose output is machine-consumed verbatim as a routine's message.content. Move the canonical i18n lines to left-aligned (zero leading indent) and re-add the display indent in the paste view (printPromptModePaste via BOOTSTRAP_PASTE_INDENT). emit is now left-aligned; the paste block keeps its indent under the numbered steps. Reconciled the #365 "paste == emit" byte-for-byte tests (watch + pipeline) to strip the display indent before comparing, and added tests asserting the emit output has zero leading whitespace on every line. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/routine/generate-pipeline.ts | 5 +- src/cli/routine/generate-watch.ts | 35 ++++++++++--- src/i18n/messages/en.ts | 12 +++-- src/i18n/messages/ja.ts | 12 +++-- tests/cli/routine-generate-pipeline.test.ts | 27 ++++++++-- tests/cli/routine-generate-watch.test.ts | 57 ++++++++++++++++++--- 6 files changed, 120 insertions(+), 28 deletions(-) diff --git a/src/cli/routine/generate-pipeline.ts b/src/cli/routine/generate-pipeline.ts index 75db29b..b40b895 100644 --- a/src/cli/routine/generate-pipeline.ts +++ b/src/cli/routine/generate-pipeline.ts @@ -669,8 +669,9 @@ export async function runGeneratePipelineRoutine( // --emit-bootstrap-prompt (#365): print ONLY the bootstrap prompt body // (read-only — no YAML written, no paste guidance), single-sourced from - // `buildBootstrapPrompt` so it matches the generator's bootstrap paste output - // byte-for-byte (epic #363 G3). `path` matches the generator's `destRel`. + // `buildBootstrapPrompt` (epic #363 G3). Left-aligned with zero leading indent + // for machine consumption (#377); the Web UI paste view re-adds a display + // indent. `path` matches the generator's `destRel`. if (parsed.emitBootstrapPrompt) { const promptPath = isAbsolute(parsed.output) ? relative(cwd, parsed.output) : parsed.output; log(buildBootstrapPrompt({ name: parsed.name, path: promptPath }, t)); diff --git a/src/cli/routine/generate-watch.ts b/src/cli/routine/generate-watch.ts index 83efe9b..32e8386 100644 --- a/src/cli/routine/generate-watch.ts +++ b/src/cli/routine/generate-watch.ts @@ -66,9 +66,21 @@ export type PromptMode = (typeof PROMPT_MODES)[number]; * * `name` is the routine name and `path` is the rendered routine's relative * path; both are interpolated into the prompt body. Returns the lines joined by - * `\n` (no trailing newline) so callers can either log each line or print the - * block verbatim. + * `\n` (no trailing newline), **left-aligned with zero leading indent** (#377): + * this body is machine-consumed verbatim by `--emit-bootstrap-prompt` (it + * becomes the routine's `message.content`), so it must carry no leading + * whitespace. The Web UI paste view (`printPromptModePaste`) re-adds a display + * indent on top of this canonical text for human readability under the numbered + * steps. */ +/** + * Display-only indent the Web UI paste view prepends to each canonical bootstrap + * prompt line so the block reads cleanly under the numbered step header (#377). + * The 7-space width matches the surrounding `pasteStep3Bootstrap` numbering. + * The machine-consumed `--emit-bootstrap-prompt` surface does NOT apply this. + */ +export const BOOTSTRAP_PASTE_INDENT = " "; + export function buildBootstrapPrompt( values: { name: string; path: string }, t: Translator, @@ -94,8 +106,11 @@ export function buildBootstrapPrompt( * has to be pasted into its own Web UI field). * * The bootstrap prompt body is built by `buildBootstrapPrompt` (the single - * source of truth), guaranteeing the pasted text matches - * `--emit-bootstrap-prompt` byte-for-byte. + * source of truth). The paste view indents each body line by + * {@link BOOTSTRAP_PASTE_INDENT} so it reads cleanly under the numbered step + * header; the canonical body itself stays left-aligned for the machine-consumed + * `--emit-bootstrap-prompt` surface (#377). So the paste block equals the + * emitted body **after stripping that display indent** (no longer byte-for-byte). * * `path` is the rendered routine's relative path; `name` is the routine name * (interpolated into the bootstrap prompt body). @@ -109,10 +124,12 @@ export function printPromptModePaste( if (promptMode === "bootstrap") { log(t("cli.routine.pasteStep3Bootstrap")); log(""); - // The bootstrap prompt body is the single-sourced block; log it verbatim so - // it matches `--emit-bootstrap-prompt` line-for-line. + // The bootstrap prompt body is the single-sourced, left-aligned block. + // Indent each line by BOOTSTRAP_PASTE_INDENT for the Web UI paste view so it + // reads cleanly under the numbered step header (#377). The canonical body + // stays left-aligned for the machine-consumed `--emit-bootstrap-prompt`. for (const line of buildBootstrapPrompt(values, t).split("\n")) { - log(line); + log(`${BOOTSTRAP_PASTE_INDENT}${line}`); } log(""); log(t("cli.routine.pasteStep3BootstrapSetup")); @@ -660,8 +677,10 @@ export async function runGenerateWatchRoutine( // --emit-bootstrap-prompt (#365): print ONLY the bootstrap prompt body // (read-only — no YAML written, no paste guidance) so the `routine-setup` - // Claude skill can fetch the exact same prompt the generator would paste, + // Claude skill can register it verbatim as the routine's `message.content`, // sourced from the single `buildBootstrapPrompt` helper (epic #363 G3). The + // body is left-aligned with zero leading indent for that machine consumption + // (#377); the Web UI paste view re-adds a display indent separately. The // `path` matches the generator's `destRel`: a relative `--output` is used // verbatim, an absolute one is rebased onto `cwd`. if (parsed.emitBootstrapPrompt) { diff --git a/src/i18n/messages/en.ts b/src/i18n/messages/en.ts index 354c7d7..e997690 100644 --- a/src/i18n/messages/en.ts +++ b/src/i18n/messages/en.ts @@ -1507,14 +1507,18 @@ Options: // the EXACT text to paste — it must read as a direct second-person command. "cli.routine.pasteStep3Bootstrap": " 3. For the Instructions field, paste this SHORT bootstrap prompt (prompt-mode bootstrap):", + // Canonical bootstrap prompt body — LEFT-ALIGNED (no leading indent). This is + // the machine-consumed text (`--emit-bootstrap-prompt` → routine `message.content`), + // so it must carry zero leading whitespace (#377). The Web UI paste view re-adds + // a display indent in `printPromptModePaste`. "cli.routine.bootstrapPromptLine1": ({ name }: { name: string }): string => - ` You are the \`${name}\` routine.`, + `You are the \`${name}\` routine.`, "cli.routine.bootstrapPromptLine2": ({ path }: { path: string }): string => - ` Read \`${path}\` in this repository and faithfully execute its top-level`, + `Read \`${path}\` in this repository and faithfully execute its top-level`, "cli.routine.bootstrapPromptLine3": - " `instructions:` block. Run autonomously: AskUserQuestion is NOT available,", + "`instructions:` block. Run autonomously: AskUserQuestion is NOT available,", "cli.routine.bootstrapPromptLine4": - " and local MCP servers are NOT available in this environment.", + "and local MCP servers are NOT available in this environment.", "cli.routine.pasteStep3BootstrapSetup": " For the multi-line Setup script field, extract it with yq:", "cli.routine.bootstrapReuseNote": diff --git a/src/i18n/messages/ja.ts b/src/i18n/messages/ja.ts index 4abd693..a1bbc9a 100644 --- a/src/i18n/messages/ja.ts +++ b/src/i18n/messages/ja.ts @@ -1402,14 +1402,18 @@ type 別のオプションは \`radar routine generate --help\` を参照 // --prompt-mode bootstrap (#327) "cli.routine.pasteStep3Bootstrap": " 3. Instructions 欄には、この短い bootstrap プロンプトを貼り付けます (prompt-mode bootstrap):", + // bootstrap プロンプト本文の正本 — 左寄せ (行頭インデントなし)。機械消費 + // (`--emit-bootstrap-prompt` → routine の `message.content`) で使うため、行頭空白は + // ゼロでなければならない (#377)。Web UI 貼付表示の表示用インデントは + // `printPromptModePaste` 側で付与する。 "cli.routine.bootstrapPromptLine1": ({ name }: { name: string }): string => - ` You are the \`${name}\` routine.`, + `You are the \`${name}\` routine.`, "cli.routine.bootstrapPromptLine2": ({ path }: { path: string }): string => - ` Read \`${path}\` in this repository and faithfully execute its top-level`, + `Read \`${path}\` in this repository and faithfully execute its top-level`, "cli.routine.bootstrapPromptLine3": - " `instructions:` block. Run autonomously: AskUserQuestion is NOT available,", + "`instructions:` block. Run autonomously: AskUserQuestion is NOT available,", "cli.routine.bootstrapPromptLine4": - " and local MCP servers are NOT available in this environment.", + "and local MCP servers are NOT available in this environment.", "cli.routine.pasteStep3BootstrapSetup": " 複数行の Setup script フィールドは yq で抽出します:", "cli.routine.bootstrapReuseNote": diff --git a/tests/cli/routine-generate-pipeline.test.ts b/tests/cli/routine-generate-pipeline.test.ts index 51f3c37..8e3e53a 100644 --- a/tests/cli/routine-generate-pipeline.test.ts +++ b/tests/cli/routine-generate-pipeline.test.ts @@ -12,6 +12,7 @@ import { renderPipelineRoutineTemplate, } from "../../src/cli/routine/generate-pipeline.js"; import { + BOOTSTRAP_PASTE_INDENT, PROMPT_MODES, SUPPORTED_MODELS, type SupportedModel, @@ -673,7 +674,7 @@ describe("cli/routine/generate-pipeline", () => { ).rejects.toThrow(); }); - it("--emit-bootstrap-prompt output equals the generator's bootstrap paste body (en + ja) (#365)", async () => { + it("--emit-bootstrap-prompt output equals the generator's bootstrap paste body once dedented (en + ja) (#365, #377)", async () => { for (const lang of ["en", "ja"] as const) { // 1. Capture the --emit-bootstrap-prompt output. const emitLogs: string[] = []; @@ -705,11 +706,31 @@ describe("cli/routine/generate-pipeline", () => { const stepIdx = genLogs.indexOf(t("cli.routine.pasteStep3Bootstrap")); expect(stepIdx).toBeGreaterThanOrEqual(0); // genLogs[stepIdx + 1] is the blank separator; the body is the next 4. - const pasteBody = genLogs.slice(stepIdx + 2, stepIdx + 6).join("\n"); + const pasteBodyLines = genLogs.slice(stepIdx + 2, stepIdx + 6); + // The paste view indents each line; the emit body is left-aligned (#377). + const pasteBody = pasteBodyLines + .map((l) => l.slice(BOOTSTRAP_PASTE_INDENT.length)) + .join("\n"); - // 3. The emit output must equal the paste body byte-for-byte. + // 3. The emit output equals the paste body after stripping the display indent. expect(emitted).toBe(pasteBody); } }); + + it("--emit-bootstrap-prompt output has zero leading whitespace on every line (#377)", async () => { + const emitLogs: string[] = []; + const code = await runRoutine( + ["generate", "pipeline", "--emit-bootstrap-prompt", "--name", "my-pipe"], + { cwd: workdir, io: { log: (m) => emitLogs.push(m), error: () => {} } }, + ); + expect(code).toBe(0); + const emitted = emitLogs.join("\n"); + expect(emitted.length).toBeGreaterThan(0); + for (const line of emitted.split("\n")) { + // Machine-consumed (routine message.content): no leading indent leaks. + expect(line).not.toMatch(/^\s/); + } + expect(emitted.startsWith("You are the `my-pipe` routine.")).toBe(true); + }); }); }); diff --git a/tests/cli/routine-generate-watch.test.ts b/tests/cli/routine-generate-watch.test.ts index bcc6083..841d55d 100644 --- a/tests/cli/routine-generate-watch.test.ts +++ b/tests/cli/routine-generate-watch.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { + BOOTSTRAP_PASTE_INDENT, buildBootstrapPrompt, collectSourceHosts, generateWatchRoutine, @@ -279,7 +280,23 @@ describe("cli/routine/generate-watch", () => { expect(prompt).toContain("AskUserQuestion is NOT available"); }); - it("matches the bootstrap paste-mode block byte-for-byte (en + ja) (#365)", () => { + it("returns a LEFT-ALIGNED body with zero leading indent (en + ja) (#377)", () => { + for (const locale of ["en", "ja"] as const) { + const t = createTranslator(locale); + const prompt = buildBootstrapPrompt( + { name: "feedradar-watch", path: ".claude/routines/feedradar-watch.yaml" }, + t, + ); + for (const line of prompt.split("\n")) { + // Machine-consumed body must carry no leading whitespace. + expect(line).not.toMatch(/^\s/); + } + // The first line begins immediately with the directive text. + expect(prompt.startsWith("You are the")).toBe(true); + } + }); + + it("the paste-mode block equals the body once its display indent is stripped (en + ja) (#377)", () => { for (const locale of ["en", "ja"] as const) { const t = createTranslator(locale); const values = { name: "feedradar-watch", path: ".claude/routines/feedradar-watch.yaml" }; @@ -290,9 +307,15 @@ describe("cli/routine/generate-watch", () => { const pasteLines: string[] = []; printPromptModePaste("bootstrap", values, t, (m) => pasteLines.push(m)); const blanks = pasteLines.map((l, i) => (l === "" ? i : -1)).filter((i) => i >= 0); - const body = pasteLines.slice(blanks[0] + 1, blanks[1]).join("\n"); - - expect(body).toBe(emitted); + const bodyLines = pasteLines.slice(blanks[0] + 1, blanks[1]); + + // Paste view indents every body line with the display indent (#377). + for (const line of bodyLines) { + expect(line.startsWith(BOOTSTRAP_PASTE_INDENT)).toBe(true); + } + // Stripping that indent recovers the canonical, left-aligned body. + const dedented = bodyLines.map((l) => l.slice(BOOTSTRAP_PASTE_INDENT.length)).join("\n"); + expect(dedented).toBe(emitted); } }); }); @@ -615,7 +638,7 @@ describe("cli/routine/generate-watch", () => { ).rejects.toThrow(); }); - it("--emit-bootstrap-prompt output equals the generator's bootstrap paste body (en + ja) (#365)", async () => { + it("--emit-bootstrap-prompt output equals the generator's bootstrap paste body once dedented (en + ja) (#365, #377)", async () => { for (const lang of ["en", "ja"] as const) { // 1. Capture the --emit-bootstrap-prompt output. const emitLogs: string[] = []; @@ -649,11 +672,31 @@ describe("cli/routine/generate-watch", () => { const stepIdx = genLogs.indexOf(t("cli.routine.pasteStep3Bootstrap")); expect(stepIdx).toBeGreaterThanOrEqual(0); // genLogs[stepIdx + 1] is the blank separator; the body is the next 4. - const pasteBody = genLogs.slice(stepIdx + 2, stepIdx + 6).join("\n"); + const pasteBodyLines = genLogs.slice(stepIdx + 2, stepIdx + 6); + // The paste view indents each line; the emit body is left-aligned (#377). + const pasteBody = pasteBodyLines + .map((l) => l.slice(BOOTSTRAP_PASTE_INDENT.length)) + .join("\n"); - // 3. The emit output must equal the paste body byte-for-byte. + // 3. The emit output equals the paste body after stripping the display indent. expect(emitted).toBe(pasteBody); } }); + + it("--emit-bootstrap-prompt output has zero leading whitespace on every line (#377)", async () => { + const emitLogs: string[] = []; + const code = await runRoutine( + ["generate", "watch", "--emit-bootstrap-prompt", "--name", "my-routine"], + { cwd: workdir, io: { log: (m) => emitLogs.push(m), error: () => {} } }, + ); + expect(code).toBe(0); + const emitted = emitLogs.join("\n"); + expect(emitted.length).toBeGreaterThan(0); + for (const line of emitted.split("\n")) { + // Machine-consumed (routine message.content): no leading indent leaks. + expect(line).not.toMatch(/^\s/); + } + expect(emitted.startsWith("You are the `my-routine` routine.")).toBe(true); + }); }); });