phasegate 0.152.7 → 0.152.9

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,7 @@
1
1
  // @unit installation
2
2
  // @layer application
3
3
  // @work-item-id WI-146
4
+ // @work-item-id WI-174
4
5
 
5
6
  import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink } from "node:fs/promises";
6
7
  import { dirname, join } from "node:path";
@@ -12,7 +13,7 @@ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.j
12
13
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
13
14
 
14
15
  type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
15
- type StrategyType = "json" | "shell" | "yaml-add" | "package-json";
16
+ type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed";
16
17
 
17
18
  export interface InstallPlanItem {
18
19
  readonly path: string;
@@ -36,6 +37,9 @@ export interface RunInstallInput {
36
37
  readonly includeCodex?: boolean;
37
38
  readonly includeHusky?: boolean;
38
39
  readonly includeCi?: boolean;
40
+ readonly skillSet?: "core" | "all";
41
+ readonly workflow?: "standard" | "strict";
42
+ readonly agent?: "claude" | "codex" | "both";
39
43
  }
40
44
 
41
45
  export interface RunInstallResult {
@@ -58,6 +62,8 @@ const PHASEGATE_SCRIPT_VERSION = "^0.0.0";
58
62
 
59
63
  const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
60
64
  const SHELL_END = "# === phasegate managed (END) ===";
65
+ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
66
+ const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
61
67
 
62
68
  function isRecord(value: unknown): value is Record<string, unknown> {
63
69
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -131,6 +137,50 @@ function mergeShell(existing: string | null, incoming: string): string {
131
137
  return `${existing.replace(/\s*$/, "\n\n")}${block}\n`;
132
138
  }
133
139
 
140
+ function managedMarkdownBlock(content: string): string {
141
+ const start = content.indexOf(MARKDOWN_BEGIN);
142
+ const end = content.indexOf(MARKDOWN_END);
143
+ if (start === -1 || end === -1 || end < start) return content.trim();
144
+ return content.slice(start, end + MARKDOWN_END.length).trim();
145
+ }
146
+
147
+ function mergeManagedMarkdown(existing: string | null, incoming: string): string {
148
+ const block = managedMarkdownBlock(incoming);
149
+ if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
150
+ const pattern = new RegExp(`${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}`);
151
+ if (pattern.test(existing)) return existing.replace(pattern, block).replace(/\s*$/, "\n");
152
+ return `${block}\n\n${existing.replace(/\s*$/, "\n")}`;
153
+ }
154
+
155
+ function renderAgentContextTemplate(
156
+ template: string,
157
+ options: {
158
+ readonly agent: "claude" | "codex" | "both";
159
+ readonly skillSet: "core" | "all";
160
+ readonly workflow: "standard" | "strict";
161
+ readonly includeHusky: boolean;
162
+ readonly includeCi: boolean;
163
+ },
164
+ ): string {
165
+ const commands = [
166
+ "- `phasegate doctor`",
167
+ "- `phasegate phasegate:check-ready`",
168
+ "- `phasegate validate --layer L2 --format human`",
169
+ "- `phasegate setup:agent --dry-run`",
170
+ "- `phasegate config:plan --intent l4-strict --dry-run`",
171
+ ].join("\n");
172
+ return template
173
+ .replaceAll("{{PHASEGATE_AGENT}}", options.agent)
174
+ .replaceAll("{{PHASEGATE_SKILLS_MODE}}", options.skillSet)
175
+ .replaceAll("{{PHASEGATE_WORKFLOW}}", options.workflow)
176
+ .replaceAll("{{PHASEGATE_HUSKY_STATE}}", options.includeHusky ? "managed" : "not managed by this setup run")
177
+ .replaceAll("{{PHASEGATE_CI_STATE}}", options.includeCi ? "managed" : "not managed by this setup run")
178
+ .replaceAll("{{PHASEGATE_COMMANDS}}", commands)
179
+ .replaceAll("{{PHASEGATE_SKILLS}}", options.skillSet === "core" ? "- core skills" : "- all bundled skills")
180
+ .replaceAll("{{PHASEGATE_PRESETS}}", "- `minimal`\n- `standard`\n- `full`\n- `custom`")
181
+ .replaceAll("{{PHASEGATE_USER_SECTION}}", "Project-specific agent instructions go here.");
182
+ }
183
+
134
184
  function escapeRegExp(value: string): string {
135
185
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
136
186
  }
@@ -192,6 +242,9 @@ export class RunInstallUseCase {
192
242
  const includeCodex = input.includeCodex ?? true;
193
243
  const includeHusky = input.includeHusky ?? true;
194
244
  const includeCi = input.includeCi ?? true;
245
+ const skillSet = input.skillSet ?? "all";
246
+ const workflow = input.workflow ?? "standard";
247
+ const agent = input.agent ?? (includeClaude && includeCodex ? "both" : includeCodex ? "codex" : "claude");
195
248
  const targets = this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
196
249
  const existingManifest = await this.manifestRepository.load(input.projectRoot);
197
250
  const baseManifest = existingManifest ?? DeploymentManifest.create(input.phasegateVersion);
@@ -205,7 +258,10 @@ export class RunInstallUseCase {
205
258
  for (const target of targets) {
206
259
  const absolutePath = join(input.projectRoot, target.path);
207
260
  const before = await readTextOrNull(absolutePath);
208
- const template = await readFile(join(input.harnessRoot, target.templatePath), "utf8");
261
+ const rawTemplate = await readFile(join(input.harnessRoot, target.templatePath), "utf8");
262
+ const template = target.strategy === "markdown-managed"
263
+ ? renderAgentContextTemplate(rawTemplate, { agent, skillSet, workflow, includeHusky, includeCi })
264
+ : rawTemplate;
209
265
  const repairMode = this.repairMode(target, before);
210
266
  const next = this.merge(target, before, template, input.phasegateVersion);
211
267
  const didChange = before !== next;
@@ -300,10 +356,26 @@ export class RunInstallUseCase {
300
356
  }): readonly InstallTarget[] {
301
357
  return [
302
358
  ...(options.includeClaude
303
- ? [{ path: ".claude/settings.json", strategy: "json" as const, templatePath: "templates/.claude/settings.json" }]
359
+ ? [
360
+ { path: ".claude/settings.json", strategy: "json" as const, templatePath: "templates/.claude/settings.json" },
361
+ {
362
+ path: "CLAUDE.md",
363
+ strategy: "markdown-managed" as const,
364
+ templatePath: "docs/templates/agent-context/CLAUDE.md.template.md",
365
+ block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate CLAUDE.md managed section" },
366
+ },
367
+ ]
304
368
  : []),
305
369
  ...(options.includeCodex
306
- ? [{ path: ".codex/hooks.json", strategy: "json" as const, templatePath: "templates/.codex/hooks.json" }]
370
+ ? [
371
+ { path: ".codex/hooks.json", strategy: "json" as const, templatePath: "templates/.codex/hooks.json" },
372
+ {
373
+ path: "AGENTS.md",
374
+ strategy: "markdown-managed" as const,
375
+ templatePath: "docs/templates/agent-context/AGENTS.md.template.md",
376
+ block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate AGENTS.md managed section" },
377
+ },
378
+ ]
307
379
  : []),
308
380
  ...(options.includeHusky
309
381
  ? [
@@ -346,6 +418,7 @@ export class RunInstallUseCase {
346
418
  private repairMode(target: InstallTarget, before: string | null): RepairMode {
347
419
  if (target.strategy === "shell") return shellRepairMode(before);
348
420
  if (target.strategy === "json") return jsonRepairMode(before);
421
+ if (target.strategy === "markdown-managed") return "mechanical";
349
422
  return "mechanical";
350
423
  }
351
424
 
@@ -358,6 +431,7 @@ export class RunInstallUseCase {
358
431
  private merge(target: InstallTarget, before: string | null, template: string, version: string): string {
359
432
  if (target.strategy === "yaml-add") return before ?? template;
360
433
  if (target.strategy === "shell") return mergeShell(before, template);
434
+ if (target.strategy === "markdown-managed") return mergeManagedMarkdown(before, template);
361
435
  if (target.strategy === "package-json") {
362
436
  const existing = before === null ? {} : (JSON.parse(before) as unknown);
363
437
  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
 
@@ -12,6 +12,13 @@ export interface InstallHandlerInput {
12
12
  readonly apply: boolean;
13
13
  readonly force: boolean;
14
14
  readonly json: boolean;
15
+ readonly includeClaude?: boolean;
16
+ readonly includeCodex?: boolean;
17
+ readonly includeHusky?: boolean;
18
+ readonly includeCi?: boolean;
19
+ readonly skillSet?: "core" | "all";
20
+ readonly workflow?: "standard" | "strict";
21
+ readonly agent?: "claude" | "codex" | "both";
15
22
  }
16
23
 
17
24
  export interface InstallHandlerResult {