phasegate 0.160.19 → 0.160.20

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/README.md CHANGED
@@ -100,7 +100,7 @@ npx phasegate install --apply
100
100
  npx phasegate doctor
101
101
  ```
102
102
 
103
- `install` merges PhaseGate into the current project without discarding existing Claude/Codex hooks or Husky scripts. It reports planned changes before writing, adds package scripts and the `phasegate` devDependency, deploys selected bundled skills to root `skills/`, creates agent skill links to that shared directory, writes `AGENTS.md` / `CLAUDE.md` PhaseGate managed sections for the selected agent targets, writes the CI workflow when missing, and records managed files in `.phasegate/manifest.json`. If an existing file needs a forced managed update, run `npx phasegate install --apply --force`; PhaseGate backs up replaced files under `.phasegate/backups/`. <!-- @work-item-id WI-174 --> <!-- @work-item-id WI-210 -->
103
+ `install` merges PhaseGate into the current project without discarding existing Claude/Codex hooks, Husky scripts, or user-owned skills. It reports planned changes before writing, adds package scripts and the `phasegate` devDependency, deploys selected bundled skills to root `skills/`, creates agent skill links to that shared directory, writes `AGENTS.md` / `CLAUDE.md` PhaseGate managed sections for the selected agent targets, writes the CI workflow when missing, and records managed files in `.phasegate/manifest.json`. If an existing file needs a forced managed update, run `npx phasegate install --apply --force`; PhaseGate backs up replaced files under `.phasegate/backups/`. <!-- @work-item-id WI-174 --> <!-- @work-item-id WI-210 --> <!-- @work-item-id WI-216 -->
104
104
 
105
105
  For personal evaluation inside a team-owned repository, use local-only install:
106
106
 
@@ -109,7 +109,7 @@ npx phasegate install --personal --agent claude --dry-run
109
109
  npx phasegate install --personal --agent claude --apply
110
110
  ```
111
111
 
112
- `--personal` does not plan or write `package.json`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, GitHub CLI config, repo secrets, or CI settings. It creates `.phasegate-local/phasegate.config.json`, runtime-visible local agent context (`.claude/CLAUDE.md` for Claude, and `AGENTS.md` for Codex only when that root file is absent or already PhaseGate-managed), real local-only agent runtime artifacts for the selected agent (`.claude/settings.json` + `.claude/skills/` and/or `.codex/hooks.json` + `.codex/skills/`), local git hooks under `.git/hooks/`, local reference docs under `.phasegate-local/docs/`, a managed local-only block in `.git/info/exclude`, and `.phasegate/manifest.json`. If a team `AGENTS.md` already exists, personal Codex install leaves it unchanged and `doctor --personal --agent codex` reports the remaining context step instead of hiding it behind `AGENTS.override.md`. Codex user-level hook feature enablement remains a manual action. <!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 --> <!-- @work-item-id WI-209 --> <!-- @work-item-id WI-213 --> <!-- @work-item-id WI-215 -->
112
+ `--personal` does not plan or write `package.json`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, GitHub CLI config, repo secrets, or CI settings. It creates `.phasegate-local/phasegate.config.json`, runtime-visible local agent context (`.claude/CLAUDE.md` for Claude, and `AGENTS.md` for Codex only when that root file is absent or already PhaseGate-managed), real local-only agent runtime artifacts for the selected agent (`.claude/settings.json` + `.claude/skills/` and/or `.codex/hooks.json` + `.codex/skills/`), local git hooks under `.git/hooks/`, local reference docs under `.phasegate-local/docs/`, a managed local-only block in `.git/info/exclude`, and `.phasegate/manifest.json`. Existing personal skills directories are merged: PhaseGate refreshes bundled skills and preserves user-owned skills. If a team `AGENTS.md` already exists, personal Codex install leaves it unchanged and `doctor --personal --agent codex` reports the remaining context step instead of hiding it behind `AGENTS.override.md`. Codex user-level hook feature enablement remains a manual action. <!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 --> <!-- @work-item-id WI-209 --> <!-- @work-item-id WI-213 --> <!-- @work-item-id WI-215 --> <!-- @work-item-id WI-216 -->
113
113
 
114
114
  For agent-driven setup planning, use:
115
115
 
@@ -129,7 +129,7 @@ npx phasegate uninstall --dry-run
129
129
  npx phasegate uninstall --apply
130
130
  ```
131
131
 
132
- `uninstall` uses the manifest to delete created files and remove only PhaseGate-managed portions from merged Claude/Codex, Husky, and `package.json` files. User content is preserved, and the manifest is archived under `.phasegate/`.
132
+ `uninstall` uses the manifest to delete created files, remove only PhaseGate-managed portions from merged Claude/Codex, Husky, and `package.json` files, and remove only PhaseGate-managed bundled skills. User content and user-owned skills are preserved, and the manifest is archived under `.phasegate/`. <!-- @work-item-id WI-216 -->
133
133
 
134
134
  When you upgrade PhaseGate, reconcile existing managed files with the current bundled templates:
135
135
 
@@ -138,7 +138,7 @@ npx phasegate reconcile --dry-run
138
138
  npx phasegate reconcile --apply
139
139
  ```
140
140
 
141
- `reconcile` updates only PhaseGate-managed portions, keeps user content, adds newly introduced managed targets, repairs missing shared skill bodies for project installs, and refreshes `.phasegate/manifest.json` with the current version and hashes. If a managed file was edited after install, `reconcile --apply` refuses that entry until you rerun with `--force`; PhaseGate writes a backup under `.phasegate/backups/reconcile-<timestamp>/`. <!-- @work-item-id WI-210 -->
141
+ `reconcile` updates only PhaseGate-managed portions, keeps user content, adds newly introduced managed targets, repairs missing shared or personal bundled skill bodies, and refreshes `.phasegate/manifest.json` with the current version and hashes. If a managed file was edited after install, `reconcile --apply` refuses that entry until you rerun with `--force`; PhaseGate writes a backup under `.phasegate/backups/reconcile-<timestamp>/`. <!-- @work-item-id WI-210 --> <!-- @work-item-id WI-216 -->
142
142
 
143
143
  ### Codex CLI
144
144
 
@@ -56,7 +56,7 @@ npx phasegate install --apply
56
56
  npx phasegate doctor
57
57
  ```
58
58
 
59
- `install --dry-run` reports whether each target will be created, merged, skipped, or refused. `install --apply` performs the merge, adds package scripts and the `phasegate` devDependency, creates `.claude/skills` and `.codex/skills` links, writes `CLAUDE.md` / `AGENTS.md` managed sections for selected agent targets, writes `.github/workflows/phasegate-aidlc-gate.yml` when CI is enabled, and records managed entries in `.phasegate/manifest.json`. See [Setup Artifacts](setup-artifacts.md) for the full managed target, generated artifact, runtime state, legacy artifact, and user-level setting inventory. <!-- @work-item-id WI-152 --> <!-- @work-item-id WI-169 --> <!-- @work-item-id WI-174 -->
59
+ `install --dry-run` reports whether each target will be created, merged, skipped, or refused. `install --apply` performs the merge, adds package scripts and the `phasegate` devDependency, deploys selected bundled skills to root `skills/`, creates `.claude/skills` and `.codex/skills` links, writes `CLAUDE.md` / `AGENTS.md` managed sections for selected agent targets, writes `.github/workflows/phasegate-aidlc-gate.yml` when CI is enabled, and records managed entries in `.phasegate/manifest.json`. Existing skills catalogs are merged: PhaseGate refreshes only bundled skill directories selected by `--skills core|all` and preserves user-owned skills. See [Setup Artifacts](setup-artifacts.md) for the full managed target, generated artifact, runtime state, legacy artifact, and user-level setting inventory. <!-- @work-item-id WI-152 --> <!-- @work-item-id WI-169 --> <!-- @work-item-id WI-174 --> <!-- @work-item-id WI-216 -->
60
60
 
61
61
  For personal evaluation inside a team-owned repository:
62
62
 
@@ -65,7 +65,7 @@ npx phasegate install --personal --agent claude --dry-run
65
65
  npx phasegate install --personal --agent claude --apply
66
66
  ```
67
67
 
68
- Personal install keeps team-owned files out of the apply path: `package.json`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, GitHub CLI config, repo secrets, and CI settings are not touched. PhaseGate writes `.phasegate-local/phasegate.config.json`, creates runtime-visible local agent context (`.claude/CLAUDE.md` for Claude, and `AGENTS.md` for Codex only when that root file is absent or already PhaseGate-managed), creates real local-only agent runtime artifacts for the selected agent (`.claude/settings.json` + `.claude/skills/` and/or `.codex/hooks.json` + `.codex/skills/`), deploys local git hooks under `.git/hooks/`, copies reference docs under `.phasegate-local/docs/`, records `.phasegate/manifest.json`, and manages a local exclude block in `.git/info/exclude`. If a team `AGENTS.md` already exists, personal Codex install leaves it unchanged and `doctor --personal --agent codex` reports the remaining context step instead of hiding it behind `AGENTS.override.md`. Codex user-level hook feature enablement is still reported as a manual action. <!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 --> <!-- @work-item-id WI-209 --> <!-- @work-item-id WI-213 --> <!-- @work-item-id WI-215 -->
68
+ Personal install keeps team-owned files out of the apply path: `package.json`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, GitHub CLI config, repo secrets, and CI settings are not touched. PhaseGate writes `.phasegate-local/phasegate.config.json`, creates runtime-visible local agent context (`.claude/CLAUDE.md` for Claude, and `AGENTS.md` for Codex only when that root file is absent or already PhaseGate-managed), creates real local-only agent runtime artifacts for the selected agent (`.claude/settings.json` + `.claude/skills/` and/or `.codex/hooks.json` + `.codex/skills/`), deploys local git hooks under `.git/hooks/`, copies reference docs under `.phasegate-local/docs/`, records `.phasegate/manifest.json`, and manages a local exclude block in `.git/info/exclude`. Existing personal skills directories are merged the same way as project `skills/`: bundled PhaseGate skills are added or refreshed, user-owned skills are preserved, and legacy `.harness-version` catalogs can be adopted into the manifest. If a team `AGENTS.md` already exists, personal Codex install leaves it unchanged and `doctor --personal --agent codex` reports the remaining context step instead of hiding it behind `AGENTS.override.md`. Codex user-level hook feature enablement is still reported as a manual action. <!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 --> <!-- @work-item-id WI-209 --> <!-- @work-item-id WI-213 --> <!-- @work-item-id WI-215 --> <!-- @work-item-id WI-216 -->
69
69
 
70
70
  If an existing repository keeps design or governance docs outside `docs/`, set `paths.designDocs`, `paths.inceptionDocs`, `paths.principlesDocs`, and `paths.folderRulesDoc` in `phasegate.config.json` before setup/reconcile. PhaseGate deploy and hook protection use those mappings instead of forcing the default `docs/` layout. <!-- @work-item-id WI-214 -->
71
71
 
@@ -93,7 +93,7 @@ npx phasegate uninstall --dry-run
93
93
  npx phasegate uninstall --apply
94
94
  ```
95
95
 
96
- `uninstall` reads `.phasegate/manifest.json`, deletes files that PhaseGate created, removes only PhaseGate-managed portions from merged JSON, markdown agent context files, Husky, and `package.json` files, and archives the manifest as `.phasegate/uninstalled-<timestamp>.json`. If a managed file was modified after install, `uninstall --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/uninstall-<timestamp>/`. <!-- @work-item-id WI-174 -->
96
+ `uninstall` reads `.phasegate/manifest.json`, deletes files that PhaseGate created, removes only PhaseGate-managed portions from merged JSON, markdown agent context files, Husky, and `package.json` files, removes manifest-managed bundled skills, and archives the manifest as `.phasegate/uninstalled-<timestamp>.json`. User-owned skill directories remain in place, and skills parent directories are kept when they still contain user-owned skills. If a managed file was modified after install, `uninstall --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/uninstall-<timestamp>/`. <!-- @work-item-id WI-174 --> <!-- @work-item-id WI-216 -->
97
97
 
98
98
  After upgrading PhaseGate, reconcile existing managed files with the bundled templates from the new version:
99
99
 
@@ -102,7 +102,7 @@ npx phasegate reconcile --dry-run
102
102
  npx phasegate reconcile --apply
103
103
  ```
104
104
 
105
- `reconcile` updates PhaseGate-managed portions, preserves user content, adds newly introduced managed targets, and refreshes `.phasegate/manifest.json` with current version/hash metadata. If a managed file was edited after install, `reconcile --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/reconcile-<timestamp>/`.
105
+ `reconcile` updates PhaseGate-managed portions, preserves user content, adds newly introduced managed targets, repairs missing or stale bundled skills in project and personal catalogs, and refreshes `.phasegate/manifest.json` with current version/hash metadata. If a managed file was edited after install, `reconcile --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/reconcile-<timestamp>/`. <!-- @work-item-id WI-216 -->
106
106
 
107
107
  ### Manual Setup Pieces
108
108
 
@@ -134,7 +134,7 @@ npx phasegate reconcile --dry-run
134
134
  npx phasegate reconcile --apply
135
135
  ```
136
136
 
137
- `phasegate update-skills` remains available as a compatibility alias, but `reconcile` is the preferred upgrade path because it updates all managed files recorded in `.phasegate/manifest.json`.
137
+ `phasegate update-skills` remains available as a compatibility alias. It now follows the same manifest-aware reconcile path, including project and personal bundled skill repair, so `reconcile` remains the preferred explicit upgrade command. <!-- @work-item-id WI-216 -->
138
138
 
139
139
  `doctor --report-out <path>` writes exactly to the provided path. `.phasegate/last-doctor-report.json` is not a fixed output file unless you choose that path explicitly. <!-- @work-item-id WI-152 -->
140
140
 
@@ -8,6 +8,7 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
8
8
  <!-- @work-item-id WI-208 -->
9
9
  <!-- @work-item-id WI-209 -->
10
10
  <!-- @work-item-id WI-215 -->
11
+ <!-- @work-item-id WI-216 -->
11
12
 
12
13
  ## Artifact Classes
13
14
 
@@ -43,11 +44,11 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
43
44
 
44
45
  `phasegate install --personal --agent claude --apply` is a local-only bootstrap for evaluating PhaseGate in a team-owned repository. PhaseGate config is stored under `.phasegate-local/`, while selected agent runtime paths are real project-local files/directories: `.claude/settings.json` and `.claude/skills/` for Claude Code, `.codex/hooks.json` and `.codex/skills/` for Codex. These paths are hidden by `.git/info/exclude`; PhaseGate does not edit team `.gitignore`.
45
46
 
46
- If `.claude/*` or `.codex/*` already exists and is not a PhaseGate-managed personal artifact, personal install reports manual review and preserves the existing path. <!-- @work-item-id WI-209 -->
47
+ If `.claude/*` or `.codex/*` already exists and is not a PhaseGate-managed personal artifact, personal install reports manual review and preserves the existing path. Existing `.claude/skills/` and `.codex/skills/` directories are mergeable skill catalogs: PhaseGate refreshes bundled skill directories and preserves user-owned skill directories. <!-- @work-item-id WI-209 --> <!-- @work-item-id WI-216 -->
47
48
 
48
49
  Personal agent context is also placed only where the runtime will read it. Claude Code uses `.claude/CLAUDE.md`. Codex uses root `AGENTS.md` when PhaseGate can create or manage it locally; if a team `AGENTS.md` already exists, PhaseGate leaves it unchanged and doctor reports `codex-context-missing` instead of creating `AGENTS.override.md`. <!-- @work-item-id WI-215 -->
49
50
 
50
- Project install uses a different topology: selected bundled skills are deployed once to root `skills/`, and `.claude/skills` / `.codex/skills` point to that shared target. If an older project install has the links but an empty `skills/` target, `phasegate doctor` reports the selected agent skill check and `phasegate reconcile --apply` repairs the shared skill bodies. <!-- @work-item-id WI-210 -->
51
+ Project install uses a different topology: selected bundled skills are deployed once to root `skills/`, and `.claude/skills` / `.codex/skills` point to that shared target. Existing root `skills/` is also a mergeable catalog: selected bundled skill directories are refreshed, selection-excluded or user-owned skills are not removed, and uninstall deletes only manifest-managed bundled skills plus metadata. If an older project install has the links but an empty `skills/` target, `phasegate doctor` reports the selected agent skill check and `phasegate reconcile --apply` repairs the shared skill bodies. <!-- @work-item-id WI-210 --> <!-- @work-item-id WI-216 -->
51
52
 
52
53
  ## Doctor Findings
53
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.160.19",
3
+ "version": "0.160.20",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -0,0 +1,46 @@
1
+ // @unit installation
2
+ // @layer application
3
+ // @work-item-id WI-216
4
+
5
+ export type SkillSet = "core" | "all";
6
+
7
+ const CORE_SKILLS = [
8
+ "cascade-updater",
9
+ "codebase-mapper",
10
+ "consistency-checker",
11
+ "doc-freshness-checker",
12
+ "engineering-perspective",
13
+ "implementation-readiness-checker",
14
+ "pointer-validator",
15
+ "test-coverage-checker",
16
+ ] as const;
17
+
18
+ const AIDLC_SKILLS = [
19
+ "domain-designer",
20
+ "environment-designer",
21
+ "implementation-planner",
22
+ "it-test-designer",
23
+ "it-test-logic-designer",
24
+ "logical-designer",
25
+ "mock-designer",
26
+ "product-architect",
27
+ "quick-implementor",
28
+ "scenario-test-designer",
29
+ "scenario-test-logic-designer",
30
+ "story-implementor",
31
+ "story-mapper",
32
+ "story-writer",
33
+ "uiux-designer",
34
+ "unit-designer",
35
+ "unit-test-designer",
36
+ "unit-test-logic-designer",
37
+ ] as const;
38
+
39
+ const UTILITY_SKILLS = ["codex-delegator", "skill-creator"] as const;
40
+ const GUIDANCE_SKILLS = ["phasegate-toolkit-guide", "phasegate-config-doctor"] as const;
41
+
42
+ export function getBundledSkillsForSet(skillSet: SkillSet): string[] {
43
+ if (skillSet === "core") return [...CORE_SKILLS];
44
+ return [...CORE_SKILLS, ...AIDLC_SKILLS, ...UTILITY_SKILLS, ...GUIDANCE_SKILLS];
45
+ }
46
+
@@ -3,8 +3,10 @@
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-209
5
5
  // @work-item-id WI-210
6
+ // @work-item-id WI-216
6
7
 
7
8
  import { join } from "node:path";
9
+ import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
8
10
  import type { CheckId } from "../../domain/check-id.js";
9
11
  import { DiagnosticFinding, type DiagnosticSeverity } from "../../domain/diagnostic-finding.js";
10
12
  import type { RepairMode } from "../../domain/repair-mode.js";
@@ -69,3 +71,12 @@ export function skillTargetLooksValid(target: string | null): boolean {
69
71
  export function skillDirectoryLooksValid(files: readonly string[]): boolean {
70
72
  return files.some((file) => file.endsWith("/SKILL.md") || file.endsWith("\\SKILL.md") || file.endsWith(".harness-version"));
71
73
  }
74
+
75
+ export function phasegateSkillDirectoryLooksComplete(files: readonly string[], metadata: string | null): boolean {
76
+ const skillSet: SkillSet = metadata?.includes('"skillSet": "core"') ? "core" : "all";
77
+ const expectedSkills = getBundledSkillsForSet(skillSet);
78
+ return expectedSkills.every((skill) => files.some((file) => {
79
+ const normalized = file.replaceAll("\\", "/");
80
+ return normalized.endsWith(`/${skill}/SKILL.md`);
81
+ }));
82
+ }
@@ -3,11 +3,12 @@
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-209
5
5
  // @work-item-id WI-210
6
+ // @work-item-id WI-216
6
7
 
7
8
  import type { FileInspectorPort } from "../ports/file-inspector-port.js";
8
9
  import type { HeuristicCheck } from "../../domain/ports/heuristic-check.js";
9
10
  import type { DiagnosticFinding } from "../../domain/diagnostic-finding.js";
10
- import { createFinding, projectPath, skillDirectoryLooksValid, skillTargetLooksValid } from "./check-utils.js";
11
+ import { createFinding, phasegateSkillDirectoryLooksComplete, projectPath, skillTargetLooksValid } from "./check-utils.js";
11
12
 
12
13
  export class ClaudeSkillsSymlinkCheck implements HeuristicCheck {
13
14
  readonly checkId = "claude-skills-symlink" as const;
@@ -16,8 +17,9 @@ export class ClaudeSkillsSymlinkCheck implements HeuristicCheck {
16
17
  async run(projectRoot: string, inspector: FileInspectorPort): Promise<DiagnosticFinding | null> {
17
18
  const link = await inspector.readSymlink(projectPath(projectRoot, this.target));
18
19
  const files = await inspector.listFiles(projectPath(projectRoot, this.target));
19
- if (skillTargetLooksValid(link) && skillDirectoryLooksValid(files)) return null;
20
- if (link === null && skillDirectoryLooksValid(files)) return null;
20
+ const metadata = await inspector.readText(projectPath(projectRoot, `${this.target}/.harness-version`));
21
+ if (skillTargetLooksValid(link) && phasegateSkillDirectoryLooksComplete(files, metadata)) return null;
22
+ if (link === null && phasegateSkillDirectoryLooksComplete(files, metadata)) return null;
21
23
  return createFinding({
22
24
  checkId: this.checkId,
23
25
  severity: "red",
@@ -3,11 +3,12 @@
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-209
5
5
  // @work-item-id WI-210
6
+ // @work-item-id WI-216
6
7
 
7
8
  import type { FileInspectorPort } from "../ports/file-inspector-port.js";
8
9
  import type { HeuristicCheck } from "../../domain/ports/heuristic-check.js";
9
10
  import type { DiagnosticFinding } from "../../domain/diagnostic-finding.js";
10
- import { createFinding, projectPath, skillDirectoryLooksValid, skillTargetLooksValid } from "./check-utils.js";
11
+ import { createFinding, phasegateSkillDirectoryLooksComplete, projectPath, skillTargetLooksValid } from "./check-utils.js";
11
12
 
12
13
  export class CodexSkillsSymlinkCheck implements HeuristicCheck {
13
14
  readonly checkId = "codex-skills-symlink" as const;
@@ -16,8 +17,9 @@ export class CodexSkillsSymlinkCheck implements HeuristicCheck {
16
17
  async run(projectRoot: string, inspector: FileInspectorPort): Promise<DiagnosticFinding | null> {
17
18
  const link = await inspector.readSymlink(projectPath(projectRoot, this.target));
18
19
  const files = await inspector.listFiles(projectPath(projectRoot, this.target));
19
- if (skillTargetLooksValid(link) && skillDirectoryLooksValid(files)) return null;
20
- if (link === null && skillDirectoryLooksValid(files)) return null;
20
+ const metadata = await inspector.readText(projectPath(projectRoot, `${this.target}/.harness-version`));
21
+ if (skillTargetLooksValid(link) && phasegateSkillDirectoryLooksComplete(files, metadata)) return null;
22
+ if (link === null && phasegateSkillDirectoryLooksComplete(files, metadata)) return null;
21
23
  return createFinding({
22
24
  checkId: this.checkId,
23
25
  severity: "red",
@@ -14,14 +14,15 @@
14
14
  // @work-item-id WI-213
15
15
  // @work-item-id WI-214
16
16
  // @work-item-id WI-215
17
+ // @work-item-id WI-216
17
18
 
18
19
  import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink, readdir, rm } from "node:fs/promises";
19
20
  import { dirname, join } from "node:path";
20
- import { getSkillsForSet, type SkillSet } from "../../../setup/skill-deployer.js";
21
21
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
22
22
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
23
23
  import type { ManagedBlockInput } from "../../domain/managed-block.js";
24
24
  import type { RepairMode } from "../../domain/repair-mode.js";
25
+ import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
25
26
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
26
27
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
27
28
 
@@ -131,9 +132,19 @@ async function copyDirectory(src: string, dest: string): Promise<void> {
131
132
  }
132
133
  }
133
134
 
135
+ async function copySelectedSkillDirectories(harnessRoot: string, targetRoot: string, skills: readonly string[]): Promise<void> {
136
+ await mkdir(targetRoot, { recursive: true });
137
+ for (const skill of skills) {
138
+ const source = join(harnessRoot, "skills", skill);
139
+ const target = join(targetRoot, skill);
140
+ await rm(target, { recursive: true, force: true });
141
+ await copyDirectory(source, target);
142
+ }
143
+ }
144
+
134
145
  async function listSelectedBundledSkills(harnessRoot: string, skillSet: SkillSet): Promise<string[]> {
135
146
  const skillsSource = join(harnessRoot, "skills");
136
- const allowed = new Set(getSkillsForSet(skillSet));
147
+ const allowed = new Set(getBundledSkillsForSet(skillSet));
137
148
  const entries = await readdir(skillsSource, { withFileTypes: true });
138
149
  return entries
139
150
  .filter((entry) => entry.isDirectory() && allowed.has(entry.name))
@@ -428,12 +439,13 @@ export class RunInstallUseCase {
428
439
  ...(includeCodex ? [".codex/skills"] : []),
429
440
  ]
430
441
  : [];
442
+ const selectedPersonalSkills = input.personal ? await listSelectedBundledSkills(input.harnessRoot, skillSet) : [];
431
443
  for (const skillPath of personalSkillTargets) {
432
- const item = await this.planPersonalSkillDirectory(input, skillPath, baseManifest);
444
+ const item = await this.planPersonalSkillDirectory(input, skillPath, selectedPersonalSkills, baseManifest);
433
445
  plan.push(item);
434
446
  if (input.apply && item.changed && item.repairMode === "mechanical") {
435
447
  try {
436
- await copyDirectory(join(input.harnessRoot, "skills"), join(input.projectRoot, skillPath));
448
+ await copySelectedSkillDirectories(input.harnessRoot, join(input.projectRoot, skillPath), selectedPersonalSkills);
437
449
  await writeFile(
438
450
  join(input.projectRoot, skillPath, ".harness-version"),
439
451
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -444,10 +456,17 @@ export class RunInstallUseCase {
444
456
  }
445
457
  changed.push(item);
446
458
  manifest = this.addManifestEntry(baseManifest, manifest, {
447
- path: item.path,
459
+ path: `${item.path}/.harness-version`,
448
460
  mode: "created",
449
- contentForHash: this.personalSkillsHashInput(item.path, input.phasegateVersion, skillSet),
461
+ contentForHash: this.personalSkillsVersionHashInput(item.path, input.phasegateVersion, skillSet, selectedPersonalSkills),
450
462
  });
463
+ for (const skill of selectedPersonalSkills) {
464
+ manifest = this.addManifestEntry(baseManifest, manifest, {
465
+ path: `${item.path}/${skill}`,
466
+ mode: "created",
467
+ contentForHash: this.personalSkillHashInput(item.path, skill, input.phasegateVersion, skillSet),
468
+ });
469
+ }
451
470
  }
452
471
  }
453
472
 
@@ -755,32 +774,58 @@ export class RunInstallUseCase {
755
774
  private async planPersonalSkillDirectory(
756
775
  input: RunInstallInput,
757
776
  relativePath: string,
777
+ skills: readonly string[],
758
778
  baseManifest: DeploymentManifest,
759
779
  ): Promise<InstallPlanItem> {
760
780
  const absolutePath = join(input.projectRoot, relativePath);
781
+ try {
782
+ const stat = await lstat(absolutePath);
783
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
784
+ return {
785
+ path: relativePath,
786
+ action: "will-merge",
787
+ repairMode: "manual",
788
+ strategy: "copy-dir",
789
+ changed: false,
790
+ summary: `${relativePath}: existing non-directory skills path requires manual review`,
791
+ diff: "manual review required",
792
+ skillHint: null,
793
+ };
794
+ }
795
+ } catch {}
761
796
  const versionPath = join(absolutePath, ".harness-version");
762
797
  const current = await readTextOrNull(versionPath);
763
- const expectedNeedle = `"version": "${input.phasegateVersion}"`;
764
- const manifestEntry = baseManifest.findEntry(relativePath);
765
- const pathExists = await exists(absolutePath);
766
- const unmanagedExisting = pathExists && (current === null || manifestEntry === null);
767
- const changed = !unmanagedExisting && (current === null || !current.includes(expectedNeedle));
798
+ const skillSet = input.skillSet ?? "all";
799
+ const expectedVersion = `"version": "${input.phasegateVersion}"`;
800
+ const expectedSkillSet = `"skillSet": "${skillSet}"`;
801
+ let missingSkill = false;
802
+ for (const skill of skills) {
803
+ if (!(await exists(join(absolutePath, skill, "SKILL.md")))) {
804
+ missingSkill = true;
805
+ break;
806
+ }
807
+ }
808
+ const missingManifest = baseManifest.findEntry(`${relativePath}/.harness-version`) === null
809
+ || skills.some((skill) => baseManifest.findEntry(`${relativePath}/${skill}`) === null);
810
+ const changed = current === null || !current.includes(expectedVersion) || !current.includes(expectedSkillSet) || missingSkill || missingManifest;
768
811
  return {
769
812
  path: relativePath,
770
- action: changed ? "missing" : unmanagedExisting ? "will-merge" : "will-skip",
771
- repairMode: unmanagedExisting ? "manual" : "mechanical",
813
+ action: changed ? "missing" : "will-skip",
814
+ repairMode: "mechanical",
772
815
  strategy: "copy-dir",
773
816
  changed,
774
- summary: unmanagedExisting
775
- ? `${relativePath}: existing non-phasegate directory requires manual review`
776
- : changed ? `${relativePath}: deploy bundled skills` : `${relativePath}: already up to date`,
777
- diff: unmanagedExisting ? "manual review required" : changed ? "+ bundled skills" : "no changes",
817
+ summary: changed ? `${relativePath}: deploy bundled skills` : `${relativePath}: already up to date`,
818
+ diff: changed ? `+ ${skills.length} bundled skills (${skillSet})` : "no changes",
778
819
  skillHint: null,
779
820
  };
780
821
  }
781
822
 
782
- private personalSkillsHashInput(path: string, version: string, skillSet: "core" | "all"): string {
783
- return `personal-skills:${path}:${version}:${skillSet}`;
823
+ private personalSkillsVersionHashInput(path: string, version: string, skillSet: "core" | "all", skills: readonly string[]): string {
824
+ return `personal-skills-version:${path}:${version}:${skillSet}:${skills.join(",")}`;
825
+ }
826
+
827
+ private personalSkillHashInput(path: string, skill: string, version: string, skillSet: "core" | "all"): string {
828
+ return `personal-skill:${path}:${skill}:${version}:${skillSet}`;
784
829
  }
785
830
 
786
831
  private async planSharedSkillDirectory(
@@ -817,13 +862,7 @@ export class RunInstallUseCase {
817
862
 
818
863
  private async deploySharedSkills(input: RunInstallInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
819
864
  const targetRoot = join(input.projectRoot, "skills");
820
- await mkdir(targetRoot, { recursive: true });
821
- for (const skill of skills) {
822
- const source = join(input.harnessRoot, "skills", skill);
823
- const target = join(targetRoot, skill);
824
- await rm(target, { recursive: true, force: true });
825
- await copyDirectory(source, target);
826
- }
865
+ await copySelectedSkillDirectories(input.harnessRoot, targetRoot, skills);
827
866
  await writeFile(
828
867
  join(targetRoot, ".harness-version"),
829
868
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -4,14 +4,15 @@
4
4
  // @work-item-id WI-174
5
5
  // @work-item-id WI-198
6
6
  // @work-item-id WI-210
7
+ // @work-item-id WI-216
7
8
 
8
9
  import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
9
10
  import { dirname, join, relative, resolve } from "node:path";
10
- import { getSkillsForSet, type SkillSet } from "../../../setup/skill-deployer.js";
11
11
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
12
12
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
13
13
  import type { ManagedBlockInput } from "../../domain/managed-block.js";
14
14
  import type { RepairMode } from "../../domain/repair-mode.js";
15
+ import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
15
16
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
16
17
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
17
18
 
@@ -95,8 +96,18 @@ async function copyDirectory(src: string, dest: string): Promise<void> {
95
96
  }
96
97
  }
97
98
 
99
+ async function copySelectedSkillDirectories(harnessRoot: string, targetRoot: string, skills: readonly string[]): Promise<void> {
100
+ await mkdir(targetRoot, { recursive: true });
101
+ for (const skill of skills) {
102
+ const source = join(harnessRoot, "skills", skill);
103
+ const target = join(targetRoot, skill);
104
+ await rm(target, { recursive: true, force: true });
105
+ await copyDirectory(source, target);
106
+ }
107
+ }
108
+
98
109
  async function listSelectedBundledSkills(harnessRoot: string, skillSet: SkillSet): Promise<string[]> {
99
- const allowed = new Set(getSkillsForSet(skillSet));
110
+ const allowed = new Set(getBundledSkillsForSet(skillSet));
100
111
  const entries = await readdir(join(harnessRoot, "skills"), { withFileTypes: true });
101
112
  return entries
102
113
  .filter((entry) => entry.isDirectory() && allowed.has(entry.name))
@@ -285,12 +296,21 @@ export class RunReconcileUseCase {
285
296
  }
286
297
  }
287
298
 
288
- if (this.manifestIntendsSharedSkills(manifest)) {
299
+ const personalInstall = this.isPersonalManifest(manifest);
300
+ if (!personalInstall && this.manifestIntendsSharedSkills(manifest)) {
289
301
  const sharedSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
290
302
  const outcome = await this.planSharedSkills(input, manifest, sharedSkills, "all");
291
303
  outcomes.push(outcome);
292
304
  plan.push(outcome.item);
293
305
  }
306
+ if (personalInstall) {
307
+ const personalSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
308
+ for (const skillPath of this.personalSkillPaths(manifest)) {
309
+ const outcome = await this.planPersonalSkills(input, manifest, skillPath, personalSkills, "all");
310
+ outcomes.push(outcome);
311
+ plan.push(outcome.item);
312
+ }
313
+ }
294
314
 
295
315
  if (!input.apply || refused.length > 0) {
296
316
  return { plan, refused, changed, backupDir: null };
@@ -311,6 +331,12 @@ export class RunReconcileUseCase {
311
331
  for (const skill of sharedSkills) {
312
332
  nextManifest = nextManifest.addEntry(this.createdEntry(`skills/${skill}`, this.sharedSkillHashInput(skill, input.phasegateVersion, "all")));
313
333
  }
334
+ } else if (outcome.item.strategy === "copy-dir" && (outcome.item.path === ".claude/skills" || outcome.item.path === ".codex/skills")) {
335
+ const personalSkills = await listSelectedBundledSkills(input.harnessRoot, "all");
336
+ nextManifest = nextManifest.addEntry(this.createdEntry(`${outcome.item.path}/.harness-version`, this.personalSkillsVersionHashInput(outcome.item.path, input.phasegateVersion, "all", personalSkills)));
337
+ for (const skill of personalSkills) {
338
+ nextManifest = nextManifest.addEntry(this.createdEntry(`${outcome.item.path}/${skill}`, this.personalSkillHashInput(outcome.item.path, skill, input.phasegateVersion, "all")));
339
+ }
314
340
  } else {
315
341
  const mode = outcome.item.strategy === "symlink" ? "symlink" : outcome.item.action === "add" ? "created" : (manifest.findEntry(outcome.item.path)?.mode ?? "merged");
316
342
  nextManifest = nextManifest.addEntry(
@@ -435,6 +461,19 @@ export class RunReconcileUseCase {
435
461
  || manifest.entries.some((entry) => entry.path.startsWith("skills/"));
436
462
  }
437
463
 
464
+ private isPersonalManifest(manifest: DeploymentManifest): boolean {
465
+ return manifest.findEntry(".phasegate-local/phasegate.config.json") !== null
466
+ || manifest.entries.some((entry) => (entry.path === ".claude/skills" || entry.path === ".codex/skills") && entry.mode === "created")
467
+ || manifest.entries.some((entry) => entry.path.startsWith(".claude/skills/") || entry.path.startsWith(".codex/skills/"));
468
+ }
469
+
470
+ private personalSkillPaths(manifest: DeploymentManifest): Array<".claude/skills" | ".codex/skills"> {
471
+ const paths = new Set<".claude/skills" | ".codex/skills">();
472
+ if (manifest.findEntry(".claude/skills") !== null || manifest.entries.some((entry) => entry.path.startsWith(".claude/skills/"))) paths.add(".claude/skills");
473
+ if (manifest.findEntry(".codex/skills") !== null || manifest.entries.some((entry) => entry.path.startsWith(".codex/skills/"))) paths.add(".codex/skills");
474
+ return [...paths].sort();
475
+ }
476
+
438
477
  private async planSharedSkills(
439
478
  input: RunReconcileInput,
440
479
  manifest: DeploymentManifest,
@@ -475,13 +514,7 @@ export class RunReconcileUseCase {
475
514
 
476
515
  private async deploySharedSkills(input: RunReconcileInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
477
516
  const targetRoot = this.resolveProjectPath(input.projectRoot, "skills");
478
- await mkdir(targetRoot, { recursive: true });
479
- for (const skill of skills) {
480
- const source = join(input.harnessRoot, "skills", skill);
481
- const target = join(targetRoot, skill);
482
- await rm(target, { recursive: true, force: true });
483
- await copyDirectory(source, target);
484
- }
517
+ await copySelectedSkillDirectories(input.harnessRoot, targetRoot, skills);
485
518
  await writeFile(
486
519
  join(targetRoot, ".harness-version"),
487
520
  `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
@@ -489,6 +522,52 @@ export class RunReconcileUseCase {
489
522
  );
490
523
  }
491
524
 
525
+ private async planPersonalSkills(
526
+ input: RunReconcileInput,
527
+ manifest: DeploymentManifest,
528
+ relativePath: ".claude/skills" | ".codex/skills",
529
+ skills: readonly string[],
530
+ skillSet: SkillSet,
531
+ ) {
532
+ const versionPath = this.resolveProjectPath(input.projectRoot, `${relativePath}/.harness-version`);
533
+ const versionContent = await readTextOrNull(versionPath);
534
+ const expectedVersion = `"version": "${input.phasegateVersion}"`;
535
+ let missingSkill = false;
536
+ for (const skill of skills) {
537
+ if (!(await exists(this.resolveProjectPath(input.projectRoot, `${relativePath}/${skill}/SKILL.md`)))) {
538
+ missingSkill = true;
539
+ break;
540
+ }
541
+ }
542
+ const missingManifest = manifest.findEntry(`${relativePath}/.harness-version`) === null
543
+ || skills.some((skill) => manifest.findEntry(`${relativePath}/${skill}`) === null);
544
+ const changed = versionContent === null || !versionContent.includes(expectedVersion) || missingSkill || missingManifest;
545
+ return {
546
+ item: this.item(
547
+ relativePath,
548
+ changed ? "add" : "skip",
549
+ "mechanical",
550
+ "copy-dir",
551
+ changed,
552
+ changed ? `${relativePath}: deploy bundled skills` : `${relativePath}: already up to date`,
553
+ changed ? `+ ${skills.length} bundled skills (${skillSet})` : "no changes",
554
+ null,
555
+ ),
556
+ needsBackup: false,
557
+ apply: async () => {
558
+ if (!changed) return null;
559
+ const targetRoot = this.resolveProjectPath(input.projectRoot, relativePath);
560
+ await copySelectedSkillDirectories(input.harnessRoot, targetRoot, skills);
561
+ await writeFile(
562
+ join(targetRoot, ".harness-version"),
563
+ `${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
564
+ "utf8",
565
+ );
566
+ return this.personalSkillsVersionHashInput(relativePath, input.phasegateVersion, skillSet, skills);
567
+ },
568
+ };
569
+ }
570
+
492
571
  private createdEntry(path: string, hashInput: string): DeploymentEntry {
493
572
  return DeploymentEntry.create({
494
573
  path,
@@ -507,6 +586,14 @@ export class RunReconcileUseCase {
507
586
  return `shared-skill:${skill}:${version}:${skillSet}`;
508
587
  }
509
588
 
589
+ private personalSkillsVersionHashInput(path: string, version: string, skillSet: SkillSet, skills: readonly string[]): string {
590
+ return `personal-skills-version:${path}:${version}:${skillSet}:${skills.join(",")}`;
591
+ }
592
+
593
+ private personalSkillHashInput(path: string, skill: string, version: string, skillSet: SkillSet): string {
594
+ return `personal-skill:${path}:${skill}:${version}:${skillSet}`;
595
+ }
596
+
510
597
  private reconcileContent(target: ReconcileTarget, before: string | null, template: string, version: string): string {
511
598
  if (target.strategy === "yaml-add") return template;
512
599
  if (target.strategy === "shell") return reconcileShell(before, template);
@@ -6,8 +6,9 @@
6
6
  // @work-item-id WI-207
7
7
  // @work-item-id WI-208
8
8
  // @work-item-id WI-209
9
+ // @work-item-id WI-216
9
10
 
10
- import { access, copyFile, lstat, mkdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
11
+ import { access, copyFile, lstat, mkdir, readFile, readdir, readlink, rm, rmdir, writeFile } from "node:fs/promises";
11
12
  import { dirname, join, relative, resolve } from "node:path";
12
13
  import type { DeploymentEntry } from "../../domain/deployment-entry.js";
13
14
  import type { RepairMode } from "../../domain/repair-mode.js";
@@ -222,7 +223,7 @@ export class RunUninstallUseCase {
222
223
  const strategy = this.strategyFor(entry.path, entry.mode);
223
224
  if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
224
225
  if (entry.mode === "created" && await this.isDirectory(absolutePath)) {
225
- return this.planCreatedDirectory(input.projectRoot, entry);
226
+ return this.planCreatedDirectory(input, entry);
226
227
  }
227
228
  const currentContent = await readTextOrNull(absolutePath);
228
229
  if (currentContent === null && strategy !== "symlink") {
@@ -271,8 +272,9 @@ export class RunUninstallUseCase {
271
272
  }
272
273
  }
273
274
 
274
- private async planCreatedDirectory(projectRoot: string, entry: DeploymentEntry) {
275
- const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
275
+ private async planCreatedDirectory(input: RunUninstallInput, entry: DeploymentEntry) {
276
+ if (entry.path === ".claude/skills" || entry.path === ".codex/skills") return this.planLegacyPersonalSkillsDirectory(input, entry);
277
+ const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
276
278
  return {
277
279
  item: this.item(
278
280
  entry.path,
@@ -291,10 +293,39 @@ export class RunUninstallUseCase {
291
293
  };
292
294
  }
293
295
 
296
+ private async planLegacyPersonalSkillsDirectory(input: RunUninstallInput, entry: DeploymentEntry) {
297
+ const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
298
+ const bundledSkills = await this.listBundledSkills(input.harnessRoot);
299
+ return {
300
+ item: this.item(
301
+ entry.path,
302
+ "delete",
303
+ "mechanical",
304
+ "created",
305
+ true,
306
+ `${entry.path}: remove managed bundled skills from legacy personal catalog`,
307
+ `- ${bundledSkills.length} bundled skills and .harness-version`,
308
+ null,
309
+ ),
310
+ needsBackup: false,
311
+ apply: async () => {
312
+ for (const skill of bundledSkills) {
313
+ await rm(join(absolutePath, skill), { recursive: true, force: true });
314
+ }
315
+ await rm(join(absolutePath, ".harness-version"), { force: true });
316
+ },
317
+ };
318
+ }
319
+
320
+ private async listBundledSkills(harnessRoot: string): Promise<string[]> {
321
+ const entries = await readdir(join(harnessRoot, "skills"), { withFileTypes: true });
322
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
323
+ }
324
+
294
325
  private async planCreated(projectRoot: string, entry: DeploymentEntry, currentContent: string) {
295
326
  const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
296
327
  const currentHash = this.hashCalculator.compute(currentContent);
297
- const matchesManifest = currentHash.equals(entry.hash);
328
+ const matchesManifest = currentHash.equals(entry.hash) || entry.path.endsWith("/.harness-version") || entry.path === "skills/.harness-version";
298
329
  const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
299
330
  return {
300
331
  item: this.item(