phasegate 0.44.0 → 0.63.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.
Files changed (52) hide show
  1. package/README.ja.md +32 -0
  2. package/README.md +33 -0
  3. package/docs/guide/codex-integration.md +162 -0
  4. package/docs/guide/quick-vs-full-mode.md +141 -0
  5. package/package.json +1 -1
  6. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +60 -0
  7. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +299 -0
  8. package/scripts/harness/agent-integration/presentation/session-start-hook.ts +54 -0
  9. package/scripts/harness/agent-integration/presentation/user-prompt-submit-hook.ts +70 -0
  10. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +15 -0
  11. package/scripts/harness/integrations/pre-commit.ts +128 -28
  12. package/scripts/harness/main.ts +87 -9
  13. package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-input.ts +9 -0
  14. package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-output.ts +17 -0
  15. package/scripts/harness/phase2-extensions/application/usecases/check-initial-creation-expiration-usecase.ts +103 -0
  16. package/scripts/harness/phase2-extensions/composition-root.ts +22 -0
  17. package/scripts/harness/phase2-extensions/domain/aggregates/initial-creation-expiration-rule.ts +103 -0
  18. package/scripts/harness/phase2-extensions/domain/ports/frontmatter-reader-port.ts +17 -0
  19. package/scripts/harness/phase2-extensions/domain/ports/initial-creation-age-port.ts +9 -0
  20. package/scripts/harness/phase2-extensions/domain/ports/initial-creation-expiration-config-port.ts +9 -0
  21. package/scripts/harness/phase2-extensions/domain/services/initial-creation-expiration-check-service.ts +54 -0
  22. package/scripts/harness/phase2-extensions/domain/value-objects/initial-creation-age.ts +54 -0
  23. package/scripts/harness/phase2-extensions/infrastructure/adapters/git-log-initial-creation-age-adapter.ts +77 -0
  24. package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-initial-creation-expiration-adapter.ts +57 -0
  25. package/scripts/harness/phase2-extensions/infrastructure/adapters/markdown-frontmatter-reader-adapter.ts +56 -0
  26. package/scripts/harness/phase2-extensions/presentation/formatters/initial-creation-expiration-result-formatter.ts +23 -0
  27. package/scripts/harness/phase2-extensions/presentation/handlers/check-initial-creation-expiration-handler.ts +38 -0
  28. package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +20 -0
  29. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +83 -0
  30. package/scripts/harness/quick-mode/composition-root.ts +12 -0
  31. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +38 -32
  32. package/scripts/harness/quick-mode/domain/value-objects/quick-mode-config.ts +36 -4
  33. package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +6 -0
  34. package/scripts/harness/quick-mode/presentation/formatters/change-category-formatter.ts +42 -0
  35. package/scripts/harness/quick-mode/presentation/handlers/check-change-category-handler.ts +50 -0
  36. package/scripts/harness/setup/skill-deployer.ts +30 -0
  37. package/scripts/harness/traceability-model/composition-root.ts +14 -0
  38. package/scripts/harness/traceability-model/domain/value-objects/project-relative-path.ts +3 -0
  39. package/scripts/harness/traceability-model/infrastructure/parsers/markdown-story-annotation-parser.ts +39 -4
  40. package/scripts/harness/traceability-model/presentation/cli/validate-metadata-command-handler.ts +103 -9
  41. package/skills/domain-designer/SKILL.md +34 -0
  42. package/skills/it-test-logic-designer/SKILL.md +16 -0
  43. package/skills/logical-designer/SKILL.md +34 -0
  44. package/skills/quick-implementor/SKILL.md +10 -1
  45. package/skills/scenario-test-logic-designer/SKILL.md +16 -0
  46. package/skills/story-implementor/SKILL.md +58 -0
  47. package/skills/unit-designer/SKILL.md +41 -0
  48. package/skills/unit-test-logic-designer/SKILL.md +18 -0
  49. package/templates/.codex/hooks.json +63 -0
  50. package/templates/logical_design.template.md +79 -0
  51. package/templates/source.template.ts +18 -0
  52. package/templates/test.template.ts +37 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * @layer presentation
3
+ * @unit agent-integration
4
+ *
5
+ * Phasegate 状態を context 文字列として組み立てる共有ヘルパー。
6
+ * SessionStart / UserPromptSubmit hook が共通で使用する。
7
+ *
8
+ * 副作用: phasegate.config.json の読み込み / docs/product/construction/ の走査のみ。
9
+ */
10
+
11
+ import * as path from 'node:path';
12
+ import * as fs from 'node:fs/promises';
13
+ import { exec } from 'node:child_process';
14
+ import { promisify } from 'node:util';
15
+
16
+ const execAsync = promisify(exec);
17
+
18
+ export interface PhasegateConfig {
19
+ protectedFiles?: {
20
+ patterns?: string[];
21
+ exclude?: string[];
22
+ };
23
+ project?: {
24
+ paths?: {
25
+ docs?: {
26
+ construction?: string;
27
+ };
28
+ };
29
+ };
30
+ }
31
+
32
+ export interface PhasegateStatus {
33
+ configFound: boolean;
34
+ protectedPatterns: readonly string[];
35
+ blockedUnits: readonly string[];
36
+ }
37
+
38
+ export type ViolationType = 'protected_file' | 'phase_gate';
39
+
40
+ export interface RecentViolation {
41
+ readonly type: ViolationType;
42
+ readonly filePath: string;
43
+ readonly detail: string;
44
+ }
45
+
46
+ const DEFAULT_PROTECTED_PATTERNS = [
47
+ 'biome.json',
48
+ '.biome.json',
49
+ 'tsconfig.json',
50
+ 'package.json',
51
+ 'package-lock.json',
52
+ ] as const;
53
+
54
+ export async function findConfigPath(startDir: string): Promise<string | null> {
55
+ let dir = startDir;
56
+ while (true) {
57
+ const candidate = path.join(dir, 'phasegate.config.json');
58
+ try {
59
+ await fs.access(candidate);
60
+ return candidate;
61
+ } catch {
62
+ const parent = path.dirname(dir);
63
+ if (parent === dir) return null;
64
+ dir = parent;
65
+ }
66
+ }
67
+ }
68
+
69
+ async function loadConfig(configPath: string): Promise<PhasegateConfig> {
70
+ try {
71
+ const raw = await fs.readFile(configPath, 'utf8');
72
+ return JSON.parse(raw) as PhasegateConfig;
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+
78
+ function computeProtectedPatterns(config: PhasegateConfig): string[] {
79
+ const additional = config.protectedFiles?.patterns ?? [];
80
+ const exclude = new Set(config.protectedFiles?.exclude ?? []);
81
+ const base = DEFAULT_PROTECTED_PATTERNS.filter((p) => !exclude.has(p));
82
+ return [...base, ...additional];
83
+ }
84
+
85
+ async function findBlockedUnits(projectRoot: string, config: PhasegateConfig): Promise<string[]> {
86
+ const constructionDir = config.project?.paths?.docs?.construction
87
+ ?? path.join('docs', 'product', 'construction');
88
+ const absConstructionDir = path.isAbsolute(constructionDir)
89
+ ? constructionDir
90
+ : path.join(projectRoot, constructionDir);
91
+
92
+ try {
93
+ await fs.access(absConstructionDir);
94
+ } catch {
95
+ return [];
96
+ }
97
+
98
+ let unitDirs: string[];
99
+ try {
100
+ const entries = await fs.readdir(absConstructionDir, { withFileTypes: true });
101
+ unitDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
102
+ } catch {
103
+ return [];
104
+ }
105
+
106
+ const blocked: string[] = [];
107
+ for (const unit of unitDirs) {
108
+ const unitDir = path.join(absConstructionDir, unit);
109
+ const hasLogicalDesign = await fs.access(path.join(unitDir, 'logical_design.md')).then(() => true).catch(() => false);
110
+ const hasDomainModel = await fs.access(path.join(unitDir, 'domain_model.md')).then(() => true).catch(() => false);
111
+ if (!hasLogicalDesign || !hasDomainModel) {
112
+ const missing: string[] = [];
113
+ if (!hasLogicalDesign) missing.push('logical_design.md');
114
+ if (!hasDomainModel) missing.push('domain_model.md');
115
+ blocked.push(`${unit} (missing: ${missing.join(', ')})`);
116
+ }
117
+ }
118
+ return blocked;
119
+ }
120
+
121
+ export async function collectPhasegateStatus(cwd: string): Promise<PhasegateStatus> {
122
+ const configPath = await findConfigPath(cwd);
123
+ const configFound = configPath !== null;
124
+ const config: PhasegateConfig = configFound ? await loadConfig(configPath) : {};
125
+ const projectRoot = configFound ? path.dirname(configPath) : cwd;
126
+
127
+ const protectedPatterns = computeProtectedPatterns(config);
128
+ const blockedUnits = await findBlockedUnits(projectRoot, config);
129
+
130
+ return { configFound, protectedPatterns, blockedUnits };
131
+ }
132
+
133
+ /**
134
+ * `git diff --name-only HEAD` で working tree + staged の変更ファイル一覧を取得する。
135
+ * git 未初期化 / gitコマンド無しの場合は空配列を返す。
136
+ */
137
+ async function getChangedFiles(projectRoot: string): Promise<string[]> {
138
+ try {
139
+ const { stdout } = await execAsync('git diff --name-only HEAD', {
140
+ cwd: projectRoot,
141
+ timeout: 5000,
142
+ });
143
+ return stdout.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
144
+ } catch {
145
+ return [];
146
+ }
147
+ }
148
+
149
+ function matchesProtectedPattern(filePath: string, pattern: string): boolean {
150
+ if (filePath === '') return false;
151
+ if (pattern === filePath) return true;
152
+ const regexStr = pattern
153
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
154
+ .replace(/\*\*/g, '__DOUBLE_STAR__')
155
+ .replace(/\*/g, '[^/]*')
156
+ .replace(/__DOUBLE_STAR__/g, '.*')
157
+ .replace(/\?/g, '[^/]');
158
+ const regex = new RegExp(`^${regexStr}$`);
159
+ return regex.test(filePath) || regex.test(path.basename(filePath));
160
+ }
161
+
162
+ /**
163
+ * 現在の working tree (git diff HEAD) をスキャンし、保護ファイル違反 /
164
+ * 既知の phase-gate ブロック Unit への書き込みを列挙する。
165
+ *
166
+ * ISSUE-013 Wave 3 / C-6 (軽量版): Codex のネイティブ apply_patch など hook を
167
+ * バイパスする経路で起きた違反を、次ターン UserPromptSubmit で遅延検知する。
168
+ */
169
+ export async function collectRecentViolations(
170
+ projectRoot: string,
171
+ status: PhasegateStatus,
172
+ ): Promise<RecentViolation[]> {
173
+ const changedFiles = await getChangedFiles(projectRoot);
174
+ if (changedFiles.length === 0) return [];
175
+
176
+ const violations: RecentViolation[] = [];
177
+ const seen = new Set<string>();
178
+
179
+ const blockedUnitNames = status.blockedUnits.map((entry) => entry.split(' ')[0]);
180
+
181
+ for (const file of changedFiles) {
182
+ // 保護ファイルチェック
183
+ for (const pattern of status.protectedPatterns) {
184
+ if (matchesProtectedPattern(file, pattern)) {
185
+ const key = `protected:${file}`;
186
+ if (!seen.has(key)) {
187
+ seen.add(key);
188
+ violations.push({
189
+ type: 'protected_file',
190
+ filePath: file,
191
+ detail: `matched pattern \`${pattern}\``,
192
+ });
193
+ }
194
+ break;
195
+ }
196
+ }
197
+
198
+ // 簡易 phase-gate チェック: 変更ファイルのパスに blocked unit 名が含まれるか
199
+ for (const unitName of blockedUnitNames) {
200
+ if (unitName.length === 0) continue;
201
+ if (file.includes(`/${unitName}/`) || file.startsWith(`${unitName}/`)) {
202
+ const key = `phase_gate:${file}:${unitName}`;
203
+ if (!seen.has(key)) {
204
+ seen.add(key);
205
+ violations.push({
206
+ type: 'phase_gate',
207
+ filePath: file,
208
+ detail: `within blocked unit \`${unitName}\``,
209
+ });
210
+ }
211
+ break;
212
+ }
213
+ }
214
+ }
215
+
216
+ return violations;
217
+ }
218
+
219
+ export function buildSessionStartContext(status: PhasegateStatus): string {
220
+ const lines: string[] = [
221
+ '# Phasegate status (auto-injected by SessionStart hook)',
222
+ '',
223
+ 'This project uses Phasegate for AIDLC quality enforcement. Follow these rules:',
224
+ '',
225
+ '- Do NOT write to protected files without going through `/quick-implementor` skill.',
226
+ '- Do NOT create/structurally modify source files under units listed as "blocked" below — the required design docs (logical_design.md / domain_model.md) are missing, and pre-tool-use hooks will block writes.',
227
+ '- Prefer the native `apply_patch` tool for edits, BUT note that Codex\'s apply_patch bypasses pre-edit hooks. Violations surface at pre-commit time.',
228
+ '',
229
+ ];
230
+
231
+ if (!status.configFound) {
232
+ lines.push('_(phasegate.config.json not found — using default patterns.)_');
233
+ lines.push('');
234
+ }
235
+
236
+ lines.push('## Protected files (pre-tool-use blocks writes to these)');
237
+ if (status.protectedPatterns.length === 0) {
238
+ lines.push('- (none)');
239
+ } else {
240
+ for (const p of status.protectedPatterns) {
241
+ lines.push(`- \`${p}\``);
242
+ }
243
+ }
244
+ lines.push('');
245
+
246
+ lines.push('## Units currently blocked by phase-gate');
247
+ if (status.blockedUnits.length === 0) {
248
+ lines.push('- (none — all units have required design docs)');
249
+ } else {
250
+ for (const u of status.blockedUnits) {
251
+ lines.push(`- ${u}`);
252
+ }
253
+ }
254
+ lines.push('');
255
+ lines.push('If you attempt to write to a blocked unit, your edit will be rejected. Use the `/story-implementor` skill (Phase 1 planning first) to create the required design docs.');
256
+
257
+ return lines.join('\n');
258
+ }
259
+
260
+ export function buildUserPromptSubmitContext(
261
+ status: PhasegateStatus,
262
+ violations: readonly RecentViolation[] = [],
263
+ ): string {
264
+ // UserPromptSubmit は毎ターン発火するため、簡潔に最新状態のみを通知する。
265
+ // SessionStart で既に運用ルールは注入済みの前提。
266
+ const lines: string[] = [
267
+ '# Phasegate status refresh',
268
+ '',
269
+ `- Protected files (${status.protectedPatterns.length}): ${
270
+ status.protectedPatterns.length === 0
271
+ ? '(none)'
272
+ : status.protectedPatterns.map((p) => `\`${p}\``).join(', ')
273
+ }`,
274
+ ];
275
+
276
+ if (status.blockedUnits.length === 0) {
277
+ lines.push('- Phase-gate: all units unlocked (no missing logical_design.md / domain_model.md)');
278
+ } else {
279
+ lines.push(`- Phase-gate: ${status.blockedUnits.length} unit(s) currently blocked — writes will be rejected:`);
280
+ for (const u of status.blockedUnits) {
281
+ lines.push(` - ${u}`);
282
+ }
283
+ lines.push(' Use `/story-implementor` to create the missing design docs.');
284
+ }
285
+
286
+ if (violations.length > 0) {
287
+ lines.push('');
288
+ lines.push(`## ⚠️ Phasegate violations detected in current working tree (${violations.length})`);
289
+ lines.push('');
290
+ lines.push('These changes violate Phasegate rules and WILL be blocked at pre-commit time. Native `apply_patch` edits bypass pre-edit hooks, so this turn-boundary check is the only early warning. Consider reverting with `git checkout <file>` or `git restore <file>` if unintended.');
291
+ lines.push('');
292
+ for (const v of violations) {
293
+ const label = v.type === 'protected_file' ? 'PROTECTED FILE' : 'PHASE-GATE';
294
+ lines.push(`- [${label}] \`${v.filePath}\` — ${v.detail}`);
295
+ }
296
+ }
297
+
298
+ return lines.join('\n');
299
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @layer presentation
3
+ * @unit agent-integration
4
+ *
5
+ * SessionStart Hook Adapter (ISSUE-013 Wave 3 / C-4)
6
+ *
7
+ * Codex CLI のセッション開始時に phase-gate 状態 (運用ルール + 保護ファイル一覧 +
8
+ * ブロック中 Unit) を `hookSpecificOutput.additionalContext` として注入する。
9
+ *
10
+ * 出力スキーマ (Codex 公式):
11
+ * {
12
+ * "hookSpecificOutput": {
13
+ * "hookEventName": "SessionStart",
14
+ * "additionalContext": "<developer context を string で>"
15
+ * }
16
+ * }
17
+ */
18
+
19
+ import { buildSessionStartContext, collectPhasegateStatus } from './phasegate-status-context.js';
20
+
21
+ async function readStdin(): Promise<string> {
22
+ const chunks: Buffer[] = [];
23
+ for await (const chunk of process.stdin) {
24
+ chunks.push(chunk as Buffer);
25
+ }
26
+ return Buffer.concat(chunks).toString('utf8');
27
+ }
28
+
29
+ async function main(): Promise<void> {
30
+ try {
31
+ await readStdin();
32
+ } catch {
33
+ // stdin が無くても続行
34
+ }
35
+
36
+ const status = await collectPhasegateStatus(process.cwd());
37
+ const additionalContext = buildSessionStartContext(status);
38
+
39
+ const output = {
40
+ hookSpecificOutput: {
41
+ hookEventName: 'SessionStart',
42
+ additionalContext,
43
+ },
44
+ };
45
+
46
+ process.stdout.write(JSON.stringify(output));
47
+ process.exit(0);
48
+ }
49
+
50
+ main().catch((error) => {
51
+ process.stderr.write(`SessionStart hook error: ${String(error)}\n`);
52
+ // SessionStart で失敗してもセッション継続できるよう exit 0
53
+ process.exit(0);
54
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @layer presentation
3
+ * @unit agent-integration
4
+ *
5
+ * UserPromptSubmit Hook Adapter (ISSUE-013 Wave 3 / C-5)
6
+ *
7
+ * Codex CLI の UserPromptSubmit 時 (ユーザーが prompt を送信する毎) に、
8
+ * 最新の phase-gate 状態を簡潔に `hookSpecificOutput.additionalContext` として注入する。
9
+ *
10
+ * SessionStart と異なり毎ターン発火するため、運用ルールの再掲は省いて
11
+ * 「現在の保護ファイル数」「ブロック中 Unit 数と内訳」のみを通知する。
12
+ * ルール本体は SessionStart hook で既に injection 済みの前提。
13
+ *
14
+ * 出力スキーマ (Codex 公式):
15
+ * {
16
+ * "hookSpecificOutput": {
17
+ * "hookEventName": "UserPromptSubmit",
18
+ * "additionalContext": "<developer context を string で>"
19
+ * }
20
+ * }
21
+ */
22
+
23
+ import * as path from 'node:path';
24
+ import {
25
+ buildUserPromptSubmitContext,
26
+ collectPhasegateStatus,
27
+ collectRecentViolations,
28
+ findConfigPath,
29
+ } from './phasegate-status-context.js';
30
+
31
+ async function readStdin(): Promise<string> {
32
+ const chunks: Buffer[] = [];
33
+ for await (const chunk of process.stdin) {
34
+ chunks.push(chunk as Buffer);
35
+ }
36
+ return Buffer.concat(chunks).toString('utf8');
37
+ }
38
+
39
+ async function main(): Promise<void> {
40
+ try {
41
+ await readStdin();
42
+ } catch {
43
+ // stdin が無くても続行
44
+ }
45
+
46
+ const cwd = process.cwd();
47
+ const status = await collectPhasegateStatus(cwd);
48
+ // 違反検知の起点は config が見つかったディレクトリ (= project root)。
49
+ // config が無ければ cwd をそのまま使う。
50
+ const configPath = await findConfigPath(cwd);
51
+ const projectRoot = configPath !== null ? path.dirname(configPath) : cwd;
52
+ const violations = await collectRecentViolations(projectRoot, status);
53
+ const additionalContext = buildUserPromptSubmitContext(status, violations);
54
+
55
+ const output = {
56
+ hookSpecificOutput: {
57
+ hookEventName: 'UserPromptSubmit',
58
+ additionalContext,
59
+ },
60
+ };
61
+
62
+ process.stdout.write(JSON.stringify(output));
63
+ process.exit(0);
64
+ }
65
+
66
+ main().catch((error) => {
67
+ process.stderr.write(`UserPromptSubmit hook error: ${String(error)}\n`);
68
+ // UserPromptSubmit で失敗してもターン継続できるよう exit 0
69
+ process.exit(0);
70
+ });
@@ -153,6 +153,21 @@
153
153
  "type": "string"
154
154
  },
155
155
  "uniqueItems": true
156
+ },
157
+ "fullModeRequiredWhen": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "properties": {
161
+ "mixedCategories": {
162
+ "type": "boolean"
163
+ },
164
+ "newDomainFile": {
165
+ "type": "boolean"
166
+ },
167
+ "apiContractChange": {
168
+ "type": "boolean"
169
+ }
170
+ }
156
171
  }
157
172
  }
158
173
  },
@@ -3,8 +3,9 @@
3
3
  * @layer presentation
4
4
  *
5
5
  * Pre-commit CLI entry.
6
- * Runs L2 validators (phase-gate / metadata / test-quality) against staged
7
- * TypeScript files. Invoked from `.husky/pre-commit` or `npx phasegate pre-commit`.
6
+ * Runs L2 validators against staged TypeScript files AND design-document
7
+ * metadata checks against staged Markdown files. Invoked from `.husky/pre-commit`
8
+ * or `npx phasegate pre-commit`.
8
9
  *
9
10
  * Exit codes:
10
11
  * 0 = pass (or nothing to check)
@@ -17,6 +18,8 @@ import { createValidatorSystemModule } from '../validator-system/composition-roo
17
18
  import { HumanValidationResultFormatter } from '../validator-system/presentation/formatters/human-validation-result-formatter.js';
18
19
  import type { AggregatedValidationReport } from '../validator-system/application/dto/aggregated-validation-report.js';
19
20
  import type { ValidationResultContract } from '../validator-system/application/dto/validation-result-contract.js';
21
+ import { createTraceabilityModelModule } from '../traceability-model/composition-root.js';
22
+ import type { ValidateMetadataCommandOutput } from '../traceability-model/presentation/cli/validate-metadata-command-handler.js';
20
23
 
21
24
  const GREEN = '\x1b[32m';
22
25
  const RED = '\x1b[31m';
@@ -24,6 +27,48 @@ const BOLD = '\x1b[1m';
24
27
  const DIM = '\x1b[2m';
25
28
  const RESET = '\x1b[0m';
26
29
 
30
+ const TS_EXTENSION = '.ts';
31
+ const MD_EXTENSION = '.md';
32
+ const TEST_FILE_SUFFIXES = Object.freeze([
33
+ '.test.ts',
34
+ '.test.tsx',
35
+ '.spec.ts',
36
+ '.spec.tsx',
37
+ ]);
38
+
39
+ function isTestFile(path: string): boolean {
40
+ return TEST_FILE_SUFFIXES.some((suffix) => path.endsWith(suffix));
41
+ }
42
+
43
+ interface RunL2Input {
44
+ readonly targetPaths: readonly string[];
45
+ readonly unitName: string;
46
+ readonly currentPhase: string;
47
+ }
48
+
49
+ interface RunL2UseCaseLike {
50
+ execute(input: RunL2Input): Promise<readonly ValidationResultContract[]>;
51
+ }
52
+
53
+ interface ValidateMetadataInput {
54
+ readonly filePaths: readonly string[];
55
+ readonly json?: boolean;
56
+ }
57
+
58
+ interface ValidateMetadataHandlerLike {
59
+ execute(input: ValidateMetadataInput): Promise<ValidateMetadataCommandOutput>;
60
+ }
61
+
62
+ export interface PreCommitDeps {
63
+ readonly runL2ValidatorsUseCase: RunL2UseCaseLike;
64
+ readonly validateMetadataCommandHandler: ValidateMetadataHandlerLike;
65
+ }
66
+
67
+ export interface PreCommitResult {
68
+ readonly exitCode: 0 | 1 | 2;
69
+ readonly stdout: string;
70
+ }
71
+
27
72
  function getStagedFiles(): string[] {
28
73
  try {
29
74
  const output = execSync('git diff --cached --name-only --diff-filter=ACM', {
@@ -39,7 +84,9 @@ function getStagedFiles(): string[] {
39
84
  }
40
85
  }
41
86
 
42
- function buildReport(results: readonly ValidationResultContract[]): AggregatedValidationReport {
87
+ function buildReport(
88
+ results: readonly ValidationResultContract[],
89
+ ): AggregatedValidationReport {
43
90
  const passed = results.filter((r) => r.passed && !r.skipped).length;
44
91
  const failed = results.filter((r) => !r.passed && !r.skipped).length;
45
92
  const skipped = results.filter((r) => r.skipped).length;
@@ -62,37 +109,90 @@ function buildReport(results: readonly ValidationResultContract[]): AggregatedVa
62
109
  };
63
110
  }
64
111
 
65
- async function main(): Promise<void> {
66
- const stagedFiles = getStagedFiles();
67
- const tsFiles = stagedFiles.filter((f) => f.endsWith('.ts'));
112
+ function maxExitCode(a: 0 | 1 | 2, b: 0 | 1 | 2): 0 | 1 | 2 {
113
+ return (Math.max(a, b) as 0 | 1 | 2);
114
+ }
68
115
 
69
- if (tsFiles.length === 0) {
70
- process.stdout.write(`${DIM}[phasegate] No staged TypeScript files. Skipping.${RESET}\n`);
71
- process.exit(0);
116
+ export async function runPreCommit(
117
+ stagedFiles: readonly string[],
118
+ deps: PreCommitDeps,
119
+ ): Promise<PreCommitResult> {
120
+ const tsFiles = stagedFiles.filter((f) => f.endsWith(TS_EXTENSION));
121
+ const mdFiles = stagedFiles.filter((f) => f.endsWith(MD_EXTENSION));
122
+ const testFiles = tsFiles.filter((f) => isTestFile(f));
123
+ const metadataFiles = [...mdFiles, ...testFiles];
124
+
125
+ if (tsFiles.length === 0 && mdFiles.length === 0) {
126
+ return {
127
+ exitCode: 0,
128
+ stdout: `${DIM}[phasegate] No staged files to check. Skipping.${RESET}`,
129
+ };
72
130
  }
73
131
 
74
- process.stdout.write(`${BOLD}[phasegate]${RESET} Pre-commit check (${tsFiles.length} file(s))\n`);
132
+ const sections: string[] = [];
133
+ sections.push(
134
+ `${BOLD}[phasegate]${RESET} Pre-commit check ` +
135
+ `(${tsFiles.length} .ts file(s), ${mdFiles.length} .md file(s))`,
136
+ );
137
+
138
+ let exitCode: 0 | 1 | 2 = 0;
75
139
 
76
- const mod = createValidatorSystemModule();
77
- const results = await mod.runL2ValidatorsUseCase.execute({
78
- targetPaths: tsFiles,
79
- unitName: '',
80
- currentPhase: '',
81
- });
140
+ if (tsFiles.length > 0) {
141
+ const results = await deps.runL2ValidatorsUseCase.execute({
142
+ targetPaths: tsFiles,
143
+ unitName: '',
144
+ currentPhase: '',
145
+ });
146
+ const report = buildReport(results);
147
+ sections.push('');
148
+ sections.push(`${BOLD}== TypeScript 実装 (${tsFiles.length} file(s)) ==${RESET}`);
149
+ sections.push(new HumanValidationResultFormatter().format(report));
150
+ if (!report.overallPassed) {
151
+ exitCode = maxExitCode(exitCode, 1);
152
+ }
153
+ }
82
154
 
83
- const report = buildReport(results);
84
- process.stdout.write(`${new HumanValidationResultFormatter().format(report)}\n`);
155
+ if (metadataFiles.length > 0) {
156
+ const metadataResult = await deps.validateMetadataCommandHandler.execute({
157
+ filePaths: metadataFiles,
158
+ });
159
+ sections.push('');
160
+ sections.push(
161
+ `${BOLD}== 設計 / テスト メタデータ注釈 (${metadataFiles.length} file(s)) ==${RESET}`,
162
+ );
163
+ sections.push(metadataResult.text);
164
+ exitCode = maxExitCode(exitCode, metadataResult.exitCode);
165
+ }
85
166
 
86
- if (!report.overallPassed) {
87
- process.stdout.write(`\n${RED}${BOLD}[phasegate] Commit blocked.${RESET}\n`);
88
- process.exit(1);
167
+ sections.push('');
168
+ if (exitCode === 0) {
169
+ sections.push(`${GREEN}[phasegate]${RESET} All checks passed.`);
170
+ } else {
171
+ sections.push(`${RED}${BOLD}[phasegate] Commit blocked.${RESET}`);
89
172
  }
90
- process.stdout.write(`\n${GREEN}[phasegate]${RESET} All checks passed.\n`);
91
- process.exit(0);
173
+
174
+ return {
175
+ exitCode,
176
+ stdout: sections.join('\n'),
177
+ };
92
178
  }
93
179
 
94
- main().catch((err) => {
95
- const msg = err instanceof Error ? err.message : String(err);
96
- process.stderr.write(`${RED}[phasegate] Unexpected error:${RESET} ${msg}\n`);
97
- process.exit(2);
98
- });
180
+ export async function runPreCommitCli(): Promise<void> {
181
+ try {
182
+ const stagedFiles = getStagedFiles();
183
+ const validatorMod = createValidatorSystemModule();
184
+ const traceabilityMod = createTraceabilityModelModule(process.cwd());
185
+
186
+ const result = await runPreCommit(stagedFiles, {
187
+ runL2ValidatorsUseCase: validatorMod.runL2ValidatorsUseCase,
188
+ validateMetadataCommandHandler: traceabilityMod.validateMetadataCommandHandler,
189
+ });
190
+
191
+ process.stdout.write(`${result.stdout}\n`);
192
+ process.exit(result.exitCode);
193
+ } catch (err) {
194
+ const msg = err instanceof Error ? err.message : String(err);
195
+ process.stderr.write(`${RED}[phasegate] Unexpected error:${RESET} ${msg}\n`);
196
+ process.exit(2);
197
+ }
198
+ }