phasegate 0.145.2 → 0.145.4

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.145.4] - 2026-05-11
11
+
12
+ ### Added
13
+
14
+ - **WI-148 — manifest-driven reconcile + init deprecation** — `phasegate reconcile --dry-run|--apply [--force] [--json]` を追加し、`.phasegate/manifest.json` に記録済みの PhaseGate managed files を現行 bundled template / package version に追従できるようにした。
15
+ - `merged` entries は PhaseGate managed portion だけを更新し、user scripts / hooks / dependencies は保持する。
16
+ - `created` entries は hash が manifest と一致する場合のみ template に追従し、user 改変ありの場合は `--force` 無しで refuse、force 時は `.phasegate/backups/reconcile-*/` に退避してから上書きする。
17
+ - manifest に無い現行 deploy target は install と同じく追加し、apply 後は manifest の version/hash を更新する。
18
+ - `update-skills` は互換 alias として `reconcile` に委譲し、`init` 実行時は v1.0 削除予定の deprecation warning を表示する。
19
+
20
+ ## [0.145.3] - 2026-05-11
21
+
22
+ ### Added
23
+
24
+ - **WI-147 — manifest-driven uninstall** — `phasegate uninstall --dry-run|--apply [--force] [--json]` を追加し、`.phasegate/manifest.json` に記録された managed files を clean removal できるようにした。
25
+ - `created` / `symlink` entries は削除し、JSON / shell / `package.json` の `merged` entries は PhaseGate managed portion だけを除去して user content を保持する。
26
+ - hash mismatch は `--force` 無しで refuse し、force 時は `.phasegate/backups/uninstall-*/` に snapshot を保存してから削除する。
27
+ - uninstall 完了後、manifest は `.phasegate/uninstalled-*.json` に archive される。
28
+
10
29
  ## [0.145.2] - 2026-05-11
11
30
 
12
31
  ### Added
package/README.ja.md CHANGED
@@ -69,7 +69,7 @@ Node.js >= 18, npm >= 9, TypeScript 5.x
69
69
  # 1. インストール
70
70
  npm install --save-dev phasegate
71
71
 
72
- # 2. プロジェクトを初期化
72
+ # 2. 新規プロジェクトを初期化
73
73
  npx phasegate init --name my-project --with-husky --with-ci
74
74
 
75
75
  # 3. AI agent を起動して /product-architect から始める
@@ -97,6 +97,34 @@ claude
97
97
 
98
98
  「設計してから書け」を強制する仕組みなので、設計文書はユーザーがスキル経由で作るのが既定動作です。
99
99
 
100
+ 既存プロジェクトに導入する場合は、構造化 install で差分を確認してから適用します。
101
+
102
+ ```bash
103
+ npx phasegate install --dry-run
104
+ npx phasegate install --apply
105
+ npx phasegate doctor
106
+ ```
107
+
108
+ `install` は既存の Claude / Codex hooks や Husky script を捨てずに PhaseGate の設定を merge します。書き込み前に変更予定を表示し、package scripts と `phasegate` devDependency、agent skill symlink、未作成の CI workflow、`.phasegate/manifest.json` を整えます。強制的な managed 更新が必要な場合は `npx phasegate install --apply --force` を使います。この場合、置き換え対象は `.phasegate/backups/` に退避されます。
109
+
110
+ 後で PhaseGate を外す場合は、manifest ベースの uninstall を使います。
111
+
112
+ ```bash
113
+ npx phasegate uninstall --dry-run
114
+ npx phasegate uninstall --apply
115
+ ```
116
+
117
+ `uninstall` は manifest を読んで、PhaseGate が作成したファイルを削除し、merge した Claude / Codex / Husky / `package.json` から PhaseGate 管理部分だけを取り除きます。ユーザーの既存設定は保持し、manifest は `.phasegate/` 配下に履歴として archive します。
118
+
119
+ PhaseGate をアップグレードした後は、reconcile で既存の managed files を現在の bundled template に追従できます。
120
+
121
+ ```sh
122
+ npx phasegate reconcile --dry-run
123
+ npx phasegate reconcile --apply
124
+ ```
125
+
126
+ `reconcile` は PhaseGate 管理部分だけを更新し、ユーザーの hook / script / dependency は保持します。新しい deploy target が追加されていれば install と同じく追加し、`.phasegate/manifest.json` の version / hash も更新します。install 後に user 改変された managed file は `--force` 無しでは refuse し、force 時は `.phasegate/backups/reconcile-<timestamp>/` に退避してから上書きします。
127
+
100
128
  ### Codex CLI を使う場合
101
129
 
102
130
  ```bash
package/README.md CHANGED
@@ -70,7 +70,7 @@ Node.js >= 18, npm >= 9, TypeScript 5.x
70
70
  # 1. Install
71
71
  npm install --save-dev phasegate
72
72
 
73
- # 2. Initialize the project
73
+ # 2. Initialize a new project
74
74
  npx phasegate init --name my-project --with-husky --with-ci
75
75
 
76
76
  # 3. Start your AI agent and begin with product design
@@ -90,6 +90,34 @@ claude
90
90
 
91
91
  `init` intentionally does **not** create `docs/inception/` work item directories or `docs/product/` design documents. Those are produced later by skills such as `/product-architect`, `/domain-designer`, and `/logical-designer`. That is the core contract: no design, no code.
92
92
 
93
+ For an existing project, preview and apply a structured install instead:
94
+
95
+ ```bash
96
+ npx phasegate install --dry-run
97
+ npx phasegate install --apply
98
+ npx phasegate doctor
99
+ ```
100
+
101
+ `install` merges PhaseGate into the current project without discarding existing Claude/Codex hooks or Husky scripts. It reports planned changes before writing, adds package scripts and the `phasegate` devDependency, creates agent skill links, writes the CI workflow when missing, and records managed files in `.phasegate/manifest.json`. If an existing file needs a forced managed update, run `npx phasegate install --apply --force`; PhaseGate backs up replaced files under `.phasegate/backups/`.
102
+
103
+ To remove PhaseGate from that project later, run:
104
+
105
+ ```bash
106
+ npx phasegate uninstall --dry-run
107
+ npx phasegate uninstall --apply
108
+ ```
109
+
110
+ `uninstall` uses the manifest to delete created files and remove only PhaseGate-managed portions from merged Claude/Codex, Husky, and `package.json` files. User content is preserved, and the manifest is archived under `.phasegate/`.
111
+
112
+ When you upgrade PhaseGate, reconcile existing managed files with the current bundled templates:
113
+
114
+ ```sh
115
+ npx phasegate reconcile --dry-run
116
+ npx phasegate reconcile --apply
117
+ ```
118
+
119
+ `reconcile` updates only PhaseGate-managed portions, keeps user content, adds newly introduced managed targets, and refreshes `.phasegate/manifest.json` with the current version and hashes. If a managed file was edited after install, `reconcile --apply` refuses that entry until you rerun with `--force`; PhaseGate writes a backup under `.phasegate/backups/reconcile-<timestamp>/`.
120
+
93
121
  ### Codex CLI
94
122
 
95
123
  ```bash
@@ -17,7 +17,7 @@ Or add it directly to your `package.json`:
17
17
  ```json
18
18
  {
19
19
  "devDependencies": {
20
- "phasegate": "^0.14.0"
20
+ "phasegate": "^0.145.3"
21
21
  }
22
22
  }
23
23
  ```
@@ -30,7 +30,7 @@ npm install
30
30
 
31
31
  ## Project Setup
32
32
 
33
- ### 1. Initialize
33
+ ### New Projects
34
34
 
35
35
  ```bash
36
36
  npx phasegate init --name <project-name>
@@ -44,7 +44,49 @@ For Codex, project initialization stops at the project boundary. After `npx phas
44
44
  codex features enable codex_hooks
45
45
  ```
46
46
 
47
- ### 2. Copy design principle documents
47
+ ### Existing Projects
48
+
49
+ Use `install` when the project already has package scripts, hooks, or CI files that should be preserved.
50
+
51
+ ```bash
52
+ npx phasegate install --dry-run
53
+ npx phasegate install --apply
54
+ npx phasegate doctor
55
+ ```
56
+
57
+ `install --dry-run` reports whether each target will be created, merged, skipped, or refused. `install --apply` performs the merge, adds package scripts and the `phasegate` devDependency, creates `.claude/skills` and `.codex/skills` links, writes the CI workflow when missing, and records managed entries in `.phasegate/manifest.json`.
58
+
59
+ If a managed update must replace existing custom content, use:
60
+
61
+ ```bash
62
+ npx phasegate install --apply --force
63
+ ```
64
+
65
+ Forced updates write backups under `.phasegate/backups/<timestamp>/` before applying changes.
66
+
67
+ To remove PhaseGate-managed files later, preview and apply uninstall:
68
+
69
+ ```bash
70
+ npx phasegate uninstall --dry-run
71
+ npx phasegate uninstall --apply
72
+ ```
73
+
74
+ `uninstall` reads `.phasegate/manifest.json`, deletes files that PhaseGate created, removes only PhaseGate-managed portions from merged JSON, Husky, and `package.json` files, and archives the manifest as `.phasegate/uninstalled-<timestamp>.json`. If a managed file was modified after install, `uninstall --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/uninstall-<timestamp>/`.
75
+
76
+ After upgrading PhaseGate, reconcile existing managed files with the bundled templates from the new version:
77
+
78
+ ```bash
79
+ npx phasegate reconcile --dry-run
80
+ npx phasegate reconcile --apply
81
+ ```
82
+
83
+ `reconcile` updates PhaseGate-managed portions, preserves user content, adds newly introduced managed targets, and refreshes `.phasegate/manifest.json` with current version/hash metadata. If a managed file was edited after install, `reconcile --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/reconcile-<timestamp>/`.
84
+
85
+ `phasegate update-skills` remains available as a compatibility alias for `phasegate reconcile`.
86
+
87
+ ### Manual Setup Pieces
88
+
89
+ If you do not use `init` or `install`, copy the design principle documents manually:
48
90
 
49
91
  ```bash
50
92
  cp node_modules/phasegate/docs/folder_management_rules.md docs/
@@ -52,11 +94,11 @@ mkdir -p docs/principles
52
94
  cp node_modules/phasegate/docs/principles/*.md docs/principles/
53
95
  ```
54
96
 
55
- ### 3. Create product overview
97
+ ### Create product overview
56
98
 
57
99
  Create `docs/product/<your_product>_overview.md` as the starting point for AIDLC.
58
100
 
59
- ### 4. Start Claude Code
101
+ ### Start Claude Code
60
102
 
61
103
  ```bash
62
104
  claude # at project root
@@ -88,5 +130,6 @@ reports/
88
130
  ```bash
89
131
  npx phasegate --version
90
132
  npx phasegate lint
91
- npm run phasegate:status
133
+ npx phasegate doctor
134
+ npm run phasegate:check-ready
92
135
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.145.2",
3
+ "version": "0.145.4",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -8,5 +8,5 @@ export interface ManifestRepositoryPort {
8
8
  load(projectRoot: string): Promise<DeploymentManifest | null>;
9
9
  save(projectRoot: string, manifest: DeploymentManifest): Promise<void>;
10
10
  exists(projectRoot: string): Promise<boolean>;
11
- archive(projectRoot: string): Promise<void>;
11
+ archive(projectRoot: string): Promise<string>;
12
12
  }
@@ -0,0 +1,437 @@
1
+ // @unit installation
2
+ // @layer application
3
+ // @work-item-id WI-148
4
+
5
+ import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, symlink, writeFile } from "node:fs/promises";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
+ import { DeploymentEntry } from "../../domain/deployment-entry.js";
8
+ import { DeploymentManifest } from "../../domain/deployment-manifest.js";
9
+ import type { ManagedBlockInput } from "../../domain/managed-block.js";
10
+ import type { RepairMode } from "../../domain/repair-mode.js";
11
+ import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
12
+ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
13
+
14
+ type ReconcileAction = "missing-manifest" | "update" | "add" | "link" | "skip" | "refuse";
15
+ type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "symlink" | "unknown";
16
+
17
+ export interface ReconcilePlanItem {
18
+ readonly path: string;
19
+ readonly action: ReconcileAction;
20
+ readonly repairMode: RepairMode;
21
+ readonly strategy: StrategyType;
22
+ readonly changed: boolean;
23
+ readonly summary: string;
24
+ readonly diff: string;
25
+ readonly skillHint: string | null;
26
+ }
27
+
28
+ export interface RunReconcileInput {
29
+ readonly projectRoot: string;
30
+ readonly harnessRoot: string;
31
+ readonly phasegateVersion: string;
32
+ readonly dryRun: boolean;
33
+ readonly apply: boolean;
34
+ readonly force: boolean;
35
+ }
36
+
37
+ export interface RunReconcileResult {
38
+ readonly plan: readonly ReconcilePlanItem[];
39
+ readonly refused: readonly ReconcilePlanItem[];
40
+ readonly changed: readonly ReconcilePlanItem[];
41
+ readonly backupDir: string | null;
42
+ }
43
+
44
+ interface ReconcileTarget {
45
+ readonly path: string;
46
+ readonly strategy: StrategyType;
47
+ readonly templatePath?: string;
48
+ readonly executable?: boolean;
49
+ readonly block?: ManagedBlockInput;
50
+ }
51
+
52
+ const SKILL_HINT = "invoke /phasegate-config-doctor";
53
+ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
54
+ const SHELL_END = "# === phasegate managed (END) ===";
55
+
56
+ function isRecord(value: unknown): value is Record<string, unknown> {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+
60
+ async function exists(path: string): Promise<boolean> {
61
+ try {
62
+ await access(path);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ async function readTextOrNull(path: string): Promise<string | null> {
70
+ try {
71
+ return await readFile(path, "utf8");
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function normalizeJsonEntry(value: unknown): string {
78
+ return JSON.stringify(value);
79
+ }
80
+
81
+ function escapeRegExp(value: string): string {
82
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
+ }
84
+
85
+ function phasegateOwnedJsonEntry(entry: unknown): boolean {
86
+ return normalizeJsonEntry(entry).includes("phasegate");
87
+ }
88
+
89
+ function replaceHookArrays(existing: unknown, incoming: unknown): unknown[] {
90
+ const userEntries = (Array.isArray(existing) ? existing : []).filter((entry) => !phasegateOwnedJsonEntry(entry));
91
+ const seen = new Set(userEntries.map((entry) => normalizeJsonEntry(entry)));
92
+ const result = [...userEntries];
93
+ for (const entry of Array.isArray(incoming) ? incoming : []) {
94
+ const key = normalizeJsonEntry(entry);
95
+ if (!seen.has(key)) {
96
+ result.push(entry);
97
+ seen.add(key);
98
+ }
99
+ }
100
+ return result;
101
+ }
102
+
103
+ function reconcileJsonObject(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
104
+ const result: Record<string, unknown> = { ...existing };
105
+ const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
106
+ const incomingHooks = isRecord(incoming.hooks) ? incoming.hooks : {};
107
+ const nextHooks: Record<string, unknown> = {};
108
+ const events = new Set([...Object.keys(existingHooks), ...Object.keys(incomingHooks)]);
109
+ for (const event of events) {
110
+ const entries = replaceHookArrays(existingHooks[event], incomingHooks[event]);
111
+ if (entries.length > 0) nextHooks[event] = entries;
112
+ }
113
+ if (Object.keys(nextHooks).length > 0) result.hooks = nextHooks;
114
+ else delete result.hooks;
115
+
116
+ const existingPermissions = isRecord(existing.permissions) ? existing.permissions : {};
117
+ const incomingPermissions = isRecord(incoming.permissions) ? incoming.permissions : {};
118
+ const incomingDeny = Array.isArray(incomingPermissions.deny) ? incomingPermissions.deny : [];
119
+ if (Object.keys(existingPermissions).length > 0 || Object.keys(incomingPermissions).length > 0) {
120
+ const existingDeny = Array.isArray(existingPermissions.deny) ? existingPermissions.deny : [];
121
+ const preservedDeny = existingDeny.filter((entry) => typeof entry !== "string" || !incomingDeny.includes(entry));
122
+ result.permissions = {
123
+ ...existingPermissions,
124
+ ...incomingPermissions,
125
+ deny: [...new Set([...preservedDeny, ...incomingDeny])],
126
+ };
127
+ }
128
+ return result;
129
+ }
130
+
131
+ function reconcileShell(existing: string | null, incoming: string): string {
132
+ const incomingBlock = `${SHELL_BEGIN}\n${incoming.trim()}\n${SHELL_END}`;
133
+ if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
134
+ const pattern = new RegExp(`${escapeRegExp(SHELL_BEGIN)}[\\s\\S]*?${escapeRegExp(SHELL_END)}`);
135
+ if (pattern.test(existing)) return existing.replace(pattern, incomingBlock).replace(/\s*$/, "\n");
136
+ return `${existing.replace(/\s*$/, "\n\n")}${incomingBlock}\n`;
137
+ }
138
+
139
+ function reconcilePackageJson(existing: Record<string, unknown>, version: string): Record<string, unknown> {
140
+ const devDependencies = isRecord(existing.devDependencies) ? existing.devDependencies : {};
141
+ const scripts = isRecord(existing.scripts) ? existing.scripts : {};
142
+ return {
143
+ ...existing,
144
+ scripts: {
145
+ ...scripts,
146
+ "phasegate:lint": "phasegate lint",
147
+ "phasegate:check-ready": "phasegate phasegate:check-ready",
148
+ "phasegate:doctor": "phasegate doctor",
149
+ },
150
+ devDependencies: {
151
+ ...devDependencies,
152
+ phasegate: `^${version}`,
153
+ },
154
+ };
155
+ }
156
+
157
+ export class RunReconcileUseCase {
158
+ constructor(
159
+ private readonly manifestRepository: ManifestRepositoryPort,
160
+ private readonly hashCalculator: HashCalculatorPort,
161
+ ) {}
162
+
163
+ async execute(input: RunReconcileInput): Promise<RunReconcileResult> {
164
+ const manifest = await this.manifestRepository.load(input.projectRoot);
165
+ if (manifest === null) {
166
+ const item = this.item(
167
+ ".phasegate/manifest.json",
168
+ "missing-manifest",
169
+ "manual",
170
+ "unknown",
171
+ false,
172
+ "manifest missing; run phasegate install before reconcile",
173
+ "manual setup required",
174
+ SKILL_HINT,
175
+ );
176
+ return { plan: [item], refused: [], changed: [], backupDir: null };
177
+ }
178
+
179
+ const targets = this.createTargets();
180
+ const targetsByPath = new Map(targets.map((target) => [target.path, target]));
181
+ let nextManifest = DeploymentManifest.reconstitute({
182
+ version: input.phasegateVersion,
183
+ installedAt: manifest.installedAt,
184
+ entries: manifest.entries,
185
+ });
186
+ const plan: ReconcilePlanItem[] = [];
187
+ const refused: ReconcilePlanItem[] = [];
188
+ const changed: ReconcilePlanItem[] = [];
189
+ const backupStamp = `reconcile-${new Date().toISOString().replace(/[:.]/g, "-")}`;
190
+ let backupDir: string | null = null;
191
+
192
+ const outcomes: Array<{
193
+ readonly item: ReconcilePlanItem;
194
+ readonly needsBackup: boolean;
195
+ readonly apply: () => Promise<string | null>;
196
+ }> = [];
197
+
198
+ for (const entry of manifest.entries) {
199
+ const target = targetsByPath.get(entry.path);
200
+ if (target === undefined) {
201
+ const item = this.item(entry.path, "skip", "manual", "unknown", false, `${entry.path}: no bundled template`, "manual review required", SKILL_HINT);
202
+ plan.push(item);
203
+ continue;
204
+ }
205
+ const outcome = await this.planManagedEntry(input, entry, target);
206
+ outcomes.push(outcome);
207
+ plan.push(outcome.item);
208
+ if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
209
+ refused.push({ ...outcome.item, action: "refuse" });
210
+ }
211
+ }
212
+
213
+ for (const target of targets) {
214
+ if (manifest.findEntry(target.path) !== null) continue;
215
+ const outcome = await this.planMissingTarget(input, target);
216
+ outcomes.push(outcome);
217
+ plan.push(outcome.item);
218
+ if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
219
+ refused.push({ ...outcome.item, action: "refuse" });
220
+ }
221
+ }
222
+
223
+ if (!input.apply || refused.length > 0) {
224
+ return { plan, refused, changed, backupDir: null };
225
+ }
226
+
227
+ for (const outcome of outcomes) {
228
+ if (!outcome.item.changed) continue;
229
+ if (outcome.needsBackup) {
230
+ backupDir ??= join(input.projectRoot, ".phasegate", "backups", backupStamp);
231
+ await this.backup(input.projectRoot, outcome.item.path, backupDir);
232
+ }
233
+ const hashContent = await outcome.apply();
234
+ changed.push(outcome.item);
235
+ if (hashContent !== null) {
236
+ const mode = outcome.item.strategy === "symlink" ? "symlink" : outcome.item.action === "add" ? "created" : (manifest.findEntry(outcome.item.path)?.mode ?? "merged");
237
+ nextManifest = nextManifest.addEntry(
238
+ DeploymentEntry.create({
239
+ path: outcome.item.path,
240
+ mode,
241
+ block: mode === "merged" ? this.managedBlockFor(outcome.item.path, outcome.item.strategy) : null,
242
+ hash: this.hashCalculator.compute(hashContent),
243
+ deployedAt: new Date().toISOString(),
244
+ }),
245
+ );
246
+ }
247
+ }
248
+
249
+ if (changed.length > 0 || manifest.version !== input.phasegateVersion) {
250
+ await this.manifestRepository.save(input.projectRoot, nextManifest);
251
+ }
252
+ return { plan, refused, changed, backupDir };
253
+ }
254
+
255
+ private async planManagedEntry(input: RunReconcileInput, entry: DeploymentEntry, target: ReconcileTarget) {
256
+ if (target.strategy === "symlink") return this.planSymlink(input.projectRoot, target.path);
257
+ const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
258
+ const before = await readTextOrNull(absolutePath);
259
+ if (before === null) return this.planMissingTarget(input, target);
260
+ const currentHash = this.hashCalculator.compute(before);
261
+ const matchesManifest = currentHash.equals(entry.hash);
262
+ const template = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
263
+ const next = entry.mode === "created" && target.strategy !== "package-json"
264
+ ? template
265
+ : this.reconcileContent(target, before, template, input.phasegateVersion);
266
+ const changed = before !== next;
267
+ const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
268
+ return {
269
+ item: this.item(
270
+ entry.path,
271
+ changed ? "update" : "skip",
272
+ repairMode,
273
+ target.strategy,
274
+ changed,
275
+ changed ? `${entry.path}: update managed portion` : `${entry.path}: already up to date`,
276
+ this.diffSummary(before, next),
277
+ repairMode === "ai-assisted" ? SKILL_HINT : null,
278
+ ),
279
+ needsBackup: !matchesManifest || input.force,
280
+ apply: async () => {
281
+ await mkdir(dirname(absolutePath), { recursive: true });
282
+ await writeFile(absolutePath, next, "utf8");
283
+ if (target.executable) await chmod(absolutePath, 0o755);
284
+ return next;
285
+ },
286
+ };
287
+ }
288
+
289
+ private async planMissingTarget(input: RunReconcileInput, target: ReconcileTarget) {
290
+ if (target.strategy === "symlink") return this.planSymlink(input.projectRoot, target.path);
291
+ const absolutePath = this.resolveProjectPath(input.projectRoot, target.path);
292
+ const before = await readTextOrNull(absolutePath);
293
+ const template = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
294
+ const next = before === null && target.strategy !== "package-json"
295
+ ? template
296
+ : this.reconcileContent(target, before, template, input.phasegateVersion);
297
+ const changed = before !== next;
298
+ const repairMode: RepairMode = target.strategy === "shell" && before !== null && !before.includes(SHELL_BEGIN) ? "ai-assisted" : "mechanical";
299
+ return {
300
+ item: this.item(
301
+ target.path,
302
+ before === null ? "add" : changed ? "update" : "skip",
303
+ repairMode,
304
+ target.strategy,
305
+ changed,
306
+ changed ? `${target.path}: add missing managed target` : `${target.path}: already up to date`,
307
+ this.diffSummary(before, next),
308
+ repairMode === "ai-assisted" ? SKILL_HINT : null,
309
+ ),
310
+ needsBackup: before !== null,
311
+ apply: async () => {
312
+ await mkdir(dirname(absolutePath), { recursive: true });
313
+ await writeFile(absolutePath, next, "utf8");
314
+ if (target.executable) await chmod(absolutePath, 0o755);
315
+ return next;
316
+ },
317
+ };
318
+ }
319
+
320
+ private async planSymlink(projectRoot: string, relativePath: string) {
321
+ const absolutePath = this.resolveProjectPath(projectRoot, relativePath);
322
+ try {
323
+ const stat = await lstat(absolutePath);
324
+ if (stat.isSymbolicLink() && (await readlink(absolutePath)) === "../skills") {
325
+ return {
326
+ item: this.item(relativePath, "skip", "mechanical", "symlink", false, `${relativePath}: already linked`, "no changes", null),
327
+ needsBackup: false,
328
+ apply: async () => "../skills",
329
+ };
330
+ }
331
+ return {
332
+ item: this.item(relativePath, "skip", "manual", "symlink", false, `${relativePath}: existing non-phasegate path requires manual review`, "manual review required", SKILL_HINT),
333
+ needsBackup: false,
334
+ apply: async () => null,
335
+ };
336
+ } catch {
337
+ return {
338
+ item: this.item(relativePath, "link", "mechanical", "symlink", true, `${relativePath}: create symlink`, "+ symlink ../skills", null),
339
+ needsBackup: false,
340
+ apply: async () => {
341
+ await mkdir(join(projectRoot, "skills"), { recursive: true });
342
+ await mkdir(dirname(absolutePath), { recursive: true });
343
+ await symlink("../skills", absolutePath, process.platform === "win32" ? "junction" : "dir");
344
+ return "../skills";
345
+ },
346
+ };
347
+ }
348
+ }
349
+
350
+ private reconcileContent(target: ReconcileTarget, before: string | null, template: string, version: string): string {
351
+ if (target.strategy === "yaml-add") return template;
352
+ if (target.strategy === "shell") return reconcileShell(before, template);
353
+ if (target.strategy === "package-json") {
354
+ const existing = before === null ? {} : (JSON.parse(before) as unknown);
355
+ return `${JSON.stringify(reconcilePackageJson(isRecord(existing) ? existing : {}, version), null, 2)}\n`;
356
+ }
357
+ const existing = before === null ? {} : (JSON.parse(before) as unknown);
358
+ const incoming = JSON.parse(template) as unknown;
359
+ return `${JSON.stringify(reconcileJsonObject(isRecord(existing) ? existing : {}, isRecord(incoming) ? incoming : {}), null, 2)}\n`;
360
+ }
361
+
362
+ private createTargets(): readonly ReconcileTarget[] {
363
+ return [
364
+ { path: ".claude/settings.json", strategy: "json", templatePath: "templates/.claude/settings.json" },
365
+ { path: ".codex/hooks.json", strategy: "json", templatePath: "templates/.codex/hooks.json" },
366
+ {
367
+ path: ".husky/pre-commit",
368
+ strategy: "shell",
369
+ templatePath: "docs/templates/hooks/pre-commit",
370
+ executable: true,
371
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-commit managed block" },
372
+ },
373
+ {
374
+ path: ".husky/commit-msg",
375
+ strategy: "shell",
376
+ templatePath: "docs/templates/hooks/commit-msg",
377
+ executable: true,
378
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate commit-msg managed block" },
379
+ },
380
+ {
381
+ path: ".husky/pre-push",
382
+ strategy: "shell",
383
+ templatePath: "docs/templates/hooks/pre-push",
384
+ executable: true,
385
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-push managed block" },
386
+ },
387
+ { path: ".github/workflows/phasegate-aidlc-gate.yml", strategy: "yaml-add", templatePath: "docs/templates/ci/aidlc-gate.yml" },
388
+ { path: "package.json", strategy: "package-json", templatePath: "package.json" },
389
+ { path: ".claude/skills", strategy: "symlink" },
390
+ { path: ".codex/skills", strategy: "symlink" },
391
+ ];
392
+ }
393
+
394
+ private async backup(projectRoot: string, relativePath: string, backupDir: string): Promise<void> {
395
+ const source = this.resolveProjectPath(projectRoot, relativePath);
396
+ if (!(await exists(source))) return;
397
+ const target = join(backupDir, relativePath);
398
+ await mkdir(dirname(target), { recursive: true });
399
+ await copyFile(source, target);
400
+ }
401
+
402
+ private managedBlockFor(path: string, strategy: StrategyType): ManagedBlockInput | null {
403
+ if (strategy === "shell") return { start: SHELL_BEGIN, end: SHELL_END, content: `phasegate ${path} managed block` };
404
+ if (strategy === "json" || strategy === "package-json") {
405
+ return { start: "phasegate structured merge", end: "phasegate structured merge", content: `${strategy}:${path}` };
406
+ }
407
+ return null;
408
+ }
409
+
410
+ private resolveProjectPath(projectRoot: string, relativePath: string): string {
411
+ const absolutePath = resolve(projectRoot, relativePath);
412
+ const root = resolve(projectRoot);
413
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}/`) && relative(root, absolutePath).startsWith("..")) {
414
+ throw new Error(`Manifest entry escapes project root: ${relativePath}`);
415
+ }
416
+ return absolutePath;
417
+ }
418
+
419
+ private diffSummary(before: string | null, next: string): string {
420
+ if (before === next) return "no changes";
421
+ if (before === null) return `+ ${next.split(/\r?\n/).filter(Boolean).length} lines`;
422
+ return `~ ${before.length} bytes -> ${next.length} bytes`;
423
+ }
424
+
425
+ private item(
426
+ path: string,
427
+ action: ReconcileAction,
428
+ repairMode: RepairMode,
429
+ strategy: StrategyType,
430
+ changed: boolean,
431
+ summary: string,
432
+ diff: string,
433
+ skillHint: string | null,
434
+ ): ReconcilePlanItem {
435
+ return { path, action, repairMode, strategy, changed, summary, diff, skillHint };
436
+ }
437
+ }