phasegate 0.160.18 → 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 +4 -4
- package/docs/guide/installation.md +5 -5
- package/docs/guide/setup-artifacts.md +7 -3
- package/docs/templates/personal/git-info-exclude +1 -0
- package/package.json +1 -1
- package/scripts/harness/installation/application/bundled-skill-selection.ts +46 -0
- package/scripts/harness/installation/application/checks/check-utils.ts +11 -0
- package/scripts/harness/installation/application/checks/claude-context-missing-check.ts +35 -0
- package/scripts/harness/installation/application/checks/claude-skills-symlink-check.ts +5 -3
- package/scripts/harness/installation/application/checks/codex-context-missing-check.ts +32 -0
- package/scripts/harness/installation/application/checks/codex-skills-symlink-check.ts +5 -3
- package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +3 -2
- package/scripts/harness/installation/application/usecases/run-install.ts +77 -33
- package/scripts/harness/installation/application/usecases/run-reconcile.ts +97 -10
- package/scripts/harness/installation/application/usecases/run-uninstall.ts +41 -6
- package/scripts/harness/installation/composition-root.ts +5 -0
- package/scripts/harness/installation/domain/check-id.ts +3 -0
- package/scripts/harness/installation/domain/repair-table.ts +3 -0
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
|
|
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`, `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -7,6 +7,8 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
|
|
|
7
7
|
<!-- @work-item-id WI-169 -->
|
|
8
8
|
<!-- @work-item-id WI-208 -->
|
|
9
9
|
<!-- @work-item-id WI-209 -->
|
|
10
|
+
<!-- @work-item-id WI-215 -->
|
|
11
|
+
<!-- @work-item-id WI-216 -->
|
|
10
12
|
|
|
11
13
|
## Artifact Classes
|
|
12
14
|
|
|
@@ -16,7 +18,7 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
|
|
|
16
18
|
| Configuration | `phasegate.config.json`, `package.json` | User owned, PhaseGate assisted | Created by `init` or project install when absent; `install` may merge scripts/devDependency into `package.json` |
|
|
17
19
|
| Generated artifact | `.phasegate/manifest.json`, `.phasegate/backups/*`, `.phasegate/uninstalled-*.json`, `.phasegate/baseline.json` | PhaseGate | Written by lifecycle commands and validators; safe to regenerate only through the owning command |
|
|
18
20
|
| Runtime state/report | `.phasegate/hook-skip-events.jsonl`, explicit `doctor --report-out <path>` output, `reports/regression/*`, resolved `reporting.outputDir` reports | PhaseGate command output | Produced while hooks, doctor, and validation commands run |
|
|
19
|
-
| Personal install artifact | `.phasegate-local/phasegate.config.json`, ignored real `.claude/settings.json` + `.claude/skills/`, ignored real `.codex/hooks.json` + `.codex/skills/`, `.git/info/exclude` PhaseGate block | One developer on one machine | Created by `phasegate install --personal --agent <agent>`; team `.gitignore`, team-owned files, GitHub CLI config, repo secrets, and CI settings are not modified |
|
|
21
|
+
| Personal install artifact | `.phasegate-local/phasegate.config.json`, ignored real `.claude/CLAUDE.md` + `.claude/settings.json` + `.claude/skills/`, ignored real `.codex/hooks.json` + `.codex/skills/`, optional ignored root `AGENTS.md`, `.git/info/exclude` PhaseGate block | One developer on one machine | Created by `phasegate install --personal --agent <agent>`; team `.gitignore`, team-owned files, GitHub CLI config, repo secrets, and CI settings are not modified |
|
|
20
22
|
| Legacy artifact | `.harness-hooks.yml`, old Fuse hook files, `.harness/session-state.json`, `.harness/context-priority.json`, `.harness/reports` fallback | Compatibility only | Not required for current install lifecycle unless a project intentionally keeps an archived integration |
|
|
21
23
|
| User-level setting | Codex CLI `hooks` feature flag | User machine | Must be enabled manually with `codex features enable hooks`; project commands do not modify it |
|
|
22
24
|
|
|
@@ -42,9 +44,11 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
|
|
|
42
44
|
|
|
43
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`.
|
|
44
46
|
|
|
45
|
-
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 -->
|
|
46
48
|
|
|
47
|
-
|
|
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 -->
|
|
50
|
+
|
|
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 -->
|
|
48
52
|
|
|
49
53
|
## Doctor Findings
|
|
50
54
|
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer application
|
|
3
|
+
// @work-item-id WI-215
|
|
4
|
+
|
|
5
|
+
import type { FileInspectorPort } from "../ports/file-inspector-port.js";
|
|
6
|
+
import type { HeuristicCheck } from "../../domain/ports/heuristic-check.js";
|
|
7
|
+
import type { DiagnosticFinding } from "../../domain/diagnostic-finding.js";
|
|
8
|
+
import { createFinding, projectPath } from "./check-utils.js";
|
|
9
|
+
|
|
10
|
+
const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
|
|
11
|
+
|
|
12
|
+
export class ClaudeContextMissingCheck implements HeuristicCheck {
|
|
13
|
+
readonly checkId = "claude-context-missing" as const;
|
|
14
|
+
private readonly target = ".claude/CLAUDE.md";
|
|
15
|
+
private readonly projectTarget = "CLAUDE.md";
|
|
16
|
+
private readonly legacyTarget = ".claude/CLAUDE.local.md";
|
|
17
|
+
|
|
18
|
+
async run(projectRoot: string, inspector: FileInspectorPort): Promise<DiagnosticFinding | null> {
|
|
19
|
+
const content = await inspector.readText(projectPath(projectRoot, this.target));
|
|
20
|
+
if (content?.includes(MARKDOWN_BEGIN) === true && content.includes("PhaseGate")) return null;
|
|
21
|
+
const projectContent = await inspector.readText(projectPath(projectRoot, this.projectTarget));
|
|
22
|
+
if (projectContent?.includes(MARKDOWN_BEGIN) === true && projectContent.includes("PhaseGate")) return null;
|
|
23
|
+
|
|
24
|
+
const legacyContent = await inspector.readText(projectPath(projectRoot, this.legacyTarget));
|
|
25
|
+
return createFinding({
|
|
26
|
+
checkId: this.checkId,
|
|
27
|
+
severity: "red",
|
|
28
|
+
target: this.target,
|
|
29
|
+
message: legacyContent?.includes("PhaseGate") === true
|
|
30
|
+
? ".claude/CLAUDE.local.md は Claude Code の documented discovery path ではありません"
|
|
31
|
+
: ".claude/CLAUDE.md に runtime-visible PhaseGate context がありません",
|
|
32
|
+
repairMode: "mechanical",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -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,
|
|
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
|
-
|
|
20
|
-
if (link
|
|
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",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer application
|
|
3
|
+
// @work-item-id WI-215
|
|
4
|
+
|
|
5
|
+
import type { FileInspectorPort } from "../ports/file-inspector-port.js";
|
|
6
|
+
import type { HeuristicCheck } from "../../domain/ports/heuristic-check.js";
|
|
7
|
+
import type { DiagnosticFinding } from "../../domain/diagnostic-finding.js";
|
|
8
|
+
import { createFinding, projectPath } from "./check-utils.js";
|
|
9
|
+
|
|
10
|
+
const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
|
|
11
|
+
|
|
12
|
+
export class CodexContextMissingCheck implements HeuristicCheck {
|
|
13
|
+
readonly checkId = "codex-context-missing" as const;
|
|
14
|
+
private readonly target = "AGENTS.md";
|
|
15
|
+
private readonly legacyTarget = ".codex/AGENTS.local.md";
|
|
16
|
+
|
|
17
|
+
async run(projectRoot: string, inspector: FileInspectorPort): Promise<DiagnosticFinding | null> {
|
|
18
|
+
const content = await inspector.readText(projectPath(projectRoot, this.target));
|
|
19
|
+
if (content?.includes(MARKDOWN_BEGIN) === true && content.includes("PhaseGate")) return null;
|
|
20
|
+
|
|
21
|
+
const legacyContent = await inspector.readText(projectPath(projectRoot, this.legacyTarget));
|
|
22
|
+
return createFinding({
|
|
23
|
+
checkId: this.checkId,
|
|
24
|
+
severity: "red",
|
|
25
|
+
target: this.target,
|
|
26
|
+
message: legacyContent?.includes("PhaseGate") === true
|
|
27
|
+
? ".codex/AGENTS.local.md は Codex の default discovery path ではありません"
|
|
28
|
+
: "AGENTS.md に runtime-visible PhaseGate context がありません",
|
|
29
|
+
repairMode: content === null ? "mechanical" : "manual",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -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,
|
|
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
|
-
|
|
20
|
-
if (link
|
|
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,6 +3,7 @@
|
|
|
3
3
|
// @work-item-id WI-145
|
|
4
4
|
// @work-item-id WI-178
|
|
5
5
|
// @work-item-id WI-208
|
|
6
|
+
// @work-item-id WI-215
|
|
6
7
|
|
|
7
8
|
import { DiagnosticReport } from "../../domain/diagnostic-report.js";
|
|
8
9
|
import type { CheckId } from "../../domain/check-id.js";
|
|
@@ -32,8 +33,8 @@ export interface RunDoctorDiagnosticsOutput {
|
|
|
32
33
|
readonly exitCode: number;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
const CLAUDE_ONLY_CHECKS = new Set<CheckId>(["claude-hook-missing", "claude-skills-symlink"]);
|
|
36
|
-
const CODEX_ONLY_CHECKS = new Set<CheckId>(["codex-hook-missing", "codex-skills-symlink"]);
|
|
36
|
+
const CLAUDE_ONLY_CHECKS = new Set<CheckId>(["claude-hook-missing", "claude-context-missing", "claude-skills-symlink"]);
|
|
37
|
+
const CODEX_ONLY_CHECKS = new Set<CheckId>(["codex-hook-missing", "codex-context-missing", "codex-skills-symlink"]);
|
|
37
38
|
const PERSONAL_SCOPED_OUT_CHECKS = new Set<CheckId>([
|
|
38
39
|
"husky-pre-commit-missing",
|
|
39
40
|
"husky-commit-msg-missing",
|
|
@@ -13,14 +13,16 @@
|
|
|
13
13
|
// @work-item-id WI-210
|
|
14
14
|
// @work-item-id WI-213
|
|
15
15
|
// @work-item-id WI-214
|
|
16
|
+
// @work-item-id WI-215
|
|
17
|
+
// @work-item-id WI-216
|
|
16
18
|
|
|
17
19
|
import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink, readdir, rm } from "node:fs/promises";
|
|
18
20
|
import { dirname, join } from "node:path";
|
|
19
|
-
import { getSkillsForSet, type SkillSet } from "../../../setup/skill-deployer.js";
|
|
20
21
|
import { DeploymentEntry } from "../../domain/deployment-entry.js";
|
|
21
22
|
import { DeploymentManifest } from "../../domain/deployment-manifest.js";
|
|
22
23
|
import type { ManagedBlockInput } from "../../domain/managed-block.js";
|
|
23
24
|
import type { RepairMode } from "../../domain/repair-mode.js";
|
|
25
|
+
import { getBundledSkillsForSet, type SkillSet } from "../bundled-skill-selection.js";
|
|
24
26
|
import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
|
|
25
27
|
import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
|
|
26
28
|
|
|
@@ -78,6 +80,7 @@ interface InstallTarget {
|
|
|
78
80
|
readonly templatePath: string;
|
|
79
81
|
readonly executable?: boolean;
|
|
80
82
|
readonly block?: ManagedBlockInput;
|
|
83
|
+
readonly personalManualIfUnmanaged?: boolean;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
const SKILL_HINT = "invoke /phasegate-config-doctor";
|
|
@@ -89,7 +92,7 @@ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
|
|
|
89
92
|
const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
|
|
90
93
|
const TEXT_BEGIN = "# phasegate personal install exclude (BEGIN)";
|
|
91
94
|
const TEXT_END = "# phasegate personal install exclude (END)";
|
|
92
|
-
const PERSONAL_AGENT_RUNTIME_FILES = new Set([".claude/settings.json", ".codex/hooks.json"]);
|
|
95
|
+
const PERSONAL_AGENT_RUNTIME_FILES = new Set([".claude/CLAUDE.md", ".claude/settings.json", "AGENTS.md", ".codex/hooks.json"]);
|
|
93
96
|
const SHARED_SKILLS_VERSION_PATH = "skills/.harness-version";
|
|
94
97
|
const PERSONAL_PRINCIPLES_DOCS = ".phasegate-local/docs/principles";
|
|
95
98
|
const PERSONAL_FOLDER_RULES_DOC = ".phasegate-local/docs/folder_management_rules.md";
|
|
@@ -129,9 +132,19 @@ async function copyDirectory(src: string, dest: string): Promise<void> {
|
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
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
|
+
|
|
132
145
|
async function listSelectedBundledSkills(harnessRoot: string, skillSet: SkillSet): Promise<string[]> {
|
|
133
146
|
const skillsSource = join(harnessRoot, "skills");
|
|
134
|
-
const allowed = new Set(
|
|
147
|
+
const allowed = new Set(getBundledSkillsForSet(skillSet));
|
|
135
148
|
const entries = await readdir(skillsSource, { withFileTypes: true });
|
|
136
149
|
return entries
|
|
137
150
|
.filter((entry) => entry.isDirectory() && allowed.has(entry.name))
|
|
@@ -350,8 +363,9 @@ export class RunInstallUseCase {
|
|
|
350
363
|
const existingEntry = baseManifest.findEntry(target.path);
|
|
351
364
|
const beforeHash = before === null ? null : this.hashCalculator.compute(before);
|
|
352
365
|
const unmanagedPersonalRuntimeFile = input.personal
|
|
353
|
-
&& PERSONAL_AGENT_RUNTIME_FILES.has(target.path)
|
|
366
|
+
&& (PERSONAL_AGENT_RUNTIME_FILES.has(target.path) || target.personalManualIfUnmanaged === true)
|
|
354
367
|
&& before !== null
|
|
368
|
+
&& !before.includes(MARKDOWN_BEGIN)
|
|
355
369
|
&& (existingEntry === null || beforeHash === null || !beforeHash.equals(existingEntry.hash));
|
|
356
370
|
const repairMode = unmanagedPersonalRuntimeFile ? "manual" : this.repairMode(target, before);
|
|
357
371
|
const next = unmanagedPersonalRuntimeFile ? before : this.merge(target, before, template, input.phasegateVersion);
|
|
@@ -364,7 +378,7 @@ export class RunInstallUseCase {
|
|
|
364
378
|
strategy: target.strategy,
|
|
365
379
|
changed: didChange,
|
|
366
380
|
summary: unmanagedPersonalRuntimeFile
|
|
367
|
-
? `${target.path}: existing non-phasegate path requires manual review`
|
|
381
|
+
? `${target.path}: existing non-phasegate runtime path requires manual review`
|
|
368
382
|
: didChange ? `${target.path}: ${action}` : `${target.path}: already up to date`,
|
|
369
383
|
diff: unmanagedPersonalRuntimeFile ? "manual review required" : this.diffSummary(before, next),
|
|
370
384
|
skillHint: repairMode === "ai-assisted" ? SKILL_HINT : null,
|
|
@@ -425,12 +439,13 @@ export class RunInstallUseCase {
|
|
|
425
439
|
...(includeCodex ? [".codex/skills"] : []),
|
|
426
440
|
]
|
|
427
441
|
: [];
|
|
442
|
+
const selectedPersonalSkills = input.personal ? await listSelectedBundledSkills(input.harnessRoot, skillSet) : [];
|
|
428
443
|
for (const skillPath of personalSkillTargets) {
|
|
429
|
-
const item = await this.planPersonalSkillDirectory(input, skillPath, baseManifest);
|
|
444
|
+
const item = await this.planPersonalSkillDirectory(input, skillPath, selectedPersonalSkills, baseManifest);
|
|
430
445
|
plan.push(item);
|
|
431
446
|
if (input.apply && item.changed && item.repairMode === "mechanical") {
|
|
432
447
|
try {
|
|
433
|
-
await
|
|
448
|
+
await copySelectedSkillDirectories(input.harnessRoot, join(input.projectRoot, skillPath), selectedPersonalSkills);
|
|
434
449
|
await writeFile(
|
|
435
450
|
join(input.projectRoot, skillPath, ".harness-version"),
|
|
436
451
|
`${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
|
|
@@ -441,10 +456,17 @@ export class RunInstallUseCase {
|
|
|
441
456
|
}
|
|
442
457
|
changed.push(item);
|
|
443
458
|
manifest = this.addManifestEntry(baseManifest, manifest, {
|
|
444
|
-
path: item.path
|
|
459
|
+
path: `${item.path}/.harness-version`,
|
|
445
460
|
mode: "created",
|
|
446
|
-
contentForHash: this.
|
|
461
|
+
contentForHash: this.personalSkillsVersionHashInput(item.path, input.phasegateVersion, skillSet, selectedPersonalSkills),
|
|
447
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
|
+
}
|
|
448
470
|
}
|
|
449
471
|
}
|
|
450
472
|
|
|
@@ -629,10 +651,11 @@ export class RunInstallUseCase {
|
|
|
629
651
|
...(options.includeClaude
|
|
630
652
|
? [
|
|
631
653
|
{
|
|
632
|
-
path: ".claude/CLAUDE.
|
|
654
|
+
path: ".claude/CLAUDE.md",
|
|
633
655
|
strategy: "markdown-managed" as const,
|
|
634
656
|
templatePath: "docs/templates/agent-context/CLAUDE.md.template.md",
|
|
635
|
-
block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate personal CLAUDE.
|
|
657
|
+
block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate personal .claude/CLAUDE.md managed section" },
|
|
658
|
+
personalManualIfUnmanaged: true,
|
|
636
659
|
},
|
|
637
660
|
{
|
|
638
661
|
path: ".claude/settings.json",
|
|
@@ -644,10 +667,11 @@ export class RunInstallUseCase {
|
|
|
644
667
|
...(options.includeCodex
|
|
645
668
|
? [
|
|
646
669
|
{
|
|
647
|
-
path: "
|
|
670
|
+
path: "AGENTS.md",
|
|
648
671
|
strategy: "markdown-managed" as const,
|
|
649
672
|
templatePath: "docs/templates/agent-context/AGENTS.md.template.md",
|
|
650
|
-
block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate personal AGENTS.
|
|
673
|
+
block: { start: MARKDOWN_BEGIN, end: MARKDOWN_END, content: "phasegate personal AGENTS.md managed section" },
|
|
674
|
+
personalManualIfUnmanaged: true,
|
|
651
675
|
},
|
|
652
676
|
{
|
|
653
677
|
path: ".codex/hooks.json",
|
|
@@ -750,32 +774,58 @@ export class RunInstallUseCase {
|
|
|
750
774
|
private async planPersonalSkillDirectory(
|
|
751
775
|
input: RunInstallInput,
|
|
752
776
|
relativePath: string,
|
|
777
|
+
skills: readonly string[],
|
|
753
778
|
baseManifest: DeploymentManifest,
|
|
754
779
|
): Promise<InstallPlanItem> {
|
|
755
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 {}
|
|
756
796
|
const versionPath = join(absolutePath, ".harness-version");
|
|
757
797
|
const current = await readTextOrNull(versionPath);
|
|
758
|
-
const
|
|
759
|
-
const
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
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;
|
|
763
811
|
return {
|
|
764
812
|
path: relativePath,
|
|
765
|
-
action: changed ? "missing" :
|
|
766
|
-
repairMode:
|
|
813
|
+
action: changed ? "missing" : "will-skip",
|
|
814
|
+
repairMode: "mechanical",
|
|
767
815
|
strategy: "copy-dir",
|
|
768
816
|
changed,
|
|
769
|
-
summary:
|
|
770
|
-
|
|
771
|
-
: changed ? `${relativePath}: deploy bundled skills` : `${relativePath}: already up to date`,
|
|
772
|
-
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",
|
|
773
819
|
skillHint: null,
|
|
774
820
|
};
|
|
775
821
|
}
|
|
776
822
|
|
|
777
|
-
private
|
|
778
|
-
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}`;
|
|
779
829
|
}
|
|
780
830
|
|
|
781
831
|
private async planSharedSkillDirectory(
|
|
@@ -812,13 +862,7 @@ export class RunInstallUseCase {
|
|
|
812
862
|
|
|
813
863
|
private async deploySharedSkills(input: RunInstallInput, skills: readonly string[], skillSet: SkillSet): Promise<void> {
|
|
814
864
|
const targetRoot = join(input.projectRoot, "skills");
|
|
815
|
-
await
|
|
816
|
-
for (const skill of skills) {
|
|
817
|
-
const source = join(input.harnessRoot, "skills", skill);
|
|
818
|
-
const target = join(targetRoot, skill);
|
|
819
|
-
await rm(target, { recursive: true, force: true });
|
|
820
|
-
await copyDirectory(source, target);
|
|
821
|
-
}
|
|
865
|
+
await copySelectedSkillDirectories(input.harnessRoot, targetRoot, skills);
|
|
822
866
|
await writeFile(
|
|
823
867
|
join(targetRoot, ".harness-version"),
|
|
824
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
275
|
-
|
|
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(
|
|
@@ -365,7 +396,9 @@ export class RunUninstallUseCase {
|
|
|
365
396
|
if (mode === "symlink") return "symlink";
|
|
366
397
|
if (mode === "created") return path.endsWith(".yml") || path.endsWith(".yaml") ? "yaml-add" : "created";
|
|
367
398
|
if (path === "package.json") return "package-json";
|
|
368
|
-
if (path === "AGENTS.md" || path === "CLAUDE.md"
|
|
399
|
+
if (path === "AGENTS.md" || path === "CLAUDE.md" || path === ".claude/CLAUDE.md" || path === ".claude/CLAUDE.local.md" || path === ".codex/AGENTS.local.md") {
|
|
400
|
+
return "markdown-managed";
|
|
401
|
+
}
|
|
369
402
|
if (path === ".git/info/exclude") return "text-managed";
|
|
370
403
|
if (path.endsWith(".json")) return "json";
|
|
371
404
|
if (path.startsWith(".husky/")) return "shell";
|
|
@@ -376,7 +409,9 @@ export class RunUninstallUseCase {
|
|
|
376
409
|
if (path === ".claude/settings.json") return "templates/.claude/settings.json";
|
|
377
410
|
if (path === ".codex/hooks.json") return "templates/.codex/hooks.json";
|
|
378
411
|
if (path === "CLAUDE.md") return "docs/templates/agent-context/CLAUDE.md.template.md";
|
|
412
|
+
if (path === ".claude/CLAUDE.md" || path === ".claude/CLAUDE.local.md") return "docs/templates/agent-context/CLAUDE.md.template.md";
|
|
379
413
|
if (path === "AGENTS.md") return "docs/templates/agent-context/AGENTS.md.template.md";
|
|
414
|
+
if (path === ".codex/AGENTS.local.md") return "docs/templates/agent-context/AGENTS.md.template.md";
|
|
380
415
|
throw new Error(`No template for ${path}`);
|
|
381
416
|
}
|
|
382
417
|
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// @layer presentation
|
|
3
3
|
// @work-item-id WI-145
|
|
4
4
|
// @work-item-id WI-148
|
|
5
|
+
// @work-item-id WI-215
|
|
5
6
|
|
|
7
|
+
import { ClaudeContextMissingCheck } from "./application/checks/claude-context-missing-check.js";
|
|
6
8
|
import { ClaudeHookMissingCheck } from "./application/checks/claude-hook-missing-check.js";
|
|
7
9
|
import { ClaudeSkillsSymlinkCheck } from "./application/checks/claude-skills-symlink-check.js";
|
|
10
|
+
import { CodexContextMissingCheck } from "./application/checks/codex-context-missing-check.js";
|
|
8
11
|
import { CodexHookMissingCheck } from "./application/checks/codex-hook-missing-check.js";
|
|
9
12
|
import { CodexSkillsSymlinkCheck } from "./application/checks/codex-skills-symlink-check.js";
|
|
10
13
|
import { CiWorkflowMissingCheck } from "./application/checks/ci-workflow-missing-check.js";
|
|
@@ -42,7 +45,9 @@ export function createInstallationModule() {
|
|
|
42
45
|
const hashCalculator = new NodeCryptoHashAdapter();
|
|
43
46
|
const checks = [
|
|
44
47
|
new ClaudeHookMissingCheck(),
|
|
48
|
+
new ClaudeContextMissingCheck(),
|
|
45
49
|
new CodexHookMissingCheck(),
|
|
50
|
+
new CodexContextMissingCheck(),
|
|
46
51
|
new HuskyPreCommitMissingCheck(),
|
|
47
52
|
new HuskyCommitMsgMissingCheck(),
|
|
48
53
|
new HuskyPrePushMissingCheck(),
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// @unit installation
|
|
2
2
|
// @layer domain
|
|
3
3
|
// @work-item-id WI-145
|
|
4
|
+
// @work-item-id WI-215
|
|
4
5
|
|
|
5
6
|
export const CHECK_IDS = [
|
|
6
7
|
"claude-hook-missing",
|
|
8
|
+
"claude-context-missing",
|
|
7
9
|
"codex-hook-missing",
|
|
10
|
+
"codex-context-missing",
|
|
8
11
|
"husky-pre-commit-missing",
|
|
9
12
|
"husky-commit-msg-missing",
|
|
10
13
|
"husky-pre-push-missing",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @unit installation
|
|
2
2
|
// @layer domain
|
|
3
3
|
// @work-item-id WI-145
|
|
4
|
+
// @work-item-id WI-215
|
|
4
5
|
|
|
5
6
|
import type { CheckId } from "./check-id.js";
|
|
6
7
|
import { SuggestedSkill } from "./suggested-skill.js";
|
|
@@ -23,7 +24,9 @@ export class RepairTable {
|
|
|
23
24
|
constructor() {
|
|
24
25
|
this.table = new Map<CheckId, SuggestedSkill | null>([
|
|
25
26
|
["claude-hook-missing", CONFIG_DOCTOR],
|
|
27
|
+
["claude-context-missing", CONFIG_DOCTOR],
|
|
26
28
|
["codex-hook-missing", CONFIG_DOCTOR],
|
|
29
|
+
["codex-context-missing", CONFIG_DOCTOR],
|
|
27
30
|
["husky-pre-commit-missing", CONFIG_DOCTOR],
|
|
28
31
|
["husky-commit-msg-missing", CONFIG_DOCTOR],
|
|
29
32
|
["husky-pre-push-missing", null],
|