phasegate 0.315.0 → 0.340.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 (106) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.ja.md +22 -9
  3. package/README.md +28 -17
  4. package/docs/ADR/038-config-state-operation-permission-policy.md +5 -2
  5. package/docs/ADR/039-hook-observable-state-as-authorization-unit.md +74 -0
  6. package/docs/ADR/040-quick-mode-config-via-preset-resolution.md +66 -0
  7. package/docs/ADR/041-non-excludable-agent-trust-roots.md +68 -0
  8. package/docs/guide/antigravity-integration.md +26 -0
  9. package/docs/guide/cli-reference.md +93 -11
  10. package/docs/guide/codex-integration.md +44 -107
  11. package/docs/guide/configuration.md +43 -2
  12. package/docs/guide/grok-integration.md +26 -0
  13. package/docs/guide/hooks-integration.md +27 -4
  14. package/docs/guide/installation.md +4 -2
  15. package/docs/guide/quick-vs-full-mode.md +36 -1
  16. package/docs/guide/setup-artifacts.md +4 -4
  17. package/docs/guide/skills-overview.md +1 -1
  18. package/package.json +2 -2
  19. package/scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts +6 -2
  20. package/scripts/harness/agent-integration/application/dto/normalized-pre-tool-use-request.ts +32 -0
  21. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +17 -9
  22. package/scripts/harness/agent-integration/domain/ports/full-mode-requirement-query-port.ts +3 -1
  23. package/scripts/harness/agent-integration/domain/services/apply-patch-write-target-extractor.ts +98 -0
  24. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +196 -54
  25. package/scripts/harness/agent-integration/domain/value-objects/hook-translation-result.ts +1 -1
  26. package/scripts/harness/agent-integration/domain/value-objects/protected-file-list.ts +39 -17
  27. package/scripts/harness/agent-integration/infrastructure/adapters/quick-mode-full-mode-requirement-adapter.ts +15 -1
  28. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +1 -1
  29. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +1 -0
  30. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +129 -73
  31. package/scripts/harness/agent-integration/presentation/pre-tool-use-payload-normalizer.ts +228 -0
  32. package/scripts/harness/agent-integration/presentation/pre-tool-use-response-renderer.ts +38 -0
  33. package/scripts/harness/ci-governance/application/dto/scaffold-inception-input.ts +9 -0
  34. package/scripts/harness/ci-governance/application/dto/scaffold-inception-output.ts +13 -0
  35. package/scripts/harness/ci-governance/application/usecases/list-templates-usecase.ts +30 -0
  36. package/scripts/harness/ci-governance/application/usecases/scaffold-inception-usecase.ts +63 -0
  37. package/scripts/harness/ci-governance/application/usecases/show-template-usecase.ts +39 -0
  38. package/scripts/harness/ci-governance/composition-root.ts +55 -3
  39. package/scripts/harness/ci-governance/domain/ports/inception-doc-writer-port.ts +19 -0
  40. package/scripts/harness/ci-governance/domain/ports/inception-template-repository-port.ts +16 -0
  41. package/scripts/harness/ci-governance/domain/ports/template-catalog-port.ts +23 -0
  42. package/scripts/harness/ci-governance/domain/value-objects/inception-doc-kind.ts +109 -0
  43. package/scripts/harness/ci-governance/domain/value-objects/integrity-target.ts +2 -0
  44. package/scripts/harness/ci-governance/domain/value-objects/template-catalog-entry.ts +53 -0
  45. package/scripts/harness/ci-governance/domain/value-objects/template-name.ts +50 -0
  46. package/scripts/harness/ci-governance/infrastructure/adapters/file-system-inception-doc-writer-adapter.ts +42 -0
  47. package/scripts/harness/ci-governance/infrastructure/adapters/file-system-inception-template-repository-adapter.ts +32 -0
  48. package/scripts/harness/ci-governance/infrastructure/adapters/file-system-template-catalog-adapter.ts +60 -0
  49. package/scripts/harness/ci-governance/presentation/handlers/scaffold-inception-handler.ts +101 -0
  50. package/scripts/harness/ci-governance/presentation/handlers/templates-handler.ts +103 -0
  51. package/scripts/harness/config-foundation/domain/harness-config.ts +12 -0
  52. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +2 -2
  53. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +2 -2
  54. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +2 -2
  55. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +44 -1
  56. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +42 -1
  57. package/scripts/harness/harness-api/domain/value-objects/known-harness-commands.ts +3 -1
  58. package/scripts/harness/installation/application/checks/antigravity-hook-missing-check.ts +79 -0
  59. package/scripts/harness/installation/application/checks/codex-hook-missing-check.ts +70 -6
  60. package/scripts/harness/installation/application/checks/grok-hook-missing-check.ts +79 -0
  61. package/scripts/harness/installation/application/checks/husky-runtime-inactive-check.ts +33 -0
  62. package/scripts/harness/installation/application/named-hook-json.ts +17 -0
  63. package/scripts/harness/installation/application/operator-notice.ts +42 -0
  64. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +18 -7
  65. package/scripts/harness/installation/application/usecases/run-install.ts +152 -52
  66. package/scripts/harness/installation/application/usecases/run-reconcile.ts +86 -10
  67. package/scripts/harness/installation/application/usecases/run-uninstall.ts +149 -25
  68. package/scripts/harness/installation/composition-root.ts +10 -0
  69. package/scripts/harness/installation/domain/agent-target.ts +39 -0
  70. package/scripts/harness/installation/domain/check-id.ts +5 -0
  71. package/scripts/harness/installation/domain/husky-runtime-state.ts +34 -0
  72. package/scripts/harness/installation/domain/ports/git-hooks-runtime-probe.ts +9 -0
  73. package/scripts/harness/installation/domain/repair-table.ts +5 -0
  74. package/scripts/harness/installation/infrastructure/adapters/git-hooks-runtime-probe-adapter.ts +62 -0
  75. package/scripts/harness/installation/presentation/cli/install-handler.ts +23 -4
  76. package/scripts/harness/installation/presentation/cli/reconcile-handler.ts +7 -1
  77. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +27 -0
  78. package/scripts/harness/main.ts +202 -51
  79. package/scripts/harness/phase-dependency-model/application/services/evidence-bundle-assembler.ts +2 -1
  80. package/scripts/harness/phase-dependency-model/domain/ports/plan-document-reader-port.ts +12 -0
  81. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/markdown-plan-document-reader.ts +4 -1
  82. package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +1 -1
  83. package/scripts/harness/quick-mode/application/dto/quick-mode-eligibility-contract.ts +1 -1
  84. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +3 -1
  85. package/scripts/harness/quick-mode/domain/errors/quick-mode-config-error.ts +19 -0
  86. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +59 -19
  87. package/scripts/harness/quick-mode/domain/types/rejection-rule.ts +1 -1
  88. package/scripts/harness/quick-mode/domain/value-objects/category-override-rules.ts +147 -0
  89. package/scripts/harness/quick-mode/domain/value-objects/change-category.ts +33 -0
  90. package/scripts/harness/quick-mode/domain/value-objects/quick-mode-config.ts +31 -10
  91. package/scripts/harness/quick-mode/index.ts +7 -1
  92. package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +83 -32
  93. package/scripts/harness/regression-suite/infrastructure/adapters/vitest-test-runner-adapter.ts +13 -3
  94. package/skills/product-architect/SKILL.md +19 -0
  95. package/skills/story-mapper/SKILL.md +11 -0
  96. package/skills/story-writer/SKILL.md +11 -0
  97. package/skills/unit-designer/SKILL.md +11 -0
  98. package/templates/.agents/hooks.json +16 -0
  99. package/templates/.claude/scripts/analyze-errors-hook.sh +2 -2
  100. package/templates/.claude/settings.json +5 -3
  101. package/templates/.codex/hooks.json +2 -2
  102. package/templates/product_overview.template.md +85 -0
  103. package/templates/product_overview_plan.template.md +55 -0
  104. package/templates/story_mapping_plan.template.md +61 -0
  105. package/templates/story_writer_plan.template.md +61 -0
  106. package/templates/unit_design_plan.template.md +63 -0
@@ -1,16 +1,28 @@
1
1
  /**
2
2
  * @layer domain
3
3
  * @unit phase-dependency-model
4
+ * @work-item-id WI-369
4
5
  */
5
6
 
7
+ import type { PathRoots } from '../values/artifact.js';
6
8
  import type { PhaseNode } from '../values/phase-node.js';
7
9
  import type { PlanEvidence } from '../values/plan-evidence.js';
8
10
  import type { PlanningMode } from '../values/planning-mode.js';
9
11
 
10
12
  export interface PlanDocumentReaderPort {
13
+ /**
14
+ * plan 文書の存在と QA evidence を読む。
15
+ *
16
+ * `pathRoots` は `phasegate.config.json` の `paths` を解決したもの。
17
+ * 省略時は既定ルート(`docs/inception` / `docs/product/construction`)を使う。
18
+ * WI-369: これを渡さないと `paths.inceptionDocs` を移設した PJ で
19
+ * plan 文書だけが既定パスから探され、成果物検査と plan evidence の
20
+ * 探索先がズレる(成果物は在るのに「plan文書が不足」で落ちる)。
21
+ */
11
22
  readEvidence(
12
23
  node: PhaseNode,
13
24
  scope: { unitId?: string; storyId?: string },
14
25
  expectedMode: PlanningMode,
26
+ pathRoots?: PathRoots,
15
27
  ): Promise<PlanEvidence>;
16
28
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * @layer infrastructure
3
3
  * @unit phase-dependency-model
4
+ * @work-item-id WI-369
4
5
  */
5
6
 
6
7
  import { readFile } from "node:fs/promises";
7
8
  import * as path from "node:path";
8
9
  import type { PlanDocumentReaderPort } from "../../domain/ports/plan-document-reader-port.js";
10
+ import { DEFAULT_PATH_ROOTS, type PathRoots } from "../../domain/values/artifact.js";
9
11
  import type { PhaseNode } from "../../domain/values/phase-node.js";
10
12
  import { PlanEvidence } from "../../domain/values/plan-evidence.js";
11
13
  import type { PlanningMode } from "../../domain/values/planning-mode.js";
@@ -41,6 +43,7 @@ export class MarkdownPlanDocumentReader implements PlanDocumentReaderPort {
41
43
  node: PhaseNode,
42
44
  scope: { unitId?: string; storyId?: string },
43
45
  expectedMode: PlanningMode,
46
+ pathRoots: PathRoots = DEFAULT_PATH_ROOTS,
44
47
  ): Promise<PlanEvidence> {
45
48
  const planArtifacts = node.planArtifacts();
46
49
 
@@ -55,7 +58,7 @@ export class MarkdownPlanDocumentReader implements PlanDocumentReaderPort {
55
58
  const artifact = planArtifacts[0];
56
59
  let resolvedPath: string;
57
60
  try {
58
- resolvedPath = artifact.resolve(scope);
61
+ resolvedPath = artifact.resolve(scope, pathRoots);
59
62
  } catch {
60
63
  return PlanEvidence.create({
61
64
  exists: false,
@@ -11,6 +11,6 @@ export interface ChangeCategoryClassificationContract {
11
11
  readonly dominantCategory: string | null;
12
12
  readonly perFile: readonly ChangeCategoryPerFile[];
13
13
  readonly fullModeRequired: boolean;
14
- readonly rejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
14
+ readonly rejectionRule?: 'CATEGORY_NOT_ALLOWED' | 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
15
15
  readonly rejectionReason?: string;
16
16
  }
@@ -8,6 +8,6 @@
8
8
  export interface QuickModeEligibilityContract {
9
9
  readonly eligible: boolean;
10
10
  readonly reason: string;
11
- readonly rejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
11
+ readonly rejectionRule?: 'CATEGORY_NOT_ALLOWED' | 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
12
12
  readonly rejectedFiles?: readonly { filePath: string; changeKind: string }[];
13
13
  }
@@ -2,6 +2,7 @@
2
2
  * @layer application
3
3
  * @unit quick-mode
4
4
  * @story H10-05
5
+ * @work-item-id WI-384
5
6
  *
6
7
  * paths から変更カテゴリを分類し fullModeRequired 判定と理由を返す UseCase
7
8
  */
@@ -20,6 +21,7 @@ export interface ClassifyChangeCategoryUseCaseInput {
20
21
  readonly paths: readonly string[];
21
22
  readonly targetChanges?: readonly {
22
23
  readonly filePath: string;
24
+ readonly changeKind?: ChangeKind;
23
25
  readonly beforeContent?: string | null;
24
26
  readonly afterContent?: string | null;
25
27
  }[];
@@ -94,7 +96,7 @@ export class ClassifyChangeCategoryUseCase {
94
96
  // 変更前の内容が無く変更後の内容がある場合は新規作成 (CREATE) とみなす。
95
97
  // 以前は無条件で MODIFY 固定だったため、新規 domain/ ファイルが
96
98
  // NEW_DOMAIN 判定を回避して quick mode をすり抜けていた。
97
- changeKind: beforeContent === null && afterContent !== null ? "CREATE" : "MODIFY",
99
+ changeKind: targetChange?.changeKind ?? (beforeContent === null && afterContent !== null ? "CREATE" : "MODIFY"),
98
100
  beforeContent,
99
101
  afterContent,
100
102
  });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit quick-mode
4
+ * @work-item-id WI-372
5
+ *
6
+ * QuickMode 設定の不変条件違反を表すエラー。
7
+ *
8
+ * `quick-mode-config.ts` と `category-override-rules.ts` の双方から参照されるため、
9
+ * 循環 import を避ける目的で独立ファイルに切り出している。
10
+ * 後方互換のため `quick-mode-config.ts` からも re-export される。
11
+ */
12
+
13
+ export class QuickModeConfigError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = 'QuickModeConfigError';
17
+ Object.setPrototypeOf(this, new.target.prototype);
18
+ }
19
+ }
@@ -4,10 +4,13 @@
4
4
  * @work-item-id WI-204
5
5
  * @work-item-id WI-349
6
6
  * @work-item-id WI-352
7
+ * @work-item-id WI-372
8
+ * @work-item-id WI-390
7
9
  *
8
10
  * ChangedFile[]をChangeClassificationに変換し、3拒否ルールを評価してQuickModeEligibilityを返すドメインサービス
9
11
  */
10
12
 
13
+ import { CategoryOverrideRules } from "../value-objects/category-override-rules.js";
11
14
  import { ChangeCategory } from "../value-objects/change-category.js";
12
15
  import { ChangeClassification } from "../value-objects/change-classification.js";
13
16
  import type { ChangedFile } from "../value-objects/changed-file.js";
@@ -15,17 +18,6 @@ import type { QuickModeConfig } from "../value-objects/quick-mode-config.js";
15
18
  import { QuickModeEligibility } from "../value-objects/quick-mode-eligibility.js";
16
19
  import { isCommentOnlyDiff } from "./comment-only-diff-detector.js";
17
20
 
18
- // リスク順優先度(api > domain > feature > bugfix > test > config > docs)
19
- const RISK_PRIORITY: Record<string, number> = {
20
- api: 6,
21
- domain: 5,
22
- feature: 4,
23
- bugfix: 3,
24
- test: 2,
25
- config: 1,
26
- docs: 0,
27
- };
28
-
29
21
  // config: リポジトリ直下の bootstrap 設定ファイル(列挙型 allowlist)。
30
22
  // これらは unit を持たず、CREATE だと feature に落ちて allowedCategories に
31
23
  // 入れる手段がないため greenfield な初期セットアップが恒久的にブロックされていた。
@@ -57,7 +49,7 @@ function isRootBootstrapConfigFile(filePath: string): boolean {
57
49
  return filePath.startsWith(HUSKY_DIRECTORY_PREFIX) && filePath.slice(HUSKY_DIRECTORY_PREFIX.length).length > 0;
58
50
  }
59
51
 
60
- function categorizeFile(file: ChangedFile): ChangeCategory {
52
+ function categorizeFileByBuiltInRules(file: ChangedFile): ChangeCategory {
61
53
  const { filePath, changeKind } = file;
62
54
 
63
55
  // Config files must stay config even when Edit payload snippets look like
@@ -101,6 +93,15 @@ function categorizeFile(file: ChangedFile): ChangeCategory {
101
93
  return ChangeCategory.fromString("test");
102
94
  }
103
95
 
96
+ // Markdown は配置場所にかかわらず文書として扱う。config / API / test の
97
+ // 明示ルールは優先しつつ、domain ディレクトリや CREATE フォールバックより
98
+ // 前に評価して README.md / *.mdx の新規作成を feature/domain に誤分類しない。
99
+ // 信頼境界となる root agent instructions は protected-file 側で別途保護する。
100
+ // @work-item-id WI-390
101
+ if (filePath.endsWith(".md") || filePath.endsWith(".mdx")) {
102
+ return ChangeCategory.fromString("docs");
103
+ }
104
+
104
105
  // domain: domain/ 配下
105
106
  if (filePath.includes("/domain/") || filePath.startsWith("domain/")) {
106
107
  return ChangeCategory.fromString("domain");
@@ -130,6 +131,43 @@ function categorizeFile(file: ChangedFile): ChangeCategory {
130
131
  return ChangeCategory.fromString("bugfix");
131
132
  }
132
133
 
134
+ // 組み込み判定が構造的に高リスク(domain / api)のカテゴリは override で降格できない。
135
+ // @work-item-id WI-372
136
+ const NON_DOWNGRADABLE_BUILT_IN_CATEGORIES: ReadonlySet<string> = new Set(["domain", "api"]);
137
+
138
+ /**
139
+ * WI-372: `quickMode.categoryOverrides` を反映したカテゴリ判定。
140
+ *
141
+ * override は「このパスはこの種類の変更である」という利用者の明示宣言なので、
142
+ * 組み込みルールより **先** に評価する(DD-1)。組み込みの後段に置くと
143
+ * `notes/x.config.json` のような偶発的な組み込みマッチに常に負け、
144
+ * 「設定したのに効かない」不可解な挙動になるため。
145
+ *
146
+ * ただし組み込み判定が `domain` / `api` のファイルは override で降格できない(DD-2)。
147
+ * `judge()` の NEW_DOMAIN は CREATE のみ、API_CONTRACT は port/adapter のみを見るため、
148
+ * ガードが無いと domain ファイルの MODIFY が `docs` として素通りしてしまう。
149
+ * 降格は禁じるが昇格(domain → api 等)は許す。
150
+ *
151
+ * override 未設定時は組み込み分類と完全に一致する(後方互換)。
152
+ */
153
+ function categorizeFile(file: ChangedFile, overrides: CategoryOverrideRules): ChangeCategory {
154
+ const builtIn = categorizeFileByBuiltInRules(file);
155
+ if (overrides.isEmpty()) {
156
+ return builtIn;
157
+ }
158
+
159
+ const override = overrides.resolve(file.filePath);
160
+ if (override === null) {
161
+ return builtIn;
162
+ }
163
+
164
+ if (NON_DOWNGRADABLE_BUILT_IN_CATEGORIES.has(builtIn.toString())) {
165
+ return override.riskPriority() > builtIn.riskPriority() ? override : builtIn;
166
+ }
167
+
168
+ return override;
169
+ }
170
+
133
171
  /**
134
172
  * WI-349: 遮断理由に判定根拠(分類カテゴリと変更種別)を含める。
135
173
  *
@@ -142,17 +180,18 @@ function describeChangedFile(file: ChangedFile, category: string): string {
142
180
  }
143
181
 
144
182
  export class QuickModeJudgmentEngine {
145
- classify(changedFiles: readonly ChangedFile[], _config?: QuickModeConfig): ChangeClassification {
183
+ classify(changedFiles: readonly ChangedFile[], config?: QuickModeConfig): ChangeClassification {
146
184
  if (changedFiles.length === 0) {
147
185
  return new ChangeClassification(null, new Map(), 0);
148
186
  }
149
187
 
188
+ const overrides = config?.categoryOverrides ?? CategoryOverrideRules.empty();
150
189
  const categorizedMap = new Map<string, ChangedFile[]>();
151
190
  let dominantCategory: ChangeCategory | null = null;
152
191
  let dominantPriority = -1;
153
192
 
154
193
  for (const file of changedFiles) {
155
- const category = categorizeFile(file);
194
+ const category = categorizeFile(file, overrides);
156
195
  const key = category.toString();
157
196
 
158
197
  if (!categorizedMap.has(key)) {
@@ -160,7 +199,7 @@ export class QuickModeJudgmentEngine {
160
199
  }
161
200
  categorizedMap.get(key)!.push(file);
162
201
 
163
- const priority = RISK_PRIORITY[key] ?? 0;
202
+ const priority = category.riskPriority();
164
203
  if (priority > dominantPriority) {
165
204
  dominantPriority = priority;
166
205
  dominantCategory = category;
@@ -173,7 +212,7 @@ export class QuickModeJudgmentEngine {
173
212
  judge(changedFiles: readonly ChangedFile[], config: QuickModeConfig): QuickModeEligibility {
174
213
  const classification = this.classify(changedFiles, config);
175
214
 
176
- // 1. MIXED_CHANGES評価: allowedCategories 外のカテゴリが含まれる場合
215
+ // 1. カテゴリ評価: 不許可カテゴリを含む単一カテゴリ変更と、複数カテゴリ変更を区別する。
177
216
  if (config.isFullModeRequiredFor("mixedCategories")) {
178
217
  const notAllowedFiles: ChangedFile[] = [];
179
218
  const notAllowedDescriptions: string[] = [];
@@ -187,8 +226,9 @@ export class QuickModeJudgmentEngine {
187
226
  });
188
227
 
189
228
  if (notAllowedFiles.length > 0) {
229
+ const rejectionRule = classification.categorizedFiles.size >= 2 ? "MIXED_CHANGES" : "CATEGORY_NOT_ALLOWED";
190
230
  return QuickModeEligibility.rejected(
191
- "MIXED_CHANGES",
231
+ rejectionRule,
192
232
  notAllowedFiles,
193
233
  `allowedCategories外のファイルが含まれています: ${notAllowedDescriptions.join(", ")}`,
194
234
  );
@@ -206,7 +246,7 @@ export class QuickModeJudgmentEngine {
206
246
  "NEW_DOMAIN",
207
247
  newDomainFiles,
208
248
  `domain/配下に新規ファイルが追加されています: ${newDomainFiles
209
- .map((f) => describeChangedFile(f, categorizeFile(f).toString()))
249
+ .map((f) => describeChangedFile(f, categorizeFile(f, config.categoryOverrides).toString()))
210
250
  .join(", ")}`,
211
251
  );
212
252
  }
@@ -223,7 +263,7 @@ export class QuickModeJudgmentEngine {
223
263
  "API_CONTRACT",
224
264
  apiContractFiles,
225
265
  `Port/Adapterインターフェースファイルの変更が含まれています: ${apiContractFiles
226
- .map((f) => describeChangedFile(f, categorizeFile(f).toString()))
266
+ .map((f) => describeChangedFile(f, categorizeFile(f, config.categoryOverrides).toString()))
227
267
  .join(", ")}`,
228
268
  );
229
269
  }
@@ -5,4 +5,4 @@
5
5
  * Quick Mode拒否ルール識別子
6
6
  */
7
7
 
8
- export type RejectionRule = 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
8
+ export type RejectionRule = 'CATEGORY_NOT_ALLOWED' | 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @layer domain
3
+ * @unit quick-mode
4
+ * @work-item-id WI-372
5
+ *
6
+ * `quickMode.categoryOverrides` を表す値オブジェクト。
7
+ * ChangeCategory 7 値をキー、glob パターン列を値とする写像で、
8
+ * プロジェクト固有パス(`results/**` 等)を任意カテゴリへ割り当てる。
9
+ */
10
+
11
+ import { QuickModeConfigError } from '../errors/quick-mode-config-error.js';
12
+ import {
13
+ CHANGE_CATEGORY_VALUES,
14
+ ChangeCategory,
15
+ type ChangeCategoryValue,
16
+ isChangeCategoryValue,
17
+ } from './change-category.js';
18
+
19
+ /**
20
+ * glob → 正規表現の変換。
21
+ *
22
+ * domain 層は repo 全体で外部 npm パッケージを import していないため
23
+ * (picomatch は infrastructure adapter 専用)、
24
+ * `agent-integration/domain/value-objects/protected-file-list.ts` と同型の
25
+ * 純正規表現実装を採る(logical_design.md LD-10)。
26
+ *
27
+ * サポートする記法:
28
+ * - `**` … `/` を含む任意の文字列
29
+ * - `*` … `/` を含まない任意の文字列
30
+ * - `?` … `/` 以外の 1 文字
31
+ * - その他はリテラル(正規表現メタ文字はエスケープ)
32
+ */
33
+ function globToRegExp(pattern: string): RegExp {
34
+ const regexSource = pattern
35
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
36
+ .replace(/\*\*/g, '__DOUBLE_STAR__')
37
+ .replace(/\*/g, '[^/]*')
38
+ .replace(/__DOUBLE_STAR__/g, '.*')
39
+ .replace(/\?/g, '[^/]');
40
+ return new RegExp(`^${regexSource}$`);
41
+ }
42
+
43
+ export type CategoryOverrideRulesInput = Readonly<Record<string, readonly string[]>>;
44
+
45
+ export class CategoryOverrideRules {
46
+ private readonly patterns: ReadonlyMap<ChangeCategoryValue, readonly string[]>;
47
+ private readonly matchers: ReadonlyMap<ChangeCategoryValue, readonly RegExp[]>;
48
+
49
+ private constructor(
50
+ patterns: ReadonlyMap<ChangeCategoryValue, readonly string[]>,
51
+ matchers: ReadonlyMap<ChangeCategoryValue, readonly RegExp[]>,
52
+ ) {
53
+ this.patterns = patterns;
54
+ this.matchers = matchers;
55
+ Object.freeze(this);
56
+ }
57
+
58
+ static empty(): CategoryOverrideRules {
59
+ return new CategoryOverrideRules(new Map(), new Map());
60
+ }
61
+
62
+ static create(raw: CategoryOverrideRulesInput | undefined | null): CategoryOverrideRules {
63
+ if (raw === undefined || raw === null) {
64
+ return CategoryOverrideRules.empty();
65
+ }
66
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
67
+ throw new QuickModeConfigError('categoryOverrides must be an object keyed by change category');
68
+ }
69
+
70
+ const patterns = new Map<ChangeCategoryValue, readonly string[]>();
71
+ const matchers = new Map<ChangeCategoryValue, readonly RegExp[]>();
72
+
73
+ for (const [key, value] of Object.entries(raw)) {
74
+ if (!isChangeCategoryValue(key)) {
75
+ throw new QuickModeConfigError(
76
+ `categoryOverrides contains unknown category "${key}". Valid values: ${CHANGE_CATEGORY_VALUES.join(', ')}`,
77
+ );
78
+ }
79
+ if (!Array.isArray(value)) {
80
+ throw new QuickModeConfigError(`categoryOverrides.${key} must be an array of glob patterns`);
81
+ }
82
+ for (const pattern of value) {
83
+ if (typeof pattern !== 'string' || pattern.length === 0) {
84
+ throw new QuickModeConfigError(
85
+ `categoryOverrides.${key} must not contain an empty or non-string glob pattern`,
86
+ );
87
+ }
88
+ }
89
+ if (value.length === 0) {
90
+ continue;
91
+ }
92
+ patterns.set(key, Object.freeze([...value]));
93
+ matchers.set(key, Object.freeze(value.map(globToRegExp)));
94
+ }
95
+
96
+ return new CategoryOverrideRules(patterns, matchers);
97
+ }
98
+
99
+ isEmpty(): boolean {
100
+ return this.matchers.size === 0;
101
+ }
102
+
103
+ /**
104
+ * パスに対応する override カテゴリを返す。
105
+ * 一致が無ければ null(組み込み分類へ委譲)。
106
+ * 複数カテゴリに一致した場合はリスク優先度が最も高いカテゴリを返すため、
107
+ * JSON のキー列挙順に依存しない決定的な結果になる(domain_model.md DD-4)。
108
+ */
109
+ resolve(filePath: string): ChangeCategory | null {
110
+ if (filePath === '' || this.matchers.size === 0) {
111
+ return null;
112
+ }
113
+
114
+ let resolved: ChangeCategory | null = null;
115
+ let resolvedPriority = -1;
116
+
117
+ for (const [category, regexps] of this.matchers) {
118
+ if (!regexps.some((regexp) => regexp.test(filePath))) {
119
+ continue;
120
+ }
121
+ const candidate = ChangeCategory.fromString(category);
122
+ const priority = candidate.riskPriority();
123
+ if (priority > resolvedPriority) {
124
+ resolvedPriority = priority;
125
+ resolved = candidate;
126
+ }
127
+ }
128
+
129
+ return resolved;
130
+ }
131
+
132
+ /** キーは ChangeCategory 語彙順に正規化する(equals をキー順に依存させないため) */
133
+ toRecord(): Readonly<Record<string, readonly string[]>> {
134
+ const record: Record<string, readonly string[]> = {};
135
+ for (const category of CHANGE_CATEGORY_VALUES) {
136
+ const globs = this.patterns.get(category);
137
+ if (globs !== undefined) {
138
+ record[category] = globs;
139
+ }
140
+ }
141
+ return Object.freeze(record);
142
+ }
143
+
144
+ equals(other: CategoryOverrideRules): boolean {
145
+ return JSON.stringify(this.toRecord()) === JSON.stringify(other.toRecord());
146
+ }
147
+ }
@@ -24,6 +24,31 @@ const VALID_CATEGORIES: readonly ChangeCategoryValue[] = [
24
24
  'api',
25
25
  ] as const;
26
26
 
27
+ /**
28
+ * ChangeCategory の語彙。config の enum 検証や categoryOverrides の
29
+ * キー検証はこの定義を唯一の権威として参照する。
30
+ * @work-item-id WI-372
31
+ */
32
+ export const CHANGE_CATEGORY_VALUES: readonly ChangeCategoryValue[] = VALID_CATEGORIES;
33
+
34
+ /** 与えられた文字列が ChangeCategory の語彙に含まれるか(正規化はしない) */
35
+ export function isChangeCategoryValue(raw: string): raw is ChangeCategoryValue {
36
+ return VALID_CATEGORIES.includes(raw as ChangeCategoryValue);
37
+ }
38
+
39
+ // リスク順優先度(api > domain > feature > bugfix > test > config > docs)。
40
+ // 複数の分類条件に一致した場合に、より高リスク側へ倒すための順序定義。
41
+ // @work-item-id WI-372
42
+ const RISK_PRIORITY: Record<ChangeCategoryValue, number> = {
43
+ api: 6,
44
+ domain: 5,
45
+ feature: 4,
46
+ bugfix: 3,
47
+ test: 2,
48
+ config: 1,
49
+ docs: 0,
50
+ };
51
+
27
52
  export class UnknownChangeCategoryError extends Error {
28
53
  constructor(raw: string) {
29
54
  super(`Unknown change category: "${raw}". Valid values: ${VALID_CATEGORIES.join(', ')}`);
@@ -50,6 +75,14 @@ export class ChangeCategory {
50
75
  return this.value === 'domain' || this.value === 'feature' || this.value === 'api';
51
76
  }
52
77
 
78
+ /**
79
+ * リスク優先度。値が大きいほど高リスク(api=6 ... docs=0)。
80
+ * @work-item-id WI-372
81
+ */
82
+ riskPriority(): number {
83
+ return RISK_PRIORITY[this.value];
84
+ }
85
+
53
86
  toString(): string {
54
87
  return this.value;
55
88
  }
@@ -5,12 +5,14 @@
5
5
  * Quick Mode設定を表す値オブジェクト
6
6
  */
7
7
 
8
- export class QuickModeConfigError extends Error {
9
- constructor(message: string) {
10
- super(message);
11
- this.name = 'QuickModeConfigError';
12
- }
13
- }
8
+ import { QuickModeConfigError } from '../errors/quick-mode-config-error.js';
9
+ import {
10
+ CategoryOverrideRules,
11
+ type CategoryOverrideRulesInput,
12
+ } from './category-override-rules.js';
13
+ import { CHANGE_CATEGORY_VALUES, isChangeCategoryValue } from './change-category.js';
14
+
15
+ export { QuickModeConfigError };
14
16
 
15
17
  export type FullModeRequiredRuleId = 'mixedCategories' | 'newDomainFile' | 'apiContractChange';
16
18
 
@@ -32,17 +34,21 @@ export class QuickModeConfig {
32
34
  readonly maintainedLayers: readonly string[];
33
35
  readonly relaxedGates: readonly string[];
34
36
  readonly fullModeRequiredWhen: FullModeRequiredRules;
37
+ /** @work-item-id WI-372 プロジェクト固有パスのカテゴリ割当ルール(未設定時は空) */
38
+ readonly categoryOverrides: CategoryOverrideRules;
35
39
 
36
40
  private constructor(
37
41
  allowedCategories: readonly string[],
38
42
  maintainedLayers: readonly string[],
39
43
  relaxedGates: readonly string[],
40
- fullModeRequiredWhen: FullModeRequiredRules
44
+ fullModeRequiredWhen: FullModeRequiredRules,
45
+ categoryOverrides: CategoryOverrideRules
41
46
  ) {
42
47
  this.allowedCategories = allowedCategories;
43
48
  this.maintainedLayers = maintainedLayers;
44
49
  this.relaxedGates = relaxedGates;
45
50
  this.fullModeRequiredWhen = fullModeRequiredWhen;
51
+ this.categoryOverrides = categoryOverrides;
46
52
  Object.freeze(this);
47
53
  }
48
54
 
@@ -51,13 +57,26 @@ export class QuickModeConfig {
51
57
  maintainedLayers: string[];
52
58
  relaxedGates: string[];
53
59
  fullModeRequiredWhen?: Partial<FullModeRequiredRules>;
60
+ categoryOverrides?: CategoryOverrideRulesInput;
54
61
  }): QuickModeConfig {
55
- const { allowedCategories, maintainedLayers, relaxedGates, fullModeRequiredWhen } = raw;
62
+ const { allowedCategories, maintainedLayers, relaxedGates, fullModeRequiredWhen, categoryOverrides } = raw;
56
63
 
57
64
  if (allowedCategories.length === 0) {
58
65
  throw new QuickModeConfigError('allowedCategories must not be empty');
59
66
  }
60
67
 
68
+ // WI-373: allowedCategories は ChangeCategory 7 値の enum。
69
+ // 従来は非空チェックのみだったため "typoo" のような未知値が黙って通り、
70
+ // 「設定したのに効かない」状態になっていた。分類結果のキーは常に小文字なので
71
+ // 大文字小文字の正規化はせず厳密一致で拒否する(正規化は効かない設定の黙認になる)。
72
+ for (const category of allowedCategories) {
73
+ if (!isChangeCategoryValue(category)) {
74
+ throw new QuickModeConfigError(
75
+ `allowedCategories contains unknown category "${category}". Valid values: ${CHANGE_CATEGORY_VALUES.join(', ')}`
76
+ );
77
+ }
78
+ }
79
+
61
80
  const mergedRules: FullModeRequiredRules = Object.freeze({
62
81
  mixedCategories: fullModeRequiredWhen?.mixedCategories ?? DEFAULT_FULL_MODE_REQUIRED_WHEN.mixedCategories,
63
82
  newDomainFile: fullModeRequiredWhen?.newDomainFile ?? DEFAULT_FULL_MODE_REQUIRED_WHEN.newDomainFile,
@@ -68,7 +87,8 @@ export class QuickModeConfig {
68
87
  Object.freeze([...allowedCategories]),
69
88
  Object.freeze([...maintainedLayers]),
70
89
  Object.freeze([...relaxedGates]),
71
- mergedRules
90
+ mergedRules,
91
+ CategoryOverrideRules.create(categoryOverrides)
72
92
  );
73
93
  }
74
94
 
@@ -100,7 +120,8 @@ export class QuickModeConfig {
100
120
  JSON.stringify(this.relaxedGates) === JSON.stringify(other.relaxedGates) &&
101
121
  this.fullModeRequiredWhen.mixedCategories === other.fullModeRequiredWhen.mixedCategories &&
102
122
  this.fullModeRequiredWhen.newDomainFile === other.fullModeRequiredWhen.newDomainFile &&
103
- this.fullModeRequiredWhen.apiContractChange === other.fullModeRequiredWhen.apiContractChange
123
+ this.fullModeRequiredWhen.apiContractChange === other.fullModeRequiredWhen.apiContractChange &&
124
+ this.categoryOverrides.equals(other.categoryOverrides)
104
125
  );
105
126
  }
106
127
  }
@@ -1,7 +1,13 @@
1
1
  // @unit quick-mode
2
2
  // @layer application
3
3
 
4
- export { ChangeCategory, UnknownChangeCategoryError } from './domain/value-objects/change-category.js';
4
+ export {
5
+ CHANGE_CATEGORY_VALUES,
6
+ ChangeCategory,
7
+ isChangeCategoryValue,
8
+ UnknownChangeCategoryError,
9
+ } from './domain/value-objects/change-category.js';
10
+ export { CategoryOverrideRules } from './domain/value-objects/category-override-rules.js';
5
11
  export { ChangedFile } from './domain/value-objects/changed-file.js';
6
12
  export { ChangeClassification } from './domain/value-objects/change-classification.js';
7
13
  export { QuickModeConfig, QuickModeConfigError } from './domain/value-objects/quick-mode-config.js';