phasegate 0.119.0 → 0.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.121.0] - 2026-05-07
11
+
12
+ ### Fixed
13
+
14
+ - **WI-087 finding #3 + WI-086 docs: PreToolUse の Quick Mode 通過時の visibility 改善 + hook 責務分離をドキュメント化** — 外部レポーター nakataj-mti / junpei-9898 が GitHub Issue [#3](https://github.com/junpei-9898/phasegate/issues/3) finding #3 / [#2](https://github.com/junpei-9898/phasegate/issues/2) で報告した「Quick Mode で write が allow されるとき hook が完全 silent で、初見ユーザーが "hook が走っていない" と誤認する」問題と「pre-tool-use が L1 lint をしないことが暗黙仕様化していてレポーター期待と齟齬している」問題を解消。
15
+ - **Quick Mode visibility notice** (`scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts`): `HandlePreToolUseOutput.quickModeAllowed` がセットされた場合、stderr に `phasegate: write allowed (Quick Mode, category=<dominantCategory>)` を出力。exit code は 0 維持で **semantics は不変**、可視性のみ向上。
16
+ - **DTO 拡張** (`scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts`): `quickModeAllowed?: { dominantCategory?: string }` を追加。`HandlePreToolUseUseCase.execute` 内で `WRITE_TOOLS + fullModeRequirementQueryPort + requiresFullMode=false` の経路で populate。block 経路や WRITE_TOOLS 外 (`Bash` 等) では出力されず、後方互換維持。
17
+ - **テスト追加** (`scripts/harness/__tests__/integration/agent-integration/handle-pre-tool-use-usecase.test.ts`): 4 ケース — `quickModeAllowed` が `dominantCategory` 付きで返る / `dominantCategory` 未設定で返る / `fullModeRequirementQueryPort` 未指定時は出力なし / WRITE_TOOLS 外 (`Bash`) では出力なし。全 49 ケース (前回 45 + 新規 4) グリーン。
18
+ - **責務分離のドキュメント明文化** (`docs/guide/hooks-integration.md`): "Responsibility Separation" セクションを冒頭に追加し、pre = フェーズゲート / post = lint / Stop = complete-check の役割分担表と「pre-tool-use は意図的に lint を実行しない (lint は書き込み後の content が必要)」旨を明記。WI-086 で指摘された「`pre-tool-use` で違反 Write を exit 2 でブロックしてほしい」期待が現行設計と乖離する理由を ユーザー視点で説明。
19
+ - **互換性**: 既存テスト全てグリーン(`expect(actual).toEqual({ shouldBlock: false })` 形式の既存 assert は `quickModeAllowed: undefined` を含む結果でも一致)。`HandlePreToolUseOutput` への field 追加は外部 API ではなく application/presentation 内部 DTO のみで、外部消費者なし。
20
+ - **スコープ外** (Phase C-2 で対応予定): WI-087 finding #4 — Stop hook `--enforce` flag (`agentIntegration.stopHook.enforce` config 追加で Complete Check 失敗時に exit 2 + decision JSON `"deny"` を返す strict mode)。config schema 拡張のため story-implementor で別リリース予定。
21
+
22
+ ## [0.120.0] - 2026-05-07
23
+
24
+ ### Fixed
25
+
26
+ - **WI-087 Phase B + WI-086 finding #1 統合対応: `phasegate init` のデフォルト値を実プロジェクト構成に追従させる** — 外部レポーター nakataj-mti / junpei-9898 が GitHub Issue [#3](https://github.com/junpei-9898/phasegate/issues/3) / [#2](https://github.com/junpei-9898/phasegate/issues/2) で報告した「デプロイされる `hook-config.json` のデフォルト値が単一パッケージ構成(`targetDirs:["src"]` / `formatter:"biome"`)固定で、モノレポ構成や biome 不在環境ではユーザーが明示的に書き換えない限り hook が silent no-op になる」問題を修正。
27
+ - **モノレポ workspace 自動検出** (`scripts/harness/setup/skill-deployer.ts:detectWorkspaceTargetDirs`): `pnpm-workspace.yaml` の `packages:` 配列 → `package.json.workspaces` (配列形式 / `{packages:[...]}` オブジェクト形式の両対応) → `lerna.json.packages` の優先順位で検出。検出した workspace glob (`pkg/*` 等) を実 FS 上で展開し、`<workspace>/src` ディレクトリが存在するもののみを `targetDirs` に採用。pnpm-workspace.yaml は依存追加を避けるため line-based の最小 parser で対応 (`packages:` ブロック検出 + `- 'pattern'` 行抽出のみ)。
28
+ - **formatter 自動検出** (`detectFormatter`): `package.json.devDependencies` (および `dependencies`) を読み、`@biomejs/biome` 存在 → `biome` + `["check","--write"]`、不在 + `prettier` 存在 → `eslint-prettier`、どちらも不在 → `null` (formatter フィールド省略で `format-typescript-hook.sh` の case 文 default フォールスルー、registry pull を回避)。
29
+ - **`deployHookScripts` 拡張**: `copyDirectory` 直前に既存 `.claude/scripts/hook-config.json` を捕捉し、(a) 既存あり → 元の内容を書き戻して **ユーザーカスタマイズ尊重**、(b) 既存なし → 検出結果を反映した新規 hook-config.json を生成。`DeployHooksResult` に `hookConfigGenerated` / `detectedTargetDirs` / `detectedFormatter` を追加し、`scripts/harness/main.ts` の init 出力に `✓ hook-config.json generated (targetDirs: ...; formatter: ...)` 行を追加。
30
+ - **`initHarnessConfig` テンプレートに `architecture: { preset: "clean" }` を追加**: 新規プロジェクトで生成される `phasegate.config.json` が schemaVersion = 'v3' と判定されるようにし、`init` 直後の v2 schema warning を解消(GitHub Issue #2 の "v2 schema warning が phasegate init 直後でも出る" を解消)。既存 v2 config を持つプロジェクトは `migrate-schema` CLI / 手動編集ルートを継続。
31
+ - **テスト追加**: `scripts/harness/__tests__/integration/setup/init-hook-config-detection.integration.test.ts` (14 ケース) — workspace 検出 (pnpm/npm/yarn 配列/yarn オブジェクト/lerna/未定義/src 不在/全 src 不在)、formatter 検出 (biome/prettier/null)、hook-config.json 生成 (既存尊重 / 新規生成)、schema v3 化 (architecture フィールド存在 / `phasegate:status` の stderr に v2 warning が出ないこと)。
32
+ - **互換性**: 既存 v2 config を読む load-resolved-config-use-case 側は変更なし。既存 `.claude/scripts/hook-config.json` をカスタマイズ済みのプロジェクトで `phasegate init` を再実行しても上書きされない (write-back ガード)。
33
+ - **スコープ外** (Phase C で対応予定): Quick Mode 通過時の stderr notice (finding #3) / Stop hook `--enforce` flag (finding #4) / `pre-tool-use` の責務範囲ドキュメント明文化 (WI-086)。
34
+
10
35
  ## [0.119.0] - 2026-05-07
11
36
 
12
37
  ### Fixed
@@ -42,9 +42,27 @@ Add the following to `.claude/settings.json`:
42
42
 
43
43
  ## Hook Behaviors
44
44
 
45
+ ### Responsibility Separation (important)
46
+
47
+ Phasegate's hooks are **strictly partitioned by phase**:
48
+
49
+ | Hook | What it checks | What it does NOT check |
50
+ |------|----------------|------------------------|
51
+ | **PreToolUse** | Phase Gate, protected files, Quick Mode category gating, story-reflection | L1 lint (`@unit` / `@layer` / `any` abuse) — these are post-write concerns |
52
+ | **PostToolUse** | L1 Biome AST rules, formatter, type-check (via `analyze-errors-hook.sh`) | Phase Gate (already checked before write) |
53
+ | **Stop** | Complete-check (L2-L4 full validation suite) | Per-edit lint (already done by PostToolUse) |
54
+
55
+ This separation is intentional:
56
+ - **Pre = "should this write happen at all?"** — concerns the caller's intent and design state.
57
+ - **Post = "is what was written valid?"** — concerns the resulting code's quality.
58
+ - **Stop = "is the session ready to end?"** — concerns the cumulative state across the session.
59
+
60
+ If you expect L1 lint (e.g., missing `@unit` annotation) to **block** a Write before it happens, that is by design **not** the case. The PreToolUse hook intentionally does not run lint, because lint requires the resulting file content (which only exists after the write). Lint violations surface as **PostToolUse** decision JSON (`decision: "block"`) and trigger Claude Code to retry.
61
+
45
62
  ### PreToolUse (before file write)
46
63
  - Enforces Phase Gate: blocks writing to source files if required design documents don't exist
47
64
  - Blocks writes to protected files (package.json, biome.json, tsconfig.json)
65
+ - Quick Mode: when a write is allowed because the change is classified within `quickMode.allowedCategories`, an informational notice is emitted to stderr (`phasegate: write allowed (Quick Mode, category=<...>)`) — exit code remains 0
48
66
  - Returns actionable error messages with:
49
67
  - Violation reason
50
68
  - Missing artifacts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.119.0",
3
+ "version": "0.121.0",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -19,4 +19,9 @@ export interface HandlePreToolUseOutput {
19
19
  fullModeRejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
20
20
  fullModeDominantCategory?: string;
21
21
  nextAction?: string;
22
+ // Quick Mode が write を許可した際に dominant カテゴリを presentation 層に伝える。
23
+ // shouldBlock=false のときのみセットされる。WI-087 finding #3。
24
+ quickModeAllowed?: {
25
+ dominantCategory?: string;
26
+ };
22
27
  }
@@ -134,6 +134,7 @@ export class HandlePreToolUseUseCase {
134
134
  }
135
135
  }
136
136
 
137
+ let quickModeAllowed: HandlePreToolUseOutput["quickModeAllowed"];
137
138
  if (
138
139
  HandlePreToolUseUseCase.WRITE_TOOLS.has(input.toolName) &&
139
140
  this.fullModeRequirementQueryPort !== undefined &&
@@ -157,29 +158,31 @@ export class HandlePreToolUseUseCase {
157
158
  unitIdForGuidance,
158
159
  );
159
160
  }
161
+ } else {
162
+ quickModeAllowed = { dominantCategory: fullModeResult.dominantCategory };
160
163
  }
161
164
  }
162
165
  }
163
166
 
164
167
  const scope = this.resolveStoryReflectionScope(input);
165
168
  if (scope === null || this.storyReflectionQueryPort === undefined) {
166
- return { shouldBlock: false };
169
+ return { shouldBlock: false, quickModeAllowed };
167
170
  }
168
171
 
169
172
  if (grandfather.allGrandfathered) {
170
173
  this.grandfatherLogger("story-reflection", input.targetFilePaths);
171
- return { shouldBlock: false };
174
+ return { shouldBlock: false, quickModeAllowed };
172
175
  }
173
176
 
174
177
  const unitId = scope.unitId;
175
178
  if (unitId === undefined) {
176
- return { shouldBlock: false };
179
+ return { shouldBlock: false, quickModeAllowed };
177
180
  }
178
181
 
179
182
  const reflectionResult = await this.storyReflectionQueryPort.checkReflection(unitId);
180
183
 
181
184
  if (reflectionResult.skipped || reflectionResult.passed) {
182
- return { shouldBlock: false };
185
+ return { shouldBlock: false, quickModeAllowed };
183
186
  }
184
187
 
185
188
  return HandlePreToolUseUseCase.buildStoryReflectionBlockOutput(
@@ -150,6 +150,14 @@ async function main(): Promise<void> {
150
150
  process.exit(2);
151
151
  }
152
152
 
153
+ // Quick Mode が write を許可した場合に visibility を上げる informational notice。
154
+ // exit 0 は維持し semantics は変えない。WI-087 finding #3。
155
+ if (output.quickModeAllowed !== undefined) {
156
+ const cat = output.quickModeAllowed.dominantCategory;
157
+ const suffix = cat !== undefined && cat !== '' ? `, category=${cat}` : '';
158
+ process.stderr.write(`phasegate: write allowed (Quick Mode${suffix})\n`);
159
+ }
160
+
153
161
  process.exit(0);
154
162
  } catch (error) {
155
163
  process.stderr.write(`実行エラー: ${String(error)}\n`);
@@ -469,7 +469,13 @@ async function main(): Promise<void> {
469
469
  const configResult = await initHarnessConfig(rootDir, projectName, phasePreset);
470
470
  const hooksResult = deployClaude
471
471
  ? await deployHookScripts(harnessRoot, rootDir)
472
- : { scriptsDeployed: 0, settingsCreated: false };
472
+ : {
473
+ scriptsDeployed: 0,
474
+ settingsCreated: false,
475
+ hookConfigGenerated: false,
476
+ detectedTargetDirs: [] as string[],
477
+ detectedFormatter: null,
478
+ };
473
479
  const codexResult = deployCodex ? await deployCodexHooks(harnessRoot, rootDir) : null;
474
480
  const designDocsResult = await deployDesignDocs(harnessRoot, rootDir);
475
481
  const withHusky = hasFlag(args, "--with-husky");
@@ -486,6 +492,13 @@ async function main(): Promise<void> {
486
492
  if (hooksResult.scriptsDeployed > 0) {
487
493
  console.log(`✓ Hook scripts deployed to .claude/scripts/ (${hooksResult.scriptsDeployed} files)`);
488
494
  }
495
+ if (hooksResult.hookConfigGenerated) {
496
+ const dirsLabel = hooksResult.detectedTargetDirs.join(", ");
497
+ const formatterLabel = hooksResult.detectedFormatter ?? "none";
498
+ console.log(
499
+ `✓ hook-config.json generated (targetDirs: ${dirsLabel}; formatter: ${formatterLabel})`,
500
+ );
501
+ }
489
502
  if (hooksResult.settingsCreated) {
490
503
  console.log(`✓ .claude/settings.json created`);
491
504
  } else if (hooksResult.scriptsDeployed > 0) {
@@ -197,15 +197,159 @@ export async function deployAgentSkillLinks(
197
197
  export interface DeployHooksResult {
198
198
  scriptsDeployed: number;
199
199
  settingsCreated: boolean;
200
+ hookConfigGenerated: boolean;
201
+ detectedTargetDirs: string[];
202
+ detectedFormatter: string | null;
203
+ }
204
+
205
+ // pnpm-workspace.yaml の packages: 配列を line-based でパースする最小実装。
206
+ // 依存追加を避けつつ pnpm の workspace 定義を扱う。
207
+ function parsePnpmWorkspacePackages(yaml: string): string[] {
208
+ const lines = yaml.split(/\r?\n/);
209
+ const packages: string[] = [];
210
+ let inPackages = false;
211
+ let packagesIndent = -1;
212
+ for (const rawLine of lines) {
213
+ const line = rawLine.replace(/#.*$/, "").replace(/\s+$/, "");
214
+ if (line.length === 0) continue;
215
+ const indent = line.length - line.trimStart().length;
216
+ if (!inPackages) {
217
+ if (/^packages\s*:\s*$/.test(line)) {
218
+ inPackages = true;
219
+ packagesIndent = indent;
220
+ }
221
+ continue;
222
+ }
223
+ if (indent <= packagesIndent) {
224
+ // packages: ブロックを抜けた
225
+ inPackages = false;
226
+ continue;
227
+ }
228
+ const itemMatch = line.match(/^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/);
229
+ if (itemMatch) {
230
+ packages.push(itemMatch[1].trim());
231
+ }
232
+ }
233
+ return packages;
234
+ }
235
+
236
+ // 単純な末尾 `*` glob (`pkg/*`) のみを展開する。`**` 等のネストパターンは対象外。
237
+ async function expandWorkspaceGlobs(projectRoot: string, patterns: string[]): Promise<string[]> {
238
+ const expanded = new Set<string>();
239
+ for (const pattern of patterns) {
240
+ if (pattern.includes("**")) {
241
+ // 安全のため、再帰 glob は無視(無限ループ・性能を避ける)
242
+ continue;
243
+ }
244
+ if (pattern.endsWith("/*")) {
245
+ const baseDir = pattern.slice(0, -2);
246
+ try {
247
+ const entries = await fs.readdir(join(projectRoot, baseDir), { withFileTypes: true });
248
+ for (const entry of entries) {
249
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
250
+ expanded.add(`${baseDir}/${entry.name}`);
251
+ }
252
+ }
253
+ } catch {}
254
+ } else {
255
+ // glob なし: そのまま採用(ディレクトリ存在チェックは後段で実施)
256
+ expanded.add(pattern);
257
+ }
258
+ }
259
+ return [...expanded];
260
+ }
261
+
262
+ export async function detectWorkspaceTargetDirs(projectRoot: string): Promise<string[]> {
263
+ let patterns: string[] = [];
264
+
265
+ // 1. pnpm-workspace.yaml
266
+ try {
267
+ const yamlContent = await fs.readFile(join(projectRoot, "pnpm-workspace.yaml"), "utf-8");
268
+ patterns = parsePnpmWorkspacePackages(yamlContent);
269
+ } catch {}
270
+
271
+ // 2. package.json の workspaces (npm/yarn)
272
+ if (patterns.length === 0) {
273
+ try {
274
+ const pkgRaw = await fs.readFile(join(projectRoot, "package.json"), "utf-8");
275
+ const pkg = JSON.parse(pkgRaw) as { workspaces?: string[] | { packages?: string[] } };
276
+ const workspaces = pkg.workspaces;
277
+ if (Array.isArray(workspaces)) {
278
+ patterns = workspaces;
279
+ } else if (workspaces && Array.isArray(workspaces.packages)) {
280
+ patterns = workspaces.packages;
281
+ }
282
+ } catch {}
283
+ }
284
+
285
+ // 3. lerna.json
286
+ if (patterns.length === 0) {
287
+ try {
288
+ const lernaRaw = await fs.readFile(join(projectRoot, "lerna.json"), "utf-8");
289
+ const lerna = JSON.parse(lernaRaw) as { packages?: string[] };
290
+ if (Array.isArray(lerna.packages)) {
291
+ patterns = lerna.packages;
292
+ }
293
+ } catch {}
294
+ }
295
+
296
+ if (patterns.length === 0) {
297
+ return ["src"];
298
+ }
299
+
300
+ const workspaceDirs = await expandWorkspaceGlobs(projectRoot, patterns);
301
+ const targetDirs: string[] = [];
302
+ for (const ws of workspaceDirs) {
303
+ try {
304
+ const stats = await fs.stat(join(projectRoot, ws, "src"));
305
+ if (stats.isDirectory()) {
306
+ targetDirs.push(`${ws}/src`);
307
+ }
308
+ } catch {}
309
+ }
310
+
311
+ // workspace 定義は見つかったが src/ を持つものが一切ない場合は ["src"] にフォールバック
312
+ return targetDirs.length > 0 ? targetDirs.sort() : ["src"];
313
+ }
314
+
315
+ export interface DetectedFormatter {
316
+ formatter: string | null;
317
+ formatterArgs: string[];
318
+ }
319
+
320
+ export async function detectFormatter(projectRoot: string): Promise<DetectedFormatter> {
321
+ try {
322
+ const pkgRaw = await fs.readFile(join(projectRoot, "package.json"), "utf-8");
323
+ const pkg = JSON.parse(pkgRaw) as {
324
+ devDependencies?: Record<string, string>;
325
+ dependencies?: Record<string, string>;
326
+ };
327
+ const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
328
+ if (allDeps["@biomejs/biome"]) {
329
+ return { formatter: "biome", formatterArgs: ["check", "--write"] };
330
+ }
331
+ if (allDeps["prettier"]) {
332
+ return { formatter: "eslint-prettier", formatterArgs: [] };
333
+ }
334
+ } catch {}
335
+ return { formatter: null, formatterArgs: [] };
200
336
  }
201
337
 
202
338
  export async function deployHookScripts(harnessRoot: string, projectRoot: string): Promise<DeployHooksResult> {
203
339
  const templateDir = join(harnessRoot, HOOKS_TEMPLATE_DIR);
204
340
  const targetDir = join(projectRoot, HOOKS_TARGET_DIR);
205
341
 
206
- // scripts/ ディレクトリをコピー
207
342
  const scriptsSource = join(templateDir, "scripts");
208
343
  const scriptsTarget = join(targetDir, "scripts");
344
+ const hookConfigPath = join(scriptsTarget, "hook-config.json");
345
+
346
+ // copyDirectory で全 scripts/ を上書きするため、ユーザーカスタマイズを尊重するには
347
+ // 事前に hook-config.json の既存内容を捕捉して後で復元する。
348
+ let preexistingHookConfig: string | null = null;
349
+ try {
350
+ preexistingHookConfig = await fs.readFile(hookConfigPath, "utf-8");
351
+ } catch {}
352
+
209
353
  let scriptsDeployed = 0;
210
354
 
211
355
  try {
@@ -221,6 +365,29 @@ export async function deployHookScripts(harnessRoot: string, projectRoot: string
221
365
  }
222
366
  } catch {}
223
367
 
368
+ // hook-config.json の決定:
369
+ // 既存あり → ユーザーカスタマイズ尊重で元の内容を書き戻し
370
+ // 既存なし → 検出結果を反映して新規生成
371
+ const detectedTargetDirs = await detectWorkspaceTargetDirs(projectRoot);
372
+ const { formatter, formatterArgs } = await detectFormatter(projectRoot);
373
+ let hookConfigGenerated = false;
374
+
375
+ if (scriptsDeployed > 0) {
376
+ if (preexistingHookConfig !== null) {
377
+ await fs.writeFile(hookConfigPath, preexistingHookConfig, "utf-8");
378
+ } else {
379
+ const hookConfig: Record<string, unknown> = {
380
+ targetDirs: detectedTargetDirs,
381
+ };
382
+ if (formatter !== null) {
383
+ hookConfig.formatter = formatter;
384
+ hookConfig.formatterArgs = formatterArgs;
385
+ }
386
+ await fs.writeFile(hookConfigPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf-8");
387
+ hookConfigGenerated = true;
388
+ }
389
+ }
390
+
224
391
  // settings.json を作成(既存があればスキップ)
225
392
  const settingsSource = join(templateDir, "settings.json");
226
393
  const settingsTarget = join(targetDir, "settings.json");
@@ -238,7 +405,13 @@ export async function deployHookScripts(harnessRoot: string, projectRoot: string
238
405
  } catch {}
239
406
  }
240
407
 
241
- return { scriptsDeployed, settingsCreated };
408
+ return {
409
+ scriptsDeployed,
410
+ settingsCreated,
411
+ hookConfigGenerated,
412
+ detectedTargetDirs,
413
+ detectedFormatter: formatter,
414
+ };
242
415
  }
243
416
 
244
417
  export async function initHarnessConfig(
@@ -259,6 +432,9 @@ export async function initHarnessConfig(
259
432
  name: projectName,
260
433
  preset: "standard",
261
434
  },
435
+ architecture: {
436
+ preset: "clean",
437
+ },
262
438
  layers: {},
263
439
  quickMode: {},
264
440
  phaseDependencies: {