phasegate 0.160.13 → 0.160.14
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 +3 -3
- package/docs/guide/getting-started.md +1 -1
- package/docs/guide/installation.md +3 -3
- package/docs/guide/setup-artifacts.md +8 -1
- package/docs/templates/personal/phasegate-local-config.json +73 -3
- package/package.json +1 -1
- package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +19 -9
- package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +19 -9
- package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +23 -12
- package/scripts/harness/agent-integration/presentation/stop-hook.ts +19 -9
- package/scripts/harness/agent-integration/presentation/user-prompt-submit-hook.ts +3 -2
- package/scripts/harness/config-foundation/infrastructure/repositories/file-system-config-repository.ts +10 -4
- package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +36 -2
- package/scripts/harness/installation/application/usecases/run-install.ts +159 -51
- package/scripts/harness/installation/application/usecases/run-uninstall.ts +38 -4
- package/scripts/harness/installation/presentation/cli/doctor-handler.ts +2 -0
- package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +11 -3
- package/scripts/harness/main.ts +3 -2
- package/skills/phasegate-config-doctor/SKILL.md +2 -2
- package/skills/phasegate-toolkit-guide/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -105,11 +105,11 @@ npx phasegate doctor
|
|
|
105
105
|
For personal evaluation inside a team-owned repository, use local-only install:
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
-
npx phasegate install --personal --dry-run
|
|
109
|
-
npx phasegate install --personal --apply
|
|
108
|
+
npx phasegate install --personal --agent claude --dry-run
|
|
109
|
+
npx phasegate install --personal --agent claude --apply
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
`--personal` does not plan or write `package.json`, `AGENTS.md`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`,
|
|
112
|
+
`--personal` does not plan or write `package.json`, `AGENTS.md`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, GitHub CLI config, repo secrets, or CI settings. For Claude Code it automatically creates `.phasegate-local/phasegate.config.json`, `.phasegate-local/claude/settings.json`, `.phasegate-local/skills/`, ignored root `.claude/settings.json` and `.claude/skills` shims, a managed local-only block in `.git/info/exclude`, and `.phasegate/manifest.json`. Codex user-level hook setup remains a manual action instead of a project-local `.codex/hooks.json` write. <!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 -->
|
|
113
113
|
|
|
114
114
|
For agent-driven setup planning, use:
|
|
115
115
|
|
|
@@ -12,7 +12,7 @@ Start with one question: what kind of repository are you setting up?
|
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| New project with agent hooks | `npx phasegate init --name <project> --agent both --with-husky --with-ci` | `phasegate.config.json`, skills, hooks, agent context files, Husky, and CI targets exist |
|
|
14
14
|
| Existing repository | `npx phasegate install --dry-run` then `npx phasegate install --apply` | Existing hooks/scripts are preserved and `.phasegate/manifest.json` records managed targets |
|
|
15
|
-
| Personal evaluation in a team repository | `npx phasegate install --personal --dry-run` then `npx phasegate install --personal --apply` | Team-owned files stay untouched; `.phasegate-local/
|
|
15
|
+
| Personal evaluation in a team repository | `npx phasegate install --personal --agent claude --dry-run` then `npx phasegate install --personal --agent claude --apply` | Team-owned files stay untouched; `.phasegate-local/` stores config/settings/skills and ignored `.claude/*` shims expose them to Claude Code |
|
|
16
16
|
| CI-only rollout | `npx phasegate setup:agent --intent ci-only --with-ci --dry-run --json` | The plan explains CI changes without requiring local hooks |
|
|
17
17
|
| Codex-only project | `npx phasegate init --agent codex --with-husky` | `.codex/hooks.json`, `AGENTS.md`, `.codex/skills`, and pre-commit backstop are present |
|
|
18
18
|
| Strict validation rollout | `npx phasegate setup:agent --intent strict --dry-run --json` | The plan lists strict checks, L4 risk, rollback, and validation commands |
|
|
@@ -61,11 +61,11 @@ npx phasegate doctor
|
|
|
61
61
|
For personal evaluation inside a team-owned repository:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
npx phasegate install --personal --dry-run
|
|
65
|
-
npx phasegate install --personal --apply
|
|
64
|
+
npx phasegate install --personal --agent claude --dry-run
|
|
65
|
+
npx phasegate install --personal --agent claude --apply
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Personal install keeps team-owned files out of both the plan and apply path: `package.json`, `AGENTS.md`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`,
|
|
68
|
+
Personal install keeps team-owned files out of both the plan and apply path: `package.json`, `AGENTS.md`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, GitHub CLI config, repo secrets, and CI settings are not touched. For Claude Code, PhaseGate writes local-only artifacts under `.phasegate-local/`, creates ignored root `.claude/settings.json` and `.claude/skills` shims for Claude discovery, records `.phasegate/manifest.json`, and manages a local exclude block in `.git/info/exclude`. Codex user-level hook setup is reported as a manual action instead of writing project-local `.codex/hooks.json`. <!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 -->
|
|
69
69
|
|
|
70
70
|
For agent-readable planning before writing files:
|
|
71
71
|
|
|
@@ -5,6 +5,7 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
|
|
|
5
5
|
<!-- @work-item-id WI-152 -->
|
|
6
6
|
<!-- @work-item-id WI-157 -->
|
|
7
7
|
<!-- @work-item-id WI-169 -->
|
|
8
|
+
<!-- @work-item-id WI-208 -->
|
|
8
9
|
|
|
9
10
|
## Artifact Classes
|
|
10
11
|
|
|
@@ -14,7 +15,7 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
|
|
|
14
15
|
| Configuration | `phasegate.config.json`, `package.json` | User owned, PhaseGate assisted | Created by `init`; `install` may merge scripts/devDependency into `package.json` |
|
|
15
16
|
| 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 |
|
|
16
17
|
| 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 |
|
|
17
|
-
| Personal install artifact | `.phasegate-local/config.json`, `.git/info/exclude` PhaseGate block | One developer on one machine | Created by `phasegate install --personal`; team `.gitignore
|
|
18
|
+
| Personal install artifact | `.phasegate-local/phasegate.config.json`, `.phasegate-local/claude/settings.json`, `.phasegate-local/skills/`, ignored root `.claude/settings.json` and `.claude/skills` shims, `.git/info/exclude` PhaseGate block | One developer on one machine | Created by `phasegate install --personal --agent claude`; team `.gitignore`, team-owned files, GitHub CLI config, repo secrets, and CI settings are not modified |
|
|
18
19
|
| 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 |
|
|
19
20
|
| 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 |
|
|
20
21
|
|
|
@@ -34,6 +35,12 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
|
|
|
34
35
|
|
|
35
36
|
`init --with-ci` still deploys the legacy-compatible template set, including `.github/workflows/aidlc-gate.yml`, `.github/workflows/consistency-check.yml`, and `.github/workflows/agent-context-refresh.yml`. Structured `install` uses `.github/workflows/phasegate-aidlc-gate.yml` so it can coexist with existing project CI without taking over a generic workflow filename.
|
|
36
37
|
|
|
38
|
+
## Personal Install Artifacts
|
|
39
|
+
|
|
40
|
+
`phasegate install --personal --agent claude --apply` is a local-only bootstrap for evaluating PhaseGate in a team-owned repository. Substantive files are stored under `.phasegate-local/`, while root `.claude/settings.json` and `.claude/skills` are symlinks so Claude Code can discover the settings and skills. The root shims are hidden by `.git/info/exclude`; PhaseGate does not edit team `.gitignore`.
|
|
41
|
+
|
|
42
|
+
If `.claude/settings.json` or `.claude/skills` already exists and is not the PhaseGate personal shim, personal install reports manual review and preserves the existing path.
|
|
43
|
+
|
|
37
44
|
## Doctor Findings
|
|
38
45
|
|
|
39
46
|
`phasegate doctor` evaluates setup health from the managed targets and related project state. Findings include `repairMode`, optional `repairHint`, and optional `suggestedSkill`.
|
|
@@ -1,7 +1,77 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.0",
|
|
3
2
|
"project": {
|
|
4
|
-
"name": "personal-phasegate"
|
|
3
|
+
"name": "personal-phasegate",
|
|
4
|
+
"preset": "standard"
|
|
5
5
|
},
|
|
6
|
-
"
|
|
6
|
+
"layers": {
|
|
7
|
+
"L1": {
|
|
8
|
+
"enabled": true
|
|
9
|
+
},
|
|
10
|
+
"L2": {
|
|
11
|
+
"enabled": true
|
|
12
|
+
},
|
|
13
|
+
"L3": {
|
|
14
|
+
"enabled": true
|
|
15
|
+
},
|
|
16
|
+
"L4": {
|
|
17
|
+
"enabled": false
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"quickMode": {
|
|
21
|
+
"allowedCategories": [
|
|
22
|
+
"bugfix",
|
|
23
|
+
"docs",
|
|
24
|
+
"test",
|
|
25
|
+
"config"
|
|
26
|
+
],
|
|
27
|
+
"maintainedLayers": [
|
|
28
|
+
"L1",
|
|
29
|
+
"L2"
|
|
30
|
+
],
|
|
31
|
+
"relaxedGates": [
|
|
32
|
+
"phase-gate",
|
|
33
|
+
"2-phase-execution"
|
|
34
|
+
],
|
|
35
|
+
"fullModeRequiredWhen": {
|
|
36
|
+
"mixedCategories": true,
|
|
37
|
+
"newDomainFile": true,
|
|
38
|
+
"apiContractChange": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"phaseDependencies": {
|
|
42
|
+
"preset": "default",
|
|
43
|
+
"override": false,
|
|
44
|
+
"customRules": []
|
|
45
|
+
},
|
|
46
|
+
"planningMode": {
|
|
47
|
+
"default": "interactive",
|
|
48
|
+
"perPhase": {}
|
|
49
|
+
},
|
|
50
|
+
"harnesses": {
|
|
51
|
+
"agentLessonCollection": false,
|
|
52
|
+
"cascadeUpdate": false,
|
|
53
|
+
"bundleSizeLimit": 0,
|
|
54
|
+
"deadCodeGC": false
|
|
55
|
+
},
|
|
56
|
+
"paths": {
|
|
57
|
+
"designDocs": "docs/product/construction",
|
|
58
|
+
"inceptionDocs": "docs/inception"
|
|
59
|
+
},
|
|
60
|
+
"reporting": {
|
|
61
|
+
"format": "json",
|
|
62
|
+
"outputDir": "reports"
|
|
63
|
+
},
|
|
64
|
+
"protectedFiles": {
|
|
65
|
+
"exclude": [
|
|
66
|
+
"tsconfig.json",
|
|
67
|
+
"package.json"
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
"baseline": {
|
|
71
|
+
"enabled": true,
|
|
72
|
+
"path": ".phasegate/baseline.json"
|
|
73
|
+
},
|
|
74
|
+
"architecture": {
|
|
75
|
+
"preset": "clean"
|
|
76
|
+
}
|
|
7
77
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @layer presentation
|
|
3
3
|
* @unit agent-integration
|
|
4
|
+
* @work-item-id WI-208
|
|
4
5
|
*
|
|
5
6
|
* Phasegate 状態を context 文字列として組み立てる共有ヘルパー。
|
|
6
7
|
* SessionStart / UserPromptSubmit hook が共通で使用する。
|
|
@@ -54,18 +55,27 @@ const DEFAULT_PROTECTED_PATTERNS = [
|
|
|
54
55
|
export async function findConfigPath(startDir: string): Promise<string | null> {
|
|
55
56
|
let dir = startDir;
|
|
56
57
|
while (true) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
const candidates = [
|
|
59
|
+
path.join(dir, 'phasegate.config.json'),
|
|
60
|
+
path.join(dir, '.phasegate-local', 'phasegate.config.json'),
|
|
61
|
+
];
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(candidate);
|
|
65
|
+
return candidate;
|
|
66
|
+
} catch {}
|
|
65
67
|
}
|
|
68
|
+
const parent = path.dirname(dir);
|
|
69
|
+
if (parent === dir) return null;
|
|
70
|
+
dir = parent;
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
export function projectRootForConfig(configPath: string): string {
|
|
75
|
+
const configDir = path.dirname(configPath);
|
|
76
|
+
return path.basename(configDir) === '.phasegate-local' ? path.dirname(configDir) : configDir;
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
async function loadConfig(configPath: string): Promise<PhasegateConfig> {
|
|
70
80
|
try {
|
|
71
81
|
const raw = await fs.readFile(configPath, 'utf8');
|
|
@@ -122,7 +132,7 @@ export async function collectPhasegateStatus(cwd: string): Promise<PhasegateStat
|
|
|
122
132
|
const configPath = await findConfigPath(cwd);
|
|
123
133
|
const configFound = configPath !== null;
|
|
124
134
|
const config: PhasegateConfig = configFound ? await loadConfig(configPath) : {};
|
|
125
|
-
const projectRoot = configFound ?
|
|
135
|
+
const projectRoot = configFound ? projectRootForConfig(configPath) : cwd;
|
|
126
136
|
|
|
127
137
|
const protectedPatterns = computeProtectedPatterns(config);
|
|
128
138
|
const blockedUnits = await findBlockedUnits(projectRoot, config);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @layer presentation
|
|
3
3
|
* @unit agent-integration
|
|
4
|
+
* @work-item-id WI-208
|
|
4
5
|
*
|
|
5
6
|
* PostToolUse Hook Adapter
|
|
6
7
|
* Claude Code の PostToolUse Hook エントリポイント
|
|
@@ -30,19 +31,28 @@ async function readStdin(): Promise<string> {
|
|
|
30
31
|
async function findConfigPath(): Promise<string> {
|
|
31
32
|
let dir = process.cwd();
|
|
32
33
|
while (true) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
const candidates = [
|
|
35
|
+
path.join(dir, 'phasegate.config.json'),
|
|
36
|
+
path.join(dir, '.phasegate-local', 'phasegate.config.json'),
|
|
37
|
+
];
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
try {
|
|
40
|
+
await fs.access(candidate);
|
|
41
|
+
return candidate;
|
|
42
|
+
} catch {}
|
|
41
43
|
}
|
|
44
|
+
const parent = path.dirname(dir);
|
|
45
|
+
if (parent === dir) break;
|
|
46
|
+
dir = parent;
|
|
42
47
|
}
|
|
43
48
|
return path.join(process.cwd(), 'phasegate.config.json');
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
function projectRootForConfig(configPath: string): string {
|
|
52
|
+
const configDir = path.dirname(configPath);
|
|
53
|
+
return path.basename(configDir) === '.phasegate-local' ? path.dirname(configDir) : configDir;
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
async function main(): Promise<void> {
|
|
47
57
|
let raw: string;
|
|
48
58
|
try {
|
|
@@ -82,7 +92,7 @@ async function main(): Promise<void> {
|
|
|
82
92
|
|
|
83
93
|
if (output.skipReason) {
|
|
84
94
|
await recordHookSkipEvent({
|
|
85
|
-
projectRoot:
|
|
95
|
+
projectRoot: projectRootForConfig(configPath),
|
|
86
96
|
hookType: 'post-tool-use',
|
|
87
97
|
reason: output.skipReason,
|
|
88
98
|
targetPaths: [],
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* @unit agent-integration
|
|
4
4
|
* @work-item-id WI-202 / WI-204
|
|
5
5
|
* @work-item-id WI-206
|
|
6
|
+
* @work-item-id WI-208
|
|
6
7
|
*
|
|
7
8
|
* PreToolUse Hook Adapter
|
|
8
9
|
* Claude Code の PreToolUse Hook エントリポイント
|
|
@@ -57,19 +58,28 @@ async function readStdin(): Promise<string> {
|
|
|
57
58
|
async function findConfigPath(startDir: string): Promise<string> {
|
|
58
59
|
let dir = startDir;
|
|
59
60
|
while (true) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
const candidates = [
|
|
62
|
+
path.join(dir, 'phasegate.config.json'),
|
|
63
|
+
path.join(dir, '.phasegate-local', 'phasegate.config.json'),
|
|
64
|
+
];
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
try {
|
|
67
|
+
await fs.access(candidate);
|
|
68
|
+
return candidate;
|
|
69
|
+
} catch {}
|
|
68
70
|
}
|
|
71
|
+
const parent = path.dirname(dir);
|
|
72
|
+
if (parent === dir) break;
|
|
73
|
+
dir = parent;
|
|
69
74
|
}
|
|
70
75
|
return path.join(startDir, 'phasegate.config.json');
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
function projectRootForConfig(configPath: string): string {
|
|
79
|
+
const configDir = path.dirname(configPath);
|
|
80
|
+
return path.basename(configDir) === '.phasegate-local' ? path.dirname(configDir) : configDir;
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
function isProjectExternalAbsolutePath(filePath: string): boolean {
|
|
74
84
|
return path.isAbsolute(filePath);
|
|
75
85
|
}
|
|
@@ -139,24 +149,25 @@ async function main(): Promise<void> {
|
|
|
139
149
|
const configPath = await findConfigPath(cwd);
|
|
140
150
|
const projectTargetFilePaths = targetFilePaths.filter((filePath) => !isProjectExternalAbsolutePath(filePath));
|
|
141
151
|
const projectTargetChanges = targetChanges.filter((change) => !isProjectExternalAbsolutePath(change.filePath));
|
|
152
|
+
const projectRoot = projectRootForConfig(configPath);
|
|
142
153
|
const configQueryPort = new HarnessConfigConfigQueryAdapter(configPath);
|
|
143
154
|
const phaseGateQueryPort = new PhaseGateQueryAdapter();
|
|
144
155
|
const storyReflectionQueryPort = new FileSystemStoryReflectionQueryAdapter({
|
|
145
|
-
rootDir:
|
|
156
|
+
rootDir: projectRoot,
|
|
146
157
|
configPath,
|
|
147
158
|
});
|
|
148
159
|
const fullModeRequirementQueryPort = new QuickModeFullModeRequirementAdapter({
|
|
149
160
|
classifyUseCaseFactory: () => createQuickModeCompositionRoot().classifyUseCase,
|
|
150
161
|
});
|
|
151
162
|
const baselineGrandfatherQueryPort = new CiGovernanceBaselineGrandfatherAdapter({
|
|
152
|
-
baseDir:
|
|
163
|
+
baseDir: projectRoot,
|
|
153
164
|
configQueryPort,
|
|
154
165
|
});
|
|
155
166
|
const errorGuidanceQueryPort = new HarnessErrorGuidanceAdapter({
|
|
156
|
-
rootDir:
|
|
167
|
+
rootDir: projectRoot,
|
|
157
168
|
});
|
|
158
169
|
const fullModeSessionQueryPort = new FileSystemFullModeSessionQueryAdapter({
|
|
159
|
-
rootDir:
|
|
170
|
+
rootDir: projectRoot,
|
|
160
171
|
configQueryPort,
|
|
161
172
|
});
|
|
162
173
|
const useCase = new HandlePreToolUseUseCase({
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* @layer presentation
|
|
3
3
|
* @unit agent-integration
|
|
4
4
|
* @work-item-id WI-203
|
|
5
|
+
* @work-item-id WI-208
|
|
5
6
|
*
|
|
6
7
|
* Stop Hook Adapter
|
|
7
8
|
* Claude Code の Stop Hook エントリポイント
|
|
@@ -45,19 +46,28 @@ async function readStdin(): Promise<string> {
|
|
|
45
46
|
async function findConfigPath(): Promise<string> {
|
|
46
47
|
let dir = process.cwd();
|
|
47
48
|
while (true) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
const candidates = [
|
|
50
|
+
path.join(dir, 'phasegate.config.json'),
|
|
51
|
+
path.join(dir, '.phasegate-local', 'phasegate.config.json'),
|
|
52
|
+
];
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
try {
|
|
55
|
+
await fs.access(candidate);
|
|
56
|
+
return candidate;
|
|
57
|
+
} catch {}
|
|
56
58
|
}
|
|
59
|
+
const parent = path.dirname(dir);
|
|
60
|
+
if (parent === dir) break;
|
|
61
|
+
dir = parent;
|
|
57
62
|
}
|
|
58
63
|
return path.join(process.cwd(), 'phasegate.config.json');
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
function projectRootForConfig(configPath: string): string {
|
|
67
|
+
const configDir = path.dirname(configPath);
|
|
68
|
+
return path.basename(configDir) === '.phasegate-local' ? path.dirname(configDir) : configDir;
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
async function main(): Promise<void> {
|
|
62
72
|
let raw: string;
|
|
63
73
|
try {
|
|
@@ -99,7 +109,7 @@ async function main(): Promise<void> {
|
|
|
99
109
|
|
|
100
110
|
if (output.skipReason === 'REENTRY_DETECTED') {
|
|
101
111
|
await recordHookSkipEvent({
|
|
102
|
-
projectRoot:
|
|
112
|
+
projectRoot: projectRootForConfig(configPath),
|
|
103
113
|
hookType: 'stop',
|
|
104
114
|
reason: output.skipReason,
|
|
105
115
|
targetPaths: [],
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// @unit agent-integration
|
|
2
2
|
// @layer presentation
|
|
3
|
+
// @work-item-id WI-208
|
|
3
4
|
|
|
4
|
-
import * as path from 'node:path';
|
|
5
5
|
import {
|
|
6
6
|
buildUserPromptSubmitContext,
|
|
7
7
|
collectPhasegateStatus,
|
|
8
8
|
collectRecentViolations,
|
|
9
9
|
findConfigPath,
|
|
10
|
+
projectRootForConfig,
|
|
10
11
|
} from './phasegate-status-context.js';
|
|
11
12
|
|
|
12
13
|
async function readStdin(): Promise<string> {
|
|
@@ -29,7 +30,7 @@ async function main(): Promise<void> {
|
|
|
29
30
|
// 違反検知の起点は config が見つかったディレクトリ (= project root)。
|
|
30
31
|
// config が無ければ cwd をそのまま使う。
|
|
31
32
|
const configPath = await findConfigPath(cwd);
|
|
32
|
-
const projectRoot = configPath !== null ?
|
|
33
|
+
const projectRoot = configPath !== null ? projectRootForConfig(configPath) : cwd;
|
|
33
34
|
const violations = await collectRecentViolations(projectRoot, status);
|
|
34
35
|
const additionalContext = buildUserPromptSubmitContext(status, violations);
|
|
35
36
|
|
|
@@ -7,6 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import type { ConfigRepositoryPort } from '../../domain/ports/config-repository-port.js';
|
|
8
8
|
|
|
9
9
|
const DEFAULT_CONFIG_FILE_NAME = 'phasegate.config.json';
|
|
10
|
+
const PERSONAL_CONFIG_PATH = path.join('.phasegate-local', DEFAULT_CONFIG_FILE_NAME);
|
|
10
11
|
|
|
11
12
|
export class ConfigNotFoundError extends Error {
|
|
12
13
|
readonly configPath: string;
|
|
@@ -45,10 +46,15 @@ async function findNearestConfig(startDirectory: string): Promise<string | null>
|
|
|
45
46
|
let currentDirectory = path.resolve(startDirectory);
|
|
46
47
|
|
|
47
48
|
while (true) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const candidates = [
|
|
50
|
+
path.join(currentDirectory, DEFAULT_CONFIG_FILE_NAME),
|
|
51
|
+
path.join(currentDirectory, PERSONAL_CONFIG_PATH),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (await exists(candidate)) {
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
const parentDirectory = path.dirname(currentDirectory);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @layer application
|
|
3
3
|
// @work-item-id WI-145
|
|
4
4
|
// @work-item-id WI-178
|
|
5
|
+
// @work-item-id WI-208
|
|
5
6
|
|
|
6
7
|
import { DiagnosticReport } from "../../domain/diagnostic-report.js";
|
|
7
8
|
import type { CheckId } from "../../domain/check-id.js";
|
|
@@ -26,12 +27,20 @@ export interface RunDoctorDiagnosticsInput {
|
|
|
26
27
|
export interface RunDoctorDiagnosticsOutput {
|
|
27
28
|
readonly report: DiagnosticReport;
|
|
28
29
|
readonly agent: DoctorAgentScope;
|
|
30
|
+
readonly installationMode: "project" | "personal";
|
|
29
31
|
readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
|
|
30
32
|
readonly exitCode: number;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const CLAUDE_ONLY_CHECKS = new Set<CheckId>(["claude-hook-missing", "claude-skills-symlink"]);
|
|
34
36
|
const CODEX_ONLY_CHECKS = new Set<CheckId>(["codex-hook-missing", "codex-skills-symlink"]);
|
|
37
|
+
const PERSONAL_SCOPED_OUT_CHECKS = new Set<CheckId>([
|
|
38
|
+
"husky-pre-commit-missing",
|
|
39
|
+
"husky-commit-msg-missing",
|
|
40
|
+
"husky-pre-push-missing",
|
|
41
|
+
"ci-workflow-missing",
|
|
42
|
+
"package-json-devdep-missing",
|
|
43
|
+
]);
|
|
35
44
|
|
|
36
45
|
export class RunDoctorDiagnosticsUseCase {
|
|
37
46
|
constructor(
|
|
@@ -42,15 +51,20 @@ export class RunDoctorDiagnosticsUseCase {
|
|
|
42
51
|
|
|
43
52
|
async execute(input: RunDoctorDiagnosticsInput): Promise<RunDoctorDiagnosticsOutput> {
|
|
44
53
|
const agent = input.agent ?? "both";
|
|
45
|
-
await this.manifestRepository.load(input.projectRoot).catch(() => null);
|
|
54
|
+
const manifest = await this.manifestRepository.load(input.projectRoot).catch(() => null);
|
|
55
|
+
const installationMode = manifest?.findEntry(".phasegate-local/phasegate.config.json") !== null && manifest !== null ? "personal" : "project";
|
|
46
56
|
const rawFindings = (await Promise.all(
|
|
47
57
|
this.checks.map((check) => check.run(input.projectRoot, this.inspector)),
|
|
48
58
|
)).filter((finding) => finding !== null);
|
|
49
|
-
const { findings, scopedOutFindings } = this.
|
|
59
|
+
const { findings, scopedOutFindings } = this.applyPersonalScope(
|
|
60
|
+
this.applyAgentScope(rawFindings, agent),
|
|
61
|
+
installationMode,
|
|
62
|
+
);
|
|
50
63
|
const report = DiagnosticReport.create(findings);
|
|
51
64
|
return {
|
|
52
65
|
report,
|
|
53
66
|
agent,
|
|
67
|
+
installationMode,
|
|
54
68
|
scopedOutFindings,
|
|
55
69
|
exitCode: this.decideExitCode(report, input.strict),
|
|
56
70
|
};
|
|
@@ -77,6 +91,26 @@ export class RunDoctorDiagnosticsUseCase {
|
|
|
77
91
|
return { findings: applicable, scopedOutFindings: scopedOut };
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
private applyPersonalScope(
|
|
95
|
+
scoped: { readonly findings: readonly DiagnosticFinding[]; readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[] },
|
|
96
|
+
installationMode: "project" | "personal",
|
|
97
|
+
): { readonly findings: readonly DiagnosticFinding[]; readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[] } {
|
|
98
|
+
if (installationMode !== "personal") return scoped;
|
|
99
|
+
const applicable: DiagnosticFinding[] = [];
|
|
100
|
+
const scopedOut: ScopedOutDiagnosticFinding[] = [...scoped.scopedOutFindings];
|
|
101
|
+
for (const finding of scoped.findings) {
|
|
102
|
+
if (PERSONAL_SCOPED_OUT_CHECKS.has(finding.checkId)) {
|
|
103
|
+
scopedOut.push({
|
|
104
|
+
finding,
|
|
105
|
+
scopeReason: `${finding.checkId} is a team/project install target and is intentionally out of scope for personal install.`,
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
applicable.push(finding);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { findings: applicable, scopedOutFindings: scopedOut };
|
|
112
|
+
}
|
|
113
|
+
|
|
80
114
|
private decideExitCode(report: DiagnosticReport, strict: boolean): number {
|
|
81
115
|
if (report.hasRedFlag()) return 1;
|
|
82
116
|
if (strict && report.hasWarning()) return 1;
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// @work-item-id WI-182
|
|
9
9
|
// @work-item-id WI-183
|
|
10
10
|
// @work-item-id WI-207
|
|
11
|
+
// @work-item-id WI-208
|
|
11
12
|
|
|
12
|
-
import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink } from "node:fs/promises";
|
|
13
|
+
import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink, readdir } from "node:fs/promises";
|
|
13
14
|
import { dirname, join } from "node:path";
|
|
14
15
|
import { DeploymentEntry } from "../../domain/deployment-entry.js";
|
|
15
16
|
import { DeploymentManifest } from "../../domain/deployment-manifest.js";
|
|
@@ -19,7 +20,7 @@ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.j
|
|
|
19
20
|
import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
|
|
20
21
|
|
|
21
22
|
type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
|
|
22
|
-
type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "text-managed" | "copy";
|
|
23
|
+
type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "text-managed" | "copy" | "copy-dir" | "symlink";
|
|
23
24
|
|
|
24
25
|
export interface InstallPlanItem {
|
|
25
26
|
readonly path: string;
|
|
@@ -83,6 +84,8 @@ const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
|
|
|
83
84
|
const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
|
|
84
85
|
const TEXT_BEGIN = "# phasegate personal install exclude (BEGIN)";
|
|
85
86
|
const TEXT_END = "# phasegate personal install exclude (END)";
|
|
87
|
+
const PERSONAL_CLAUDE_SETTINGS_LINK = "../.phasegate-local/claude/settings.json";
|
|
88
|
+
const PERSONAL_CLAUDE_SKILLS_LINK = "../.phasegate-local/skills";
|
|
86
89
|
|
|
87
90
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
88
91
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -105,6 +108,20 @@ async function readTextOrNull(path: string): Promise<string | null> {
|
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
async function copyDirectory(src: string, dest: string): Promise<void> {
|
|
112
|
+
await mkdir(dest, { recursive: true });
|
|
113
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const srcPath = join(src, entry.name);
|
|
116
|
+
const destPath = join(dest, entry.name);
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
await copyDirectory(srcPath, destPath);
|
|
119
|
+
} else if (entry.isFile()) {
|
|
120
|
+
await copyFile(srcPath, destPath);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
function normalizeJsonEntry(value: unknown): string {
|
|
109
126
|
return JSON.stringify(value);
|
|
110
127
|
}
|
|
@@ -295,7 +312,7 @@ export class RunInstallUseCase {
|
|
|
295
312
|
const workflow = input.workflow ?? "standard";
|
|
296
313
|
const agent = input.agent ?? (includeClaude && includeCodex ? "both" : includeCodex ? "codex" : "claude");
|
|
297
314
|
const targets = input.personal
|
|
298
|
-
? this.createPersonalTargets()
|
|
315
|
+
? this.createPersonalTargets({ includeClaude })
|
|
299
316
|
: this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
|
|
300
317
|
const existingManifest = await this.manifestRepository.load(input.projectRoot);
|
|
301
318
|
const baseManifest = existingManifest ?? DeploymentManifest.create(input.phasegateVersion);
|
|
@@ -340,23 +357,23 @@ export class RunInstallUseCase {
|
|
|
340
357
|
await this.backup(input.projectRoot, target.path, backupDir);
|
|
341
358
|
}
|
|
342
359
|
|
|
360
|
+
try {
|
|
361
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "mkdir", error);
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
await writeFile(absolutePath, next, "utf8");
|
|
367
|
+
} catch (error) {
|
|
368
|
+
return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "writeFile", error);
|
|
369
|
+
}
|
|
370
|
+
if (target.executable) {
|
|
343
371
|
try {
|
|
344
|
-
await
|
|
345
|
-
} catch (error) {
|
|
346
|
-
return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "mkdir", error);
|
|
347
|
-
}
|
|
348
|
-
try {
|
|
349
|
-
await writeFile(absolutePath, next, "utf8");
|
|
372
|
+
await chmod(absolutePath, 0o755);
|
|
350
373
|
} catch (error) {
|
|
351
|
-
return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "
|
|
352
|
-
}
|
|
353
|
-
if (target.executable) {
|
|
354
|
-
try {
|
|
355
|
-
await chmod(absolutePath, 0o755);
|
|
356
|
-
} catch (error) {
|
|
357
|
-
return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "chmod", error);
|
|
358
|
-
}
|
|
374
|
+
return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "chmod", error);
|
|
359
375
|
}
|
|
376
|
+
}
|
|
360
377
|
changed.push(item);
|
|
361
378
|
|
|
362
379
|
const mode = before === null ? "created" : "merged";
|
|
@@ -377,41 +394,63 @@ export class RunInstallUseCase {
|
|
|
377
394
|
}
|
|
378
395
|
}
|
|
379
396
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
397
|
+
if (input.personal && includeClaude) {
|
|
398
|
+
const item = await this.planPersonalSkills(input);
|
|
399
|
+
plan.push(item);
|
|
400
|
+
if (input.apply && item.changed) {
|
|
401
|
+
try {
|
|
402
|
+
await copyDirectory(join(input.harnessRoot, "skills"), join(input.projectRoot, ".phasegate-local", "skills"));
|
|
403
|
+
await writeFile(
|
|
404
|
+
join(input.projectRoot, ".phasegate-local", "skills", ".harness-version"),
|
|
405
|
+
`${JSON.stringify({ version: input.phasegateVersion, deployedAt: new Date().toISOString(), skillSet }, null, 2)}\n`,
|
|
406
|
+
"utf8",
|
|
407
|
+
);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return this.withApplyError({ plan, refused, changed, backupDir }, item.path, "copyDirectory", error);
|
|
410
|
+
}
|
|
411
|
+
changed.push(item);
|
|
412
|
+
manifest = this.addManifestEntry(baseManifest, manifest, {
|
|
413
|
+
path: item.path,
|
|
414
|
+
mode: "created",
|
|
415
|
+
contentForHash: this.personalSkillsHashInput(input.phasegateVersion, skillSet),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const linkSpecs = input.personal
|
|
421
|
+
? [
|
|
422
|
+
...(includeClaude
|
|
423
|
+
? [
|
|
424
|
+
{ path: ".claude/settings.json", target: PERSONAL_CLAUDE_SETTINGS_LINK },
|
|
425
|
+
{ path: ".claude/skills", target: PERSONAL_CLAUDE_SKILLS_LINK },
|
|
426
|
+
]
|
|
427
|
+
: []),
|
|
428
|
+
]
|
|
429
|
+
: [
|
|
430
|
+
...(includeClaude ? [{ path: ".claude/skills", target: "../skills" }] : []),
|
|
431
|
+
...(includeCodex ? [{ path: ".codex/skills", target: "../skills" }] : []),
|
|
432
|
+
];
|
|
433
|
+
for (const linkSpec of linkSpecs) {
|
|
434
|
+
const item = await this.planSkillLink(input.projectRoot, linkSpec.path, linkSpec.target);
|
|
386
435
|
plan.push(item);
|
|
387
436
|
if (!input.apply || !item.changed) continue;
|
|
388
437
|
try {
|
|
389
|
-
await mkdir(join(input.projectRoot, "skills"), { recursive: true });
|
|
390
|
-
await mkdir(dirname(join(input.projectRoot,
|
|
438
|
+
if (!input.personal) await mkdir(join(input.projectRoot, "skills"), { recursive: true });
|
|
439
|
+
await mkdir(dirname(join(input.projectRoot, linkSpec.path)), { recursive: true });
|
|
391
440
|
} catch (error) {
|
|
392
|
-
return this.withApplyError({ plan, refused, changed, backupDir },
|
|
441
|
+
return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "mkdir", error);
|
|
393
442
|
}
|
|
394
443
|
try {
|
|
395
|
-
await symlink(
|
|
444
|
+
await symlink(linkSpec.target, join(input.projectRoot, linkSpec.path), process.platform === "win32" ? "junction" : "dir");
|
|
396
445
|
} catch (error) {
|
|
397
|
-
return this.withApplyError({ plan, refused, changed, backupDir },
|
|
446
|
+
return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "symlink", error);
|
|
398
447
|
}
|
|
399
448
|
changed.push(item);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
manifest = manifest.addEntry(
|
|
406
|
-
DeploymentEntry.create({
|
|
407
|
-
path: linkPath,
|
|
408
|
-
mode: "symlink",
|
|
409
|
-
block: null,
|
|
410
|
-
hash,
|
|
411
|
-
deployedAt: new Date().toISOString(),
|
|
412
|
-
}),
|
|
413
|
-
);
|
|
414
|
-
}
|
|
449
|
+
manifest = this.addManifestEntry(baseManifest, manifest, {
|
|
450
|
+
path: linkSpec.path,
|
|
451
|
+
mode: "symlink",
|
|
452
|
+
contentForHash: linkSpec.target,
|
|
453
|
+
});
|
|
415
454
|
}
|
|
416
455
|
|
|
417
456
|
if (input.personal && includeCodex) {
|
|
@@ -525,13 +564,22 @@ export class RunInstallUseCase {
|
|
|
525
564
|
];
|
|
526
565
|
}
|
|
527
566
|
|
|
528
|
-
private createPersonalTargets(): readonly InstallTarget[] {
|
|
567
|
+
private createPersonalTargets(options: { readonly includeClaude: boolean }): readonly InstallTarget[] {
|
|
529
568
|
return [
|
|
530
569
|
{
|
|
531
|
-
path: ".phasegate-local/config.json",
|
|
570
|
+
path: ".phasegate-local/phasegate.config.json",
|
|
532
571
|
strategy: "copy" as const,
|
|
533
572
|
templatePath: "docs/templates/personal/phasegate-local-config.json",
|
|
534
573
|
},
|
|
574
|
+
...(options.includeClaude
|
|
575
|
+
? [
|
|
576
|
+
{
|
|
577
|
+
path: ".phasegate-local/claude/settings.json",
|
|
578
|
+
strategy: "copy" as const,
|
|
579
|
+
templatePath: "templates/.claude/settings.json",
|
|
580
|
+
},
|
|
581
|
+
]
|
|
582
|
+
: []),
|
|
535
583
|
{
|
|
536
584
|
path: ".git/info/exclude",
|
|
537
585
|
strategy: "text-managed" as const,
|
|
@@ -545,6 +593,8 @@ export class RunInstallUseCase {
|
|
|
545
593
|
if (target.strategy === "shell") return shellRepairMode(before);
|
|
546
594
|
if (target.strategy === "text-managed") return "mechanical";
|
|
547
595
|
if (target.strategy === "copy") return "mechanical";
|
|
596
|
+
if (target.strategy === "copy-dir") return "mechanical";
|
|
597
|
+
if (target.strategy === "symlink") return "mechanical";
|
|
548
598
|
if (target.strategy === "json") return jsonRepairMode(before);
|
|
549
599
|
if (target.strategy === "markdown-managed") return "mechanical";
|
|
550
600
|
return "mechanical";
|
|
@@ -589,16 +639,59 @@ export class RunInstallUseCase {
|
|
|
589
639
|
};
|
|
590
640
|
}
|
|
591
641
|
|
|
592
|
-
private async
|
|
642
|
+
private async planPersonalSkills(input: RunInstallInput): Promise<InstallPlanItem> {
|
|
643
|
+
const versionPath = join(input.projectRoot, ".phasegate-local", "skills", ".harness-version");
|
|
644
|
+
const current = await readTextOrNull(versionPath);
|
|
645
|
+
const expectedNeedle = `"version": "${input.phasegateVersion}"`;
|
|
646
|
+
const changed = current === null || !current.includes(expectedNeedle);
|
|
647
|
+
return {
|
|
648
|
+
path: ".phasegate-local/skills",
|
|
649
|
+
action: changed ? "missing" : "will-skip",
|
|
650
|
+
repairMode: "mechanical",
|
|
651
|
+
strategy: "copy-dir",
|
|
652
|
+
changed,
|
|
653
|
+
summary: changed ? ".phasegate-local/skills: deploy bundled skills" : ".phasegate-local/skills: already up to date",
|
|
654
|
+
diff: changed ? "+ bundled skills" : "no changes",
|
|
655
|
+
skillHint: null,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private personalSkillsHashInput(version: string, skillSet: "core" | "all"): string {
|
|
660
|
+
return `personal-skills:${version}:${skillSet}`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private addManifestEntry(
|
|
664
|
+
baseManifest: DeploymentManifest,
|
|
665
|
+
manifest: DeploymentManifest,
|
|
666
|
+
input: { readonly path: string; readonly mode: "created" | "symlink"; readonly contentForHash: string },
|
|
667
|
+
): DeploymentManifest {
|
|
668
|
+
const hash = this.hashCalculator.compute(input.contentForHash);
|
|
669
|
+
const existingEntry = baseManifest.findEntry(input.path);
|
|
670
|
+
if (existingEntry !== null && existingEntry.hash.equals(hash)) {
|
|
671
|
+
return manifest.addEntry(existingEntry);
|
|
672
|
+
}
|
|
673
|
+
return manifest.addEntry(
|
|
674
|
+
DeploymentEntry.create({
|
|
675
|
+
path: input.path,
|
|
676
|
+
mode: input.mode,
|
|
677
|
+
block: null,
|
|
678
|
+
hash,
|
|
679
|
+
deployedAt: new Date().toISOString(),
|
|
680
|
+
}),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private async planSkillLink(projectRoot: string, relativePath: string, target: string): Promise<InstallPlanItem> {
|
|
593
685
|
const absolutePath = join(projectRoot, relativePath);
|
|
686
|
+
const parentPath = dirname(absolutePath);
|
|
594
687
|
try {
|
|
595
688
|
const stat = await lstat(absolutePath);
|
|
596
|
-
if (stat.isSymbolicLink() && (await readlink(absolutePath)) ===
|
|
689
|
+
if (stat.isSymbolicLink() && (await readlink(absolutePath)) === target) {
|
|
597
690
|
return {
|
|
598
691
|
path: relativePath,
|
|
599
692
|
action: "will-skip",
|
|
600
693
|
repairMode: "mechanical",
|
|
601
|
-
strategy: "
|
|
694
|
+
strategy: "symlink",
|
|
602
695
|
changed: false,
|
|
603
696
|
summary: `${relativePath}: already linked`,
|
|
604
697
|
diff: "no changes",
|
|
@@ -609,21 +702,36 @@ export class RunInstallUseCase {
|
|
|
609
702
|
path: relativePath,
|
|
610
703
|
action: "will-merge",
|
|
611
704
|
repairMode: "manual",
|
|
612
|
-
strategy: "
|
|
705
|
+
strategy: "symlink",
|
|
613
706
|
changed: false,
|
|
614
707
|
summary: `${relativePath}: existing non-phasegate path requires manual review`,
|
|
615
708
|
diff: "manual review required",
|
|
616
709
|
skillHint: null,
|
|
617
710
|
};
|
|
618
711
|
} catch {
|
|
712
|
+
try {
|
|
713
|
+
const parentStat = await lstat(parentPath);
|
|
714
|
+
if (!parentStat.isDirectory()) {
|
|
715
|
+
return {
|
|
716
|
+
path: relativePath,
|
|
717
|
+
action: "will-merge",
|
|
718
|
+
repairMode: "manual",
|
|
719
|
+
strategy: "symlink",
|
|
720
|
+
changed: false,
|
|
721
|
+
summary: `${relativePath}: parent path exists and requires manual review`,
|
|
722
|
+
diff: "manual review required",
|
|
723
|
+
skillHint: null,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
} catch {}
|
|
619
727
|
return {
|
|
620
728
|
path: relativePath,
|
|
621
729
|
action: "missing",
|
|
622
730
|
repairMode: "mechanical",
|
|
623
|
-
strategy: "
|
|
731
|
+
strategy: "symlink",
|
|
624
732
|
changed: true,
|
|
625
733
|
summary: `${relativePath}: create symlink`,
|
|
626
|
-
diff:
|
|
734
|
+
diff: `+ symlink ${target}`,
|
|
627
735
|
skillHint: null,
|
|
628
736
|
};
|
|
629
737
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// @work-item-id WI-174
|
|
5
5
|
// @work-item-id WI-199
|
|
6
6
|
// @work-item-id WI-207
|
|
7
|
+
// @work-item-id WI-208
|
|
7
8
|
|
|
8
9
|
import { access, copyFile, lstat, mkdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
|
|
9
10
|
import { dirname, join, relative, resolve } from "node:path";
|
|
@@ -163,6 +164,7 @@ export class RunUninstallUseCase {
|
|
|
163
164
|
repairMode: "manual",
|
|
164
165
|
strategy: "unknown",
|
|
165
166
|
changed: false,
|
|
167
|
+
protected: false,
|
|
166
168
|
summary: "manifest missing; run phasegate doctor and clean up manually",
|
|
167
169
|
diff: "manual cleanup required",
|
|
168
170
|
skillHint: SKILL_HINT,
|
|
@@ -216,8 +218,12 @@ export class RunUninstallUseCase {
|
|
|
216
218
|
readonly apply: () => Promise<void>;
|
|
217
219
|
}> {
|
|
218
220
|
const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
|
|
219
|
-
const currentContent = await readTextOrNull(absolutePath);
|
|
220
221
|
const strategy = this.strategyFor(entry.path, entry.mode);
|
|
222
|
+
if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
|
|
223
|
+
if (entry.mode === "created" && await this.isDirectory(absolutePath)) {
|
|
224
|
+
return this.planCreatedDirectory(input.projectRoot, entry);
|
|
225
|
+
}
|
|
226
|
+
const currentContent = await readTextOrNull(absolutePath);
|
|
221
227
|
if (currentContent === null && strategy !== "symlink") {
|
|
222
228
|
return {
|
|
223
229
|
item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: already absent`, "no changes", null),
|
|
@@ -226,7 +232,6 @@ export class RunUninstallUseCase {
|
|
|
226
232
|
};
|
|
227
233
|
}
|
|
228
234
|
|
|
229
|
-
if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
|
|
230
235
|
if (entry.mode === "created") return this.planCreated(input.projectRoot, entry, currentContent ?? "");
|
|
231
236
|
return this.planMerged(input, entry, currentContent ?? "", strategy);
|
|
232
237
|
}
|
|
@@ -240,9 +245,9 @@ export class RunUninstallUseCase {
|
|
|
240
245
|
} catch {
|
|
241
246
|
target = null;
|
|
242
247
|
}
|
|
243
|
-
if (target
|
|
248
|
+
if (target !== null && this.hashCalculator.compute(target).equals(entry.hash)) {
|
|
244
249
|
return {
|
|
245
|
-
item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`,
|
|
250
|
+
item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`, `- symlink ${target}`, null),
|
|
246
251
|
needsBackup: false,
|
|
247
252
|
apply: async () => {
|
|
248
253
|
await rm(absolutePath, { force: true });
|
|
@@ -256,6 +261,35 @@ export class RunUninstallUseCase {
|
|
|
256
261
|
};
|
|
257
262
|
}
|
|
258
263
|
|
|
264
|
+
private async isDirectory(path: string): Promise<boolean> {
|
|
265
|
+
try {
|
|
266
|
+
const stat = await lstat(path);
|
|
267
|
+
return stat.isDirectory();
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async planCreatedDirectory(projectRoot: string, entry: DeploymentEntry) {
|
|
274
|
+
const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
|
|
275
|
+
return {
|
|
276
|
+
item: this.item(
|
|
277
|
+
entry.path,
|
|
278
|
+
"delete",
|
|
279
|
+
"mechanical",
|
|
280
|
+
"created",
|
|
281
|
+
true,
|
|
282
|
+
`${entry.path}: delete created directory`,
|
|
283
|
+
"- directory",
|
|
284
|
+
null,
|
|
285
|
+
),
|
|
286
|
+
needsBackup: false,
|
|
287
|
+
apply: async () => {
|
|
288
|
+
await rm(absolutePath, { recursive: true, force: true });
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
259
293
|
private async planCreated(projectRoot: string, entry: DeploymentEntry, currentContent: string) {
|
|
260
294
|
const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
|
|
261
295
|
const currentHash = this.hashCalculator.compute(currentContent);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @layer presentation
|
|
3
3
|
// @work-item-id WI-145
|
|
4
4
|
// @work-item-id WI-178
|
|
5
|
+
// @work-item-id WI-208
|
|
5
6
|
|
|
6
7
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
7
8
|
import { dirname, isAbsolute, join } from "node:path";
|
|
@@ -37,6 +38,7 @@ export class DoctorHandler {
|
|
|
37
38
|
const formatInput = {
|
|
38
39
|
report: result.report,
|
|
39
40
|
agent: result.agent,
|
|
41
|
+
installationMode: result.installationMode,
|
|
40
42
|
scopedOutFindings: result.scopedOutFindings,
|
|
41
43
|
phasegateVersion: input.phasegateVersion,
|
|
42
44
|
projectRoot: input.projectRoot,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// @work-item-id WI-178
|
|
5
5
|
// @work-item-id WI-179
|
|
6
6
|
// @work-item-id WI-180
|
|
7
|
+
// @work-item-id WI-208
|
|
7
8
|
|
|
8
9
|
import type { DoctorAgentScope, ScopedOutDiagnosticFinding } from "../../application/usecases/run-doctor-diagnostics.js";
|
|
9
10
|
import type { DiagnosticReport } from "../../domain/diagnostic-report.js";
|
|
@@ -11,6 +12,7 @@ import type { DiagnosticReport } from "../../domain/diagnostic-report.js";
|
|
|
11
12
|
export interface DiagnosticReportFormatterInput {
|
|
12
13
|
readonly report: DiagnosticReport;
|
|
13
14
|
readonly agent: DoctorAgentScope;
|
|
15
|
+
readonly installationMode: "project" | "personal";
|
|
14
16
|
readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
|
|
15
17
|
readonly phasegateVersion: string;
|
|
16
18
|
readonly projectRoot: string;
|
|
@@ -26,7 +28,8 @@ export class DiagnosticReportFormatter {
|
|
|
26
28
|
projectRoot: input.projectRoot,
|
|
27
29
|
scope: {
|
|
28
30
|
agent: input.agent,
|
|
29
|
-
|
|
31
|
+
installationMode: input.installationMode,
|
|
32
|
+
description: scopeDescription(input.agent, input.installationMode),
|
|
30
33
|
},
|
|
31
34
|
overallStatus: input.report.overallStatus,
|
|
32
35
|
findings: input.report.findings.map((finding) => ({
|
|
@@ -60,7 +63,7 @@ export class DiagnosticReportFormatter {
|
|
|
60
63
|
const lines = [
|
|
61
64
|
`phasegate doctor v${input.phasegateVersion}`,
|
|
62
65
|
`Project: ${input.projectRoot}`,
|
|
63
|
-
`Scope: ${input.agent} (${scopeDescription(input.agent)})`,
|
|
66
|
+
`Scope: ${input.agent} / ${input.installationMode} (${scopeDescription(input.agent, input.installationMode)})`,
|
|
64
67
|
"",
|
|
65
68
|
];
|
|
66
69
|
for (const finding of input.report.findings) {
|
|
@@ -86,7 +89,12 @@ export class DiagnosticReportFormatter {
|
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
function scopeDescription(agent: DoctorAgentScope): string {
|
|
92
|
+
function scopeDescription(agent: DoctorAgentScope, installationMode: "project" | "personal"): string {
|
|
93
|
+
if (installationMode === "personal") {
|
|
94
|
+
if (agent === "claude") return "Personal Claude Code sandbox; team/project Husky, CI, package, and Codex-only findings are not repair targets.";
|
|
95
|
+
if (agent === "codex") return "Personal Codex sandbox; team/project Husky, CI, package, and Claude-only findings are not repair targets.";
|
|
96
|
+
return "Personal sandbox diagnostics; team/project Husky, CI, and package findings are not repair targets.";
|
|
97
|
+
}
|
|
90
98
|
if (agent === "claude") return "Claude Code and shared setup targets; Codex-only findings are not applicable.";
|
|
91
99
|
if (agent === "codex") return "Codex and shared setup targets; Claude-only findings are not applicable.";
|
|
92
100
|
return "Full setup diagnostics for Claude, Codex, and shared targets.";
|
package/scripts/harness/main.ts
CHANGED
|
@@ -171,7 +171,7 @@ Setup:
|
|
|
171
171
|
scaffold-wi <unit|_cross> <story|issue|chore>
|
|
172
172
|
Create docs/inception/{unit}/WI-XXX/description.md
|
|
173
173
|
emit-agent-rules Print AGENTS.md / CLAUDE.md WI workflow rules block
|
|
174
|
-
install Install phasegate managed files (--dry-run|--apply, --force, --personal)
|
|
174
|
+
install Install phasegate managed files (--dry-run|--apply, --force, --personal, --agent <claude|codex|both>)
|
|
175
175
|
uninstall Uninstall phasegate managed files (--dry-run|--apply, --force)
|
|
176
176
|
reconcile Reconcile phasegate managed files (--dry-run|--apply, --force)
|
|
177
177
|
setup:agent Diagnose repo setup and produce/apply an agent-readable setup plan
|
|
@@ -625,7 +625,8 @@ Options:
|
|
|
625
625
|
--workflow <standard|strict> Rendered agent context workflow mode (default: standard)
|
|
626
626
|
--with-husky Include Husky hook targets
|
|
627
627
|
--with-ci Include GitHub Actions target
|
|
628
|
-
--personal Use local-only install: no package.json, agent docs, Husky, CI,
|
|
628
|
+
--personal Use local-only install: no package.json, agent docs, Husky, CI, .gitignore, GitHub CLI, secrets, or CI setting writes.
|
|
629
|
+
With --agent claude, initializes .phasegate-local config/settings/skills and ignored .claude shims.
|
|
629
630
|
--json Output machine-readable JSON
|
|
630
631
|
--help, -h Show this help`,
|
|
631
632
|
"setup:agent": `Usage: phasegate setup:agent [options]
|
|
@@ -35,7 +35,7 @@ phasegate を導入した直後の config は単純な default で、実プロ
|
|
|
35
35
|
| hook config | `.claude/scripts/hook-config.json` | 既存 hook 設定確認 |
|
|
36
36
|
| doctor report | 明示された report path、またはユーザーが指定した `.phasegate/last-doctor-report.json` | `repairMode` / `repairHint` / `suggestedSkill` の確認 |
|
|
37
37
|
| manifest | `.phasegate/manifest.json` | install / reconcile / uninstall の managed target と hash 状態確認 |
|
|
38
|
-
| personal config | `.phasegate-local/config.json` | `install --personal` の local-only 設定確認 |
|
|
38
|
+
| personal config | `.phasegate-local/phasegate.config.json` | `install --personal` の local-only 設定確認 |
|
|
39
39
|
| git local exclude | `.git/info/exclude` | `install --personal` の repository-local ignore block 確認 |
|
|
40
40
|
| Claude hooks | `.claude/settings.json` | managed hook JSON と user customization の確認 |
|
|
41
41
|
| Codex hooks | `.codex/hooks.json` | managed hook JSON と Codex hook 配線確認 |
|
|
@@ -129,7 +129,7 @@ product-architect で Unit を作り、いくつかの logical_design を書い
|
|
|
129
129
|
#### 観点 9: setup lifecycle と doctor finding
|
|
130
130
|
|
|
131
131
|
- `phasegate doctor --json` の finding に `repairMode: "ai-assisted"` と `suggestedSkill.skillName = "phasegate-config-doctor"` がある → 本 skill が merge 方針、保持する user content、実行すべき `install --apply` / `--force` / `reconcile --apply` を提案する
|
|
132
|
-
- チーム所有リポジトリで個人評価だけを行う相談では、通常の `install --apply` ではなく `phasegate install --personal --dry-run` を先に提案する。personal install は `package.json`, `AGENTS.md`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, `.codex/hooks.json
|
|
132
|
+
- チーム所有リポジトリで個人評価だけを行う相談では、通常の `install --apply` ではなく `phasegate install --personal --agent claude --dry-run` を先に提案する。personal install は `package.json`, `AGENTS.md`, `CLAUDE.md`, `.husky/*`, `.github/workflows/*`, `.gitignore`, GitHub CLI config, repo secrets, CI settings, `.codex/hooks.json` を変更対象にしない。Claude Code向けには `.phasegate-local/phasegate.config.json`, `.phasegate-local/claude/settings.json`, `.phasegate-local/skills/`, ignored root `.claude/settings.json` / `.claude/skills` shim, `.phasegate/manifest.json`, `.git/info/exclude` の managed block を自動作成する。既存 `.claude/*` がある場合は上書きせず manual review として扱う。Codex user-level hook setup は manual action として扱う。<!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 -->
|
|
133
133
|
- Claude-only / Codex-only 導入後は `phasegate doctor --agent claude --json` または `phasegate doctor --agent codex --json` を使って selected agent の readiness を読む。`scopedOutFindings` は未選択 agent の `not-applicable` 情報なので、ユーザーがその agent を導入したいと言っていない限り repair 提案にしない。`repairHint: null` / `suggestedSkill: null` は意図的な抑制で、`currentScopeRepairTarget: false` と `repairModeApplicability: "only-if-agent-selected"` は raw `repairMode` が current scope の修復指示ではないという印である。<!-- @work-item-id WI-178, WI-179, WI-180 -->
|
|
134
134
|
- `repairHint` がある mechanical finding → 原則として hint のコマンドを優先し、実行前に対象ファイルと manifest の差分を確認
|
|
135
135
|
- manifest parse error → `.phasegate/manifest.json` を手で修復する前に backup / uninstall / reinstall の選択肢を提示
|
|
@@ -130,7 +130,7 @@ docs/guide/ # phasegate リポジトリ自体 (dogfood)
|
|
|
130
130
|
- 既存プロジェクト導入: `docs/guide/retrofit-adoption.md`
|
|
131
131
|
- setup artifact / doctor finding / legacy artifact: `docs/guide/setup-artifacts.md`
|
|
132
132
|
|
|
133
|
-
チーム所有リポジトリで個人評価だけを行いたい場合は、`docs/guide/installation.md` の personal install セクションを読む。`phasegate install --personal` は `package.json`、`AGENTS.md`、`CLAUDE.md`、`.husky/*`、`.github/workflows/*`、`.gitignore
|
|
133
|
+
チーム所有リポジトリで個人評価だけを行いたい場合は、`docs/guide/installation.md` の personal install セクションを読む。`phasegate install --personal --agent claude` は `package.json`、`AGENTS.md`、`CLAUDE.md`、`.husky/*`、`.github/workflows/*`、`.gitignore`、GitHub CLI config、repo secrets、CI settings、`.codex/hooks.json` を変更せず、`.phasegate-local/phasegate.config.json`、`.phasegate-local/claude/settings.json`、`.phasegate-local/skills/`、ignored root `.claude/settings.json` / `.claude/skills` shim、`.git/info/exclude` の managed block を使う。Codex user-level hook setup は manual action として説明する。<!-- @work-item-id WI-207 --> <!-- @work-item-id WI-208 -->
|
|
134
134
|
|
|
135
135
|
`setup-artifacts.md` は managed target / generated artifact / runtime state / legacy artifact / user-level setting の分類を持つ。`doctor --report-out` は明示 path への出力で、`.phasegate/last-doctor-report.json` は固定生成物ではない点もここを参照する。<!-- @work-item-id WI-153 -->
|
|
136
136
|
|