phasegate 0.160.13 → 0.160.15

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.
Files changed (25) hide show
  1. package/README.md +3 -3
  2. package/docs/guide/getting-started.md +1 -1
  3. package/docs/guide/installation.md +3 -3
  4. package/docs/guide/setup-artifacts.md +12 -3
  5. package/docs/templates/personal/phasegate-local-config.json +73 -3
  6. package/docs/templates/project/phasegate.config.json +29 -0
  7. package/package.json +1 -1
  8. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +19 -9
  9. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +19 -9
  10. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +23 -12
  11. package/scripts/harness/agent-integration/presentation/stop-hook.ts +19 -9
  12. package/scripts/harness/agent-integration/presentation/user-prompt-submit-hook.ts +3 -2
  13. package/scripts/harness/config-foundation/infrastructure/repositories/file-system-config-repository.ts +10 -4
  14. package/scripts/harness/installation/application/checks/check-utils.ts +5 -0
  15. package/scripts/harness/installation/application/checks/claude-skills-symlink-check.ts +5 -2
  16. package/scripts/harness/installation/application/checks/codex-skills-symlink-check.ts +5 -2
  17. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +36 -2
  18. package/scripts/harness/installation/application/usecases/run-install.ts +200 -61
  19. package/scripts/harness/installation/application/usecases/run-uninstall.ts +39 -4
  20. package/scripts/harness/installation/presentation/cli/doctor-handler.ts +2 -0
  21. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +11 -3
  22. package/scripts/harness/main.ts +3 -2
  23. package/skills/README.md +1 -1
  24. package/skills/phasegate-config-doctor/SKILL.md +2 -2
  25. package/skills/phasegate-toolkit-guide/SKILL.md +1 -1
@@ -8,8 +8,10 @@
8
8
  // @work-item-id WI-182
9
9
  // @work-item-id WI-183
10
10
  // @work-item-id WI-207
11
+ // @work-item-id WI-208
12
+ // @work-item-id WI-209
11
13
 
12
- import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink } from "node:fs/promises";
14
+ import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink, readdir } from "node:fs/promises";
13
15
  import { dirname, join } from "node:path";
14
16
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
15
17
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
@@ -19,7 +21,7 @@ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.j
19
21
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
20
22
 
21
23
  type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
22
- type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "text-managed" | "copy";
24
+ type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "text-managed" | "copy" | "copy-dir" | "symlink";
23
25
 
24
26
  export interface InstallPlanItem {
25
27
  readonly path: string;
@@ -83,6 +85,7 @@ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
83
85
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
84
86
  const TEXT_BEGIN = "# phasegate personal install exclude (BEGIN)";
85
87
  const TEXT_END = "# phasegate personal install exclude (END)";
88
+ const PERSONAL_AGENT_RUNTIME_FILES = new Set([".claude/settings.json", ".codex/hooks.json"]);
86
89
 
87
90
  function isRecord(value: unknown): value is Record<string, unknown> {
88
91
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -105,6 +108,20 @@ async function readTextOrNull(path: string): Promise<string | null> {
105
108
  }
106
109
  }
107
110
 
111
+ async function copyDirectory(src: string, dest: string): Promise<void> {
112
+ await mkdir(dest, { recursive: true });
113
+ const entries = await readdir(src, { withFileTypes: true });
114
+ for (const entry of entries) {
115
+ const srcPath = join(src, entry.name);
116
+ const destPath = join(dest, entry.name);
117
+ if (entry.isDirectory()) {
118
+ await copyDirectory(srcPath, destPath);
119
+ } else if (entry.isFile()) {
120
+ await copyFile(srcPath, destPath);
121
+ }
122
+ }
123
+ }
124
+
108
125
  function normalizeJsonEntry(value: unknown): string {
109
126
  return JSON.stringify(value);
110
127
  }
@@ -295,7 +312,7 @@ export class RunInstallUseCase {
295
312
  const workflow = input.workflow ?? "standard";
296
313
  const agent = input.agent ?? (includeClaude && includeCodex ? "both" : includeCodex ? "codex" : "claude");
297
314
  const targets = input.personal
298
- ? this.createPersonalTargets()
315
+ ? this.createPersonalTargets({ includeClaude, includeCodex })
299
316
  : this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
300
317
  const existingManifest = await this.manifestRepository.load(input.projectRoot);
301
318
  const baseManifest = existingManifest ?? DeploymentManifest.create(input.phasegateVersion);
@@ -313,8 +330,14 @@ export class RunInstallUseCase {
313
330
  const template = target.strategy === "markdown-managed"
314
331
  ? renderAgentContextTemplate(rawTemplate, { agent, skillSet, workflow, includeHusky, includeCi })
315
332
  : rawTemplate;
316
- const repairMode = this.repairMode(target, before);
317
- const next = this.merge(target, before, template, input.phasegateVersion);
333
+ const existingEntry = baseManifest.findEntry(target.path);
334
+ const beforeHash = before === null ? null : this.hashCalculator.compute(before);
335
+ const unmanagedPersonalRuntimeFile = input.personal
336
+ && PERSONAL_AGENT_RUNTIME_FILES.has(target.path)
337
+ && before !== null
338
+ && (existingEntry === null || beforeHash === null || !beforeHash.equals(existingEntry.hash));
339
+ const repairMode = unmanagedPersonalRuntimeFile ? "manual" : this.repairMode(target, before);
340
+ const next = unmanagedPersonalRuntimeFile ? before : this.merge(target, before, template, input.phasegateVersion);
318
341
  const didChange = before !== next;
319
342
  const action = this.actionFor(before, didChange, input.force);
320
343
  const item: InstallPlanItem = {
@@ -323,8 +346,10 @@ export class RunInstallUseCase {
323
346
  repairMode,
324
347
  strategy: target.strategy,
325
348
  changed: didChange,
326
- summary: didChange ? `${target.path}: ${action}` : `${target.path}: already up to date`,
327
- diff: this.diffSummary(before, next),
349
+ summary: unmanagedPersonalRuntimeFile
350
+ ? `${target.path}: existing non-phasegate path requires manual review`
351
+ : didChange ? `${target.path}: ${action}` : `${target.path}: already up to date`,
352
+ diff: unmanagedPersonalRuntimeFile ? "manual review required" : this.diffSummary(before, next),
328
353
  skillHint: repairMode === "ai-assisted" ? SKILL_HINT : null,
329
354
  };
330
355
  plan.push(item);
@@ -340,30 +365,30 @@ export class RunInstallUseCase {
340
365
  await this.backup(input.projectRoot, target.path, backupDir);
341
366
  }
342
367
 
368
+ try {
369
+ await mkdir(dirname(absolutePath), { recursive: true });
370
+ } catch (error) {
371
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "mkdir", error);
372
+ }
373
+ try {
374
+ await writeFile(absolutePath, next, "utf8");
375
+ } catch (error) {
376
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "writeFile", error);
377
+ }
378
+ if (target.executable) {
343
379
  try {
344
- await mkdir(dirname(absolutePath), { recursive: true });
345
- } catch (error) {
346
- return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "mkdir", error);
347
- }
348
- try {
349
- await writeFile(absolutePath, next, "utf8");
380
+ await chmod(absolutePath, 0o755);
350
381
  } catch (error) {
351
- return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "writeFile", error);
352
- }
353
- if (target.executable) {
354
- try {
355
- await chmod(absolutePath, 0o755);
356
- } catch (error) {
357
- return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "chmod", error);
358
- }
382
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "chmod", error);
359
383
  }
384
+ }
360
385
  changed.push(item);
361
386
 
362
387
  const mode = before === null ? "created" : "merged";
363
388
  const hash = this.hashCalculator.compute(next);
364
- const existingEntry = baseManifest.findEntry(target.path);
365
- if (existingEntry !== null && existingEntry.hash.equals(hash)) {
366
- manifest = manifest.addEntry(existingEntry);
389
+ const manifestEntry = baseManifest.findEntry(target.path);
390
+ if (manifestEntry !== null && manifestEntry.hash.equals(hash)) {
391
+ manifest = manifest.addEntry(manifestEntry);
367
392
  } else {
368
393
  manifest = manifest.addEntry(
369
394
  DeploymentEntry.create({
@@ -377,52 +402,73 @@ export class RunInstallUseCase {
377
402
  }
378
403
  }
379
404
 
380
- const linkPaths = input.personal ? [] : [
381
- ...(includeClaude ? [".claude/skills"] : []),
382
- ...(includeCodex ? [".codex/skills"] : []),
383
- ];
384
- for (const linkPath of linkPaths) {
385
- const item = await this.planSkillLink(input.projectRoot, linkPath);
405
+ const personalSkillTargets = input.personal
406
+ ? [
407
+ ...(includeClaude ? [".claude/skills"] : []),
408
+ ...(includeCodex ? [".codex/skills"] : []),
409
+ ]
410
+ : [];
411
+ for (const skillPath of personalSkillTargets) {
412
+ const item = await this.planPersonalSkillDirectory(input, skillPath, baseManifest);
413
+ plan.push(item);
414
+ if (input.apply && item.changed && item.repairMode === "mechanical") {
415
+ try {
416
+ await copyDirectory(join(input.harnessRoot, "skills"), join(input.projectRoot, skillPath));
417
+ await writeFile(
418
+ join(input.projectRoot, skillPath, ".harness-version"),
419
+ `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
420
+ "utf8",
421
+ );
422
+ } catch (error) {
423
+ return this.withApplyError({ plan, refused, changed, backupDir }, item.path, "copyDirectory", error);
424
+ }
425
+ changed.push(item);
426
+ manifest = this.addManifestEntry(baseManifest, manifest, {
427
+ path: item.path,
428
+ mode: "created",
429
+ contentForHash: this.personalSkillsHashInput(item.path, input.phasegateVersion, skillSet),
430
+ });
431
+ }
432
+ }
433
+
434
+ const linkSpecs = input.personal
435
+ ? []
436
+ : [
437
+ ...(includeClaude ? [{ path: ".claude/skills", target: "../skills" }] : []),
438
+ ...(includeCodex ? [{ path: ".codex/skills", target: "../skills" }] : []),
439
+ ];
440
+ for (const linkSpec of linkSpecs) {
441
+ const item = await this.planSkillLink(input.projectRoot, linkSpec.path, linkSpec.target);
386
442
  plan.push(item);
387
443
  if (!input.apply || !item.changed) continue;
388
444
  try {
389
- await mkdir(join(input.projectRoot, "skills"), { recursive: true });
390
- await mkdir(dirname(join(input.projectRoot, linkPath)), { recursive: true });
445
+ if (!input.personal) await mkdir(join(input.projectRoot, "skills"), { recursive: true });
446
+ await mkdir(dirname(join(input.projectRoot, linkSpec.path)), { recursive: true });
391
447
  } catch (error) {
392
- return this.withApplyError({ plan, refused, changed, backupDir }, linkPath, "mkdir", error);
448
+ return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "mkdir", error);
393
449
  }
394
450
  try {
395
- await symlink("../skills", join(input.projectRoot, linkPath), process.platform === "win32" ? "junction" : "dir");
451
+ await symlink(linkSpec.target, join(input.projectRoot, linkSpec.path), process.platform === "win32" ? "junction" : "dir");
396
452
  } catch (error) {
397
- return this.withApplyError({ plan, refused, changed, backupDir }, linkPath, "symlink", error);
453
+ return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "symlink", error);
398
454
  }
399
455
  changed.push(item);
400
- const hash = this.hashCalculator.compute("../skills");
401
- const existingEntry = baseManifest.findEntry(linkPath);
402
- if (existingEntry !== null && existingEntry.hash.equals(hash)) {
403
- manifest = manifest.addEntry(existingEntry);
404
- } else {
405
- manifest = manifest.addEntry(
406
- DeploymentEntry.create({
407
- path: linkPath,
408
- mode: "symlink",
409
- block: null,
410
- hash,
411
- deployedAt: new Date().toISOString(),
412
- }),
413
- );
414
- }
456
+ manifest = this.addManifestEntry(baseManifest, manifest, {
457
+ path: linkSpec.path,
458
+ mode: "symlink",
459
+ contentForHash: linkSpec.target,
460
+ });
415
461
  }
416
462
 
417
463
  if (input.personal && includeCodex) {
418
464
  plan.push({
419
- path: "~/.codex/hooks.json",
465
+ path: "~/.codex/config.toml",
420
466
  action: "will-skip",
421
467
  repairMode: "manual",
422
468
  strategy: "json",
423
469
  changed: false,
424
- summary: "~/.codex/hooks.json: personal mode does not write user-level Codex settings; configure hooks manually",
425
- diff: "manual user-level Codex hook setup required",
470
+ summary: "~/.codex/config.toml: personal mode does not write user-level Codex feature flags; enable hooks manually when needed",
471
+ diff: "manual Codex hooks feature enablement may be required",
426
472
  skillHint: null,
427
473
  });
428
474
  }
@@ -465,6 +511,11 @@ export class RunInstallUseCase {
465
511
  readonly includeCi: boolean;
466
512
  }): readonly InstallTarget[] {
467
513
  return [
514
+ {
515
+ path: "phasegate.config.json",
516
+ strategy: "copy" as const,
517
+ templatePath: "docs/templates/project/phasegate.config.json",
518
+ },
468
519
  ...(options.includeClaude
469
520
  ? [
470
521
  { path: ".claude/settings.json", strategy: "json" as const, templatePath: "templates/.claude/settings.json" },
@@ -525,13 +576,31 @@ export class RunInstallUseCase {
525
576
  ];
526
577
  }
527
578
 
528
- private createPersonalTargets(): readonly InstallTarget[] {
579
+ private createPersonalTargets(options: { readonly includeClaude: boolean; readonly includeCodex: boolean }): readonly InstallTarget[] {
529
580
  return [
530
581
  {
531
- path: ".phasegate-local/config.json",
582
+ path: ".phasegate-local/phasegate.config.json",
532
583
  strategy: "copy" as const,
533
584
  templatePath: "docs/templates/personal/phasegate-local-config.json",
534
585
  },
586
+ ...(options.includeClaude
587
+ ? [
588
+ {
589
+ path: ".claude/settings.json",
590
+ strategy: "copy" as const,
591
+ templatePath: "templates/.claude/settings.json",
592
+ },
593
+ ]
594
+ : []),
595
+ ...(options.includeCodex
596
+ ? [
597
+ {
598
+ path: ".codex/hooks.json",
599
+ strategy: "copy" as const,
600
+ templatePath: "templates/.codex/hooks.json",
601
+ },
602
+ ]
603
+ : []),
535
604
  {
536
605
  path: ".git/info/exclude",
537
606
  strategy: "text-managed" as const,
@@ -545,6 +614,8 @@ export class RunInstallUseCase {
545
614
  if (target.strategy === "shell") return shellRepairMode(before);
546
615
  if (target.strategy === "text-managed") return "mechanical";
547
616
  if (target.strategy === "copy") return "mechanical";
617
+ if (target.strategy === "copy-dir") return "mechanical";
618
+ if (target.strategy === "symlink") return "mechanical";
548
619
  if (target.strategy === "json") return jsonRepairMode(before);
549
620
  if (target.strategy === "markdown-managed") return "mechanical";
550
621
  return "mechanical";
@@ -589,16 +660,69 @@ export class RunInstallUseCase {
589
660
  };
590
661
  }
591
662
 
592
- private async planSkillLink(projectRoot: string, relativePath: string): Promise<InstallPlanItem> {
663
+ private async planPersonalSkillDirectory(
664
+ input: RunInstallInput,
665
+ relativePath: string,
666
+ baseManifest: DeploymentManifest,
667
+ ): Promise<InstallPlanItem> {
668
+ const absolutePath = join(input.projectRoot, relativePath);
669
+ const versionPath = join(absolutePath, ".harness-version");
670
+ const current = await readTextOrNull(versionPath);
671
+ const expectedNeedle = `"version": "${input.phasegateVersion}"`;
672
+ const manifestEntry = baseManifest.findEntry(relativePath);
673
+ const pathExists = await exists(absolutePath);
674
+ const unmanagedExisting = pathExists && (current === null || manifestEntry === null);
675
+ const changed = !unmanagedExisting && (current === null || !current.includes(expectedNeedle));
676
+ return {
677
+ path: relativePath,
678
+ action: changed ? "missing" : unmanagedExisting ? "will-merge" : "will-skip",
679
+ repairMode: unmanagedExisting ? "manual" : "mechanical",
680
+ strategy: "copy-dir",
681
+ changed,
682
+ summary: unmanagedExisting
683
+ ? `${relativePath}: existing non-phasegate directory requires manual review`
684
+ : changed ? `${relativePath}: deploy bundled skills` : `${relativePath}: already up to date`,
685
+ diff: unmanagedExisting ? "manual review required" : changed ? "+ bundled skills" : "no changes",
686
+ skillHint: null,
687
+ };
688
+ }
689
+
690
+ private personalSkillsHashInput(path: string, version: string, skillSet: "core" | "all"): string {
691
+ return `personal-skills:${path}:${version}:${skillSet}`;
692
+ }
693
+
694
+ private addManifestEntry(
695
+ baseManifest: DeploymentManifest,
696
+ manifest: DeploymentManifest,
697
+ input: { readonly path: string; readonly mode: "created" | "symlink"; readonly contentForHash: string },
698
+ ): DeploymentManifest {
699
+ const hash = this.hashCalculator.compute(input.contentForHash);
700
+ const existingEntry = baseManifest.findEntry(input.path);
701
+ if (existingEntry !== null && existingEntry.hash.equals(hash)) {
702
+ return manifest.addEntry(existingEntry);
703
+ }
704
+ return manifest.addEntry(
705
+ DeploymentEntry.create({
706
+ path: input.path,
707
+ mode: input.mode,
708
+ block: null,
709
+ hash,
710
+ deployedAt: new Date().toISOString(),
711
+ }),
712
+ );
713
+ }
714
+
715
+ private async planSkillLink(projectRoot: string, relativePath: string, target: string): Promise<InstallPlanItem> {
593
716
  const absolutePath = join(projectRoot, relativePath);
717
+ const parentPath = dirname(absolutePath);
594
718
  try {
595
719
  const stat = await lstat(absolutePath);
596
- if (stat.isSymbolicLink() && (await readlink(absolutePath)) === "../skills") {
720
+ if (stat.isSymbolicLink() && (await readlink(absolutePath)) === target) {
597
721
  return {
598
722
  path: relativePath,
599
723
  action: "will-skip",
600
724
  repairMode: "mechanical",
601
- strategy: "yaml-add",
725
+ strategy: "symlink",
602
726
  changed: false,
603
727
  summary: `${relativePath}: already linked`,
604
728
  diff: "no changes",
@@ -609,21 +733,36 @@ export class RunInstallUseCase {
609
733
  path: relativePath,
610
734
  action: "will-merge",
611
735
  repairMode: "manual",
612
- strategy: "yaml-add",
736
+ strategy: "symlink",
613
737
  changed: false,
614
738
  summary: `${relativePath}: existing non-phasegate path requires manual review`,
615
739
  diff: "manual review required",
616
740
  skillHint: null,
617
741
  };
618
742
  } catch {
743
+ try {
744
+ const parentStat = await lstat(parentPath);
745
+ if (!parentStat.isDirectory()) {
746
+ return {
747
+ path: relativePath,
748
+ action: "will-merge",
749
+ repairMode: "manual",
750
+ strategy: "symlink",
751
+ changed: false,
752
+ summary: `${relativePath}: parent path exists and requires manual review`,
753
+ diff: "manual review required",
754
+ skillHint: null,
755
+ };
756
+ }
757
+ } catch {}
619
758
  return {
620
759
  path: relativePath,
621
760
  action: "missing",
622
761
  repairMode: "mechanical",
623
- strategy: "yaml-add",
762
+ strategy: "symlink",
624
763
  changed: true,
625
764
  summary: `${relativePath}: create symlink`,
626
- diff: "+ symlink ../skills",
765
+ diff: `+ symlink ${target}`,
627
766
  skillHint: null,
628
767
  };
629
768
  }
@@ -4,6 +4,8 @@
4
4
  // @work-item-id WI-174
5
5
  // @work-item-id WI-199
6
6
  // @work-item-id WI-207
7
+ // @work-item-id WI-208
8
+ // @work-item-id WI-209
7
9
 
8
10
  import { access, copyFile, lstat, mkdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
9
11
  import { dirname, join, relative, resolve } from "node:path";
@@ -163,6 +165,7 @@ export class RunUninstallUseCase {
163
165
  repairMode: "manual",
164
166
  strategy: "unknown",
165
167
  changed: false,
168
+ protected: false,
166
169
  summary: "manifest missing; run phasegate doctor and clean up manually",
167
170
  diff: "manual cleanup required",
168
171
  skillHint: SKILL_HINT,
@@ -216,8 +219,12 @@ export class RunUninstallUseCase {
216
219
  readonly apply: () => Promise<void>;
217
220
  }> {
218
221
  const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
219
- const currentContent = await readTextOrNull(absolutePath);
220
222
  const strategy = this.strategyFor(entry.path, entry.mode);
223
+ if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
224
+ if (entry.mode === "created" && await this.isDirectory(absolutePath)) {
225
+ return this.planCreatedDirectory(input.projectRoot, entry);
226
+ }
227
+ const currentContent = await readTextOrNull(absolutePath);
221
228
  if (currentContent === null && strategy !== "symlink") {
222
229
  return {
223
230
  item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: already absent`, "no changes", null),
@@ -226,7 +233,6 @@ export class RunUninstallUseCase {
226
233
  };
227
234
  }
228
235
 
229
- if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
230
236
  if (entry.mode === "created") return this.planCreated(input.projectRoot, entry, currentContent ?? "");
231
237
  return this.planMerged(input, entry, currentContent ?? "", strategy);
232
238
  }
@@ -240,9 +246,9 @@ export class RunUninstallUseCase {
240
246
  } catch {
241
247
  target = null;
242
248
  }
243
- if (target === "../skills") {
249
+ if (target !== null && this.hashCalculator.compute(target).equals(entry.hash)) {
244
250
  return {
245
- item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`, "- symlink ../skills", null),
251
+ item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`, `- symlink ${target}`, null),
246
252
  needsBackup: false,
247
253
  apply: async () => {
248
254
  await rm(absolutePath, { force: true });
@@ -256,6 +262,35 @@ export class RunUninstallUseCase {
256
262
  };
257
263
  }
258
264
 
265
+ private async isDirectory(path: string): Promise<boolean> {
266
+ try {
267
+ const stat = await lstat(path);
268
+ return stat.isDirectory();
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ private async planCreatedDirectory(projectRoot: string, entry: DeploymentEntry) {
275
+ const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
276
+ return {
277
+ item: this.item(
278
+ entry.path,
279
+ "delete",
280
+ "mechanical",
281
+ "created",
282
+ true,
283
+ `${entry.path}: delete created directory`,
284
+ "- directory",
285
+ null,
286
+ ),
287
+ needsBackup: false,
288
+ apply: async () => {
289
+ await rm(absolutePath, { recursive: true, force: true });
290
+ },
291
+ };
292
+ }
293
+
259
294
  private async planCreated(projectRoot: string, entry: DeploymentEntry, currentContent: string) {
260
295
  const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
261
296
  const currentHash = this.hashCalculator.compute(currentContent);
@@ -2,6 +2,7 @@
2
2
  // @layer presentation
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-178
5
+ // @work-item-id WI-208
5
6
 
6
7
  import { mkdir, writeFile } from "node:fs/promises";
7
8
  import { dirname, isAbsolute, join } from "node:path";
@@ -37,6 +38,7 @@ export class DoctorHandler {
37
38
  const formatInput = {
38
39
  report: result.report,
39
40
  agent: result.agent,
41
+ installationMode: result.installationMode,
40
42
  scopedOutFindings: result.scopedOutFindings,
41
43
  phasegateVersion: input.phasegateVersion,
42
44
  projectRoot: input.projectRoot,
@@ -4,6 +4,7 @@
4
4
  // @work-item-id WI-178
5
5
  // @work-item-id WI-179
6
6
  // @work-item-id WI-180
7
+ // @work-item-id WI-208
7
8
 
8
9
  import type { DoctorAgentScope, ScopedOutDiagnosticFinding } from "../../application/usecases/run-doctor-diagnostics.js";
9
10
  import type { DiagnosticReport } from "../../domain/diagnostic-report.js";
@@ -11,6 +12,7 @@ import type { DiagnosticReport } from "../../domain/diagnostic-report.js";
11
12
  export interface DiagnosticReportFormatterInput {
12
13
  readonly report: DiagnosticReport;
13
14
  readonly agent: DoctorAgentScope;
15
+ readonly installationMode: "project" | "personal";
14
16
  readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
15
17
  readonly phasegateVersion: string;
16
18
  readonly projectRoot: string;
@@ -26,7 +28,8 @@ export class DiagnosticReportFormatter {
26
28
  projectRoot: input.projectRoot,
27
29
  scope: {
28
30
  agent: input.agent,
29
- description: scopeDescription(input.agent),
31
+ installationMode: input.installationMode,
32
+ description: scopeDescription(input.agent, input.installationMode),
30
33
  },
31
34
  overallStatus: input.report.overallStatus,
32
35
  findings: input.report.findings.map((finding) => ({
@@ -60,7 +63,7 @@ export class DiagnosticReportFormatter {
60
63
  const lines = [
61
64
  `phasegate doctor v${input.phasegateVersion}`,
62
65
  `Project: ${input.projectRoot}`,
63
- `Scope: ${input.agent} (${scopeDescription(input.agent)})`,
66
+ `Scope: ${input.agent} / ${input.installationMode} (${scopeDescription(input.agent, input.installationMode)})`,
64
67
  "",
65
68
  ];
66
69
  for (const finding of input.report.findings) {
@@ -86,7 +89,12 @@ export class DiagnosticReportFormatter {
86
89
  }
87
90
  }
88
91
 
89
- function scopeDescription(agent: DoctorAgentScope): string {
92
+ function scopeDescription(agent: DoctorAgentScope, installationMode: "project" | "personal"): string {
93
+ if (installationMode === "personal") {
94
+ if (agent === "claude") return "Personal Claude Code sandbox; team/project Husky, CI, package, and Codex-only findings are not repair targets.";
95
+ if (agent === "codex") return "Personal Codex sandbox; team/project Husky, CI, package, and Claude-only findings are not repair targets.";
96
+ return "Personal sandbox diagnostics; team/project Husky, CI, and package findings are not repair targets.";
97
+ }
90
98
  if (agent === "claude") return "Claude Code and shared setup targets; Codex-only findings are not applicable.";
91
99
  if (agent === "codex") return "Codex and shared setup targets; Claude-only findings are not applicable.";
92
100
  return "Full setup diagnostics for Claude, Codex, and shared targets.";
@@ -171,7 +171,7 @@ Setup:
171
171
  scaffold-wi <unit|_cross> <story|issue|chore>
172
172
  Create docs/inception/{unit}/WI-XXX/description.md
173
173
  emit-agent-rules Print AGENTS.md / CLAUDE.md WI workflow rules block
174
- install Install phasegate managed files (--dry-run|--apply, --force, --personal)
174
+ install Install phasegate managed files (--dry-run|--apply, --force, --personal, --agent <claude|codex|both>)
175
175
  uninstall Uninstall phasegate managed files (--dry-run|--apply, --force)
176
176
  reconcile Reconcile phasegate managed files (--dry-run|--apply, --force)
177
177
  setup:agent Diagnose repo setup and produce/apply an agent-readable setup plan
@@ -625,7 +625,8 @@ Options:
625
625
  --workflow <standard|strict> Rendered agent context workflow mode (default: standard)
626
626
  --with-husky Include Husky hook targets
627
627
  --with-ci Include GitHub Actions target
628
- --personal Use local-only install: no package.json, agent docs, Husky, CI, or .gitignore writes
628
+ --personal Use local-only install: no package.json, agent docs, Husky, CI, .gitignore, GitHub CLI, secrets, or CI setting writes.
629
+ With --agent claude, initializes .phasegate-local config/settings/skills and ignored .claude shims.
629
630
  --json Output machine-readable JSON
630
631
  --help, -h Show this help`,
631
632
  "setup:agent": `Usage: phasegate setup:agent [options]
package/skills/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  `.agent/skills` は旧 setup 由来の互換パスです。新規導入では管理対象にしません。<!-- @work-item-id WI-157 -->
18
18
 
19
- `phasegate install --personal` はチーム所有リポジトリでの個人評価用です。このモードでは `.claude/skills` / `.codex/skills` のリンクを作成せず、`.phasegate-local/config.json` と `.git/info/exclude` の local-only managed block だけを扱います。通常の共有導入が必要になった時点で `phasegate install --dry-run` / `phasegate install --apply` を使います。<!-- @work-item-id WI-207 -->
19
+ `phasegate install --personal` はチーム所有リポジトリでの個人評価用です。このモードでは team-owned files を変更せず、`.phasegate-local/phasegate.config.json` と `.git/info/exclude` の local-only managed block、選択 agent 用の real `.claude/*` / `.codex/*` runtime artifacts だけを扱います。通常の共有導入が必要になった時点で `phasegate install --dry-run` / `phasegate install --apply` を使います。<!-- @work-item-id WI-207 --> <!-- @work-item-id WI-209 -->
20
20
 
21
21
  ## 新しいスキルの追加
22
22
 
@@ -35,7 +35,7 @@ phasegate を導入した直後の config は単純な default で、実プロ
35
35
  | hook config | `.claude/scripts/hook-config.json` | 既存 hook 設定確認 |
36
36
  | doctor report | 明示された report path、またはユーザーが指定した `.phasegate/last-doctor-report.json` | `repairMode` / `repairHint` / `suggestedSkill` の確認 |
37
37
  | manifest | `.phasegate/manifest.json` | install / reconcile / uninstall の managed target と hash 状態確認 |
38
- | personal config | `.phasegate-local/config.json` | `install --personal` の local-only 設定確認 |
38
+ | personal config | `.phasegate-local/phasegate.config.json` | `install --personal` の local-only 設定確認 |
39
39
  | git local exclude | `.git/info/exclude` | `install --personal` の repository-local ignore block 確認 |
40
40
  | Claude hooks | `.claude/settings.json` | managed hook JSON と user customization の確認 |
41
41
  | Codex hooks | `.codex/hooks.json` | managed hook JSON と Codex hook 配線確認 |
@@ -129,7 +129,7 @@ product-architect で Unit を作り、いくつかの logical_design を書い
129
129
  #### 観点 9: setup lifecycle と doctor finding
130
130
 
131
131
  - `phasegate doctor --json` の finding に `repairMode: "ai-assisted"` と `suggestedSkill.skillName = "phasegate-config-doctor"` がある → 本 skill が merge 方針、保持する user content、実行すべき `install --apply` / `--force` / `reconcile --apply` を提案する
132
- - チーム所有リポジトリで個人評価だけを行う相談では、通常の `install --apply` ではなく `phasegate install --personal --dry-run` を先に提案する。personal install は `package.json`, `AGENTS.md`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, `.codex/hooks.json`, skill symlink を変更対象にしない。変更対象は `.phasegate-local/config.json`, `.phasegate/manifest.json`, `.git/info/exclude` の managed block に限定し、Codex user-level hook setup は manual action として扱う。<!-- @work-item-id WI-207 -->
132
+ - チーム所有リポジトリで個人評価だけを行う相談では、通常の `install --apply` ではなく `phasegate install --personal --agent claude --dry-run` を先に提案する。personal install は `package.json`, `AGENTS.md`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, GitHub CLI config, repo secrets, CI settings を変更対象にしない。選択 agent 向けには `.phasegate-local/phasegate.config.json`, real `.claude/settings.json` / `.claude/skills/`, real `.codex/hooks.json` / `.codex/skills/`, `.phasegate/manifest.json`, `.git/info/exclude` の managed block を自動作成する。既存 `.claude/*` / `.codex/*` がある場合は上書きせず manual review として扱う。Codex user-level hook feature enablement は manual action として扱う。<!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 --> <!-- @work-item-id WI-209 -->
133
133
  - Claude-only / Codex-only 導入後は `phasegate doctor --agent claude --json` または `phasegate doctor --agent codex --json` を使って selected agent の readiness を読む。`scopedOutFindings` は未選択 agent の `not-applicable` 情報なので、ユーザーがその agent を導入したいと言っていない限り repair 提案にしない。`repairHint: null` / `suggestedSkill: null` は意図的な抑制で、`currentScopeRepairTarget: false` と `repairModeApplicability: "only-if-agent-selected"` は raw `repairMode` が current scope の修復指示ではないという印である。<!-- @work-item-id WI-178, WI-179, WI-180 -->
134
134
  - `repairHint` がある mechanical finding → 原則として hint のコマンドを優先し、実行前に対象ファイルと manifest の差分を確認
135
135
  - manifest parse error → `.phasegate/manifest.json` を手で修復する前に backup / uninstall / reinstall の選択肢を提示
@@ -130,7 +130,7 @@ docs/guide/ # phasegate リポジトリ自体 (dogfood)
130
130
  - 既存プロジェクト導入: `docs/guide/retrofit-adoption.md`
131
131
  - setup artifact / doctor finding / legacy artifact: `docs/guide/setup-artifacts.md`
132
132
 
133
- チーム所有リポジトリで個人評価だけを行いたい場合は、`docs/guide/installation.md` の personal install セクションを読む。`phasegate install --personal` は `package.json`、`AGENTS.md`、`CLAUDE.md`、`.husky/*`、`.github/workflows/*`、`.gitignore`、`.codex/hooks.json`、skill symlink を変更せず、`.phasegate-local/config.json` `.git/info/exclude` の managed block を使う。Codex user-level hook setup は manual action として説明する。<!-- @work-item-id WI-207 -->
133
+ チーム所有リポジトリで個人評価だけを行いたい場合は、`docs/guide/installation.md` の personal install セクションを読む。`phasegate install --personal --agent <agent>` は `package.json`、`AGENTS.md`、`CLAUDE.md`、`.husky/*`、`.github/workflows/*`、`.gitignore`、GitHub CLI config、repo secrets、CI settings を変更せず、`.phasegate-local/phasegate.config.json`、real `.claude/settings.json` / `.claude/skills/`、real `.codex/hooks.json` / `.codex/skills/`、`.git/info/exclude` の managed block を使う。Codex user-level hook feature enablement は manual action として説明する。<!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 --> <!-- @work-item-id WI-209 -->
134
134
 
135
135
  `setup-artifacts.md` は managed target / generated artifact / runtime state / legacy artifact / user-level setting の分類を持つ。`doctor --report-out` は明示 path への出力で、`.phasegate/last-doctor-report.json` は固定生成物ではない点もここを参照する。<!-- @work-item-id WI-153 -->
136
136