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.
- package/CHANGELOG.md +12 -0
- package/README.md +19 -1
- package/docs/guide/cli-reference.md +4 -2
- package/docs/guide/configuration.md +9 -0
- package/docs/guide/getting-started.md +66 -0
- package/docs/guide/installation.md +13 -4
- package/docs/guide/recipes.md +65 -0
- package/docs/guide/setup-artifacts.md +4 -1
- package/docs/guide/troubleshooting.md +73 -0
- package/docs/templates/agent-context/AGENTS.md.template.md +45 -0
- package/docs/templates/agent-context/CLAUDE.md.template.md +4 -0
- package/package.json +1 -1
- package/scripts/harness/ci-governance/infrastructure/adapters/agents-md-file-adapter.ts +31 -5
- package/scripts/harness/installation/application/usecases/run-install.ts +162 -11
- package/scripts/harness/installation/application/usecases/run-reconcile.ts +57 -4
- package/scripts/harness/installation/application/usecases/run-uninstall.ts +13 -1
- package/scripts/harness/installation/presentation/cli/install-handler.ts +17 -2
- package/scripts/harness/main.ts +556 -2
- package/skills/phasegate-config-doctor/SKILL.md +4 -0
- package/skills/phasegate-toolkit-guide/SKILL.md +5 -0
|
@@ -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
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
? [
|
|
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
|
-
? [
|
|
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
|
|
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
|
|
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
|
}
|