phasegate 0.283.0 → 0.315.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 (71) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/ADR/017-warning-severity-aggregation.md +17 -0
  3. package/docs/ADR/038-config-state-operation-permission-policy.md +78 -0
  4. package/docs/guide/layer-model.md +2 -0
  5. package/docs/guide/quick-vs-full-mode.md +28 -3
  6. package/docs/guide/troubleshooting.md +37 -0
  7. package/docs/templates/agent-context/CLAUDE.md.template.md +6 -6
  8. package/package.json +1 -1
  9. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +98 -18
  10. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +25 -3
  11. package/scripts/harness/agent-integration/infrastructure/adapters/file-system-full-mode-session-query-adapter.ts +75 -23
  12. package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +18 -3
  13. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +51 -6
  14. package/scripts/harness/ci-governance/composition-root.ts +2 -2
  15. package/scripts/harness/ci-governance/domain/services/claude-md-composer.ts +20 -11
  16. package/scripts/harness/ci-governance/presentation/handlers/check-repetition-handler.ts +10 -2
  17. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +1 -1
  18. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +1 -1
  19. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +1 -1
  20. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +12 -3
  21. package/scripts/harness/harness-api/domain/value-objects/ci-check-result.ts +2 -22
  22. package/scripts/harness/harness-error/application/dto/create-harness-error-input.ts +3 -1
  23. package/scripts/harness/harness-error/application/dto/harness-error-contract.ts +3 -1
  24. package/scripts/harness/harness-error/application/mappers/harness-error-contract-mapper.ts +9 -17
  25. package/scripts/harness/harness-error/application/usecases/create-harness-error-use-case.ts +7 -7
  26. package/scripts/harness/harness-error/domain/services/harness-error-factory.ts +30 -33
  27. package/scripts/harness/harness-error/domain/value-objects/error-definition.ts +28 -17
  28. package/scripts/harness/harness-error/domain/value-objects/harness-error.ts +30 -9
  29. package/scripts/harness/harness-error/domain/value-objects/remediation-type.ts +30 -0
  30. package/scripts/harness/harness-error/infrastructure/registry/l2-error-definitions.ts +44 -29
  31. package/scripts/harness/harness-error/infrastructure/registry/l3-error-definitions.ts +44 -27
  32. package/scripts/harness/harness-error/infrastructure/registry/l4-error-definitions.ts +47 -32
  33. package/scripts/harness/installation/application/checks/claude-context-missing-check.ts +13 -7
  34. package/scripts/harness/installation/application/checks/config-status-check.ts +52 -0
  35. package/scripts/harness/installation/application/checks/husky-pre-commit-missing-check.ts +6 -0
  36. package/scripts/harness/installation/application/ports/config-status-probe-port.ts +9 -0
  37. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +30 -8
  38. package/scripts/harness/installation/application/usecases/run-install.ts +44 -11
  39. package/scripts/harness/installation/application/usecases/run-reconcile.ts +44 -11
  40. package/scripts/harness/installation/composition-root.ts +13 -3
  41. package/scripts/harness/installation/domain/check-id.ts +2 -0
  42. package/scripts/harness/installation/domain/config-status.ts +17 -0
  43. package/scripts/harness/installation/domain/ports/heuristic-check.ts +10 -1
  44. package/scripts/harness/installation/infrastructure/adapters/config-status-probe-adapter.ts +79 -0
  45. package/scripts/harness/installation/presentation/cli/doctor-handler.ts +6 -1
  46. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +19 -5
  47. package/scripts/harness/integrations/pre-commit.ts +17 -3
  48. package/scripts/harness/main.ts +57 -9
  49. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/markdown-plan-document-reader.ts +60 -32
  50. package/scripts/harness/phase2-extensions/presentation/handlers/check-freshness-handler.ts +17 -9
  51. package/scripts/harness/quick-mode/application/ports/file-existence-port.ts +15 -0
  52. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +60 -22
  53. package/scripts/harness/quick-mode/composition-root.ts +25 -15
  54. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +72 -3
  55. package/scripts/harness/quick-mode/infrastructure/adapters/fs-file-existence-adapter.ts +38 -0
  56. package/scripts/harness/skill-quality/infrastructure/adapters/file-system-requirement-test-matrix-adapter.ts +51 -8
  57. package/scripts/harness/skill-quality/presentation/handlers/check-coverage-handler.ts +13 -6
  58. package/scripts/harness/traceability-model/domain/value-objects/work-item-frontmatter.ts +5 -1
  59. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-identity-gateway.ts +6 -1
  60. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +15 -2
  61. package/scripts/harness/validator-system/application/use-cases/aggregate-validation-results-usecase.ts +11 -14
  62. package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +8 -0
  63. package/scripts/harness/validator-system/domain/services/effective-severity-policy.ts +39 -0
  64. package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +6 -4
  65. package/scripts/harness/validator-system/domain/value-objects/drift-report.ts +12 -7
  66. package/scripts/harness/validator-system/domain/value-objects/validation-result.ts +11 -3
  67. package/scripts/harness/validator-system/infrastructure/adapters/file-system-security-pattern-scanner-adapter.ts +20 -17
  68. package/scripts/harness/validator-system/infrastructure/adapters/phase-dependency-phase-gate-policy-adapter.ts +35 -14
  69. package/scripts/harness/validator-system/infrastructure/adapters/traceability-metadata-policy-adapter.ts +13 -8
  70. package/scripts/harness/validator-system/presentation/formatters/agent-validation-result-formatter.ts +19 -10
  71. package/skills/quick-implementor/SKILL.md +19 -0
@@ -0,0 +1,79 @@
1
+ // @unit installation
2
+ // @layer infrastructure
3
+ // @work-item-id WI-330
4
+
5
+ import { access } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { createConfigFoundationModule } from "../../../config-foundation/composition-root.js";
8
+ import { ConfigValidationError } from "../../../config-foundation/domain/errors/config-validation-error.js";
9
+ import {
10
+ ConfigNotFoundError,
11
+ ConfigParseError,
12
+ } from "../../../config-foundation/infrastructure/repositories/file-system-config-repository.js";
13
+ import type { ConfigStatusProbePort } from "../../application/ports/config-status-probe-port.js";
14
+ import type { ConfigStatusProbeResult } from "../../domain/config-status.js";
15
+
16
+ const PROJECT_CONFIG_PATH = "phasegate.config.json";
17
+ const PERSONAL_CONFIG_PATH = join(".phasegate-local", "phasegate.config.json");
18
+
19
+ async function exists(targetPath: string): Promise<boolean> {
20
+ try {
21
+ await access(targetPath);
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * WI-330: doctor 用の config 状態 probe。
30
+ *
31
+ * FileSystemConfigRepository と同じ候補順(project 直下 → .phasegate-local/ の personal install)で
32
+ * projectRoot 直下のみを解決する。cwd からの上方探索は行わない — doctor は「このプロジェクトの
33
+ * config」を診断するため、親ディレクトリの config を拾うと診断が偽装される。
34
+ * 妥当性判定は config-foundation の実 load 経路(JSON parse + AJV schema + preset 解決)を
35
+ * そのまま使い、CLI 本体と同じ基準で invalid を検出する。
36
+ */
37
+ export class ConfigStatusProbeAdapter implements ConfigStatusProbePort {
38
+ private readonly cache = new Map<string, Promise<ConfigStatusProbeResult>>();
39
+
40
+ probe(projectRoot: string): Promise<ConfigStatusProbeResult> {
41
+ const cached = this.cache.get(projectRoot);
42
+ if (cached !== undefined) {
43
+ return cached;
44
+ }
45
+ const inspected = this.inspect(projectRoot);
46
+ this.cache.set(projectRoot, inspected);
47
+ return inspected;
48
+ }
49
+
50
+ private async inspect(projectRoot: string): Promise<ConfigStatusProbeResult> {
51
+ for (const relativePath of [PROJECT_CONFIG_PATH, PERSONAL_CONFIG_PATH]) {
52
+ const absolutePath = join(projectRoot, relativePath);
53
+ if (await exists(absolutePath)) {
54
+ return await this.classify(absolutePath, relativePath);
55
+ }
56
+ }
57
+ return { status: "missing", configPath: PROJECT_CONFIG_PATH, detail: null };
58
+ }
59
+
60
+ private async classify(absolutePath: string, relativePath: string): Promise<ConfigStatusProbeResult> {
61
+ try {
62
+ await createConfigFoundationModule().usecases.loadResolvedConfigUseCase.execute(absolutePath);
63
+ return { status: "valid", configPath: relativePath, detail: null };
64
+ } catch (error) {
65
+ if (error instanceof ConfigNotFoundError) {
66
+ return { status: "missing", configPath: relativePath, detail: null };
67
+ }
68
+ if (error instanceof ConfigParseError) {
69
+ const detail = error.cause instanceof Error ? error.cause.message : error.message;
70
+ return { status: "invalid-json", configPath: relativePath, detail };
71
+ }
72
+ if (error instanceof ConfigValidationError) {
73
+ return { status: "invalid-schema", configPath: relativePath, detail: error.message };
74
+ }
75
+ const detail = error instanceof Error ? error.message : String(error);
76
+ return { status: "invalid-schema", configPath: relativePath, detail };
77
+ }
78
+ }
79
+ }
@@ -3,10 +3,14 @@
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-178
5
5
  // @work-item-id WI-208
6
+ // @work-item-id WI-330
6
7
 
7
8
  import { mkdir, writeFile } from "node:fs/promises";
8
9
  import { dirname, isAbsolute, join } from "node:path";
9
- import type { DoctorAgentScope, RunDoctorDiagnosticsUseCase } from "../../application/usecases/run-doctor-diagnostics.js";
10
+ import type {
11
+ DoctorAgentScope,
12
+ RunDoctorDiagnosticsUseCase,
13
+ } from "../../application/usecases/run-doctor-diagnostics.js";
10
14
  import { DiagnosticReportFormatter } from "../formatters/diagnostic-report-formatter.js";
11
15
 
12
16
  export interface DoctorHandlerInput {
@@ -39,6 +43,7 @@ export class DoctorHandler {
39
43
  report: result.report,
40
44
  agent: result.agent,
41
45
  installationMode: result.installationMode,
46
+ configStatus: result.configStatus,
42
47
  scopedOutFindings: result.scopedOutFindings,
43
48
  phasegateVersion: input.phasegateVersion,
44
49
  projectRoot: input.projectRoot,
@@ -5,14 +5,20 @@
5
5
  // @work-item-id WI-179
6
6
  // @work-item-id WI-180
7
7
  // @work-item-id WI-208
8
+ // @work-item-id WI-330
8
9
 
9
- import type { DoctorAgentScope, ScopedOutDiagnosticFinding } from "../../application/usecases/run-doctor-diagnostics.js";
10
+ import type {
11
+ DoctorAgentScope,
12
+ ScopedOutDiagnosticFinding,
13
+ } from "../../application/usecases/run-doctor-diagnostics.js";
14
+ import type { ConfigStatus } from "../../domain/config-status.js";
10
15
  import type { DiagnosticReport } from "../../domain/diagnostic-report.js";
11
16
 
12
17
  export interface DiagnosticReportFormatterInput {
13
18
  readonly report: DiagnosticReport;
14
19
  readonly agent: DoctorAgentScope;
15
20
  readonly installationMode: "project" | "personal";
21
+ readonly configStatus: ConfigStatus;
16
22
  readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
17
23
  readonly phasegateVersion: string;
18
24
  readonly projectRoot: string;
@@ -32,6 +38,7 @@ export class DiagnosticReportFormatter {
32
38
  description: scopeDescription(input.agent, input.installationMode),
33
39
  },
34
40
  overallStatus: input.report.overallStatus,
41
+ configStatus: input.configStatus,
35
42
  findings: input.report.findings.map((finding) => ({
36
43
  ...finding.toJSON(),
37
44
  applicability: "applicable",
@@ -64,6 +71,7 @@ export class DiagnosticReportFormatter {
64
71
  `phasegate doctor v${input.phasegateVersion}`,
65
72
  `Project: ${input.projectRoot}`,
66
73
  `Scope: ${input.agent} / ${input.installationMode} (${scopeDescription(input.agent, input.installationMode)})`,
74
+ `Config: ${input.configStatus}`,
67
75
  "",
68
76
  ];
69
77
  for (const finding of input.report.findings) {
@@ -79,10 +87,14 @@ export class DiagnosticReportFormatter {
79
87
  }
80
88
  const redCount = input.report.findings.filter((finding) => finding.severity === "red").length;
81
89
  const warnCount = input.report.findings.filter((finding) => finding.severity === "warn").length;
82
- lines.push(`Status: ${input.report.overallStatus.toUpperCase()} (${input.report.findings.length} findings: ${redCount} red, ${warnCount} warn)`);
90
+ lines.push(
91
+ `Status: ${input.report.overallStatus.toUpperCase()} (${input.report.findings.length} findings: ${redCount} red, ${warnCount} warn)`,
92
+ );
83
93
  if (input.scopedOutFindings.length > 0) {
84
94
  const checkIds = input.scopedOutFindings.map(({ finding }) => finding.checkId).join(", ");
85
- lines.push(`Scoped out: ${input.scopedOutFindings.length} informational findings not applicable to --agent ${input.agent}; not repair targets for this scope: ${checkIds}.`);
95
+ lines.push(
96
+ `Scoped out: ${input.scopedOutFindings.length} informational findings not applicable to --agent ${input.agent}; not repair targets for this scope: ${checkIds}.`,
97
+ );
86
98
  }
87
99
  lines.push(`Exit: ${input.exitCode}`);
88
100
  return lines.join("\n");
@@ -91,8 +103,10 @@ export class DiagnosticReportFormatter {
91
103
 
92
104
  function scopeDescription(agent: DoctorAgentScope, installationMode: "project" | "personal"): string {
93
105
  if (installationMode === "personal") {
94
- if (agent === "claude") return "Personal Claude Code sandbox; team/project Husky, CI, package, and Codex-only findings are not repair targets.";
95
- if (agent === "codex") return "Personal Codex sandbox; team/project Husky, CI, package, and Claude-only findings are not repair targets.";
106
+ if (agent === "claude")
107
+ return "Personal Claude Code sandbox; team/project Husky, CI, package, and Codex-only findings are not repair targets.";
108
+ if (agent === "codex")
109
+ return "Personal Codex sandbox; team/project Husky, CI, package, and Claude-only findings are not repair targets.";
96
110
  return "Personal sandbox diagnostics; team/project Husky, CI, and package findings are not repair targets.";
97
111
  }
98
112
  if (agent === "claude") return "Claude Code and shared setup targets; Codex-only findings are not applicable.";
@@ -5,6 +5,7 @@
5
5
  * @work-item-id WI-109
6
6
  * @work-item-id WI-189
7
7
  * @work-item-id WI-305
8
+ * @work-item-id WI-332
8
9
  *
9
10
  * Pre-commit CLI entry.
10
11
  * Runs L2 validators against staged TypeScript files AND design-document
@@ -27,6 +28,7 @@ import type { ValidateMetadataCommandOutput } from "../traceability-model/presen
27
28
  import type { AggregatedValidationReport } from "../validator-system/application/dto/aggregated-validation-report.js";
28
29
  import type { ValidationResultContract } from "../validator-system/application/dto/validation-result-contract.js";
29
30
  import { createValidatorSystemModule } from "../validator-system/composition-root.js";
31
+ import { isEffectivelyPassed } from "../validator-system/domain/services/effective-severity-policy.js";
30
32
  import { HumanValidationResultFormatter } from "../validator-system/presentation/formatters/human-validation-result-formatter.js";
31
33
  import { createWorldModelModule } from "../world-model/index.js";
32
34
 
@@ -293,9 +295,19 @@ function getStagedDesignChangeFiles(): string[] {
293
295
  }
294
296
  }
295
297
 
298
+ /**
299
+ * WI-332 / ADR-017: pre-commit 経路には config の failOnWarning 配線が存在しないため、
300
+ * ADR-017 の既定値 (false) に固定する。validate.failOnWarning を pre-commit にも効かせる
301
+ * 判断をする場合は、この定数を options 経由の配線に置き換えること。
302
+ */
303
+ const PRE_COMMIT_FAIL_ON_WARNING = false;
304
+
296
305
  function buildReport(results: readonly ValidationResultContract[]): AggregatedValidationReport {
297
- const passed = results.filter((r) => r.passed && !r.skipped).length;
298
- const failed = results.filter((r) => !r.passed && !r.skipped).length;
306
+ // WI-332 / ADR-017: 手動集約 (`failed === 0` の raw passed 判定) をやめ、
307
+ // validate / ci-check / complete-check と同じ共有実効判定 isEffectivelyPassed を通す。
308
+ // warning-only の validator failure は既定で effectively passed = exit 0。
309
+ const passed = results.filter((r) => !r.skipped && isEffectivelyPassed(r, PRE_COMMIT_FAIL_ON_WARNING)).length;
310
+ const failed = results.filter((r) => !r.skipped && !isEffectivelyPassed(r, PRE_COMMIT_FAIL_ON_WARNING)).length;
299
311
  const skipped = results.filter((r) => r.skipped).length;
300
312
  const allErrors = results.flatMap((r) => r.errors);
301
313
  const errorCount = allErrors.filter((e) => e.severity === "error").length;
@@ -452,7 +464,9 @@ export async function validateBypassTrailers(
452
464
  }
453
465
 
454
466
  function classifyValidatorFailure(result: ValidationResultContract): BypassBlockerClass | undefined {
455
- if (result.passed || result.skipped) return undefined;
467
+ // WI-332: blocker 分類も buildReport と同じ実効判定を通す。warning-only failure は
468
+ // exit 0 (effectively passed) なので、bypass audit の blocker としても数えない。
469
+ if (isEffectivelyPassed(result, PRE_COMMIT_FAIL_ON_WARNING)) return undefined;
456
470
  const nonBypassable = NON_BYPASSABLE_VALIDATOR_IDS.includes(result.validatorId);
457
471
  return {
458
472
  code: result.validatorId,
@@ -41,7 +41,7 @@ import {
41
41
  rm as fsRm,
42
42
  writeFile as fsWriteFile,
43
43
  } from "node:fs/promises";
44
- import { dirname, join, resolve } from "node:path";
44
+ import { basename, dirname, join, resolve } from "node:path";
45
45
  import { fileURLToPath } from "node:url";
46
46
  import { createAdrFoundationModule } from "./adr-foundation/composition-root.js";
47
47
  import { createBiomeAstEngineModule } from "./biome-ast-engine/composition-root.js";
@@ -75,7 +75,7 @@ import {
75
75
  import { FileSystemStoryReflectionAdapter } from "./phase-dependency-model/infrastructure/filesystem/file-system-story-reflection-adapter.js";
76
76
  import { StoryReflectionStatusPresenter } from "./phase-dependency-model/presentation/cli/story-reflection-status-presenter.js";
77
77
  import { buildPhase2Extensions } from "./phase2-extensions/composition-root.js";
78
- import { createQuickModeCompositionRoot } from "./quick-mode/composition-root.js";
78
+ import { createQuickModeCompositionRoot, type QuickModeCompositionRootOptions } from "./quick-mode/composition-root.js";
79
79
  import { buildRegressionSuite } from "./regression-suite/composition-root.js";
80
80
  import type { SkillSet } from "./setup/skill-deployer.js";
81
81
  import {
@@ -228,7 +228,7 @@ Commands:
228
228
 
229
229
  lint Run lint checks (--json, --target <path>)
230
230
 
231
- validate Run validators (--layer L2|L3|L4|all; L0 prints runtime hook info, --unit, --format human|agent|ci|json, --json)
231
+ validate Run validators (--layer L0|L2|L3|L4|all; L0 prints runtime hook info, --unit, --format human|agent|ci|json, --json)
232
232
  ci-check CI check (--quick for quick mode, --fail-on-reject, --dry-run, --files)
233
233
  check-change-category Classify changed paths for quick mode (--paths <csv>, --format human|json)
234
234
 
@@ -501,6 +501,15 @@ function parseValidateFormat(args: readonly string[]): "human" | "agent" | "ci"
501
501
  throw new Error(`Invalid --format value for validate: '${raw}'. Supported values: human, agent, ci, json.`);
502
502
  }
503
503
 
504
+ function parseValidateLayer(args: readonly string[]): "L0" | "L2" | "L3" | "L4" | "all" | undefined {
505
+ const raw = parseFlag(args, "--layer");
506
+ if (raw === undefined) return undefined;
507
+ if (raw === "L0" || raw === "L2" || raw === "L3" || raw === "L4" || raw === "all") return raw;
508
+
509
+ const lintGuidance = raw === "L1" ? "\nL1 は `npx phasegate lint` で実行してください。" : "";
510
+ throw new Error(`不正な --layer 値: ${raw}\n有効値: L0, L2, L3, L4, all${lintGuidance}`);
511
+ }
512
+
504
513
  function levenshtein(a: string, b: string): number {
505
514
  const m = a.length;
506
515
  const n = b.length;
@@ -553,12 +562,25 @@ function validateKnownFlags(args: readonly string[], known: readonly string[]):
553
562
  return null;
554
563
  }
555
564
 
565
+ /**
566
+ * Full Mode session が許可する変更カテゴリ。
567
+ *
568
+ * WI-348: 照合相手は quick-mode の `ChangeCategoryValue`
569
+ * (`bugfix | docs | test | config | feature | domain | api`) であり、
570
+ * レイヤー名(application / infrastructure / presentation)ではない。
571
+ * 旧実装はレイヤー名語彙を書き出していたため、交差が domain / config のみとなり
572
+ * session を張っても feature / api の書き込みがブロックされ続けていた。
573
+ * ChangeCategory の全語彙を許可し、session のスコープ制御は
574
+ * unit / 期限 / target path 側の判定に委ねる。
575
+ */
556
576
  const FULL_MODE_SESSION_ALLOWED_CATEGORIES = Object.freeze([
557
- "domain",
558
- "application",
559
- "infrastructure",
560
- "presentation",
577
+ "bugfix",
578
+ "docs",
579
+ "test",
561
580
  "config",
581
+ "feature",
582
+ "domain",
583
+ "api",
562
584
  ]);
563
585
 
564
586
  interface FullModeSessionFile {
@@ -1901,6 +1923,32 @@ async function loadResolvedConfig(command?: string): Promise<HarnessConfigV2 | u
1901
1923
  }
1902
1924
  }
1903
1925
 
1926
+ /**
1927
+ * WI-351: quick-mode composition root へ解決済み configPath / rootDir を注入する。
1928
+ *
1929
+ * 無指定だと `HarnessConfigQuickModeConfigAdapter` は `process.cwd()/phasegate.config.json`、
1930
+ * `FsFileExistenceAdapter` は `process.cwd()` を基準にする。サブディレクトリから CLI を
1931
+ * 実行すると config を見失い、相対パスの存在判定(= CREATE/MODIFY 推定)も
1932
+ * プロジェクトルート基準の hook とずれて分類結果が食い違う。
1933
+ * config-foundation が上方探索で解決した sourcePath を基準に揃える
1934
+ * (hook 側で WI-346 が行ったのと同じ整合)。
1935
+ * config 未検出・不正時は従来どおり cwd 基準へフォールバックする(fail-open)。
1936
+ */
1937
+ async function resolveQuickModeCompositionOptions(): Promise<QuickModeCompositionRootOptions> {
1938
+ try {
1939
+ const configModule = createConfigFoundationModule();
1940
+ const { sourcePath } = await configModule.usecases.loadResolvedConfigUseCase.execute();
1941
+ if (typeof sourcePath === "string" && sourcePath !== "") {
1942
+ const configDir = dirname(sourcePath);
1943
+ const rootDir = basename(configDir) === ".phasegate-local" ? dirname(configDir) : configDir;
1944
+ return { configPath: sourcePath, rootDir };
1945
+ }
1946
+ } catch {
1947
+ // 解決できない場合はフォールバック(下の return)
1948
+ }
1949
+ return { rootDir: getProjectRoot() };
1950
+ }
1951
+
1904
1952
  async function loadWorldResolvedConfig() {
1905
1953
  try {
1906
1954
  const configModule = createConfigFoundationModule();
@@ -2888,7 +2936,7 @@ async function main(): Promise<void> {
2888
2936
  // ── validator-system ──
2889
2937
  case "validate": {
2890
2938
  const mod = createValidatorSystemModule(toValidatorSystemConfig(resolvedConfig));
2891
- const layer = parseFlag(args, "--layer") as "L0" | "L2" | "L3" | "L4" | "all" | undefined;
2939
+ const layer = parseValidateLayer(args);
2892
2940
  const unit = parseFlag(args, "--unit");
2893
2941
  const phase = parseFlag(args, "--phase");
2894
2942
  const format = parseValidateFormat(args) ?? (json ? "ci" : undefined);
@@ -2956,7 +3004,7 @@ async function main(): Promise<void> {
2956
3004
  );
2957
3005
  return;
2958
3006
  }
2959
- const mod = createQuickModeCompositionRoot();
3007
+ const mod = createQuickModeCompositionRoot(await resolveQuickModeCompositionOptions());
2960
3008
  const paths = parseFlag(args, "--paths");
2961
3009
  const format = parseFlag(args, "--format") as "human" | "json" | undefined;
2962
3010
  const failOnFullRequired = hasFlag(args, "--fail-on-full-required");
@@ -3,20 +3,32 @@
3
3
  * @unit phase-dependency-model
4
4
  */
5
5
 
6
- import { readFile } from 'node:fs/promises';
7
- import * as path from 'node:path';
8
- import type { PlanDocumentReaderPort } from '../../domain/ports/plan-document-reader-port.js';
9
- import type { PhaseNode } from '../../domain/values/phase-node.js';
10
- import type { PlanningMode } from '../../domain/values/planning-mode.js';
11
- import { PlanEvidence } from '../../domain/values/plan-evidence.js';
6
+ import { readFile } from "node:fs/promises";
7
+ import * as path from "node:path";
8
+ import type { PlanDocumentReaderPort } from "../../domain/ports/plan-document-reader-port.js";
9
+ import type { PhaseNode } from "../../domain/values/phase-node.js";
10
+ import { PlanEvidence } from "../../domain/values/plan-evidence.js";
11
+ import type { PlanningMode } from "../../domain/values/planning-mode.js";
12
12
 
13
13
  export interface MarkdownPlanDocumentReaderDeps {
14
14
  readonly rootDir: string;
15
15
  }
16
16
 
17
- const QA_SECTION_PATTERN = /^## QA\b/m;
18
- const QUESTION_PATTERN = /^Q:/gm;
19
- const ANSWER_PATTERN = /^A:/gm;
17
+ /**
18
+ * WI-358 (issue #29): 見出しの表記ゆれで Planning Mode の evidence 判定が落ちる罠を潰す。
19
+ *
20
+ * 旧パターンは "QA" 綴りしか受け付けず、エージェントが自然に書く `## Q&A` /
21
+ * 全角の `## Q&A` を弾いていた。中身は同一なのに phase gate だけが落ち、
22
+ * 原因が見出しの綴りだと気付けないまま止まる。
23
+ *
24
+ * 緩和方向のみの変更であり、既存の `## QA` / `## 4. QA(不明点・確認事項)` は
25
+ * すべて従来どおりマッチする。
26
+ */
27
+ const QA_SECTION_PATTERN = /^##+\s*(?:\d+[..]\s*)?Q[&&]?A\b/m;
28
+ const QUESTION_PATTERN = /^(?:Q:|###\s*\[Question\])/gm;
29
+ const LEGACY_ANSWER_PATTERN = /^A:/gm;
30
+ const STRUCTURED_ANSWER_PATTERN = /^\[Answer\][ \t]*$/gm;
31
+ const ANSWER_BODY_BOUNDARY_PATTERN = /^(?:###\s*\[Question\]|#{1,6}\s+)/m;
20
32
 
21
33
  export class MarkdownPlanDocumentReader implements PlanDocumentReaderPort {
22
34
  private readonly rootDir: string;
@@ -55,7 +67,7 @@ export class MarkdownPlanDocumentReader implements PlanDocumentReaderPort {
55
67
 
56
68
  let content: string;
57
69
  try {
58
- content = await readFile(absolutePath, 'utf8');
70
+ content = await readFile(absolutePath, "utf8");
59
71
  } catch {
60
72
  return PlanEvidence.create({
61
73
  exists: false,
@@ -66,11 +78,7 @@ export class MarkdownPlanDocumentReader implements PlanDocumentReaderPort {
66
78
 
67
79
  const hasQaSection = QA_SECTION_PATTERN.test(content);
68
80
  const qaComplete = this.isQaComplete(content, hasQaSection, expectedMode);
69
- const planningModeMatch = this.detectPlanningModeMatch(
70
- content,
71
- hasQaSection,
72
- expectedMode,
73
- );
81
+ const planningModeMatch = this.detectPlanningModeMatch(content, hasQaSection, expectedMode);
74
82
 
75
83
  return PlanEvidence.create({
76
84
  exists: true,
@@ -79,16 +87,12 @@ export class MarkdownPlanDocumentReader implements PlanDocumentReaderPort {
79
87
  });
80
88
  }
81
89
 
82
- private isQaComplete(
83
- content: string,
84
- hasQaSection: boolean,
85
- expectedMode: PlanningMode,
86
- ): boolean {
90
+ private isQaComplete(content: string, hasQaSection: boolean, expectedMode: PlanningMode): boolean {
87
91
  if (expectedMode.requiresAnsweredQa()) {
88
92
  if (!hasQaSection) return false;
89
93
  const qaSection = this.extractQaSection(content);
90
94
  const questionCount = (qaSection.match(QUESTION_PATTERN) ?? []).length;
91
- const answerCount = (qaSection.match(ANSWER_PATTERN) ?? []).length;
95
+ const answerCount = this.countAnswers(qaSection);
92
96
  return questionCount > 0 && questionCount === answerCount;
93
97
  }
94
98
 
@@ -99,16 +103,12 @@ export class MarkdownPlanDocumentReader implements PlanDocumentReaderPort {
99
103
  return true;
100
104
  }
101
105
 
102
- private detectPlanningModeMatch(
103
- content: string,
104
- hasQaSection: boolean,
105
- expectedMode: PlanningMode,
106
- ): boolean {
106
+ private detectPlanningModeMatch(content: string, hasQaSection: boolean, expectedMode: PlanningMode): boolean {
107
107
  if (expectedMode.requiresAnsweredQa()) {
108
108
  if (!hasQaSection) return false;
109
109
  const qaSection = this.extractQaSection(content);
110
110
  const questionCount = (qaSection.match(QUESTION_PATTERN) ?? []).length;
111
- const answerCount = (qaSection.match(ANSWER_PATTERN) ?? []).length;
111
+ const answerCount = this.countAnswers(qaSection);
112
112
  return questionCount > 0 && answerCount === questionCount;
113
113
  }
114
114
 
@@ -121,14 +121,42 @@ export class MarkdownPlanDocumentReader implements PlanDocumentReaderPort {
121
121
 
122
122
  private extractQaSection(content: string): string {
123
123
  const match = content.match(QA_SECTION_PATTERN);
124
- if (!match || match.index === undefined) return '';
124
+ if (!match || match.index === undefined) return "";
125
125
 
126
126
  const start = match.index;
127
- const nextSectionMatch = content.slice(start + match[0].length).match(/^## /m);
128
- const end = nextSectionMatch?.index
129
- ? start + match[0].length + nextSectionMatch.index
130
- : content.length;
127
+ const sectionContentStart = start + match[0].length;
128
+ const sectionContent = content.slice(sectionContentStart);
129
+ const headingMarker = match[0].match(/^##+/)?.[0];
130
+ if (!headingMarker) return "";
131
+
132
+ const sameLevelHeadingPattern = new RegExp(`^${headingMarker}(?!#)[ \\t]+(.+)$`, "gm");
133
+ let end = content.length;
134
+ for (const headingMatch of sectionContent.matchAll(sameLevelHeadingPattern)) {
135
+ if (/^\[Question\](?:\s|$)/.test(headingMatch[1].trimStart())) continue;
136
+ if (headingMatch.index !== undefined) {
137
+ end = sectionContentStart + headingMatch.index;
138
+ break;
139
+ }
140
+ }
131
141
 
132
142
  return content.slice(start, end);
133
143
  }
144
+
145
+ private countAnswers(qaSection: string): number {
146
+ const legacyAnswerCount = (qaSection.match(LEGACY_ANSWER_PATTERN) ?? []).length;
147
+ let structuredAnswerCount = 0;
148
+
149
+ for (const answerMatch of qaSection.matchAll(STRUCTURED_ANSWER_PATTERN)) {
150
+ if (answerMatch.index === undefined) continue;
151
+ const bodyStart = answerMatch.index + answerMatch[0].length;
152
+ const followingContent = qaSection.slice(bodyStart);
153
+ const boundaryMatch = followingContent.match(ANSWER_BODY_BOUNDARY_PATTERN);
154
+ const answerBody =
155
+ boundaryMatch?.index === undefined ? followingContent : followingContent.slice(0, boundaryMatch.index);
156
+ const hasBody = answerBody.split(/\r?\n/).some((line) => line.trim().length > 0);
157
+ if (hasBody) structuredAnswerCount += 1;
158
+ }
159
+
160
+ return legacyAnswerCount + structuredAnswerCount;
161
+ }
134
162
  }
@@ -2,8 +2,8 @@
2
2
  * @layer presentation
3
3
  * @unit phase2-extensions
4
4
  */
5
- import type { CheckDocFreshnessUseCase } from '../../application/usecases/check-doc-freshness-usecase.js';
6
- import { FreshnessResultFormatter } from '../formatters/freshness-result-formatter.js';
5
+ import type { CheckDocFreshnessUseCase } from "../../application/usecases/check-doc-freshness-usecase.js";
6
+ import { FreshnessResultFormatter } from "../formatters/freshness-result-formatter.js";
7
7
 
8
8
  export class CheckFreshnessHandler {
9
9
  private readonly formatter = new FreshnessResultFormatter();
@@ -12,26 +12,34 @@ export class CheckFreshnessHandler {
12
12
 
13
13
  async handle(args: string[]): Promise<{ exitCode: number; stdout: string }> {
14
14
  let targetPattern: string | undefined;
15
- let format: 'text' | 'json' = 'text';
15
+ let format: "text" | "json" = "text";
16
16
  let dryRun = false;
17
17
 
18
18
  for (let index = 0; index < args.length; index += 1) {
19
19
  const arg = args[index];
20
- if (arg === '--pattern') {
20
+ if (arg === "--pattern") {
21
21
  targetPattern = args[index + 1];
22
22
  index += 1;
23
- } else if (arg === '--format') {
24
- format = (args[index + 1] as 'text' | 'json') ?? 'text';
23
+ } else if (arg === "--format") {
24
+ format = (args[index + 1] as "text" | "json") ?? "text";
25
25
  index += 1;
26
- } else if (arg === '--dry-run') {
26
+ } else if (arg === "--dry-run") {
27
27
  dryRun = true;
28
28
  }
29
29
  }
30
30
 
31
31
  const result = await this.useCase.execute({ targetPattern, format, dryRun });
32
+ // WI-359: `--dry-run` は「副作用なしで診断のみ」(skills/doc-health-checker/SKILL.md)であり、
33
+ // シナリオ仕様 SC-P2-002 の期待結果も exit 0 である。従来は dryRun をパースだけして
34
+ // 捨てており、error 閾値超過の文書が 1 件でもあると診断のみの実行でも exit 1 になっていた。
35
+ // その結果 SC-P2-002 の e2e は「コードを一切変えなくても暦の経過だけで落ちる」状態になっていた。
36
+ // 実運用の鮮度ゲートは L4-004(`validate --layer L4`)が担い、そちらは本 handler を
37
+ // 経由しないため、dry-run を report-only にしてもゲートは緩まない。
38
+ // フラグ未指定時の exit 1 は従来どおり維持する。
39
+ const exitCode = dryRun ? 0 : result.summary.error > 0 ? 1 : 0;
32
40
  return {
33
- exitCode: result.summary.error > 0 ? 1 : 0,
34
- stdout: format === 'json' ? this.formatter.formatJson(result) : this.formatter.formatText(result),
41
+ exitCode,
42
+ stdout: format === "json" ? this.formatter.formatJson(result) : this.formatter.formatText(result),
35
43
  };
36
44
  }
37
45
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @layer application
3
+ * @unit quick-mode
4
+ * @work-item-id WI-334
5
+ *
6
+ * ファイルの存在確認を行う Application 層 Port。
7
+ * ClassifyChangeCategoryUseCase が targetChanges を受け取らない経路
8
+ * (CLI: check-change-category --paths)で changeKind を CREATE/MODIFY と
9
+ * 推定するために使用する。hook 経路(beforeContent=null → CREATE)との
10
+ * 判定一致を保証する目的で導入された(WI-334)。
11
+ */
12
+
13
+ export interface FileExistencePort {
14
+ exists(filePath: string): Promise<boolean>;
15
+ }