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.
@@ -0,0 +1,365 @@
1
+ // @unit installation
2
+ // @layer application
3
+ // @work-item-id WI-147
4
+
5
+ import { access, copyFile, lstat, mkdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
+ import type { DeploymentEntry } from "../../domain/deployment-entry.js";
8
+ import type { RepairMode } from "../../domain/repair-mode.js";
9
+ import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
10
+ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
11
+
12
+ type UninstallAction = "missing-manifest" | "delete" | "unlink" | "reverse-merge" | "skip" | "refuse";
13
+ type StrategyType = "created" | "json" | "shell" | "package-json" | "symlink" | "yaml-add" | "unknown";
14
+
15
+ export interface UninstallPlanItem {
16
+ readonly path: string;
17
+ readonly action: UninstallAction;
18
+ readonly repairMode: RepairMode;
19
+ readonly strategy: StrategyType;
20
+ readonly changed: boolean;
21
+ readonly summary: string;
22
+ readonly diff: string;
23
+ readonly skillHint: string | null;
24
+ }
25
+
26
+ export interface RunUninstallInput {
27
+ readonly projectRoot: string;
28
+ readonly harnessRoot: string;
29
+ readonly dryRun: boolean;
30
+ readonly apply: boolean;
31
+ readonly force: boolean;
32
+ }
33
+
34
+ export interface RunUninstallResult {
35
+ readonly plan: readonly UninstallPlanItem[];
36
+ readonly refused: readonly UninstallPlanItem[];
37
+ readonly changed: readonly UninstallPlanItem[];
38
+ readonly backupDir: string | null;
39
+ readonly archivedManifestPath: string | null;
40
+ }
41
+
42
+ const SKILL_HINT = "invoke /phasegate-config-doctor";
43
+ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
44
+ const SHELL_END = "# === phasegate managed (END) ===";
45
+ const PHASEGATE_SCRIPT_PREFIX = "phasegate:";
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ }
50
+
51
+ async function exists(path: string): Promise<boolean> {
52
+ try {
53
+ await access(path);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ async function readTextOrNull(path: string): Promise<string | null> {
61
+ try {
62
+ return await readFile(path, "utf8");
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function normalizeJsonEntry(value: unknown): string {
69
+ return JSON.stringify(value);
70
+ }
71
+
72
+ function removeHookEntries(existing: unknown, incoming: unknown): unknown[] {
73
+ const templateEntries = new Set((Array.isArray(incoming) ? incoming : []).map((entry) => normalizeJsonEntry(entry)));
74
+ return (Array.isArray(existing) ? existing : []).filter((entry) => !templateEntries.has(normalizeJsonEntry(entry)));
75
+ }
76
+
77
+ export function reverseJsonMerge(currentContent: string, templateContent: string): string {
78
+ const current = JSON.parse(currentContent) as unknown;
79
+ const template = JSON.parse(templateContent) as unknown;
80
+ const result: Record<string, unknown> = isRecord(current) ? { ...current } : {};
81
+ const currentHooks = isRecord(result.hooks) ? result.hooks : {};
82
+ const templateHooks = isRecord(template) && isRecord(template.hooks) ? template.hooks : {};
83
+ const nextHooks: Record<string, unknown> = {};
84
+ for (const [event, entries] of Object.entries(currentHooks)) {
85
+ const remaining = removeHookEntries(entries, templateHooks[event]);
86
+ if (remaining.length > 0) nextHooks[event] = remaining;
87
+ }
88
+ if (Object.keys(nextHooks).length > 0) result.hooks = nextHooks;
89
+ else delete result.hooks;
90
+
91
+ if (isRecord(result.permissions)) {
92
+ const templatePermissions = isRecord(template) && isRecord(template.permissions) ? template.permissions : {};
93
+ const templateDeny = new Set(Array.isArray(templatePermissions.deny) ? templatePermissions.deny : []);
94
+ const currentDeny = Array.isArray(result.permissions.deny) ? result.permissions.deny : [];
95
+ const remainingDeny = currentDeny.filter((entry) => !templateDeny.has(entry));
96
+ const nextPermissions = { ...result.permissions };
97
+ if (remainingDeny.length > 0) nextPermissions.deny = remainingDeny;
98
+ else delete nextPermissions.deny;
99
+ if (Object.keys(nextPermissions).length > 0) result.permissions = nextPermissions;
100
+ else delete result.permissions;
101
+ }
102
+
103
+ return `${JSON.stringify(result, null, 2)}\n`;
104
+ }
105
+
106
+ export function reverseShellMerge(currentContent: string): string {
107
+ const pattern = new RegExp(`\\n?${escapeRegExp(SHELL_BEGIN)}[\\s\\S]*?${escapeRegExp(SHELL_END)}\\n?`);
108
+ return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
109
+ }
110
+
111
+ export function reversePackageJsonMerge(currentContent: string): string {
112
+ const parsed = JSON.parse(currentContent) as unknown;
113
+ const result = isRecord(parsed) ? { ...parsed } : {};
114
+ if (isRecord(result.devDependencies)) {
115
+ const devDependencies = { ...result.devDependencies };
116
+ delete devDependencies.phasegate;
117
+ if (Object.keys(devDependencies).length > 0) result.devDependencies = devDependencies;
118
+ else delete result.devDependencies;
119
+ }
120
+ if (isRecord(result.scripts)) {
121
+ const scripts = Object.fromEntries(Object.entries(result.scripts).filter(([name]) => !name.startsWith(PHASEGATE_SCRIPT_PREFIX)));
122
+ if (Object.keys(scripts).length > 0) result.scripts = scripts;
123
+ else delete result.scripts;
124
+ }
125
+ return `${JSON.stringify(result, null, 2)}\n`;
126
+ }
127
+
128
+ function escapeRegExp(value: string): string {
129
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
130
+ }
131
+
132
+ export class RunUninstallUseCase {
133
+ constructor(
134
+ private readonly manifestRepository: ManifestRepositoryPort,
135
+ private readonly hashCalculator: HashCalculatorPort,
136
+ ) {}
137
+
138
+ async execute(input: RunUninstallInput): Promise<RunUninstallResult> {
139
+ const manifest = await this.manifestRepository.load(input.projectRoot);
140
+ if (manifest === null) {
141
+ const item: UninstallPlanItem = {
142
+ path: ".phasegate/manifest.json",
143
+ action: "missing-manifest",
144
+ repairMode: "manual",
145
+ strategy: "unknown",
146
+ changed: false,
147
+ summary: "manifest missing; run phasegate doctor and clean up manually",
148
+ diff: "manual cleanup required",
149
+ skillHint: SKILL_HINT,
150
+ };
151
+ return { plan: [item], refused: [], changed: [], backupDir: null, archivedManifestPath: null };
152
+ }
153
+
154
+ const plan: UninstallPlanItem[] = [];
155
+ const refused: UninstallPlanItem[] = [];
156
+ const changed: UninstallPlanItem[] = [];
157
+ const backupStamp = `uninstall-${new Date().toISOString().replace(/[:.]/g, "-")}`;
158
+ let backupDir: string | null = null;
159
+
160
+ const outcomes: Array<{
161
+ readonly entry: DeploymentEntry;
162
+ readonly item: UninstallPlanItem;
163
+ readonly needsBackup: boolean;
164
+ readonly apply: () => Promise<void>;
165
+ }> = [];
166
+ for (const entry of manifest.entries) {
167
+ const outcome = await this.planEntry(input, entry);
168
+ outcomes.push({ entry, ...outcome });
169
+ plan.push(outcome.item);
170
+ if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
171
+ refused.push({ ...outcome.item, action: "refuse" });
172
+ }
173
+ }
174
+
175
+ if (!input.apply || refused.length > 0) {
176
+ return { plan, refused, changed, backupDir: null, archivedManifestPath: null };
177
+ }
178
+
179
+ for (const outcome of outcomes) {
180
+ if (!outcome.item.changed) continue;
181
+ if (outcome.needsBackup) {
182
+ backupDir ??= join(input.projectRoot, ".phasegate", "backups", backupStamp);
183
+ await this.backup(input.projectRoot, outcome.entry.path, backupDir);
184
+ }
185
+ await outcome.apply();
186
+ await this.cleanupEmptyParents(input.projectRoot, outcome.entry.path);
187
+ changed.push(outcome.item);
188
+ }
189
+
190
+ const archivedManifestPath = await this.manifestRepository.archive(input.projectRoot);
191
+ return { plan, refused, changed, backupDir, archivedManifestPath };
192
+ }
193
+
194
+ private async planEntry(input: RunUninstallInput, entry: DeploymentEntry): Promise<{
195
+ readonly item: UninstallPlanItem;
196
+ readonly needsBackup: boolean;
197
+ readonly apply: () => Promise<void>;
198
+ }> {
199
+ const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
200
+ const currentContent = await readTextOrNull(absolutePath);
201
+ const strategy = this.strategyFor(entry.path, entry.mode);
202
+ if (currentContent === null && strategy !== "symlink") {
203
+ return {
204
+ item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: already absent`, "no changes", null),
205
+ needsBackup: false,
206
+ apply: async () => {},
207
+ };
208
+ }
209
+
210
+ if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
211
+ if (entry.mode === "created") return this.planCreated(input.projectRoot, entry, currentContent ?? "");
212
+ return this.planMerged(input, entry, currentContent ?? "", strategy);
213
+ }
214
+
215
+ private async planSymlink(projectRoot: string, entry: DeploymentEntry) {
216
+ const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
217
+ let target: string | null = null;
218
+ try {
219
+ const stat = await lstat(absolutePath);
220
+ target = stat.isSymbolicLink() ? await readlink(absolutePath) : null;
221
+ } catch {
222
+ target = null;
223
+ }
224
+ if (target === "../skills") {
225
+ return {
226
+ item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`, "- symlink ../skills", null),
227
+ needsBackup: false,
228
+ apply: async () => {
229
+ await rm(absolutePath, { force: true });
230
+ },
231
+ };
232
+ }
233
+ return {
234
+ item: this.item(entry.path, "skip", "manual", "symlink", false, `${entry.path}: non-phasegate symlink/path skipped`, "manual review required", null),
235
+ needsBackup: false,
236
+ apply: async () => {},
237
+ };
238
+ }
239
+
240
+ private async planCreated(projectRoot: string, entry: DeploymentEntry, currentContent: string) {
241
+ const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
242
+ const currentHash = this.hashCalculator.compute(currentContent);
243
+ const matchesManifest = currentHash.equals(entry.hash);
244
+ const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
245
+ return {
246
+ item: this.item(
247
+ entry.path,
248
+ "delete",
249
+ repairMode,
250
+ this.strategyFor(entry.path, entry.mode),
251
+ true,
252
+ matchesManifest ? `${entry.path}: delete created file` : `${entry.path}: hash mismatch; force required`,
253
+ "- file",
254
+ matchesManifest ? null : SKILL_HINT,
255
+ ),
256
+ needsBackup: !matchesManifest,
257
+ apply: async () => {
258
+ await rm(absolutePath, { force: true });
259
+ },
260
+ };
261
+ }
262
+
263
+ private async planMerged(input: RunUninstallInput, entry: DeploymentEntry, currentContent: string, strategy: StrategyType) {
264
+ const currentHash = this.hashCalculator.compute(currentContent);
265
+ const matchesManifest = currentHash.equals(entry.hash);
266
+ try {
267
+ const next = await this.reverseMerged(input.harnessRoot, entry.path, currentContent, strategy);
268
+ if (next === currentContent) {
269
+ return {
270
+ item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: managed portion already absent`, "no changes", null),
271
+ needsBackup: false,
272
+ apply: async () => {},
273
+ };
274
+ }
275
+ const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
276
+ return {
277
+ item: this.item(
278
+ entry.path,
279
+ "reverse-merge",
280
+ repairMode,
281
+ strategy,
282
+ true,
283
+ matchesManifest ? `${entry.path}: remove managed portion` : `${entry.path}: hash mismatch; force required`,
284
+ `~ ${currentContent.length} bytes -> ${next.length} bytes`,
285
+ matchesManifest ? null : SKILL_HINT,
286
+ ),
287
+ needsBackup: !matchesManifest,
288
+ apply: async () => {
289
+ await writeFile(this.resolveProjectPath(input.projectRoot, entry.path), next, "utf8");
290
+ },
291
+ };
292
+ } catch {
293
+ return {
294
+ item: this.item(entry.path, "skip", "manual", strategy, false, `${entry.path}: reverse merge requires manual review`, "manual review required", SKILL_HINT),
295
+ needsBackup: false,
296
+ apply: async () => {},
297
+ };
298
+ }
299
+ }
300
+
301
+ private async reverseMerged(harnessRoot: string, path: string, currentContent: string, strategy: StrategyType): Promise<string> {
302
+ if (strategy === "shell") return currentContent.includes(SHELL_BEGIN) ? reverseShellMerge(currentContent) : currentContent;
303
+ if (strategy === "package-json") return reversePackageJsonMerge(currentContent);
304
+ if (strategy === "json") return reverseJsonMerge(currentContent, await readFile(join(harnessRoot, this.templateFor(path)), "utf8"));
305
+ throw new Error(`Unsupported merged strategy: ${strategy}`);
306
+ }
307
+
308
+ private strategyFor(path: string, mode: string): StrategyType {
309
+ if (mode === "symlink") return "symlink";
310
+ if (mode === "created") return path.endsWith(".yml") || path.endsWith(".yaml") ? "yaml-add" : "created";
311
+ if (path === "package.json") return "package-json";
312
+ if (path.endsWith(".json")) return "json";
313
+ if (path.startsWith(".husky/")) return "shell";
314
+ return "unknown";
315
+ }
316
+
317
+ private templateFor(path: string): string {
318
+ if (path === ".claude/settings.json") return "templates/.claude/settings.json";
319
+ if (path === ".codex/hooks.json") return "templates/.codex/hooks.json";
320
+ throw new Error(`No template for ${path}`);
321
+ }
322
+
323
+ private async backup(projectRoot: string, relativePath: string, backupDir: string): Promise<void> {
324
+ const source = this.resolveProjectPath(projectRoot, relativePath);
325
+ if (!(await exists(source))) return;
326
+ const target = join(backupDir, relativePath);
327
+ await mkdir(dirname(target), { recursive: true });
328
+ await copyFile(source, target);
329
+ }
330
+
331
+ private async cleanupEmptyParents(projectRoot: string, relativePath: string): Promise<void> {
332
+ let current = dirname(this.resolveProjectPath(projectRoot, relativePath));
333
+ const root = resolve(projectRoot);
334
+ while (current.startsWith(root) && current !== root && relative(root, current) !== ".phasegate") {
335
+ try {
336
+ await rmdir(current);
337
+ } catch {
338
+ return;
339
+ }
340
+ current = dirname(current);
341
+ }
342
+ }
343
+
344
+ private resolveProjectPath(projectRoot: string, relativePath: string): string {
345
+ const absolutePath = resolve(projectRoot, relativePath);
346
+ const root = resolve(projectRoot);
347
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}/`)) {
348
+ throw new Error(`Manifest entry escapes project root: ${relativePath}`);
349
+ }
350
+ return absolutePath;
351
+ }
352
+
353
+ private item(
354
+ path: string,
355
+ action: UninstallAction,
356
+ repairMode: RepairMode,
357
+ strategy: StrategyType,
358
+ changed: boolean,
359
+ summary: string,
360
+ diff: string,
361
+ skillHint: string | null,
362
+ ): UninstallPlanItem {
363
+ return { path, action, repairMode, strategy, changed, summary, diff, skillHint };
364
+ }
365
+ }
@@ -1,6 +1,7 @@
1
1
  // @unit installation
2
2
  // @layer presentation
3
3
  // @work-item-id WI-145
4
+ // @work-item-id WI-148
4
5
 
5
6
  import { ClaudeHookMissingCheck } from "./application/checks/claude-hook-missing-check.js";
6
7
  import { ClaudeSkillsSymlinkCheck } from "./application/checks/claude-skills-symlink-check.js";
@@ -12,6 +13,8 @@ import { HuskyPreCommitMissingCheck } from "./application/checks/husky-pre-commi
12
13
  import { HuskyPrePushMissingCheck } from "./application/checks/husky-pre-push-missing-check.js";
13
14
  import { PackageJsonDevdepMissingCheck } from "./application/checks/package-json-devdep-missing-check.js";
14
15
  import { RunInstallUseCase } from "./application/usecases/run-install.js";
16
+ import { RunReconcileUseCase } from "./application/usecases/run-reconcile.js";
17
+ import { RunUninstallUseCase } from "./application/usecases/run-uninstall.js";
15
18
  import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
16
19
  import type { MergeStrategy } from "./domain/ports/merge-strategy.js";
17
20
  import type { ReconcileStrategy } from "./domain/ports/reconcile-strategy.js";
@@ -21,6 +24,8 @@ import { NodeCryptoHashAdapter } from "./infrastructure/adapters/node-crypto-has
21
24
  import { NodeFsFileInspectorAdapter } from "./infrastructure/adapters/node-fs-file-inspector-adapter.js";
22
25
  import { DoctorHandler } from "./presentation/cli/doctor-handler.js";
23
26
  import { InstallHandler } from "./presentation/cli/install-handler.js";
27
+ import { ReconcileHandler } from "./presentation/cli/reconcile-handler.js";
28
+ import { UninstallHandler } from "./presentation/cli/uninstall-handler.js";
24
29
 
25
30
  type FutureInstallationStrategyPorts = {
26
31
  readonly merge?: MergeStrategy<unknown>;
@@ -47,12 +52,18 @@ export function createInstallationModule() {
47
52
  ];
48
53
  const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(checks, inspector, manifestRepository);
49
54
  const runInstallUseCase = new RunInstallUseCase(manifestRepository, hashCalculator);
55
+ const runReconcileUseCase = new RunReconcileUseCase(manifestRepository, hashCalculator);
56
+ const runUninstallUseCase = new RunUninstallUseCase(manifestRepository, hashCalculator);
50
57
  return {
51
58
  manifestRepository,
52
59
  runDoctorDiagnosticsUseCase,
53
60
  runInstallUseCase,
61
+ runReconcileUseCase,
62
+ runUninstallUseCase,
54
63
  doctorHandler: new DoctorHandler(runDoctorDiagnosticsUseCase),
55
64
  installHandler: new InstallHandler(runInstallUseCase),
65
+ reconcileHandler: new ReconcileHandler(runReconcileUseCase),
66
+ uninstallHandler: new UninstallHandler(runUninstallUseCase),
56
67
  futureInstallationStrategyPorts,
57
68
  };
58
69
  }
@@ -52,8 +52,13 @@ export class FileSystemManifestRepositoryAdapter implements ManifestRepositoryPo
52
52
  }
53
53
  }
54
54
 
55
- async archive(_projectRoot: string): Promise<void> {
56
- throw new Error("Not yet implemented: manifest archive is owned by WI-147");
55
+ async archive(projectRoot: string): Promise<string> {
56
+ const phasegateDir = join(projectRoot, ".phasegate");
57
+ await mkdir(phasegateDir, { recursive: true });
58
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
59
+ const archivedPath = join(phasegateDir, `uninstalled-${stamp}.json`);
60
+ await rename(this.manifestPath(projectRoot), archivedPath);
61
+ return archivedPath;
57
62
  }
58
63
 
59
64
  private manifestPath(projectRoot: string): string {
@@ -0,0 +1,50 @@
1
+ // @unit installation
2
+ // @layer presentation
3
+ // @work-item-id WI-148
4
+
5
+ import type { RunReconcileUseCase } from "../../application/usecases/run-reconcile.js";
6
+
7
+ export interface ReconcileHandlerInput {
8
+ readonly projectRoot: string;
9
+ readonly harnessRoot: string;
10
+ readonly phasegateVersion: string;
11
+ readonly dryRun: boolean;
12
+ readonly apply: boolean;
13
+ readonly force: boolean;
14
+ readonly json: boolean;
15
+ }
16
+
17
+ export interface ReconcileHandlerResult {
18
+ readonly stdout: string;
19
+ readonly exitCode: number;
20
+ }
21
+
22
+ export class ReconcileHandler {
23
+ constructor(private readonly useCase: RunReconcileUseCase) {}
24
+
25
+ async execute(input: ReconcileHandlerInput): Promise<ReconcileHandlerResult> {
26
+ const result = await this.useCase.execute(input);
27
+ if (input.json) {
28
+ return {
29
+ stdout: JSON.stringify(result, null, 2),
30
+ exitCode: result.refused.length > 0 ? 1 : 0,
31
+ };
32
+ }
33
+ const lines = [
34
+ input.apply ? "phasegate reconcile apply" : "phasegate reconcile dry-run",
35
+ ...result.plan.map((item) => {
36
+ const hint = item.skillHint ? `; hint: ${item.skillHint}` : "";
37
+ return `- ${item.path}: ${item.action} (${item.repairMode}, ${item.strategy}); diff: ${item.diff}${hint}`;
38
+ }),
39
+ ];
40
+ if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
41
+ if (result.refused.length > 0) {
42
+ lines.push("");
43
+ lines.push("Refused ai-assisted/manual targets. Re-run with --force after reviewing the hint.");
44
+ }
45
+ return {
46
+ stdout: lines.join("\n"),
47
+ exitCode: result.refused.length > 0 ? 1 : 0,
48
+ };
49
+ }
50
+ }
@@ -0,0 +1,50 @@
1
+ // @unit installation
2
+ // @layer presentation
3
+ // @work-item-id WI-147
4
+
5
+ import type { RunUninstallUseCase } from "../../application/usecases/run-uninstall.js";
6
+
7
+ export interface UninstallHandlerInput {
8
+ readonly projectRoot: string;
9
+ readonly harnessRoot: string;
10
+ readonly dryRun: boolean;
11
+ readonly apply: boolean;
12
+ readonly force: boolean;
13
+ readonly json: boolean;
14
+ }
15
+
16
+ export interface UninstallHandlerResult {
17
+ readonly stdout: string;
18
+ readonly exitCode: number;
19
+ }
20
+
21
+ export class UninstallHandler {
22
+ constructor(private readonly useCase: RunUninstallUseCase) {}
23
+
24
+ async execute(input: UninstallHandlerInput): Promise<UninstallHandlerResult> {
25
+ const result = await this.useCase.execute(input);
26
+ if (input.json) {
27
+ return {
28
+ stdout: JSON.stringify(result, null, 2),
29
+ exitCode: result.refused.length > 0 ? 1 : 0,
30
+ };
31
+ }
32
+ const lines = [
33
+ input.apply ? "phasegate uninstall apply" : "phasegate uninstall dry-run",
34
+ ...result.plan.map((item) => {
35
+ const hint = item.skillHint ? `; hint: ${item.skillHint}` : "";
36
+ return `- ${item.path}: ${item.action} (${item.repairMode}, ${item.strategy}); diff: ${item.diff}${hint}`;
37
+ }),
38
+ ];
39
+ if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
40
+ if (result.archivedManifestPath !== null) lines.push(`archived manifest: ${result.archivedManifestPath}`);
41
+ if (result.refused.length > 0) {
42
+ lines.push("");
43
+ lines.push("Refused ai-assisted/manual targets. Re-run with --force after reviewing the hint.");
44
+ }
45
+ return {
46
+ stdout: lines.join("\n"),
47
+ exitCode: result.refused.length > 0 ? 1 : 0,
48
+ };
49
+ }
50
+ }