phasegate 0.283.0 → 0.335.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 (115) 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/ADR/039-hook-observable-state-as-authorization-unit.md +74 -0
  5. package/docs/ADR/040-quick-mode-config-via-preset-resolution.md +66 -0
  6. package/docs/guide/cli-reference.md +89 -7
  7. package/docs/guide/configuration.md +43 -2
  8. package/docs/guide/layer-model.md +2 -0
  9. package/docs/guide/quick-vs-full-mode.md +63 -3
  10. package/docs/guide/troubleshooting.md +37 -0
  11. package/docs/templates/agent-context/CLAUDE.md.template.md +6 -6
  12. package/package.json +2 -2
  13. package/scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts +5 -1
  14. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +108 -20
  15. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +215 -5
  16. package/scripts/harness/agent-integration/domain/value-objects/protected-file-list.ts +5 -0
  17. package/scripts/harness/agent-integration/infrastructure/adapters/file-system-full-mode-session-query-adapter.ts +75 -23
  18. package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +18 -3
  19. package/scripts/harness/agent-integration/infrastructure/adapters/quick-mode-full-mode-requirement-adapter.ts +15 -1
  20. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +57 -9
  21. package/scripts/harness/ci-governance/application/dto/scaffold-inception-input.ts +9 -0
  22. package/scripts/harness/ci-governance/application/dto/scaffold-inception-output.ts +13 -0
  23. package/scripts/harness/ci-governance/application/usecases/list-templates-usecase.ts +30 -0
  24. package/scripts/harness/ci-governance/application/usecases/scaffold-inception-usecase.ts +63 -0
  25. package/scripts/harness/ci-governance/application/usecases/show-template-usecase.ts +39 -0
  26. package/scripts/harness/ci-governance/composition-root.ts +57 -5
  27. package/scripts/harness/ci-governance/domain/ports/inception-doc-writer-port.ts +19 -0
  28. package/scripts/harness/ci-governance/domain/ports/inception-template-repository-port.ts +16 -0
  29. package/scripts/harness/ci-governance/domain/ports/template-catalog-port.ts +23 -0
  30. package/scripts/harness/ci-governance/domain/services/claude-md-composer.ts +20 -11
  31. package/scripts/harness/ci-governance/domain/value-objects/inception-doc-kind.ts +109 -0
  32. package/scripts/harness/ci-governance/domain/value-objects/template-catalog-entry.ts +53 -0
  33. package/scripts/harness/ci-governance/domain/value-objects/template-name.ts +50 -0
  34. package/scripts/harness/ci-governance/infrastructure/adapters/file-system-inception-doc-writer-adapter.ts +42 -0
  35. package/scripts/harness/ci-governance/infrastructure/adapters/file-system-inception-template-repository-adapter.ts +32 -0
  36. package/scripts/harness/ci-governance/infrastructure/adapters/file-system-template-catalog-adapter.ts +60 -0
  37. package/scripts/harness/ci-governance/presentation/handlers/check-repetition-handler.ts +10 -2
  38. package/scripts/harness/ci-governance/presentation/handlers/scaffold-inception-handler.ts +101 -0
  39. package/scripts/harness/ci-governance/presentation/handlers/templates-handler.ts +103 -0
  40. package/scripts/harness/config-foundation/domain/harness-config.ts +12 -0
  41. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +3 -3
  42. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +3 -3
  43. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +3 -3
  44. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +44 -1
  45. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +42 -1
  46. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +12 -3
  47. package/scripts/harness/harness-api/domain/value-objects/ci-check-result.ts +2 -22
  48. package/scripts/harness/harness-api/domain/value-objects/known-harness-commands.ts +3 -1
  49. package/scripts/harness/harness-error/application/dto/create-harness-error-input.ts +3 -1
  50. package/scripts/harness/harness-error/application/dto/harness-error-contract.ts +3 -1
  51. package/scripts/harness/harness-error/application/mappers/harness-error-contract-mapper.ts +9 -17
  52. package/scripts/harness/harness-error/application/usecases/create-harness-error-use-case.ts +7 -7
  53. package/scripts/harness/harness-error/domain/services/harness-error-factory.ts +30 -33
  54. package/scripts/harness/harness-error/domain/value-objects/error-definition.ts +28 -17
  55. package/scripts/harness/harness-error/domain/value-objects/harness-error.ts +30 -9
  56. package/scripts/harness/harness-error/domain/value-objects/remediation-type.ts +30 -0
  57. package/scripts/harness/harness-error/infrastructure/registry/l2-error-definitions.ts +44 -29
  58. package/scripts/harness/harness-error/infrastructure/registry/l3-error-definitions.ts +44 -27
  59. package/scripts/harness/harness-error/infrastructure/registry/l4-error-definitions.ts +47 -32
  60. package/scripts/harness/installation/application/checks/claude-context-missing-check.ts +13 -7
  61. package/scripts/harness/installation/application/checks/config-status-check.ts +52 -0
  62. package/scripts/harness/installation/application/checks/husky-pre-commit-missing-check.ts +6 -0
  63. package/scripts/harness/installation/application/ports/config-status-probe-port.ts +9 -0
  64. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +30 -8
  65. package/scripts/harness/installation/application/usecases/run-install.ts +44 -11
  66. package/scripts/harness/installation/application/usecases/run-reconcile.ts +44 -11
  67. package/scripts/harness/installation/composition-root.ts +13 -3
  68. package/scripts/harness/installation/domain/check-id.ts +2 -0
  69. package/scripts/harness/installation/domain/config-status.ts +17 -0
  70. package/scripts/harness/installation/domain/ports/heuristic-check.ts +10 -1
  71. package/scripts/harness/installation/infrastructure/adapters/config-status-probe-adapter.ts +79 -0
  72. package/scripts/harness/installation/presentation/cli/doctor-handler.ts +6 -1
  73. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +19 -5
  74. package/scripts/harness/integrations/pre-commit.ts +17 -3
  75. package/scripts/harness/main.ts +159 -21
  76. package/scripts/harness/phase-dependency-model/application/services/evidence-bundle-assembler.ts +2 -1
  77. package/scripts/harness/phase-dependency-model/domain/ports/plan-document-reader-port.ts +12 -0
  78. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/markdown-plan-document-reader.ts +64 -33
  79. package/scripts/harness/phase2-extensions/presentation/handlers/check-freshness-handler.ts +17 -9
  80. package/scripts/harness/quick-mode/application/ports/file-existence-port.ts +15 -0
  81. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +60 -22
  82. package/scripts/harness/quick-mode/composition-root.ts +25 -15
  83. package/scripts/harness/quick-mode/domain/errors/quick-mode-config-error.ts +19 -0
  84. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +116 -18
  85. package/scripts/harness/quick-mode/domain/value-objects/category-override-rules.ts +147 -0
  86. package/scripts/harness/quick-mode/domain/value-objects/change-category.ts +33 -0
  87. package/scripts/harness/quick-mode/domain/value-objects/quick-mode-config.ts +31 -10
  88. package/scripts/harness/quick-mode/index.ts +7 -1
  89. package/scripts/harness/quick-mode/infrastructure/adapters/fs-file-existence-adapter.ts +38 -0
  90. package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +83 -32
  91. package/scripts/harness/skill-quality/infrastructure/adapters/file-system-requirement-test-matrix-adapter.ts +51 -8
  92. package/scripts/harness/skill-quality/presentation/handlers/check-coverage-handler.ts +13 -6
  93. package/scripts/harness/traceability-model/domain/value-objects/work-item-frontmatter.ts +5 -1
  94. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-identity-gateway.ts +6 -1
  95. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +15 -2
  96. package/scripts/harness/validator-system/application/use-cases/aggregate-validation-results-usecase.ts +11 -14
  97. package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +8 -0
  98. package/scripts/harness/validator-system/domain/services/effective-severity-policy.ts +39 -0
  99. package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +6 -4
  100. package/scripts/harness/validator-system/domain/value-objects/drift-report.ts +12 -7
  101. package/scripts/harness/validator-system/domain/value-objects/validation-result.ts +11 -3
  102. package/scripts/harness/validator-system/infrastructure/adapters/file-system-security-pattern-scanner-adapter.ts +20 -17
  103. package/scripts/harness/validator-system/infrastructure/adapters/phase-dependency-phase-gate-policy-adapter.ts +35 -14
  104. package/scripts/harness/validator-system/infrastructure/adapters/traceability-metadata-policy-adapter.ts +13 -8
  105. package/scripts/harness/validator-system/presentation/formatters/agent-validation-result-formatter.ts +19 -10
  106. package/skills/product-architect/SKILL.md +19 -0
  107. package/skills/quick-implementor/SKILL.md +19 -0
  108. package/skills/story-mapper/SKILL.md +11 -0
  109. package/skills/story-writer/SKILL.md +11 -0
  110. package/skills/unit-designer/SKILL.md +11 -0
  111. package/templates/product_overview.template.md +85 -0
  112. package/templates/product_overview_plan.template.md +55 -0
  113. package/templates/story_mapping_plan.template.md +61 -0
  114. package/templates/story_writer_plan.template.md +61 -0
  115. package/templates/unit_design_plan.template.md +63 -0
@@ -0,0 +1,52 @@
1
+ // @unit installation
2
+ // @layer application
3
+ // @work-item-id WI-330
4
+
5
+ import type { DiagnosticFinding } from "../../domain/diagnostic-finding.js";
6
+ import type { HeuristicCheck } from "../../domain/ports/heuristic-check.js";
7
+ import type { ConfigStatusProbePort } from "../ports/config-status-probe-port.js";
8
+ import type { FileInspectorPort } from "../ports/file-inspector-port.js";
9
+ import { createFinding } from "./check-utils.js";
10
+
11
+ /**
12
+ * WI-330 (GitHub #40 恒久化): phasegate.config.json の存在・妥当性を doctor で可視化する。
13
+ * - missing → warn(既定設定の fail-open で動作していることをユーザーに知らせる)
14
+ * - invalid-json / invalid-schema → red(意図した設定が適用されていない)
15
+ * - valid → finding なし
16
+ */
17
+ export class ConfigStatusCheck implements HeuristicCheck {
18
+ readonly checkId = "config-status" as const;
19
+
20
+ constructor(private readonly probe: ConfigStatusProbePort) {}
21
+
22
+ async run(projectRoot: string, _inspector: FileInspectorPort): Promise<DiagnosticFinding | null> {
23
+ const result = await this.probe.probe(projectRoot);
24
+ if (result.status === "valid") {
25
+ return null;
26
+ }
27
+ if (result.status === "missing") {
28
+ return createFinding({
29
+ checkId: this.checkId,
30
+ severity: "warn",
31
+ target: result.configPath,
32
+ message:
33
+ "phasegate.config.json が存在しません(既定設定の fail-open モードで動作中)。`phasegate init` で生成できます",
34
+ repairMode: "mechanical",
35
+ repairHint: "npx phasegate init",
36
+ });
37
+ }
38
+ const reason = result.status === "invalid-json" ? "JSON 構文エラー" : "スキーマ違反";
39
+ const detail = result.detail !== null && result.detail.length > 0 ? ` — ${result.detail}` : "";
40
+ return createFinding({
41
+ checkId: this.checkId,
42
+ severity: "red",
43
+ target: result.configPath,
44
+ message: `phasegate.config.json が${reason}で壊れており、ユーザーの意図した設定が適用されていません(既定設定の fail-open モードで動作中)${detail}`,
45
+ repairMode: "manual",
46
+ repairHint:
47
+ result.status === "invalid-json"
48
+ ? "phasegate.config.json の JSON 構文エラーを修正するか、version control から復元してください"
49
+ : "報告されたパス・型のスキーマ違反を phasegate.config.json 上で修正するか、version control から復元してください",
50
+ });
51
+ }
52
+ }
@@ -1,6 +1,7 @@
1
1
  // @unit installation
2
2
  // @layer application
3
3
  // @work-item-id WI-145
4
+ // @work-item-id WI-340
4
5
 
5
6
  import type { FileInspectorPort } from "../ports/file-inspector-port.js";
6
7
  import type { HeuristicCheck } from "../../domain/ports/heuristic-check.js";
@@ -22,6 +23,11 @@ export class HuskyPreCommitMissingCheck implements HeuristicCheck {
22
23
  "main.ts check-phase-gate",
23
24
  "$HARNESS_CMD lint",
24
25
  "$HARNESS_CMD check-phase-gate",
26
+ "phasegate pre-commit",
27
+ "main.ts pre-commit",
28
+ "$PHASEGATE_CMD lint",
29
+ "$PHASEGATE_CMD check-phase-gate",
30
+ "$PHASEGATE_CMD pre-commit",
25
31
  ])
26
32
  ) {
27
33
  return null;
@@ -0,0 +1,9 @@
1
+ // @unit installation
2
+ // @layer application
3
+ // @work-item-id WI-330
4
+
5
+ import type { ConfigStatusProbeResult } from "../../domain/config-status.js";
6
+
7
+ export interface ConfigStatusProbePort {
8
+ probe(projectRoot: string): Promise<ConfigStatusProbeResult>;
9
+ }
@@ -4,11 +4,15 @@
4
4
  // @work-item-id WI-178
5
5
  // @work-item-id WI-208
6
6
  // @work-item-id WI-215
7
+ // @work-item-id WI-330
8
+ // @work-item-id WI-343
7
9
 
8
- import { DiagnosticReport } from "../../domain/diagnostic-report.js";
9
10
  import type { CheckId } from "../../domain/check-id.js";
11
+ import type { ConfigStatus } from "../../domain/config-status.js";
10
12
  import type { DiagnosticFinding } from "../../domain/diagnostic-finding.js";
13
+ import { DiagnosticReport } from "../../domain/diagnostic-report.js";
11
14
  import type { HeuristicCheck } from "../../domain/ports/heuristic-check.js";
15
+ import type { ConfigStatusProbePort } from "../ports/config-status-probe-port.js";
12
16
  import type { FileInspectorPort } from "../ports/file-inspector-port.js";
13
17
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
14
18
 
@@ -29,6 +33,7 @@ export interface RunDoctorDiagnosticsOutput {
29
33
  readonly report: DiagnosticReport;
30
34
  readonly agent: DoctorAgentScope;
31
35
  readonly installationMode: "project" | "personal";
36
+ readonly configStatus: ConfigStatus;
32
37
  readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
33
38
  readonly exitCode: number;
34
39
  }
@@ -48,15 +53,22 @@ export class RunDoctorDiagnosticsUseCase {
48
53
  private readonly checks: readonly HeuristicCheck[],
49
54
  private readonly inspector: FileInspectorPort,
50
55
  private readonly manifestRepository: ManifestRepositoryPort,
56
+ private readonly configStatusProbe: ConfigStatusProbePort,
51
57
  ) {}
52
58
 
53
59
  async execute(input: RunDoctorDiagnosticsInput): Promise<RunDoctorDiagnosticsOutput> {
54
60
  const agent = input.agent ?? "both";
55
61
  const manifest = await this.manifestRepository.load(input.projectRoot).catch(() => null);
56
- const installationMode = manifest?.findEntry(".phasegate-local/phasegate.config.json") !== null && manifest !== null ? "personal" : "project";
57
- const rawFindings = (await Promise.all(
58
- this.checks.map((check) => check.run(input.projectRoot, this.inspector)),
59
- )).filter((finding) => finding !== null);
62
+ const installationMode =
63
+ manifest?.findEntry(".phasegate-local/phasegate.config.json") !== null && manifest !== null
64
+ ? "personal"
65
+ : "project";
66
+ const configStatus = (await this.configStatusProbe.probe(input.projectRoot)).status;
67
+ const rawFindings = (
68
+ await Promise.all(
69
+ this.checks.map((check) => check.run(input.projectRoot, this.inspector, { installationMode })),
70
+ )
71
+ ).filter((finding) => finding !== null);
60
72
  const { findings, scopedOutFindings } = this.applyPersonalScope(
61
73
  this.applyAgentScope(rawFindings, agent),
62
74
  installationMode,
@@ -66,6 +78,7 @@ export class RunDoctorDiagnosticsUseCase {
66
78
  report,
67
79
  agent,
68
80
  installationMode,
81
+ configStatus,
69
82
  scopedOutFindings,
70
83
  exitCode: this.decideExitCode(report, input.strict),
71
84
  };
@@ -74,7 +87,10 @@ export class RunDoctorDiagnosticsUseCase {
74
87
  private applyAgentScope(
75
88
  findings: readonly DiagnosticFinding[],
76
89
  agent: DoctorAgentScope,
77
- ): { readonly findings: readonly DiagnosticFinding[]; readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[] } {
90
+ ): {
91
+ readonly findings: readonly DiagnosticFinding[];
92
+ readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
93
+ } {
78
94
  if (agent === "both") return { findings, scopedOutFindings: [] };
79
95
  const scopedOutChecks = agent === "claude" ? CODEX_ONLY_CHECKS : CLAUDE_ONLY_CHECKS;
80
96
  const applicable: DiagnosticFinding[] = [];
@@ -93,9 +109,15 @@ export class RunDoctorDiagnosticsUseCase {
93
109
  }
94
110
 
95
111
  private applyPersonalScope(
96
- scoped: { readonly findings: readonly DiagnosticFinding[]; readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[] },
112
+ scoped: {
113
+ readonly findings: readonly DiagnosticFinding[];
114
+ readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
115
+ },
97
116
  installationMode: "project" | "personal",
98
- ): { readonly findings: readonly DiagnosticFinding[]; readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[] } {
117
+ ): {
118
+ readonly findings: readonly DiagnosticFinding[];
119
+ readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
120
+ } {
99
121
  if (installationMode !== "personal") return scoped;
100
122
  const applicable: DiagnosticFinding[] = [];
101
123
  const scopedOut: ScopedOutDiagnosticFinding[] = [...scoped.scopedOutFindings];
@@ -18,6 +18,7 @@
18
18
  // @work-item-id WI-219
19
19
  // @work-item-id WI-315
20
20
  // @work-item-id WI-326
21
+ // @work-item-id WI-331
21
22
 
22
23
  import {
23
24
  access,
@@ -121,6 +122,7 @@ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
121
122
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
122
123
  const USER_SECTION_BEGIN = "<!-- phasegate:user-section:start -->";
123
124
  const USER_SECTION_END = "<!-- phasegate:user-section:end -->";
125
+ const USER_SECTION_PLACEHOLDER = "Project-specific agent instructions go here.";
124
126
  const TEXT_BEGIN = "# phasegate personal install exclude (BEGIN)";
125
127
  const TEXT_END = "# phasegate personal install exclude (END)";
126
128
  const PERSONAL_AGENT_RUNTIME_FILES = new Set([
@@ -301,11 +303,18 @@ function extractUserSectionBody(content: string | null): string | null {
301
303
  return body.length === 0 ? null : body;
302
304
  }
303
305
 
304
- // The CLAUDE.md template nests the user-section inside the managed section, so
305
- // replacing the managed block wholesale would wipe user-authored instructions
306
- // with the template placeholder. Re-inject the existing user-section body into
307
- // the incoming block. Templates whose managed block carries no user-section
308
- // markers (e.g. AGENTS.md) are returned unchanged.
306
+ function hasUserSectionMarkers(content: string): boolean {
307
+ const start = content.indexOf(USER_SECTION_BEGIN);
308
+ const end = content.indexOf(USER_SECTION_END);
309
+ return start !== -1 && end !== -1 && start < end;
310
+ }
311
+
312
+ // Legacy (pre-WI-331) templates nested the user-section inside the managed
313
+ // section, so replacing the managed block wholesale would wipe user-authored
314
+ // instructions with the template placeholder. Re-inject the existing
315
+ // user-section body into the incoming block. Blocks without user-section
316
+ // markers (all current templates) are returned unchanged; the outside-of-block
317
+ // migration for those is handled in mergeManagedMarkdown.
309
318
  function restoreUserSection(block: string, existing: string | null): string {
310
319
  const start = block.indexOf(USER_SECTION_BEGIN);
311
320
  const end = block.indexOf(USER_SECTION_END);
@@ -317,12 +326,36 @@ function restoreUserSection(block: string, existing: string | null): string {
317
326
 
318
327
  function mergeManagedMarkdown(existing: string | null, incoming: string): string {
319
328
  if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
320
- const block = restoreUserSection(managedMarkdownBlock(incoming), existing);
329
+ const incomingBlock = managedMarkdownBlock(incoming);
321
330
  const pattern = new RegExp(`${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}`);
322
- // Replacer function keeps user-authored text (now part of the block) from
323
- // being interpreted as `$`-substitution patterns by String.replace.
324
- if (pattern.test(existing)) return existing.replace(pattern, () => block).replace(/\s*$/, "\n");
325
- return `${block}\n\n${existing.replace(/\s*$/, "\n")}`;
331
+ if (!pattern.test(existing)) return `${incomingBlock}\n\n${existing.replace(/\s*$/, "\n")}`;
332
+
333
+ const blockStart = existing.indexOf(MARKDOWN_BEGIN);
334
+ const blockEnd = existing.indexOf(MARKDOWN_END) + MARKDOWN_END.length;
335
+ const existingBlock = existing.slice(blockStart, blockEnd);
336
+ const outsideBlock = existing.slice(0, blockStart) + existing.slice(blockEnd);
337
+ const block = restoreUserSection(incomingBlock, existing);
338
+ // Replacer function keeps user-authored text (possibly part of the block)
339
+ // from being interpreted as `$`-substitution patterns by String.replace.
340
+ let next = existing.replace(pattern, () => block);
341
+
342
+ // Structure migration (WI-331): the existing file nests the user-section
343
+ // inside the managed block (old template shape) while the incoming block
344
+ // keeps it outside. Replacing the block would drop the user-authored body,
345
+ // so relocate it to just after the managed block — the position the current
346
+ // template uses. When a user-section already exists outside the block, keep
347
+ // that one and do not duplicate.
348
+ if (
349
+ hasUserSectionMarkers(existingBlock) &&
350
+ !hasUserSectionMarkers(incomingBlock) &&
351
+ !hasUserSectionMarkers(outsideBlock)
352
+ ) {
353
+ const body = extractUserSectionBody(existingBlock) ?? USER_SECTION_PLACEHOLDER;
354
+ const insertAt = next.indexOf(MARKDOWN_END) + MARKDOWN_END.length;
355
+ const relocated = `\n\n## User Section\n\n${USER_SECTION_BEGIN}\n${body}\n${USER_SECTION_END}`;
356
+ next = `${next.slice(0, insertAt)}${relocated}${next.slice(insertAt)}`;
357
+ }
358
+ return next.replace(/\s*$/, "\n");
326
359
  }
327
360
 
328
361
  function renderAgentContextTemplate(
@@ -351,7 +384,7 @@ function renderAgentContextTemplate(
351
384
  .replaceAll("{{PHASEGATE_COMMANDS}}", commands)
352
385
  .replaceAll("{{PHASEGATE_SKILLS}}", options.skillSet === "core" ? "- `core skills`" : "- `all bundled skills`")
353
386
  .replaceAll("{{PHASEGATE_PRESETS}}", "- `minimal`\n- `standard`\n- `full`\n- `custom`")
354
- .replaceAll("{{PHASEGATE_USER_SECTION}}", "Project-specific agent instructions go here.");
387
+ .replaceAll("{{PHASEGATE_USER_SECTION}}", USER_SECTION_PLACEHOLDER);
355
388
  }
356
389
 
357
390
  function escapeRegExp(value: string): string {
@@ -9,6 +9,7 @@
9
9
  // @work-item-id WI-264
10
10
  // @work-item-id WI-315
11
11
  // @work-item-id WI-326
12
+ // @work-item-id WI-331
12
13
 
13
14
  import {
14
15
  access,
@@ -92,6 +93,7 @@ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
92
93
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
93
94
  const USER_SECTION_BEGIN = "<!-- phasegate:user-section:start -->";
94
95
  const USER_SECTION_END = "<!-- phasegate:user-section:end -->";
96
+ const USER_SECTION_PLACEHOLDER = "Project-specific agent instructions go here.";
95
97
  const SHARED_SKILLS_VERSION_PATH = "skills/.harness-version";
96
98
  const HARNESS_VERSION_BASENAME = ".harness-version";
97
99
  const SKILL_ROOT_PREFIXES = ["skills", ".claude/skills", ".codex/skills"] as const;
@@ -265,11 +267,18 @@ function extractUserSectionBody(content: string | null): string | null {
265
267
  return body.length === 0 ? null : body;
266
268
  }
267
269
 
268
- // The CLAUDE.md template nests the user-section inside the managed section, so
269
- // replacing the managed block wholesale would wipe user-authored instructions
270
- // with the template placeholder. Re-inject the existing user-section body into
271
- // the incoming block. Templates whose managed block carries no user-section
272
- // markers (e.g. AGENTS.md) are returned unchanged.
270
+ function hasUserSectionMarkers(content: string): boolean {
271
+ const start = content.indexOf(USER_SECTION_BEGIN);
272
+ const end = content.indexOf(USER_SECTION_END);
273
+ return start !== -1 && end !== -1 && start < end;
274
+ }
275
+
276
+ // Legacy (pre-WI-331) templates nested the user-section inside the managed
277
+ // section, so replacing the managed block wholesale would wipe user-authored
278
+ // instructions with the template placeholder. Re-inject the existing
279
+ // user-section body into the incoming block. Blocks without user-section
280
+ // markers (all current templates) are returned unchanged; the outside-of-block
281
+ // migration for those is handled in reconcileManagedMarkdown.
273
282
  function restoreUserSection(block: string, existing: string | null): string {
274
283
  const start = block.indexOf(USER_SECTION_BEGIN);
275
284
  const end = block.indexOf(USER_SECTION_END);
@@ -281,12 +290,36 @@ function restoreUserSection(block: string, existing: string | null): string {
281
290
 
282
291
  function reconcileManagedMarkdown(existing: string | null, incoming: string): string {
283
292
  if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
284
- const block = restoreUserSection(managedMarkdownBlock(incoming), existing);
293
+ const incomingBlock = managedMarkdownBlock(incoming);
285
294
  const pattern = new RegExp(`${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}`);
286
- // Replacer function keeps user-authored text (now part of the block) from
287
- // being interpreted as `$`-substitution patterns by String.replace.
288
- if (pattern.test(existing)) return existing.replace(pattern, () => block).replace(/\s*$/, "\n");
289
- return `${block}\n\n${existing.replace(/\s*$/, "\n")}`;
295
+ if (!pattern.test(existing)) return `${incomingBlock}\n\n${existing.replace(/\s*$/, "\n")}`;
296
+
297
+ const blockStart = existing.indexOf(MARKDOWN_BEGIN);
298
+ const blockEnd = existing.indexOf(MARKDOWN_END) + MARKDOWN_END.length;
299
+ const existingBlock = existing.slice(blockStart, blockEnd);
300
+ const outsideBlock = existing.slice(0, blockStart) + existing.slice(blockEnd);
301
+ const block = restoreUserSection(incomingBlock, existing);
302
+ // Replacer function keeps user-authored text (possibly part of the block)
303
+ // from being interpreted as `$`-substitution patterns by String.replace.
304
+ let next = existing.replace(pattern, () => block);
305
+
306
+ // Structure migration (WI-331): the existing file nests the user-section
307
+ // inside the managed block (old template shape) while the incoming block
308
+ // keeps it outside. Replacing the block would drop the user-authored body,
309
+ // so relocate it to just after the managed block — the position the current
310
+ // template uses. When a user-section already exists outside the block, keep
311
+ // that one and do not duplicate.
312
+ if (
313
+ hasUserSectionMarkers(existingBlock) &&
314
+ !hasUserSectionMarkers(incomingBlock) &&
315
+ !hasUserSectionMarkers(outsideBlock)
316
+ ) {
317
+ const body = extractUserSectionBody(existingBlock) ?? USER_SECTION_PLACEHOLDER;
318
+ const insertAt = next.indexOf(MARKDOWN_END) + MARKDOWN_END.length;
319
+ const relocated = `\n\n## User Section\n\n${USER_SECTION_BEGIN}\n${body}\n${USER_SECTION_END}`;
320
+ next = `${next.slice(0, insertAt)}${relocated}${next.slice(insertAt)}`;
321
+ }
322
+ return next.replace(/\s*$/, "\n");
290
323
  }
291
324
 
292
325
  function renderAgentContextTemplate(template: string): string {
@@ -308,7 +341,7 @@ function renderAgentContextTemplate(template: string): string {
308
341
  .replaceAll("{{PHASEGATE_COMMANDS}}", commands)
309
342
  .replaceAll("{{PHASEGATE_SKILLS}}", "- `all bundled skills`")
310
343
  .replaceAll("{{PHASEGATE_PRESETS}}", "- `minimal`\n- `standard`\n- `full`\n- `custom`")
311
- .replaceAll("{{PHASEGATE_USER_SECTION}}", "Project-specific agent instructions go here.");
344
+ .replaceAll("{{PHASEGATE_USER_SECTION}}", USER_SECTION_PLACEHOLDER);
312
345
  }
313
346
 
314
347
  function reconcilePackageJson(existing: Record<string, unknown>, version: string): Record<string, unknown> {
@@ -3,26 +3,29 @@
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-148
5
5
  // @work-item-id WI-215
6
+ // @work-item-id WI-330
6
7
 
8
+ import { CiWorkflowMissingCheck } from "./application/checks/ci-workflow-missing-check.js";
7
9
  import { ClaudeContextMissingCheck } from "./application/checks/claude-context-missing-check.js";
8
10
  import { ClaudeHookMissingCheck } from "./application/checks/claude-hook-missing-check.js";
9
11
  import { ClaudeSkillsSymlinkCheck } from "./application/checks/claude-skills-symlink-check.js";
10
12
  import { CodexContextMissingCheck } from "./application/checks/codex-context-missing-check.js";
11
13
  import { CodexHookMissingCheck } from "./application/checks/codex-hook-missing-check.js";
12
14
  import { CodexSkillsSymlinkCheck } from "./application/checks/codex-skills-symlink-check.js";
13
- import { CiWorkflowMissingCheck } from "./application/checks/ci-workflow-missing-check.js";
15
+ import { ConfigStatusCheck } from "./application/checks/config-status-check.js";
14
16
  import { HuskyCommitMsgMissingCheck } from "./application/checks/husky-commit-msg-missing-check.js";
15
17
  import { HuskyPreCommitMissingCheck } from "./application/checks/husky-pre-commit-missing-check.js";
16
18
  import { HuskyPrePushMissingCheck } from "./application/checks/husky-pre-push-missing-check.js";
17
19
  import { PackageJsonDevdepMissingCheck } from "./application/checks/package-json-devdep-missing-check.js";
18
20
  import { WiWorkflowDriftCheck } from "./application/checks/wi-workflow-drift-check.js";
21
+ import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
19
22
  import { RunInstallUseCase } from "./application/usecases/run-install.js";
20
23
  import { RunReconcileUseCase } from "./application/usecases/run-reconcile.js";
21
24
  import { RunUninstallUseCase } from "./application/usecases/run-uninstall.js";
22
- import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
23
25
  import type { MergeStrategy } from "./domain/ports/merge-strategy.js";
24
26
  import type { ReconcileStrategy } from "./domain/ports/reconcile-strategy.js";
25
27
  import type { UninstallReverseStrategy } from "./domain/ports/uninstall-reverse-strategy.js";
28
+ import { ConfigStatusProbeAdapter } from "./infrastructure/adapters/config-status-probe-adapter.js";
26
29
  import { FileSystemManifestRepositoryAdapter } from "./infrastructure/adapters/file-system-manifest-repository-adapter.js";
27
30
  import { NodeCryptoHashAdapter } from "./infrastructure/adapters/node-crypto-hash-adapter.js";
28
31
  import { NodeFsFileInspectorAdapter } from "./infrastructure/adapters/node-fs-file-inspector-adapter.js";
@@ -45,6 +48,7 @@ export function createInstallationModule() {
45
48
  const manifestRepository = new FileSystemManifestRepositoryAdapter();
46
49
  const hashCalculator = new NodeCryptoHashAdapter();
47
50
  const modelDelegation = new SkillDeployerModelDelegationAdapter();
51
+ const configStatusProbe = new ConfigStatusProbeAdapter();
48
52
  const checks = [
49
53
  new ClaudeHookMissingCheck(),
50
54
  new ClaudeContextMissingCheck(),
@@ -58,8 +62,14 @@ export function createInstallationModule() {
58
62
  new ClaudeSkillsSymlinkCheck(),
59
63
  new CodexSkillsSymlinkCheck(),
60
64
  new WiWorkflowDriftCheck(),
65
+ new ConfigStatusCheck(configStatusProbe),
61
66
  ];
62
- const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(checks, inspector, manifestRepository);
67
+ const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(
68
+ checks,
69
+ inspector,
70
+ manifestRepository,
71
+ configStatusProbe,
72
+ );
63
73
  const runInstallUseCase = new RunInstallUseCase(manifestRepository, hashCalculator, modelDelegation);
64
74
  const runReconcileUseCase = new RunReconcileUseCase(manifestRepository, hashCalculator, modelDelegation);
65
75
  const runUninstallUseCase = new RunUninstallUseCase(manifestRepository, hashCalculator);
@@ -2,6 +2,7 @@
2
2
  // @layer domain
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-215
5
+ // @work-item-id WI-330
5
6
 
6
7
  export const CHECK_IDS = [
7
8
  "claude-hook-missing",
@@ -16,6 +17,7 @@ export const CHECK_IDS = [
16
17
  "claude-skills-symlink",
17
18
  "codex-skills-symlink",
18
19
  "wi-workflow-drift",
20
+ "config-status",
19
21
  ] as const;
20
22
 
21
23
  export type CheckId = (typeof CHECK_IDS)[number];
@@ -0,0 +1,17 @@
1
+ // @unit installation
2
+ // @layer domain
3
+ // @work-item-id WI-330
4
+
5
+ export const CONFIG_STATUSES = ["missing", "invalid-json", "invalid-schema", "valid"] as const;
6
+
7
+ export type ConfigStatus = (typeof CONFIG_STATUSES)[number];
8
+
9
+ export function isConfigStatus(value: string): value is ConfigStatus {
10
+ return (CONFIG_STATUSES as readonly string[]).includes(value);
11
+ }
12
+
13
+ export interface ConfigStatusProbeResult {
14
+ readonly status: ConfigStatus;
15
+ readonly configPath: string;
16
+ readonly detail: string | null;
17
+ }
@@ -1,12 +1,21 @@
1
1
  // @unit installation
2
2
  // @layer domain
3
3
  // @work-item-id WI-145
4
+ // @work-item-id WI-343
4
5
 
5
6
  import type { CheckId } from "../check-id.js";
6
7
  import type { DiagnosticFinding } from "../diagnostic-finding.js";
7
8
  import type { FileInspector } from "./file-inspector.js";
8
9
 
10
+ export interface HeuristicCheckContext {
11
+ readonly installationMode: "project" | "personal";
12
+ }
13
+
9
14
  export interface HeuristicCheck {
10
15
  readonly checkId: CheckId;
11
- run(projectRoot: string, inspector: FileInspector): Promise<DiagnosticFinding | null>;
16
+ run(
17
+ projectRoot: string,
18
+ inspector: FileInspector,
19
+ context?: HeuristicCheckContext,
20
+ ): Promise<DiagnosticFinding | null>;
12
21
  }
@@ -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.";