phasegate 0.264.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 (92) hide show
  1. package/CHANGELOG.md +54 -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/installation.md +1 -1
  5. package/docs/guide/layer-model.md +2 -0
  6. package/docs/guide/quick-vs-full-mode.md +28 -3
  7. package/docs/guide/troubleshooting.md +37 -0
  8. package/docs/templates/agent-context/CLAUDE.md.template.md +6 -6
  9. package/docs/templates/ci/aidlc-gate.yml +22 -4
  10. package/package.json +2 -2
  11. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +98 -18
  12. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +25 -3
  13. package/scripts/harness/agent-integration/infrastructure/adapters/file-system-full-mode-session-query-adapter.ts +75 -23
  14. package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +45 -27
  15. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +29 -16
  16. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +51 -6
  17. package/scripts/harness/agent-integration/presentation/stop-hook.ts +35 -26
  18. package/scripts/harness/ci-governance/composition-root.ts +2 -2
  19. package/scripts/harness/ci-governance/domain/services/claude-md-composer.ts +20 -11
  20. package/scripts/harness/ci-governance/presentation/handlers/check-repetition-handler.ts +10 -2
  21. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +5 -1
  22. package/scripts/harness/config-foundation/domain/harness-config.ts +10 -7
  23. package/scripts/harness/config-foundation/domain/services/preset-resolution-service.ts +4 -1
  24. package/scripts/harness/config-foundation/domain/value-objects/project-config.ts +34 -18
  25. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +1 -1
  26. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +1 -1
  27. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +1 -1
  28. package/scripts/harness/config-foundation/infrastructure/repositories/file-system-config-repository.ts +27 -18
  29. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +1 -8
  30. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +1 -8
  31. package/scripts/harness/harness-api/domain/ports/config-query-port.ts +11 -1
  32. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +135 -87
  33. package/scripts/harness/harness-api/domain/services/status-derivation-service.ts +31 -21
  34. package/scripts/harness/harness-api/domain/value-objects/ci-check-result.ts +2 -22
  35. package/scripts/harness/harness-api/domain/value-objects/harness-status-summary.ts +23 -8
  36. package/scripts/harness/harness-api/infrastructure/adapters/biome-ast-engine-lint-adapter.ts +4 -4
  37. package/scripts/harness/harness-api/infrastructure/adapters/harness-config-query-adapter.ts +79 -34
  38. package/scripts/harness/harness-error/application/dto/create-harness-error-input.ts +3 -1
  39. package/scripts/harness/harness-error/application/dto/harness-error-contract.ts +3 -1
  40. package/scripts/harness/harness-error/application/mappers/harness-error-contract-mapper.ts +9 -17
  41. package/scripts/harness/harness-error/application/usecases/create-harness-error-use-case.ts +7 -7
  42. package/scripts/harness/harness-error/domain/services/harness-error-factory.ts +30 -33
  43. package/scripts/harness/harness-error/domain/value-objects/error-definition.ts +28 -17
  44. package/scripts/harness/harness-error/domain/value-objects/harness-error.ts +30 -9
  45. package/scripts/harness/harness-error/domain/value-objects/remediation-type.ts +30 -0
  46. package/scripts/harness/harness-error/infrastructure/registry/l2-error-definitions.ts +44 -29
  47. package/scripts/harness/harness-error/infrastructure/registry/l3-error-definitions.ts +44 -27
  48. package/scripts/harness/harness-error/infrastructure/registry/l4-error-definitions.ts +47 -32
  49. package/scripts/harness/installation/application/checks/claude-context-missing-check.ts +13 -7
  50. package/scripts/harness/installation/application/checks/config-status-check.ts +52 -0
  51. package/scripts/harness/installation/application/checks/husky-pre-commit-missing-check.ts +6 -0
  52. package/scripts/harness/installation/application/ports/config-status-probe-port.ts +9 -0
  53. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +30 -8
  54. package/scripts/harness/installation/application/usecases/run-install.ts +234 -53
  55. package/scripts/harness/installation/application/usecases/run-reconcile.ts +311 -70
  56. package/scripts/harness/installation/composition-root.ts +13 -3
  57. package/scripts/harness/installation/domain/check-id.ts +2 -0
  58. package/scripts/harness/installation/domain/config-status.ts +17 -0
  59. package/scripts/harness/installation/domain/deployment-manifest.ts +43 -0
  60. package/scripts/harness/installation/domain/ports/heuristic-check.ts +10 -1
  61. package/scripts/harness/installation/infrastructure/adapters/config-status-probe-adapter.ts +79 -0
  62. package/scripts/harness/installation/presentation/cli/doctor-handler.ts +6 -1
  63. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +19 -5
  64. package/scripts/harness/integrations/pre-commit.ts +17 -3
  65. package/scripts/harness/main.ts +83 -15
  66. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/markdown-plan-document-reader.ts +60 -32
  67. package/scripts/harness/phase2-extensions/presentation/handlers/check-freshness-handler.ts +17 -9
  68. package/scripts/harness/quick-mode/application/ports/file-existence-port.ts +15 -0
  69. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +60 -22
  70. package/scripts/harness/quick-mode/composition-root.ts +25 -15
  71. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +72 -3
  72. package/scripts/harness/quick-mode/infrastructure/adapters/fs-file-existence-adapter.ts +38 -0
  73. package/scripts/harness/skill-quality/infrastructure/adapters/file-system-requirement-test-matrix-adapter.ts +51 -8
  74. package/scripts/harness/skill-quality/presentation/handlers/check-coverage-handler.ts +13 -6
  75. package/scripts/harness/traceability-model/domain/value-objects/work-item-frontmatter.ts +5 -1
  76. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-identity-gateway.ts +6 -1
  77. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +15 -2
  78. package/scripts/harness/validator-system/application/use-cases/aggregate-validation-results-usecase.ts +11 -14
  79. package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +32 -7
  80. package/scripts/harness/validator-system/composition-root.ts +4 -1
  81. package/scripts/harness/validator-system/domain/ports/ac-coverage-policy-port.ts +10 -1
  82. package/scripts/harness/validator-system/domain/services/effective-severity-policy.ts +39 -0
  83. package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +6 -4
  84. package/scripts/harness/validator-system/domain/value-objects/drift-report.ts +12 -7
  85. package/scripts/harness/validator-system/domain/value-objects/validation-result.ts +11 -3
  86. package/scripts/harness/validator-system/infrastructure/adapters/file-system-security-pattern-scanner-adapter.ts +20 -17
  87. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +89 -3
  88. package/scripts/harness/validator-system/infrastructure/adapters/nyquist-ac-coverage-policy-adapter.ts +49 -20
  89. package/scripts/harness/validator-system/infrastructure/adapters/phase-dependency-phase-gate-policy-adapter.ts +35 -14
  90. package/scripts/harness/validator-system/infrastructure/adapters/traceability-metadata-policy-adapter.ts +13 -8
  91. package/scripts/harness/validator-system/presentation/formatters/agent-validation-result-formatter.ts +19 -10
  92. package/skills/quick-implementor/SKILL.md +19 -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];
@@ -16,20 +16,44 @@
16
16
  // @work-item-id WI-215
17
17
  // @work-item-id WI-216
18
18
  // @work-item-id WI-219
19
-
20
- import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink, readdir, rm } from "node:fs/promises";
19
+ // @work-item-id WI-315
20
+ // @work-item-id WI-326
21
+ // @work-item-id WI-331
22
+
23
+ import {
24
+ access,
25
+ chmod,
26
+ copyFile,
27
+ lstat,
28
+ mkdir,
29
+ readdir,
30
+ readFile,
31
+ readlink,
32
+ rm,
33
+ symlink,
34
+ writeFile,
35
+ } from "node:fs/promises";
21
36
  import { dirname, join } from "node:path";
22
37
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
23
- import { DeploymentManifest } from "../../domain/deployment-manifest.js";
38
+ import { DeploymentManifest, type InstallationFlags } from "../../domain/deployment-manifest.js";
24
39
  import type { ManagedBlockInput } from "../../domain/managed-block.js";
25
40
  import type { RepairMode } from "../../domain/repair-mode.js";
26
41
  import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
27
- import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
28
42
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
43
+ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
29
44
  import type { ModelDelegationPort } from "../ports/model-delegation-port.js";
30
45
 
31
46
  type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
32
- type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "text-managed" | "copy" | "copy-dir" | "symlink";
47
+ type StrategyType =
48
+ | "json"
49
+ | "shell"
50
+ | "yaml-add"
51
+ | "package-json"
52
+ | "markdown-managed"
53
+ | "text-managed"
54
+ | "copy"
55
+ | "copy-dir"
56
+ | "symlink";
33
57
 
34
58
  export interface InstallPlanItem {
35
59
  readonly path: string;
@@ -96,9 +120,17 @@ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
96
120
  const SHELL_END = "# === phasegate managed (END) ===";
97
121
  const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
98
122
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
123
+ const USER_SECTION_BEGIN = "<!-- phasegate:user-section:start -->";
124
+ const USER_SECTION_END = "<!-- phasegate:user-section:end -->";
125
+ const USER_SECTION_PLACEHOLDER = "Project-specific agent instructions go here.";
99
126
  const TEXT_BEGIN = "# phasegate personal install exclude (BEGIN)";
100
127
  const TEXT_END = "# phasegate personal install exclude (END)";
101
- const PERSONAL_AGENT_RUNTIME_FILES = new Set([".claude/CLAUDE.md", ".claude/settings.json", "AGENTS.md", ".codex/hooks.json"]);
128
+ const PERSONAL_AGENT_RUNTIME_FILES = new Set([
129
+ ".claude/CLAUDE.md",
130
+ ".claude/settings.json",
131
+ "AGENTS.md",
132
+ ".codex/hooks.json",
133
+ ]);
102
134
  const SHARED_SKILLS_VERSION_PATH = "skills/.harness-version";
103
135
  const PERSONAL_PRINCIPLES_DOCS = ".phasegate-local/docs/principles";
104
136
  const PERSONAL_FOLDER_RULES_DOC = ".phasegate-local/docs/folder_management_rules.md";
@@ -207,7 +239,10 @@ function mergeHookArrays(existing: unknown, incoming: unknown): unknown[] {
207
239
  return result;
208
240
  }
209
241
 
210
- function mergeJsonObject(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
242
+ function mergeJsonObject(
243
+ existing: Record<string, unknown>,
244
+ incoming: Record<string, unknown>,
245
+ ): Record<string, unknown> {
211
246
  const result: Record<string, unknown> = { ...existing };
212
247
  const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
213
248
  const incomingHooks = isRecord(incoming.hooks) ? incoming.hooks : {};
@@ -256,12 +291,71 @@ function managedMarkdownBlock(content: string): string {
256
291
  return content.slice(start, end + MARKDOWN_END.length).trim();
257
292
  }
258
293
 
294
+ // Extracts the user-authored body between the user-section markers. Returns
295
+ // null when the markers are absent or the body is blank, so callers fall back
296
+ // to the template placeholder.
297
+ function extractUserSectionBody(content: string | null): string | null {
298
+ if (content === null) return null;
299
+ const start = content.indexOf(USER_SECTION_BEGIN);
300
+ const end = content.indexOf(USER_SECTION_END);
301
+ if (start === -1 || end === -1 || end < start) return null;
302
+ const body = content.slice(start + USER_SECTION_BEGIN.length, end).trim();
303
+ return body.length === 0 ? null : body;
304
+ }
305
+
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.
318
+ function restoreUserSection(block: string, existing: string | null): string {
319
+ const start = block.indexOf(USER_SECTION_BEGIN);
320
+ const end = block.indexOf(USER_SECTION_END);
321
+ if (start === -1 || end === -1 || end < start) return block;
322
+ const preserved = extractUserSectionBody(existing);
323
+ if (preserved === null) return block;
324
+ return `${block.slice(0, start + USER_SECTION_BEGIN.length)}\n${preserved}\n${block.slice(end)}`;
325
+ }
326
+
259
327
  function mergeManagedMarkdown(existing: string | null, incoming: string): string {
260
- const block = managedMarkdownBlock(incoming);
261
328
  if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
329
+ const incomingBlock = managedMarkdownBlock(incoming);
262
330
  const pattern = new RegExp(`${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}`);
263
- if (pattern.test(existing)) return existing.replace(pattern, block).replace(/\s*$/, "\n");
264
- 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");
265
359
  }
266
360
 
267
361
  function renderAgentContextTemplate(
@@ -290,7 +384,7 @@ function renderAgentContextTemplate(
290
384
  .replaceAll("{{PHASEGATE_COMMANDS}}", commands)
291
385
  .replaceAll("{{PHASEGATE_SKILLS}}", options.skillSet === "core" ? "- `core skills`" : "- `all bundled skills`")
292
386
  .replaceAll("{{PHASEGATE_PRESETS}}", "- `minimal`\n- `standard`\n- `full`\n- `custom`")
293
- .replaceAll("{{PHASEGATE_USER_SECTION}}", "Project-specific agent instructions go here.");
387
+ .replaceAll("{{PHASEGATE_USER_SECTION}}", USER_SECTION_PLACEHOLDER);
294
388
  }
295
389
 
296
390
  function escapeRegExp(value: string): string {
@@ -321,7 +415,8 @@ function hasCustomJson(content: string | null): boolean {
321
415
  const parsed = JSON.parse(content) as unknown;
322
416
  if (!isRecord(parsed)) return true;
323
417
  const withoutEmptyHooks = { ...parsed };
324
- if (isRecord(withoutEmptyHooks.hooks) && Object.keys(withoutEmptyHooks.hooks).length === 0) delete withoutEmptyHooks.hooks;
418
+ if (isRecord(withoutEmptyHooks.hooks) && Object.keys(withoutEmptyHooks.hooks).length === 0)
419
+ delete withoutEmptyHooks.hooks;
325
420
  return Object.keys(withoutEmptyHooks).length > 0;
326
421
  } catch {
327
422
  return true;
@@ -343,10 +438,13 @@ function likelyCauseFor(code: string): string {
343
438
  }
344
439
 
345
440
  function recoveryFor(code: string, target: string): string {
346
- if (code === "EPERM") return `Review sandbox or filesystem permissions for ${target}, ask the user for write access when needed, then rerun phasegate setup:agent --apply or phasegate install --apply.`;
441
+ if (code === "EPERM")
442
+ return `Review sandbox or filesystem permissions for ${target}, ask the user for write access when needed, then rerun phasegate setup:agent --apply or phasegate install --apply.`;
347
443
  if (code === "EACCES") return `Fix ownership or permissions for ${target}, then rerun phasegate install --apply.`;
348
- if (code === "EROFS") return `Move the project to a writable filesystem or rerun in a writable workspace before applying ${target}.`;
349
- if (code === "EEXIST" || code === "ENOTDIR") return `Inspect the parent path for ${target}; if it is user-owned, rename or move it before rerunning phasegate install --dry-run --json and then --apply.`;
444
+ if (code === "EROFS")
445
+ return `Move the project to a writable filesystem or rerun in a writable workspace before applying ${target}.`;
446
+ if (code === "EEXIST" || code === "ENOTDIR")
447
+ return `Inspect the parent path for ${target}; if it is user-owned, rename or move it before rerunning phasegate install --dry-run --json and then --apply.`;
350
448
  return `Inspect ${target}, run phasegate install --dry-run --json, then rerun with --apply after resolving the filesystem issue.`;
351
449
  }
352
450
 
@@ -380,12 +478,22 @@ export class RunInstallUseCase {
380
478
  const skillSet = input.skillSet ?? "all";
381
479
  const workflow = input.workflow ?? "standard";
382
480
  const agent = input.agent ?? (includeClaude && includeCodex ? "both" : includeCodex ? "codex" : "claude");
383
- const targets = input.personal
481
+ const personal = input.personal ?? false;
482
+ const targets = personal
384
483
  ? this.createPersonalTargets({ includeClaude, includeCodex })
385
484
  : this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
485
+ // Persist the effective opt-in state so a later reconcile can honor the
486
+ // original install options. Personal installs never deploy Husky/CI
487
+ // targets, so their effective state is recorded as false regardless of
488
+ // input.
489
+ const installationFlags: InstallationFlags = {
490
+ includeHusky: personal ? false : includeHusky,
491
+ includeCi: personal ? false : includeCi,
492
+ personal,
493
+ };
386
494
  const existingManifest = await this.manifestRepository.load(input.projectRoot);
387
495
  const baseManifest = existingManifest ?? DeploymentManifest.create(input.phasegateVersion);
388
- let manifest = baseManifest;
496
+ let manifest = baseManifest.withInstallationFlags(installationFlags);
389
497
  const plan: InstallPlanItem[] = [];
390
498
  const refused: InstallPlanItem[] = [];
391
499
  const changed: InstallPlanItem[] = [];
@@ -396,16 +504,18 @@ export class RunInstallUseCase {
396
504
  const absolutePath = join(input.projectRoot, target.path);
397
505
  const before = await readTextOrNull(absolutePath);
398
506
  const rawTemplate = await readFile(join(input.harnessRoot, target.templatePath), "utf8");
399
- const template = target.strategy === "markdown-managed"
400
- ? renderAgentContextTemplate(rawTemplate, { agent, skillSet, workflow, includeHusky, includeCi })
401
- : rawTemplate;
507
+ const template =
508
+ target.strategy === "markdown-managed"
509
+ ? renderAgentContextTemplate(rawTemplate, { agent, skillSet, workflow, includeHusky, includeCi })
510
+ : rawTemplate;
402
511
  const existingEntry = baseManifest.findEntry(target.path);
403
512
  const beforeHash = before === null ? null : this.hashCalculator.compute(before);
404
- const unmanagedPersonalRuntimeFile = input.personal
405
- && (PERSONAL_AGENT_RUNTIME_FILES.has(target.path) || target.personalManualIfUnmanaged === true)
406
- && before !== null
407
- && !before.includes(MARKDOWN_BEGIN)
408
- && (existingEntry === null || beforeHash === null || !beforeHash.equals(existingEntry.hash));
513
+ const unmanagedPersonalRuntimeFile =
514
+ input.personal &&
515
+ (PERSONAL_AGENT_RUNTIME_FILES.has(target.path) || target.personalManualIfUnmanaged === true) &&
516
+ before !== null &&
517
+ !before.includes(MARKDOWN_BEGIN) &&
518
+ (existingEntry === null || beforeHash === null || !beforeHash.equals(existingEntry.hash));
409
519
  const repairMode = unmanagedPersonalRuntimeFile ? "manual" : this.repairMode(target, before);
410
520
  const next = unmanagedPersonalRuntimeFile ? before : this.merge(target, before, template, input.phasegateVersion);
411
521
  const didChange = before !== next;
@@ -414,11 +524,11 @@ export class RunInstallUseCase {
414
524
  // (merge returns `before`). For executable git hooks this means the
415
525
  // phasegate hook is NOT wired in — warn instead of skipping silently.
416
526
  const preservedExistingHook =
417
- target.strategy === "copy"
418
- && target.executable === true
419
- && before !== null
420
- && before.trim() !== template.trim()
421
- && !input.force;
527
+ target.strategy === "copy" &&
528
+ target.executable === true &&
529
+ before !== null &&
530
+ before.trim() !== template.trim() &&
531
+ !input.force;
422
532
  const warning = preservedExistingHook
423
533
  ? `${target.path}: an existing hook was found and left unchanged; phasegate's checks are NOT wired in. Merge the phasegate hook manually (or rerun with --force to back up and overwrite).`
424
534
  : null;
@@ -432,7 +542,9 @@ export class RunInstallUseCase {
432
542
  ? `${target.path}: existing non-phasegate runtime path requires manual review`
433
543
  : warning !== null
434
544
  ? warning
435
- : didChange ? `${target.path}: ${action}` : `${target.path}: already up to date`,
545
+ : didChange
546
+ ? `${target.path}: ${action}`
547
+ : `${target.path}: already up to date`,
436
548
  diff: unmanagedPersonalRuntimeFile ? "manual review required" : this.diffSummary(before, next),
437
549
  skillHint: repairMode === "ai-assisted" ? SKILL_HINT : null,
438
550
  warning,
@@ -488,10 +600,7 @@ export class RunInstallUseCase {
488
600
  }
489
601
 
490
602
  const personalSkillTargets = input.personal
491
- ? [
492
- ...(includeClaude ? [".claude/skills"] : []),
493
- ...(includeCodex ? [".codex/skills"] : []),
494
- ]
603
+ ? [...(includeClaude ? [".claude/skills"] : []), ...(includeCodex ? [".codex/skills"] : [])]
495
604
  : [];
496
605
  const selectedPersonalSkills = input.personal ? await listSelectedBundledSkills(input.harnessRoot, skillSet) : [];
497
606
  for (const skillPath of personalSkillTargets) {
@@ -499,7 +608,13 @@ export class RunInstallUseCase {
499
608
  plan.push(item);
500
609
  if (input.apply && item.changed && item.repairMode === "mechanical") {
501
610
  try {
502
- await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, join(input.projectRoot, skillPath), selectedPersonalSkills);
611
+ await copySelectedSkillDirectories(
612
+ this.modelDelegation,
613
+ input.harnessRoot,
614
+ input.projectRoot,
615
+ join(input.projectRoot, skillPath),
616
+ selectedPersonalSkills,
617
+ );
503
618
  await writeFile(
504
619
  join(input.projectRoot, skillPath, ".harness-version"),
505
620
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -512,7 +627,12 @@ export class RunInstallUseCase {
512
627
  manifest = this.addManifestEntry(baseManifest, manifest, {
513
628
  path: `${item.path}/.harness-version`,
514
629
  mode: "created",
515
- contentForHash: this.personalSkillsVersionHashInput(item.path, input.phasegateVersion, skillSet, selectedPersonalSkills),
630
+ contentForHash: this.personalSkillsVersionHashInput(
631
+ item.path,
632
+ input.phasegateVersion,
633
+ skillSet,
634
+ selectedPersonalSkills,
635
+ ),
516
636
  });
517
637
  for (const skill of selectedPersonalSkills) {
518
638
  manifest = this.addManifestEntry(baseManifest, manifest, {
@@ -567,7 +687,11 @@ export class RunInstallUseCase {
567
687
  return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "mkdir", error);
568
688
  }
569
689
  try {
570
- await symlink(linkSpec.target, join(input.projectRoot, linkSpec.path), process.platform === "win32" ? "junction" : "dir");
690
+ await symlink(
691
+ linkSpec.target,
692
+ join(input.projectRoot, linkSpec.path),
693
+ process.platform === "win32" ? "junction" : "dir",
694
+ );
571
695
  } catch (error) {
572
696
  return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "symlink", error);
573
697
  }
@@ -586,24 +710,45 @@ export class RunInstallUseCase {
586
710
  repairMode: "manual",
587
711
  strategy: "json",
588
712
  changed: false,
589
- summary: "~/.codex/config.toml: personal mode does not write user-level Codex feature flags; enable hooks manually when needed",
713
+ summary:
714
+ "~/.codex/config.toml: personal mode does not write user-level Codex feature flags; enable hooks manually when needed",
590
715
  diff: "manual Codex hooks feature enablement may be required",
591
716
  skillHint: null,
592
717
  warning: null,
593
718
  });
594
719
  }
595
720
 
596
- if (input.apply && changed.length > 0) {
721
+ // Re-save on apply when the recorded install flags drift from a previous
722
+ // manifest (e.g. re-install with different --with-husky/--with-ci), even
723
+ // if no managed file content changed. A missing manifest with zero changes
724
+ // keeps the legacy behavior of not creating one.
725
+ const flagsDrifted =
726
+ existingManifest !== null && !this.installationFlagsEqual(existingManifest.installationFlags, installationFlags);
727
+ if (input.apply && (changed.length > 0 || flagsDrifted)) {
597
728
  try {
598
729
  await this.manifestRepository.save(input.projectRoot, manifest);
599
730
  } catch (error) {
600
- return this.withApplyError({ plan, refused, changed, backupDir }, ".phasegate/manifest.json", "manifest-save", error);
731
+ return this.withApplyError(
732
+ { plan, refused, changed, backupDir },
733
+ ".phasegate/manifest.json",
734
+ "manifest-save",
735
+ error,
736
+ );
601
737
  }
602
738
  }
603
739
 
604
740
  return { plan, refused, changed, backupDir };
605
741
  }
606
742
 
743
+ private installationFlagsEqual(existing: InstallationFlags | undefined, next: InstallationFlags): boolean {
744
+ return (
745
+ existing !== undefined &&
746
+ existing.includeHusky === next.includeHusky &&
747
+ existing.includeCi === next.includeCi &&
748
+ existing.personal === next.personal
749
+ );
750
+ }
751
+
607
752
  private withApplyError(
608
753
  result: RunInstallResult,
609
754
  target: string,
@@ -638,7 +783,11 @@ export class RunInstallUseCase {
638
783
  },
639
784
  ...(options.includeClaude
640
785
  ? [
641
- { path: ".claude/settings.json", strategy: "json" as const, templatePath: "templates/.claude/settings.json" },
786
+ {
787
+ path: ".claude/settings.json",
788
+ strategy: "json" as const,
789
+ templatePath: "templates/.claude/settings.json",
790
+ },
642
791
  {
643
792
  path: "CLAUDE.md",
644
793
  strategy: "markdown-managed" as const,
@@ -696,7 +845,10 @@ export class RunInstallUseCase {
696
845
  ];
697
846
  }
698
847
 
699
- private createPersonalTargets(options: { readonly includeClaude: boolean; readonly includeCodex: boolean }): readonly InstallTarget[] {
848
+ private createPersonalTargets(options: {
849
+ readonly includeClaude: boolean;
850
+ readonly includeCodex: boolean;
851
+ }): readonly InstallTarget[] {
700
852
  return [
701
853
  {
702
854
  path: ".phasegate-local/phasegate.config.json",
@@ -709,7 +861,11 @@ export class RunInstallUseCase {
709
861
  path: ".claude/CLAUDE.md",
710
862
  strategy: "markdown-managed" as const,
711
863
  templatePath: "docs/templates/agent-context/CLAUDE.md.template.md",
712
- block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate personal .claude/CLAUDE.md managed section" },
864
+ block: {
865
+ start: MARKDOWN_BEGIN,
866
+ end: MARKDOWN_END,
867
+ content: "phasegate personal .claude/CLAUDE.md managed section",
868
+ },
713
869
  personalManualIfUnmanaged: true,
714
870
  },
715
871
  {
@@ -725,7 +881,11 @@ export class RunInstallUseCase {
725
881
  path: "AGENTS.md",
726
882
  strategy: "markdown-managed" as const,
727
883
  templatePath: "docs/templates/agent-context/AGENTS.md.template.md",
728
- block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate personal AGENTS.md managed section" },
884
+ block: {
885
+ start: MARKDOWN_BEGIN,
886
+ end: MARKDOWN_END,
887
+ content: "phasegate personal AGENTS.md managed section",
888
+ },
729
889
  personalManualIfUnmanaged: true,
730
890
  },
731
891
  {
@@ -861,9 +1021,15 @@ export class RunInstallUseCase {
861
1021
  break;
862
1022
  }
863
1023
  }
864
- const missingManifest = baseManifest.findEntry(`${relativePath}/.harness-version`) === null
865
- || skills.some((skill) => baseManifest.findEntry(`${relativePath}/${skill}`) === null);
866
- const changed = current === null || !current.includes(expectedVersion) || !current.includes(expectedSkillSet) || missingSkill || missingManifest;
1024
+ const missingManifest =
1025
+ baseManifest.findEntry(`${relativePath}/.harness-version`) === null ||
1026
+ skills.some((skill) => baseManifest.findEntry(`${relativePath}/${skill}`) === null);
1027
+ const changed =
1028
+ current === null ||
1029
+ !current.includes(expectedVersion) ||
1030
+ !current.includes(expectedSkillSet) ||
1031
+ missingSkill ||
1032
+ missingManifest;
867
1033
  return {
868
1034
  path: relativePath,
869
1035
  action: changed ? "missing" : "will-skip",
@@ -877,7 +1043,12 @@ export class RunInstallUseCase {
877
1043
  };
878
1044
  }
879
1045
 
880
- private personalSkillsVersionHashInput(path: string, version: string, skillSet: "core" | "all", skills: readonly string[]): string {
1046
+ private personalSkillsVersionHashInput(
1047
+ path: string,
1048
+ version: string,
1049
+ skillSet: "core" | "all",
1050
+ skills: readonly string[],
1051
+ ): string {
881
1052
  return `personal-skills-version:${path}:${version}:${skillSet}:${skills.join(",")}`;
882
1053
  }
883
1054
 
@@ -902,9 +1073,15 @@ export class RunInstallUseCase {
902
1073
  break;
903
1074
  }
904
1075
  }
905
- const missingManifest = baseManifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null
906
- || skills.some((skill) => baseManifest.findEntry(`skills/${skill}`) === null);
907
- const changed = current === null || !current.includes(expectedVersion) || !current.includes(expectedSkillSet) || missingSkill || missingManifest;
1076
+ const missingManifest =
1077
+ baseManifest.findEntry(SHARED_SKILLS_VERSION_PATH) === null ||
1078
+ skills.some((skill) => baseManifest.findEntry(`skills/${skill}`) === null);
1079
+ const changed =
1080
+ current === null ||
1081
+ !current.includes(expectedVersion) ||
1082
+ !current.includes(expectedSkillSet) ||
1083
+ missingSkill ||
1084
+ missingManifest;
908
1085
  return {
909
1086
  path: "skills",
910
1087
  action: changed ? "missing" : "will-skip",
@@ -918,7 +1095,11 @@ export class RunInstallUseCase {
918
1095
  };
919
1096
  }
920
1097
 
921
- private async deploySharedSkills(input: RunInstallInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
1098
+ private async deploySharedSkills(
1099
+ input: RunInstallInput,
1100
+ skills: readonly string[],
1101
+ skillSet: SkillSet,
1102
+ ): Promise<void> {
922
1103
  const targetRoot = join(input.projectRoot, "skills");
923
1104
  await copySelectedSkillDirectories(this.modelDelegation, input.harnessRoot, input.projectRoot, targetRoot, skills);
924
1105
  await writeFile(