Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/cli/routine/generate-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
35 changes: 27 additions & 8 deletions src/cli/routine/generate-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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).
Expand All @@ -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"));
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 8 additions & 4 deletions src/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
12 changes: 8 additions & 4 deletions src/i18n/messages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1402,14 +1402,18 @@ type 別のオプションは \`radar routine generate <type> --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":
Expand Down
27 changes: 24 additions & 3 deletions tests/cli/routine-generate-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
renderPipelineRoutineTemplate,
} from "../../src/cli/routine/generate-pipeline.js";
import {
BOOTSTRAP_PASTE_INDENT,
PROMPT_MODES,
SUPPORTED_MODELS,
type SupportedModel,
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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);
});
});
});
57 changes: 50 additions & 7 deletions tests/cli/routine-generate-watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" };
Expand All @@ -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);
}
});
});
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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);
});
});
});
Loading