phasegate 0.152.8 → 0.153.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.
@@ -1,6 +1,8 @@
1
1
  // @unit installation
2
2
  // @layer application
3
3
  // @work-item-id WI-146
4
+ // @work-item-id WI-174
5
+ // @work-item-id WI-175
4
6
 
5
7
  import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink } from "node:fs/promises";
6
8
  import { dirname, join } from "node:path";
@@ -12,7 +14,7 @@ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.j
12
14
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
13
15
 
14
16
  type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
15
- type StrategyType = "json" | "shell" | "yaml-add" | "package-json";
17
+ type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed";
16
18
 
17
19
  export interface InstallPlanItem {
18
20
  readonly path: string;
@@ -36,6 +38,9 @@ export interface RunInstallInput {
36
38
  readonly includeCodex?: boolean;
37
39
  readonly includeHusky?: boolean;
38
40
  readonly includeCi?: boolean;
41
+ readonly skillSet?: "core" | "all";
42
+ readonly workflow?: "standard" | "strict";
43
+ readonly agent?: "claude" | "codex" | "both";
39
44
  }
40
45
 
41
46
  export interface RunInstallResult {
@@ -43,6 +48,16 @@ export interface RunInstallResult {
43
48
  readonly refused: readonly InstallPlanItem[];
44
49
  readonly changed: readonly InstallPlanItem[];
45
50
  readonly backupDir: string | null;
51
+ readonly error?: TargetAwareApplyError;
52
+ }
53
+
54
+ export interface TargetAwareApplyError {
55
+ readonly target: string;
56
+ readonly operation: string;
57
+ readonly code: string;
58
+ readonly likelyCause: string;
59
+ readonly recovery: string;
60
+ readonly partialChanges: readonly string[];
46
61
  }
47
62
 
48
63
  interface InstallTarget {
@@ -58,6 +73,8 @@ const PHASEGATE_SCRIPT_VERSION = "^0.0.0";
58
73
 
59
74
  const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
60
75
  const SHELL_END = "# === phasegate managed (END) ===";
76
+ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
77
+ const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
61
78
 
62
79
  function isRecord(value: unknown): value is Record<string, unknown> {
63
80
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -131,6 +148,50 @@ function mergeShell(existing: string | null, incoming: string): string {
131
148
  return `${existing.replace(/\s*$/, "\n\n")}${block}\n`;
132
149
  }
133
150
 
151
+ function managedMarkdownBlock(content: string): string {
152
+ const start = content.indexOf(MARKDOWN_BEGIN);
153
+ const end = content.indexOf(MARKDOWN_END);
154
+ if (start === -1 || end === -1 || end < start) return content.trim();
155
+ return content.slice(start, end + MARKDOWN_END.length).trim();
156
+ }
157
+
158
+ function mergeManagedMarkdown(existing: string | null, incoming: string): string {
159
+ const block = managedMarkdownBlock(incoming);
160
+ if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
161
+ const pattern = new RegExp(`${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}`);
162
+ if (pattern.test(existing)) return existing.replace(pattern, block).replace(/\s*$/, "\n");
163
+ return `${block}\n\n${existing.replace(/\s*$/, "\n")}`;
164
+ }
165
+
166
+ function renderAgentContextTemplate(
167
+ template: string,
168
+ options: {
169
+ readonly agent: "claude" | "codex" | "both";
170
+ readonly skillSet: "core" | "all";
171
+ readonly workflow: "standard" | "strict";
172
+ readonly includeHusky: boolean;
173
+ readonly includeCi: boolean;
174
+ },
175
+ ): string {
176
+ const commands = [
177
+ "- `phasegate doctor`",
178
+ "- `phasegate phasegate:check-ready`",
179
+ "- `phasegate validate --layer L2 --format human`",
180
+ "- `phasegate setup:agent --dry-run`",
181
+ "- `phasegate config:plan --intent l4-strict --dry-run`",
182
+ ].join("\n");
183
+ return template
184
+ .replaceAll("{{PHASEGATE_AGENT}}", options.agent)
185
+ .replaceAll("{{PHASEGATE_SKILLS_MODE}}", options.skillSet)
186
+ .replaceAll("{{PHASEGATE_WORKFLOW}}", options.workflow)
187
+ .replaceAll("{{PHASEGATE_HUSKY_STATE}}", options.includeHusky ? "managed" : "not managed by this setup run")
188
+ .replaceAll("{{PHASEGATE_CI_STATE}}", options.includeCi ? "managed" : "not managed by this setup run")
189
+ .replaceAll("{{PHASEGATE_COMMANDS}}", commands)
190
+ .replaceAll("{{PHASEGATE_SKILLS}}", options.skillSet === "core" ? "- core skills" : "- all bundled skills")
191
+ .replaceAll("{{PHASEGATE_PRESETS}}", "- `minimal`\n- `standard`\n- `full`\n- `custom`")
192
+ .replaceAll("{{PHASEGATE_USER_SECTION}}", "Project-specific agent instructions go here.");
193
+ }
194
+
134
195
  function escapeRegExp(value: string): string {
135
196
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
136
197
  }
@@ -166,6 +227,26 @@ function hasCustomJson(content: string | null): boolean {
166
227
  }
167
228
  }
168
229
 
230
+ function errorCode(error: unknown): string {
231
+ if (isRecord(error) && typeof error.code === "string") return error.code;
232
+ return "UNKNOWN";
233
+ }
234
+
235
+ function likelyCauseFor(code: string): string {
236
+ if (code === "EPERM") return "The filesystem or sandbox denied this write operation.";
237
+ if (code === "EACCES") return "The current user does not have permission to write this target.";
238
+ if (code === "EROFS") return "The project is on a read-only filesystem.";
239
+ if (code === "ENOTDIR") return "A parent path exists but is not a directory.";
240
+ return "The managed target could not be written.";
241
+ }
242
+
243
+ function recoveryFor(code: string, target: string): string {
244
+ if (code === "EPERM") return `Review sandbox or filesystem permissions for ${target}, then rerun phasegate setup:agent --apply or phasegate install --apply.`;
245
+ if (code === "EACCES") return `Fix ownership or permissions for ${target}, then rerun phasegate install --apply.`;
246
+ if (code === "EROFS") return `Move the project to a writable filesystem or rerun in a writable workspace before applying ${target}.`;
247
+ return `Inspect ${target}, run phasegate install --dry-run --json, then rerun with --apply after resolving the filesystem issue.`;
248
+ }
249
+
169
250
  function shellRepairMode(content: string | null): RepairMode {
170
251
  if (content === null || content.trim().length === 0 || content.includes(SHELL_BEGIN)) return "mechanical";
171
252
  return "ai-assisted";
@@ -192,6 +273,9 @@ export class RunInstallUseCase {
192
273
  const includeCodex = input.includeCodex ?? true;
193
274
  const includeHusky = input.includeHusky ?? true;
194
275
  const includeCi = input.includeCi ?? true;
276
+ const skillSet = input.skillSet ?? "all";
277
+ const workflow = input.workflow ?? "standard";
278
+ const agent = input.agent ?? (includeClaude && includeCodex ? "both" : includeCodex ? "codex" : "claude");
195
279
  const targets = this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
196
280
  const existingManifest = await this.manifestRepository.load(input.projectRoot);
197
281
  const baseManifest = existingManifest ?? DeploymentManifest.create(input.phasegateVersion);
@@ -205,7 +289,10 @@ export class RunInstallUseCase {
205
289
  for (const target of targets) {
206
290
  const absolutePath = join(input.projectRoot, target.path);
207
291
  const before = await readTextOrNull(absolutePath);
208
- const template = await readFile(join(input.harnessRoot, target.templatePath), "utf8");
292
+ const rawTemplate = await readFile(join(input.harnessRoot, target.templatePath), "utf8");
293
+ const template = target.strategy === "markdown-managed"
294
+ ? renderAgentContextTemplate(rawTemplate, { agent, skillSet, workflow, includeHusky, includeCi })
295
+ : rawTemplate;
209
296
  const repairMode = this.repairMode(target, before);
210
297
  const next = this.merge(target, before, template, input.phasegateVersion);
211
298
  const didChange = before !== next;
@@ -233,9 +320,23 @@ export class RunInstallUseCase {
233
320
  await this.backup(input.projectRoot, target.path, backupDir);
234
321
  }
235
322
 
236
- await mkdir(dirname(absolutePath), { recursive: true });
237
- await writeFile(absolutePath, next, "utf8");
238
- if (target.executable) await chmod(absolutePath, 0o755);
323
+ try {
324
+ await mkdir(dirname(absolutePath), { recursive: true });
325
+ } catch (error) {
326
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "mkdir", error);
327
+ }
328
+ try {
329
+ await writeFile(absolutePath, next, "utf8");
330
+ } catch (error) {
331
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "writeFile", error);
332
+ }
333
+ if (target.executable) {
334
+ try {
335
+ await chmod(absolutePath, 0o755);
336
+ } catch (error) {
337
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "chmod", error);
338
+ }
339
+ }
239
340
  changed.push(item);
240
341
 
241
342
  const mode = before === null ? "created" : "merged";
@@ -264,9 +365,17 @@ export class RunInstallUseCase {
264
365
  const item = await this.planSkillLink(input.projectRoot, linkPath);
265
366
  plan.push(item);
266
367
  if (!input.apply || !item.changed) continue;
267
- await mkdir(join(input.projectRoot, "skills"), { recursive: true });
268
- await mkdir(dirname(join(input.projectRoot, linkPath)), { recursive: true });
269
- await symlink("../skills", join(input.projectRoot, linkPath), process.platform === "win32" ? "junction" : "dir");
368
+ try {
369
+ await mkdir(join(input.projectRoot, "skills"), { recursive: true });
370
+ await mkdir(dirname(join(input.projectRoot, linkPath)), { recursive: true });
371
+ } catch (error) {
372
+ return this.withApplyError({ plan, refused, changed, backupDir }, linkPath, "mkdir", error);
373
+ }
374
+ try {
375
+ await symlink("../skills", join(input.projectRoot, linkPath), process.platform === "win32" ? "junction" : "dir");
376
+ } catch (error) {
377
+ return this.withApplyError({ plan, refused, changed, backupDir }, linkPath, "symlink", error);
378
+ }
270
379
  changed.push(item);
271
380
  const hash = this.hashCalculator.compute("../skills");
272
381
  const existingEntry = baseManifest.findEntry(linkPath);
@@ -286,12 +395,36 @@ export class RunInstallUseCase {
286
395
  }
287
396
 
288
397
  if (input.apply && changed.length > 0) {
289
- await this.manifestRepository.save(input.projectRoot, manifest);
398
+ try {
399
+ await this.manifestRepository.save(input.projectRoot, manifest);
400
+ } catch (error) {
401
+ return this.withApplyError({ plan, refused, changed, backupDir }, ".phasegate/manifest.json", "manifest-save", error);
402
+ }
290
403
  }
291
404
 
292
405
  return { plan, refused, changed, backupDir };
293
406
  }
294
407
 
408
+ private withApplyError(
409
+ result: RunInstallResult,
410
+ target: string,
411
+ operation: string,
412
+ error: unknown,
413
+ ): RunInstallResult {
414
+ const code = errorCode(error);
415
+ return {
416
+ ...result,
417
+ error: {
418
+ target,
419
+ operation,
420
+ code,
421
+ likelyCause: likelyCauseFor(code),
422
+ recovery: recoveryFor(code, target),
423
+ partialChanges: result.changed.map((item) => item.path),
424
+ },
425
+ };
426
+ }
427
+
295
428
  private createTargets(options: {
296
429
  readonly includeClaude: boolean;
297
430
  readonly includeCodex: boolean;
@@ -300,10 +433,26 @@ export class RunInstallUseCase {
300
433
  }): readonly InstallTarget[] {
301
434
  return [
302
435
  ...(options.includeClaude
303
- ? [{ path: ".claude/settings.json", strategy: "json" as const, templatePath: "templates/.claude/settings.json" }]
436
+ ? [
437
+ { path: ".claude/settings.json", strategy: "json" as const, templatePath: "templates/.claude/settings.json" },
438
+ {
439
+ path: "CLAUDE.md",
440
+ strategy: "markdown-managed" as const,
441
+ templatePath: "docs/templates/agent-context/CLAUDE.md.template.md",
442
+ block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate CLAUDE.md managed section" },
443
+ },
444
+ ]
304
445
  : []),
305
446
  ...(options.includeCodex
306
- ? [{ path: ".codex/hooks.json", strategy: "json" as const, templatePath: "templates/.codex/hooks.json" }]
447
+ ? [
448
+ { path: ".codex/hooks.json", strategy: "json" as const, templatePath: "templates/.codex/hooks.json" },
449
+ {
450
+ path: "AGENTS.md",
451
+ strategy: "markdown-managed" as const,
452
+ templatePath: "docs/templates/agent-context/AGENTS.md.template.md",
453
+ block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate AGENTS.md managed section" },
454
+ },
455
+ ]
307
456
  : []),
308
457
  ...(options.includeHusky
309
458
  ? [
@@ -346,6 +495,7 @@ export class RunInstallUseCase {
346
495
  private repairMode(target: InstallTarget, before: string | null): RepairMode {
347
496
  if (target.strategy === "shell") return shellRepairMode(before);
348
497
  if (target.strategy === "json") return jsonRepairMode(before);
498
+ if (target.strategy === "markdown-managed") return "mechanical";
349
499
  return "mechanical";
350
500
  }
351
501
 
@@ -358,6 +508,7 @@ export class RunInstallUseCase {
358
508
  private merge(target: InstallTarget, before: string | null, template: string, version: string): string {
359
509
  if (target.strategy === "yaml-add") return before ?? template;
360
510
  if (target.strategy === "shell") return mergeShell(before, template);
511
+ if (target.strategy === "markdown-managed") return mergeManagedMarkdown(before, template);
361
512
  if (target.strategy === "package-json") {
362
513
  const existing = before === null ? {} : (JSON.parse(before) as unknown);
363
514
  const merged = mergePackageJson(isRecord(existing) ? existing : {}, version || PHASEGATE_SCRIPT_VERSION);
@@ -1,6 +1,7 @@
1
1
  // @unit installation
2
2
  // @layer application
3
3
  // @work-item-id WI-148
4
+ // @work-item-id WI-174
4
5
 
5
6
  import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, symlink, writeFile } from "node:fs/promises";
6
7
  import { dirname, join, relative, resolve } from "node:path";
@@ -12,7 +13,7 @@ import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
12
13
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
13
14
 
14
15
  type ReconcileAction = "missing-manifest" | "update" | "add" | "link" | "skip" | "refuse";
15
- type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "symlink" | "unknown";
16
+ type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "symlink" | "unknown";
16
17
 
17
18
  export interface ReconcilePlanItem {
18
19
  readonly path: string;
@@ -52,6 +53,8 @@ interface ReconcileTarget {
52
53
  const SKILL_HINT = "invoke /phasegate-config-doctor";
53
54
  const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
54
55
  const SHELL_END = "# === phasegate managed (END) ===";
56
+ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
57
+ const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
55
58
 
56
59
  function isRecord(value: unknown): value is Record<string, unknown> {
57
60
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -136,6 +139,41 @@ function reconcileShell(existing: string | null, incoming: string): string {
136
139
  return `${existing.replace(/\s*$/, "\n\n")}${incomingBlock}\n`;
137
140
  }
138
141
 
142
+ function managedMarkdownBlock(content: string): string {
143
+ const start = content.indexOf(MARKDOWN_BEGIN);
144
+ const end = content.indexOf(MARKDOWN_END);
145
+ if (start === -1 || end === -1 || end < start) return content.trim();
146
+ return content.slice(start, end + MARKDOWN_END.length).trim();
147
+ }
148
+
149
+ function reconcileManagedMarkdown(existing: string | null, incoming: string): string {
150
+ const block = managedMarkdownBlock(incoming);
151
+ if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
152
+ const pattern = new RegExp(`${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}`);
153
+ if (pattern.test(existing)) return existing.replace(pattern, block).replace(/\s*$/, "\n");
154
+ return `${block}\n\n${existing.replace(/\s*$/, "\n")}`;
155
+ }
156
+
157
+ function renderAgentContextTemplate(template: string): string {
158
+ const commands = [
159
+ "- `phasegate doctor`",
160
+ "- `phasegate phasegate:check-ready`",
161
+ "- `phasegate validate --layer L2 --format human`",
162
+ "- `phasegate setup:agent --dry-run`",
163
+ "- `phasegate config:plan --intent l4-strict --dry-run`",
164
+ ].join("\n");
165
+ return template
166
+ .replaceAll("{{PHASEGATE_AGENT}}", "both")
167
+ .replaceAll("{{PHASEGATE_SKILLS_MODE}}", "all")
168
+ .replaceAll("{{PHASEGATE_WORKFLOW}}", "standard")
169
+ .replaceAll("{{PHASEGATE_HUSKY_STATE}}", "managed")
170
+ .replaceAll("{{PHASEGATE_CI_STATE}}", "managed")
171
+ .replaceAll("{{PHASEGATE_COMMANDS}}", commands)
172
+ .replaceAll("{{PHASEGATE_SKILLS}}", "- all bundled skills")
173
+ .replaceAll("{{PHASEGATE_PRESETS}}", "- `minimal`\n- `standard`\n- `full`\n- `custom`")
174
+ .replaceAll("{{PHASEGATE_USER_SECTION}}", "Project-specific agent instructions go here.");
175
+ }
176
+
139
177
  function reconcilePackageJson(existing: Record<string, unknown>, version: string): Record<string, unknown> {
140
178
  const devDependencies = isRecord(existing.devDependencies) ? existing.devDependencies : {};
141
179
  const scripts = isRecord(existing.scripts) ? existing.scripts : {};
@@ -259,7 +297,8 @@ export class RunReconcileUseCase {
259
297
  if (before === null) return this.planMissingTarget(input, target);
260
298
  const currentHash = this.hashCalculator.compute(before);
261
299
  const matchesManifest = currentHash.equals(entry.hash);
262
- const template = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
300
+ const rawTemplate = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
301
+ const template = target.strategy === "markdown-managed" ? renderAgentContextTemplate(rawTemplate) : rawTemplate;
263
302
  const next = entry.mode === "created" && target.strategy !== "package-json"
264
303
  ? template
265
304
  : this.reconcileContent(target, before, template, input.phasegateVersion);
@@ -290,7 +329,8 @@ export class RunReconcileUseCase {
290
329
  if (target.strategy === "symlink") return this.planSymlink(input.projectRoot, target.path);
291
330
  const absolutePath = this.resolveProjectPath(input.projectRoot, target.path);
292
331
  const before = await readTextOrNull(absolutePath);
293
- const template = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
332
+ const rawTemplate = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
333
+ const template = target.strategy === "markdown-managed" ? renderAgentContextTemplate(rawTemplate) : rawTemplate;
294
334
  const next = before === null && target.strategy !== "package-json"
295
335
  ? template
296
336
  : this.reconcileContent(target, before, template, input.phasegateVersion);
@@ -350,6 +390,7 @@ export class RunReconcileUseCase {
350
390
  private reconcileContent(target: ReconcileTarget, before: string | null, template: string, version: string): string {
351
391
  if (target.strategy === "yaml-add") return template;
352
392
  if (target.strategy === "shell") return reconcileShell(before, template);
393
+ if (target.strategy === "markdown-managed") return reconcileManagedMarkdown(before, template);
353
394
  if (target.strategy === "package-json") {
354
395
  const existing = before === null ? {} : (JSON.parse(before) as unknown);
355
396
  return `${JSON.stringify(reconcilePackageJson(isRecord(existing) ? existing : {}, version), null, 2)}\n`;
@@ -362,7 +403,19 @@ export class RunReconcileUseCase {
362
403
  private createTargets(): readonly ReconcileTarget[] {
363
404
  return [
364
405
  { path: ".claude/settings.json", strategy: "json", templatePath: "templates/.claude/settings.json" },
406
+ {
407
+ path: "CLAUDE.md",
408
+ strategy: "markdown-managed",
409
+ templatePath: "docs/templates/agent-context/CLAUDE.md.template.md",
410
+ block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate CLAUDE.md managed section" },
411
+ },
365
412
  { path: ".codex/hooks.json", strategy: "json", templatePath: "templates/.codex/hooks.json" },
413
+ {
414
+ path: "AGENTS.md",
415
+ strategy: "markdown-managed",
416
+ templatePath: "docs/templates/agent-context/AGENTS.md.template.md",
417
+ block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate AGENTS.md managed section" },
418
+ },
366
419
  {
367
420
  path: ".husky/pre-commit",
368
421
  strategy: "shell",
@@ -401,7 +454,7 @@ export class RunReconcileUseCase {
401
454
 
402
455
  private managedBlockFor(path: string, strategy: StrategyType): ManagedBlockInput | null {
403
456
  if (strategy === "shell") return { start: SHELL_BEGIN, end: SHELL_END, content: `phasegate ${path} managed block` };
404
- if (strategy === "json" || strategy === "package-json") {
457
+ if (strategy === "json" || strategy === "package-json" || strategy === "markdown-managed") {
405
458
  return { start: "phasegate structured merge", end: "phasegate structured merge", content: `${strategy}:${path}` };
406
459
  }
407
460
  return null;
@@ -1,6 +1,7 @@
1
1
  // @unit installation
2
2
  // @layer application
3
3
  // @work-item-id WI-147
4
+ // @work-item-id WI-174
4
5
 
5
6
  import { access, copyFile, lstat, mkdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
6
7
  import { dirname, join, relative, resolve } from "node:path";
@@ -10,7 +11,7 @@ import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
10
11
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
11
12
 
12
13
  type UninstallAction = "missing-manifest" | "delete" | "unlink" | "reverse-merge" | "skip" | "refuse";
13
- type StrategyType = "created" | "json" | "shell" | "package-json" | "symlink" | "yaml-add" | "unknown";
14
+ type StrategyType = "created" | "json" | "shell" | "package-json" | "markdown-managed" | "symlink" | "yaml-add" | "unknown";
14
15
 
15
16
  export interface UninstallPlanItem {
16
17
  readonly path: string;
@@ -42,6 +43,8 @@ export interface RunUninstallResult {
42
43
  const SKILL_HINT = "invoke /phasegate-config-doctor";
43
44
  const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
44
45
  const SHELL_END = "# === phasegate managed (END) ===";
46
+ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
47
+ const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
45
48
  const PHASEGATE_SCRIPT_PREFIX = "phasegate:";
46
49
 
47
50
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -108,6 +111,11 @@ export function reverseShellMerge(currentContent: string): string {
108
111
  return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
109
112
  }
110
113
 
114
+ export function reverseManagedMarkdown(currentContent: string): string {
115
+ const pattern = new RegExp(`\\n?${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}\\n?`);
116
+ return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
117
+ }
118
+
111
119
  export function reversePackageJsonMerge(currentContent: string): string {
112
120
  const parsed = JSON.parse(currentContent) as unknown;
113
121
  const result = isRecord(parsed) ? { ...parsed } : {};
@@ -300,6 +308,7 @@ export class RunUninstallUseCase {
300
308
 
301
309
  private async reverseMerged(harnessRoot: string, path: string, currentContent: string, strategy: StrategyType): Promise<string> {
302
310
  if (strategy === "shell") return currentContent.includes(SHELL_BEGIN) ? reverseShellMerge(currentContent) : currentContent;
311
+ if (strategy === "markdown-managed") return currentContent.includes(MARKDOWN_BEGIN) ? reverseManagedMarkdown(currentContent) : currentContent;
303
312
  if (strategy === "package-json") return reversePackageJsonMerge(currentContent);
304
313
  if (strategy === "json") return reverseJsonMerge(currentContent, await readFile(join(harnessRoot, this.templateFor(path)), "utf8"));
305
314
  throw new Error(`Unsupported merged strategy: ${strategy}`);
@@ -309,6 +318,7 @@ export class RunUninstallUseCase {
309
318
  if (mode === "symlink") return "symlink";
310
319
  if (mode === "created") return path.endsWith(".yml") || path.endsWith(".yaml") ? "yaml-add" : "created";
311
320
  if (path === "package.json") return "package-json";
321
+ if (path === "AGENTS.md" || path === "CLAUDE.md") return "markdown-managed";
312
322
  if (path.endsWith(".json")) return "json";
313
323
  if (path.startsWith(".husky/")) return "shell";
314
324
  return "unknown";
@@ -317,6 +327,8 @@ export class RunUninstallUseCase {
317
327
  private templateFor(path: string): string {
318
328
  if (path === ".claude/settings.json") return "templates/.claude/settings.json";
319
329
  if (path === ".codex/hooks.json") return "templates/.codex/hooks.json";
330
+ if (path === "CLAUDE.md") return "docs/templates/agent-context/CLAUDE.md.template.md";
331
+ if (path === "AGENTS.md") return "docs/templates/agent-context/AGENTS.md.template.md";
320
332
  throw new Error(`No template for ${path}`);
321
333
  }
322
334
 
@@ -1,6 +1,7 @@
1
1
  // @unit installation
2
2
  // @layer presentation
3
3
  // @work-item-id WI-146
4
+ // @work-item-id WI-175
4
5
 
5
6
  import type { RunInstallUseCase } from "../../application/usecases/run-install.js";
6
7
 
@@ -12,6 +13,13 @@ export interface InstallHandlerInput {
12
13
  readonly apply: boolean;
13
14
  readonly force: boolean;
14
15
  readonly json: boolean;
16
+ readonly includeClaude?: boolean;
17
+ readonly includeCodex?: boolean;
18
+ readonly includeHusky?: boolean;
19
+ readonly includeCi?: boolean;
20
+ readonly skillSet?: "core" | "all";
21
+ readonly workflow?: "standard" | "strict";
22
+ readonly agent?: "claude" | "codex" | "both";
15
23
  }
16
24
 
17
25
  export interface InstallHandlerResult {
@@ -27,7 +35,7 @@ export class InstallHandler {
27
35
  if (input.json) {
28
36
  return {
29
37
  stdout: JSON.stringify(result, null, 2),
30
- exitCode: result.refused.length > 0 ? 1 : 0,
38
+ exitCode: result.refused.length > 0 || result.error !== undefined ? 1 : 0,
31
39
  };
32
40
  }
33
41
  const lines = [
@@ -38,13 +46,20 @@ export class InstallHandler {
38
46
  }),
39
47
  ];
40
48
  if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
49
+ if (result.error !== undefined) {
50
+ lines.push("");
51
+ lines.push(`Apply error: ${result.error.target} ${result.error.operation} failed with ${result.error.code}`);
52
+ lines.push(`Cause: ${result.error.likelyCause}`);
53
+ lines.push(`Recovery: ${result.error.recovery}`);
54
+ if (result.error.partialChanges.length > 0) lines.push(`Partial changes: ${result.error.partialChanges.join(", ")}`);
55
+ }
41
56
  if (result.refused.length > 0) {
42
57
  lines.push("");
43
58
  lines.push("Refused ai-assisted/manual targets. Re-run with --force after reviewing the hint.");
44
59
  }
45
60
  return {
46
61
  stdout: lines.join("\n"),
47
- exitCode: result.refused.length > 0 ? 1 : 0,
62
+ exitCode: result.refused.length > 0 || result.error !== undefined ? 1 : 0,
48
63
  };
49
64
  }
50
65
  }