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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ src/
fetcher.ts # gh api で Issue 取得・優先度ソート
pipeline.ts # 1 Issue の処理パイプライン + resumeSpecReview(DI パターン)
prompt.ts # プロンプトテンプレート読み込み・展開
prompt-migration.ts # フラットレイアウト→言語別ディレクトリへの移行
executor.ts # Claude CLI 実行
worktree.ts # git worktree ライフサイクル管理
label.ts # ラベル遷移操作(applyLabelTransition)
Expand Down Expand Up @@ -143,7 +144,7 @@ spec の review 状態では、ワーカーが毎サイクル評価を行う。
|---|---|
| 設定ファイル | `~/.sabori-flow/config.yml` |
| 認証トークン | `~/.sabori-flow/auth-token` |
| プロンプトテンプレート | `~/.sabori-flow/prompts/` |
| プロンプトテンプレート | `~/.sabori-flow/prompts/{lang}/` |
| ログ | `~/.sabori-flow/logs/` |
| git worktree | `~/.sabori-flow/worktrees/<owner>/<repo>/issue-<番号>-<タイムスタンプ>/` |
| plist バックアップ | `~/.sabori-flow/com.github.sabori-flow.plist` |
Expand All @@ -152,11 +153,11 @@ spec の review 状態では、ワーカーが毎サイクル評価を行う。

### プロンプトテンプレート

パッケージ同梱テンプレートは `prompts/{lang}/` に言語別で配置される。`init` コマンド実行時に選択言語のテンプレートが `~/.sabori-flow/prompts/` にコピーされ、ユーザーが自由にカスタマイズ可能。
パッケージ同梱テンプレートは `prompts/{lang}/` に言語別で配置される。`init` コマンド実行時に選択言語のテンプレートが `~/.sabori-flow/prompts/{lang}/` にコピーされ、ユーザーが自由にカスタマイズ可能。ワーカー起動時およびinit実行時に、旧フラットレイアウト(`~/.sabori-flow/prompts/` 直下)のテンプレートを言語別ディレクトリへ自動移行する。

テンプレート読み込みの優先順位(2 層フォールバック):

1. `~/.sabori-flow/prompts/`(ユーザー共通、init 時にコピー)
1. `~/.sabori-flow/prompts/{lang}/`(ユーザーカスタム、init 時にコピー)
2. パッケージ同梱 `prompts/{lang}/`(フォールバック)


Expand Down
8 changes: 6 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { input, select, confirm } from "@inquirer/prompts";
import { stringify } from "yaml";
import fs from "fs";
import { join, resolve } from "node:path";
import { getBaseDir, getConfigPath, getUserPromptsDir, getDefaultPromptsDir } from "../utils/paths.js";
import { getBaseDir, getConfigPath, getUserPromptsLanguageDir, getDefaultPromptsDir } from "../utils/paths.js";
import {
getDefaultPriorityLabels,
getDefaultExecution,
Expand All @@ -16,6 +16,7 @@ import { setLanguage, t } from "../i18n/index.js";
import type { Language } from "../i18n/types.js";
import { Autonomy } from "../worker/models.js";
import { TEMPLATE_FILES } from "../worker/prompt.js";
import { migrateFlatPromptTemplates } from "../worker/prompt-migration.js";

function buildConfigData(
repos: RepositoryInput[],
Expand Down Expand Up @@ -43,8 +44,11 @@ function buildConfigData(
}

async function copyPromptTemplates(language: Language): Promise<void> {
// Migrate before copy so overwrite prompts target user-customized files, not stale flat ones.
migrateFlatPromptTemplates(language);

const srcDir = join(getDefaultPromptsDir(), language);
const destDir = getUserPromptsDir();
const destDir = getUserPromptsLanguageDir(language);
fs.mkdirSync(destDir, { recursive: true, mode: 0o700 });

for (const filename of Object.values(TEMPLATE_FILES)) {
Expand Down
9 changes: 8 additions & 1 deletion src/utils/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { fileURLToPath } from "node:url";
import path, { dirname, resolve } from "node:path";
import { homedir } from "node:os";

import type { Language } from "../i18n/types.js";

// ESM で __dirname を代替
// src/utils/ → ../../ = package root
// ビルド後: dist/utils/ → ../../ = package root
Expand Down Expand Up @@ -70,11 +72,16 @@ export function getDefaultPromptsDir(): string {
return DEFAULT_PROMPTS_DIR;
}

/** ~/.sabori-flow/prompts — ユーザーカスタムプロンプトディレクトリ */
/** ~/.sabori-flow/prompts — 言語別ディレクトリの親 */
export function getUserPromptsDir(): string {
return path.join(getBaseDir(), "prompts");
}

/** ~/.sabori-flow/prompts/{language} — 言語別ユーザーカスタムプロンプトディレクトリ */
export function getUserPromptsLanguageDir(language: Language): string {
return path.join(getUserPromptsDir(), language);
}

/** ~/.sabori-flow/logs */
export function getLogsDir(): string {
return path.join(getBaseDir(), "logs");
Expand Down
6 changes: 6 additions & 0 deletions src/worker/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { loadConfig } from "./config.js";
import { fetchIssues } from "./fetcher.js";
import { processIssue, resumeSpecReview } from "./pipeline.js";
import { resolveAutonomyLogMessage } from "./executor.js";
import { migrateFlatPromptTemplates } from "./prompt-migration.js";
import { configureLogger, createLogger, rotateOldLogs } from "./logger.js";
import { getConfigPath, getLogsDir } from "../utils/paths.js";
import { readAuthToken } from "../utils/auth-token.js";
Expand All @@ -13,6 +14,7 @@ import {
ProcessTimeoutError,
ProcessExecutionError,
} from "./process.js";
import type { Language } from "../i18n/types.js";

const logger = createLogger("main");

Expand Down Expand Up @@ -46,6 +48,7 @@ export interface WorkerDeps {
labels: readonly string[],
) => Promise<void>;
readAuthToken: () => string | null;
migrateFlatPromptTemplates: (language: Language) => void;
}

export const defaultWorkerDeps: WorkerDeps = {
Expand All @@ -55,6 +58,7 @@ export const defaultWorkerDeps: WorkerDeps = {
resumeSpecReview,
ensureLabelsExist,
readAuthToken,
migrateFlatPromptTemplates,
};

// ---------- Step definitions ----------
Expand Down Expand Up @@ -312,6 +316,8 @@ export async function workerMain(
appConfig.repositories.length,
);

deps.migrateFlatPromptTemplates(appConfig.language);

const autonomyLog = resolveAutonomyLogMessage(appConfig.execution.autonomy);
if (autonomyLog !== null) {
logger[autonomyLog.level](autonomyLog.message);
Expand Down
48 changes: 48 additions & 0 deletions src/worker/prompt-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { existsSync, mkdirSync, renameSync } from "node:fs";
import { join } from "node:path";

import type { Language } from "../i18n/types.js";
import { getUserPromptsDir, getUserPromptsLanguageDir } from "../utils/paths.js";
import { TEMPLATE_FILES } from "./prompt.js";
import { createLogger } from "./logger.js";

const logger = createLogger("prompt-migration");

/**
* Migrate flat prompt templates into a language-specific subdirectory.
*
* The actual language of flat-side templates is unknown; we use the
* config.yml `language` value as the target directory label.
*/
export function migrateFlatPromptTemplates(language: Language): void {
const flatDir = getUserPromptsDir();
const langDir = getUserPromptsLanguageDir(language);

for (const filename of Object.values(TEMPLATE_FILES)) {
const src = join(flatDir, filename);
if (!existsSync(src)) {
continue;
}

const dest = join(langDir, filename);
if (existsSync(dest)) {
// Preserve user customizations; let the user merge manually.
logger.warn(
"Skipping migration: both %s and %s exist",
src,
dest,
);
continue;
}

// Per-file try/catch so one failure does not block others;
// unmigrated files will be retried on the next startup.
try {
mkdirSync(langDir, { recursive: true, mode: 0o700 });
renameSync(src, dest);
logger.info("Migrated %s -> %s", src, dest);
} catch (error: unknown) {
logger.warn("Failed to migrate %s: %s", filename, error);
}
}
}
10 changes: 5 additions & 5 deletions src/worker/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { basename, join, resolve } from "node:path";
import type { Language } from "../i18n/types.js";
import type { Issue, RepositoryConfig } from "./models.js";
import { Phase, repoFullName } from "./models.js";
import { getUserPromptsDir, getDefaultPromptsDir } from "../utils/paths.js";
import { getUserPromptsLanguageDir, getDefaultPromptsDir } from "../utils/paths.js";
import { createLogger } from "./logger.js";

/** テンプレート関連のエラー */
Expand Down Expand Up @@ -42,7 +42,7 @@ const USER_INPUT_KEYS: ReadonlySet<string> = new Set([
*
* テンプレートファイルを読み込み、プレースホルダを展開して返す。
* 2 段階のフォールバックでテンプレートを解決する:
* 1. ユーザーディレクトリ (`~/.sabori-flow/prompts/`)
* 1. ユーザーディレクトリ (`~/.sabori-flow/prompts/<language>/`)
* 2. パッケージ同梱のデフォルトテンプレート (`prompts/<language>/`)
*
* @throws {PromptTemplateError} テンプレートの読み込みまたは展開に失敗した場合
Expand All @@ -53,7 +53,7 @@ export function buildPrompt(
language: Language,
specContext: string | null = null,
): string {
const userDir = getUserPromptsDir();
const userDir = getUserPromptsLanguageDir(language);
const defaultDir = join(getDefaultPromptsDir(), language);
const template = loadTemplate(issue.phase, userDir, defaultDir);
const variables = buildVariables(issue, repoConfig, specContext);
Expand All @@ -71,7 +71,7 @@ export function buildPrompt(
/**
* 2 段階フォールバックでテンプレートファイルを読み込む。
*
* 1. ユーザーディレクトリ (`~/.sabori-flow/prompts/`)
* 1. ユーザーディレクトリ (`~/.sabori-flow/prompts/<language>/`)
* 2. パッケージ同梱のデフォルトディレクトリ (`prompts/<language>/`)
*
* @throws {PromptTemplateError} フェーズが未定義、テンプレートが存在しない、
Expand All @@ -87,7 +87,7 @@ function loadTemplate(
throw new PromptTemplateError(`Unknown phase: ${phase}`);
}

// 1. ユーザーディレクトリ (~/.sabori-flow/prompts/)
// 1. ユーザーディレクトリ (~/.sabori-flow/prompts/<language>/)
const userPath = resolve(userDir, filename);
if (existsSync(userPath)) {
logger.info("Loaded template from user directory: %s", userDir);
Expand Down
42 changes: 38 additions & 4 deletions tests/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ vi.mock("../../src/worker/prompt.js", () => ({
},
}));

vi.mock("../../src/worker/prompt-migration.js", () => ({
migrateFlatPromptTemplates: vi.fn(),
}));

vi.mock("../../src/utils/paths.js", async (importOriginal) => {
const original = await importOriginal<typeof import("../../src/utils/paths.js")>();
Expand All @@ -43,6 +46,7 @@ vi.mock("../../src/utils/paths.js", async (importOriginal) => {
getBaseDir: vi.fn().mockReturnValue("/mock/config/dir"),
getConfigPath: vi.fn().mockReturnValue("/mock/config/dir/config.yml"),
getUserPromptsDir: vi.fn().mockReturnValue("/mock/config/dir/prompts"),
getUserPromptsLanguageDir: vi.fn().mockReturnValue("/mock/config/dir/prompts/ja"),
getDefaultPromptsDir: vi.fn().mockReturnValue("/mock/package/prompts"),
};
});
Expand All @@ -52,7 +56,8 @@ import { confirm, select, input } from "@inquirer/prompts";
import { promptRepository } from "../../src/commands/helpers/repository-prompt.js";
import type { RepositoryInput } from "../../src/commands/helpers/repository-prompt.js";
import { setTokenCommand } from "../../src/commands/set-token.js";
import { getBaseDir, getConfigPath, getUserPromptsDir, getDefaultPromptsDir } from "../../src/utils/paths.js";
import { getBaseDir, getConfigPath, getUserPromptsDir, getUserPromptsLanguageDir, getDefaultPromptsDir } from "../../src/utils/paths.js";
import { migrateFlatPromptTemplates } from "../../src/worker/prompt-migration.js";
import { Autonomy } from "../../src/worker/models.js";
import type { Language } from "../../src/i18n/types.js";

Expand All @@ -65,7 +70,9 @@ const mockedSetTokenCommand = vi.mocked(setTokenCommand);
const mockedGetBaseDir = vi.mocked(getBaseDir);
const mockedGetConfigPath = vi.mocked(getConfigPath);
const mockedGetUserPromptsDir = vi.mocked(getUserPromptsDir);
const mockedGetUserPromptsLanguageDir = vi.mocked(getUserPromptsLanguageDir);
const mockedGetDefaultPromptsDir = vi.mocked(getDefaultPromptsDir);
const mockedMigrateFlatPromptTemplates = vi.mocked(migrateFlatPromptTemplates);

// ---------- Helpers ----------

Expand Down Expand Up @@ -147,7 +154,9 @@ beforeEach(() => {
mockedGetBaseDir.mockReturnValue("/mock/config/dir");
mockedGetConfigPath.mockReturnValue("/mock/config/dir/config.yml");
mockedGetUserPromptsDir.mockReturnValue("/mock/config/dir/prompts");
mockedGetUserPromptsLanguageDir.mockReturnValue("/mock/config/dir/prompts/ja");
mockedGetDefaultPromptsDir.mockReturnValue("/mock/package/prompts");
mockedMigrateFlatPromptTemplates.mockReset();

// 言語 / autonomy / interval_minutes / timeout_minutes のデフォルト応答
setupInitPrompts();
Expand Down Expand Up @@ -468,9 +477,9 @@ describe("initCommand - テンプレートコピー", () => {

await runInitCommand();

// prompts ディレクトリが作成される
// prompts 言語ディレクトリが作成される
expect(mockedFs.mkdirSync).toHaveBeenCalledWith(
"/mock/config/dir/prompts",
"/mock/config/dir/prompts/ja",
{ recursive: true, mode: 0o700 },
);
// テンプレートファイルがコピーされる(spec.md, plan.md, impl.md)
Expand Down Expand Up @@ -606,8 +615,33 @@ describe("initCommand - テンプレートコピー", () => {
await runInitCommand();

expect(mockedFs.mkdirSync).toHaveBeenCalledWith(
"/mock/config/dir/prompts",
"/mock/config/dir/prompts/ja",
{ recursive: true, mode: 0o700 },
);
});

it("migrateFlatPromptTemplates is called with the selected language", async () => {
mockExistsSyncForConfig(false);
mockedPromptRepository.mockResolvedValueOnce(makeRepoInput());
mockedConfirm.mockResolvedValueOnce(false);

await runInitCommand();

expect(mockedMigrateFlatPromptTemplates).toHaveBeenCalledOnce();
expect(mockedMigrateFlatPromptTemplates).toHaveBeenCalledWith("ja");
});

it("migrateFlatPromptTemplates is called before copyFileSync", async () => {
mockExistsSyncForConfig(false);
mockedPromptRepository.mockResolvedValueOnce(makeRepoInput());
mockedConfirm.mockResolvedValueOnce(false);

await runInitCommand();

const migrationOrder = mockedMigrateFlatPromptTemplates.mock.invocationCallOrder[0];
const copyOrders = mockedFs.copyFileSync.mock.invocationCallOrder;
for (const copyOrder of copyOrders) {
expect(migrationOrder).toBeLessThan(copyOrder);
}
});
});
16 changes: 16 additions & 0 deletions tests/utils/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ describe("getUserPromptsDir", () => {
});
});

describe("getUserPromptsLanguageDir", () => {
it("~/.sabori-flow/prompts/ja を返す (language=ja)", async () => {
const { getUserPromptsLanguageDir } = await import("../../src/utils/paths.js");
expect(getUserPromptsLanguageDir("ja")).toBe(
path.join("/mock/home", ".sabori-flow", "prompts", "ja"),
);
});

it("~/.sabori-flow/prompts/en を返す (language=en)", async () => {
const { getUserPromptsLanguageDir } = await import("../../src/utils/paths.js");
expect(getUserPromptsLanguageDir("en")).toBe(
path.join("/mock/home", ".sabori-flow", "prompts", "en"),
);
});
});

describe("getLogsDir", () => {
it("~/.sabori-flow/logs を返す", async () => {
const { getLogsDir } = await import("../../src/utils/paths.js");
Expand Down
1 change: 1 addition & 0 deletions tests/worker/helpers/mock-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function createMockWorkerDeps(
resumeSpecReview: vi.fn().mockResolvedValue({ outcome: "deferred", claudeExecuted: false }),
ensureLabelsExist: vi.fn().mockResolvedValue(undefined),
readAuthToken: vi.fn().mockReturnValue(null),
migrateFlatPromptTemplates: vi.fn(),
...overrides,
};
}
25 changes: 25 additions & 0 deletions tests/worker/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,31 @@ describe("workerMain", () => {
});
});

// -----------------------------------------------------------------------
// migrateFlatPromptTemplates
// -----------------------------------------------------------------------

describe("migrateFlatPromptTemplates", () => {
it("workerMain calls migrateFlatPromptTemplates with appConfig.language", async () => {
vi.mocked(deps.loadConfig).mockReturnValue(makeAppConfig({ language: "ja" }));
vi.mocked(deps.fetchIssues).mockResolvedValue([]);

await workerMain("/path/to/config.yml", deps);

expect(deps.migrateFlatPromptTemplates).toHaveBeenCalledOnce();
expect(deps.migrateFlatPromptTemplates).toHaveBeenCalledWith("ja");
});

it("workerMain calls migrateFlatPromptTemplates with 'en' when language is en", async () => {
vi.mocked(deps.loadConfig).mockReturnValue(makeAppConfig({ language: "en" }));
vi.mocked(deps.fetchIssues).mockResolvedValue([]);

await workerMain("/path/to/config.yml", deps);

expect(deps.migrateFlatPromptTemplates).toHaveBeenCalledWith("en");
});
});

// -----------------------------------------------------------------------
// autonomy startup ログ
// -----------------------------------------------------------------------
Expand Down
Loading