phasegate 0.160.12 → 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.
Files changed (24) hide show
  1. package/README.md +9 -0
  2. package/docs/guide/getting-started.md +1 -0
  3. package/docs/guide/installation.md +9 -0
  4. package/docs/guide/recipes.md +9 -0
  5. package/docs/guide/setup-artifacts.md +8 -0
  6. package/docs/templates/personal/git-info-exclude +6 -0
  7. package/docs/templates/personal/phasegate-local-config.json +77 -0
  8. package/package.json +1 -1
  9. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +19 -9
  10. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +19 -9
  11. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +23 -12
  12. package/scripts/harness/agent-integration/presentation/stop-hook.ts +19 -9
  13. package/scripts/harness/agent-integration/presentation/user-prompt-submit-hook.ts +3 -2
  14. package/scripts/harness/config-foundation/infrastructure/repositories/file-system-config-repository.ts +10 -4
  15. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +36 -2
  16. package/scripts/harness/installation/application/usecases/run-install.ts +204 -49
  17. package/scripts/harness/installation/application/usecases/run-uninstall.ts +49 -5
  18. package/scripts/harness/installation/presentation/cli/doctor-handler.ts +2 -0
  19. package/scripts/harness/installation/presentation/cli/install-handler.ts +3 -1
  20. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +11 -3
  21. package/scripts/harness/main.ts +8 -4
  22. package/skills/README.md +2 -0
  23. package/skills/phasegate-config-doctor/SKILL.md +3 -0
  24. package/skills/phasegate-toolkit-guide/SKILL.md +2 -0
package/README.md CHANGED
@@ -102,6 +102,15 @@ npx phasegate doctor
102
102
 
103
103
  `install` merges PhaseGate into the current project without discarding existing Claude/Codex hooks or Husky scripts. It reports planned changes before writing, adds package scripts and the `phasegate` devDependency, creates agent skill links, 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 -->
104
104
 
105
+ For personal evaluation inside a team-owned repository, use local-only install:
106
+
107
+ ```bash
108
+ npx phasegate install --personal --agent claude --dry-run
109
+ npx phasegate install --personal --agent claude --apply
110
+ ```
111
+
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
+
105
114
  For agent-driven setup planning, use:
106
115
 
107
116
  ```bash
@@ -12,6 +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 --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 |
15
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 |
16
17
  | Codex-only project | `npx phasegate init --agent codex --with-husky` | `.codex/hooks.json`, `AGENTS.md`, `.codex/skills`, and pre-commit backstop are present |
17
18
  | Strict validation rollout | `npx phasegate setup:agent --intent strict --dry-run --json` | The plan lists strict checks, L4 risk, rollback, and validation commands |
@@ -58,6 +58,15 @@ npx phasegate doctor
58
58
 
59
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 -->
60
60
 
61
+ For personal evaluation inside a team-owned repository:
62
+
63
+ ```bash
64
+ npx phasegate install --personal --agent claude --dry-run
65
+ npx phasegate install --personal --agent claude --apply
66
+ ```
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/*`, `.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
+
61
70
  For agent-readable planning before writing files:
62
71
 
63
72
  ```bash
@@ -24,6 +24,15 @@ npx phasegate baseline --dry-run
24
24
 
25
25
  Use this when a repository already has meaningful history or custom automation. Treat any refused target as an agent-assisted review task instead of forcing it blindly.
26
26
 
27
+ ## Personal Evaluation Recipe
28
+
29
+ ```bash
30
+ npx phasegate install --personal --dry-run
31
+ npx phasegate install --personal --apply
32
+ ```
33
+
34
+ Use this inside a team-owned repository when you want local PhaseGate artifacts without changing `package.json`, agent context files, Husky hooks, CI workflows, or team `.gitignore`. <!-- @work-item-id WI-207 -->
35
+
27
36
  ## Agent Hooks Recipe
28
37
 
29
38
  ```bash
@@ -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,6 +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 |
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 |
17
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 |
18
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 |
19
21
 
@@ -33,6 +35,12 @@ PhaseGate setup is more than `phasegate.config.json`. A healthy installation is
33
35
 
34
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.
35
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
+
36
44
  ## Doctor Findings
37
45
 
38
46
  `phasegate doctor` evaluates setup health from the managed targets and related project state. Findings include `repairMode`, optional `repairHint`, and optional `suggestedSkill`.
@@ -0,0 +1,6 @@
1
+ .phasegate/
2
+ .phasegate-local/
3
+ .codex/
4
+ .claude/
5
+ skills/
6
+ phasegate.config.json
@@ -0,0 +1,77 @@
1
+ {
2
+ "project": {
3
+ "name": "personal-phasegate",
4
+ "preset": "standard"
5
+ },
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
+ }
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.160.12",
3
+ "version": "0.160.14",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -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 candidate = path.join(dir, 'phasegate.config.json');
58
- try {
59
- await fs.access(candidate);
60
- return candidate;
61
- } catch {
62
- const parent = path.dirname(dir);
63
- if (parent === dir) return null;
64
- dir = parent;
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 ? path.dirname(configPath) : cwd;
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 candidate = path.join(dir, 'phasegate.config.json');
34
- try {
35
- await fs.access(candidate);
36
- return candidate;
37
- } catch {
38
- const parent = path.dirname(dir);
39
- if (parent === dir) break;
40
- dir = parent;
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: path.dirname(configPath),
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 candidate = path.join(dir, 'phasegate.config.json');
61
- try {
62
- await fs.access(candidate);
63
- return candidate;
64
- } catch {
65
- const parent = path.dirname(dir);
66
- if (parent === dir) break;
67
- dir = parent;
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: path.dirname(configPath),
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: path.dirname(configPath),
163
+ baseDir: projectRoot,
153
164
  configQueryPort,
154
165
  });
155
166
  const errorGuidanceQueryPort = new HarnessErrorGuidanceAdapter({
156
- rootDir: path.dirname(configPath),
167
+ rootDir: projectRoot,
157
168
  });
158
169
  const fullModeSessionQueryPort = new FileSystemFullModeSessionQueryAdapter({
159
- rootDir: path.dirname(configPath),
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 candidate = path.join(dir, 'phasegate.config.json');
49
- try {
50
- await fs.access(candidate);
51
- return candidate;
52
- } catch {
53
- const parent = path.dirname(dir);
54
- if (parent === dir) break;
55
- dir = parent;
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: path.dirname(configPath),
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 ? path.dirname(configPath) : cwd;
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 candidate = path.join(currentDirectory, DEFAULT_CONFIG_FILE_NAME);
49
-
50
- if (await exists(candidate)) {
51
- return candidate;
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.applyAgentScope(rawFindings, agent);
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;
@@ -7,8 +7,10 @@
7
7
  // @work-item-id WI-177
8
8
  // @work-item-id WI-182
9
9
  // @work-item-id WI-183
10
+ // @work-item-id WI-207
11
+ // @work-item-id WI-208
10
12
 
11
- 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";
12
14
  import { dirname, join } from "node:path";
13
15
  import { DeploymentEntry } from "../../domain/deployment-entry.js";
14
16
  import { DeploymentManifest } from "../../domain/deployment-manifest.js";
@@ -18,7 +20,7 @@ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.j
18
20
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
19
21
 
20
22
  type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
21
- type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed";
23
+ type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "text-managed" | "copy" | "copy-dir" | "symlink";
22
24
 
23
25
  export interface InstallPlanItem {
24
26
  readonly path: string;
@@ -45,6 +47,7 @@ export interface RunInstallInput {
45
47
  readonly skillSet?: "core" | "all";
46
48
  readonly workflow?: "standard" | "strict";
47
49
  readonly agent?: "claude" | "codex" | "both";
50
+ readonly personal?: boolean;
48
51
  }
49
52
 
50
53
  export interface RunInstallResult {
@@ -79,6 +82,10 @@ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
79
82
  const SHELL_END = "# === phasegate managed (END) ===";
80
83
  const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
81
84
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
85
+ const TEXT_BEGIN = "# phasegate personal install exclude (BEGIN)";
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";
82
89
 
83
90
  function isRecord(value: unknown): value is Record<string, unknown> {
84
91
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -101,6 +108,20 @@ async function readTextOrNull(path: string): Promise<string | null> {
101
108
  }
102
109
  }
103
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
+
104
125
  function normalizeJsonEntry(value: unknown): string {
105
126
  return JSON.stringify(value);
106
127
  }
@@ -152,6 +173,14 @@ function mergeShell(existing: string | null, incoming: string): string {
152
173
  return `${existing.replace(/\s*$/, "\n\n")}${block}\n`;
153
174
  }
154
175
 
176
+ function mergeTextManaged(existing: string | null, incoming: string): string {
177
+ const block = `${TEXT_BEGIN}\n${incoming.trim()}\n${TEXT_END}`;
178
+ if (existing === null || existing.trim().length === 0) return `${block}\n`;
179
+ const pattern = new RegExp(`${escapeRegExp(TEXT_BEGIN)}[\\s\\S]*?${escapeRegExp(TEXT_END)}`);
180
+ if (pattern.test(existing)) return existing.replace(pattern, block).replace(/\s*$/, "\n");
181
+ return `${existing.replace(/\s*$/, "\n\n")}${block}\n`;
182
+ }
183
+
155
184
  function managedMarkdownBlock(content: string): string {
156
185
  const start = content.indexOf(MARKDOWN_BEGIN);
157
186
  const end = content.indexOf(MARKDOWN_END);
@@ -282,7 +311,9 @@ export class RunInstallUseCase {
282
311
  const skillSet = input.skillSet ?? "all";
283
312
  const workflow = input.workflow ?? "standard";
284
313
  const agent = input.agent ?? (includeClaude && includeCodex ? "both" : includeCodex ? "codex" : "claude");
285
- const targets = this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
314
+ const targets = input.personal
315
+ ? this.createPersonalTargets({ includeClaude })
316
+ : this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
286
317
  const existingManifest = await this.manifestRepository.load(input.projectRoot);
287
318
  const baseManifest = existingManifest ?? DeploymentManifest.create(input.phasegateVersion);
288
319
  let manifest = baseManifest;
@@ -326,23 +357,23 @@ export class RunInstallUseCase {
326
357
  await this.backup(input.projectRoot, target.path, backupDir);
327
358
  }
328
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) {
329
371
  try {
330
- await mkdir(dirname(absolutePath), { recursive: true });
331
- } catch (error) {
332
- return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "mkdir", error);
333
- }
334
- try {
335
- await writeFile(absolutePath, next, "utf8");
372
+ await chmod(absolutePath, 0o755);
336
373
  } catch (error) {
337
- return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "writeFile", error);
338
- }
339
- if (target.executable) {
340
- try {
341
- await chmod(absolutePath, 0o755);
342
- } catch (error) {
343
- return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "chmod", error);
344
- }
374
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "chmod", error);
345
375
  }
376
+ }
346
377
  changed.push(item);
347
378
 
348
379
  const mode = before === null ? "created" : "merged";
@@ -363,41 +394,76 @@ export class RunInstallUseCase {
363
394
  }
364
395
  }
365
396
 
366
- const linkPaths = [
367
- ...(includeClaude ? [".claude/skills"] : []),
368
- ...(includeCodex ? [".codex/skills"] : []),
369
- ];
370
- for (const linkPath of linkPaths) {
371
- const item = await this.planSkillLink(input.projectRoot, linkPath);
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);
372
435
  plan.push(item);
373
436
  if (!input.apply || !item.changed) continue;
374
437
  try {
375
- await mkdir(join(input.projectRoot, "skills"), { recursive: true });
376
- await mkdir(dirname(join(input.projectRoot, linkPath)), { recursive: true });
438
+ if (!input.personal) await mkdir(join(input.projectRoot, "skills"), { recursive: true });
439
+ await mkdir(dirname(join(input.projectRoot, linkSpec.path)), { recursive: true });
377
440
  } catch (error) {
378
- return this.withApplyError({ plan, refused, changed, backupDir }, linkPath, "mkdir", error);
441
+ return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "mkdir", error);
379
442
  }
380
443
  try {
381
- await symlink("../skills", join(input.projectRoot, linkPath), process.platform === "win32" ? "junction" : "dir");
444
+ await symlink(linkSpec.target, join(input.projectRoot, linkSpec.path), process.platform === "win32" ? "junction" : "dir");
382
445
  } catch (error) {
383
- return this.withApplyError({ plan, refused, changed, backupDir }, linkPath, "symlink", error);
446
+ return this.withApplyError({ plan, refused, changed, backupDir }, linkSpec.path, "symlink", error);
384
447
  }
385
448
  changed.push(item);
386
- const hash = this.hashCalculator.compute("../skills");
387
- const existingEntry = baseManifest.findEntry(linkPath);
388
- if (existingEntry !== null && existingEntry.hash.equals(hash)) {
389
- manifest = manifest.addEntry(existingEntry);
390
- } else {
391
- manifest = manifest.addEntry(
392
- DeploymentEntry.create({
393
- path: linkPath,
394
- mode: "symlink",
395
- block: null,
396
- hash,
397
- deployedAt: new Date().toISOString(),
398
- }),
399
- );
400
- }
449
+ manifest = this.addManifestEntry(baseManifest, manifest, {
450
+ path: linkSpec.path,
451
+ mode: "symlink",
452
+ contentForHash: linkSpec.target,
453
+ });
454
+ }
455
+
456
+ if (input.personal && includeCodex) {
457
+ plan.push({
458
+ path: "~/.codex/hooks.json",
459
+ action: "will-skip",
460
+ repairMode: "manual",
461
+ strategy: "json",
462
+ changed: false,
463
+ summary: "~/.codex/hooks.json: personal mode does not write user-level Codex settings; configure hooks manually",
464
+ diff: "manual user-level Codex hook setup required",
465
+ skillHint: null,
466
+ });
401
467
  }
402
468
 
403
469
  if (input.apply && changed.length > 0) {
@@ -498,8 +564,37 @@ export class RunInstallUseCase {
498
564
  ];
499
565
  }
500
566
 
567
+ private createPersonalTargets(options: { readonly includeClaude: boolean }): readonly InstallTarget[] {
568
+ return [
569
+ {
570
+ path: ".phasegate-local/phasegate.config.json",
571
+ strategy: "copy" as const,
572
+ templatePath: "docs/templates/personal/phasegate-local-config.json",
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
+ : []),
583
+ {
584
+ path: ".git/info/exclude",
585
+ strategy: "text-managed" as const,
586
+ templatePath: "docs/templates/personal/git-info-exclude",
587
+ block: { start: TEXT_BEGIN, end: TEXT_END, content: "phasegate personal install exclude block" },
588
+ },
589
+ ];
590
+ }
591
+
501
592
  private repairMode(target: InstallTarget, before: string | null): RepairMode {
502
593
  if (target.strategy === "shell") return shellRepairMode(before);
594
+ if (target.strategy === "text-managed") return "mechanical";
595
+ if (target.strategy === "copy") return "mechanical";
596
+ if (target.strategy === "copy-dir") return "mechanical";
597
+ if (target.strategy === "symlink") return "mechanical";
503
598
  if (target.strategy === "json") return jsonRepairMode(before);
504
599
  if (target.strategy === "markdown-managed") return "mechanical";
505
600
  return "mechanical";
@@ -513,7 +608,9 @@ export class RunInstallUseCase {
513
608
 
514
609
  private merge(target: InstallTarget, before: string | null, template: string, version: string): string {
515
610
  if (target.strategy === "yaml-add") return before ?? template;
611
+ if (target.strategy === "copy") return before ?? template;
516
612
  if (target.strategy === "shell") return mergeShell(before, template);
613
+ if (target.strategy === "text-managed") return mergeTextManaged(before, template);
517
614
  if (target.strategy === "markdown-managed") return mergeManagedMarkdown(before, template);
518
615
  if (target.strategy === "package-json") {
519
616
  const existing = before === null ? {} : (JSON.parse(before) as unknown);
@@ -542,16 +639,59 @@ export class RunInstallUseCase {
542
639
  };
543
640
  }
544
641
 
545
- private async planSkillLink(projectRoot: string, relativePath: string): Promise<InstallPlanItem> {
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> {
546
685
  const absolutePath = join(projectRoot, relativePath);
686
+ const parentPath = dirname(absolutePath);
547
687
  try {
548
688
  const stat = await lstat(absolutePath);
549
- if (stat.isSymbolicLink() && (await readlink(absolutePath)) === "../skills") {
689
+ if (stat.isSymbolicLink() && (await readlink(absolutePath)) === target) {
550
690
  return {
551
691
  path: relativePath,
552
692
  action: "will-skip",
553
693
  repairMode: "mechanical",
554
- strategy: "yaml-add",
694
+ strategy: "symlink",
555
695
  changed: false,
556
696
  summary: `${relativePath}: already linked`,
557
697
  diff: "no changes",
@@ -562,21 +702,36 @@ export class RunInstallUseCase {
562
702
  path: relativePath,
563
703
  action: "will-merge",
564
704
  repairMode: "manual",
565
- strategy: "yaml-add",
705
+ strategy: "symlink",
566
706
  changed: false,
567
707
  summary: `${relativePath}: existing non-phasegate path requires manual review`,
568
708
  diff: "manual review required",
569
709
  skillHint: null,
570
710
  };
571
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 {}
572
727
  return {
573
728
  path: relativePath,
574
729
  action: "missing",
575
730
  repairMode: "mechanical",
576
- strategy: "yaml-add",
731
+ strategy: "symlink",
577
732
  changed: true,
578
733
  summary: `${relativePath}: create symlink`,
579
- diff: "+ symlink ../skills",
734
+ diff: `+ symlink ${target}`,
580
735
  skillHint: null,
581
736
  };
582
737
  }
@@ -3,6 +3,8 @@
3
3
  // @work-item-id WI-147
4
4
  // @work-item-id WI-174
5
5
  // @work-item-id WI-199
6
+ // @work-item-id WI-207
7
+ // @work-item-id WI-208
6
8
 
7
9
  import { access, copyFile, lstat, mkdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
8
10
  import { dirname, join, relative, resolve } from "node:path";
@@ -12,7 +14,7 @@ import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
12
14
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
13
15
 
14
16
  type UninstallAction = "missing-manifest" | "delete" | "unlink" | "reverse-merge" | "skip" | "refuse";
15
- type StrategyType = "created" | "json" | "shell" | "package-json" | "markdown-managed" | "symlink" | "yaml-add" | "unknown";
17
+ type StrategyType = "created" | "json" | "shell" | "package-json" | "markdown-managed" | "symlink" | "yaml-add" | "text-managed" | "unknown";
16
18
 
17
19
  export interface UninstallPlanItem {
18
20
  readonly path: string;
@@ -47,6 +49,8 @@ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
47
49
  const SHELL_END = "# === phasegate managed (END) ===";
48
50
  const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
49
51
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
52
+ const TEXT_BEGIN = "# phasegate personal install exclude (BEGIN)";
53
+ const TEXT_END = "# phasegate personal install exclude (END)";
50
54
  const PHASEGATE_SCRIPT_PREFIX = "phasegate:";
51
55
  const PROTECTED_UNINSTALL_PATHS = new Set(["package.json", "package-lock.json"]);
52
56
 
@@ -119,6 +123,11 @@ export function reverseManagedMarkdown(currentContent: string): string {
119
123
  return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
120
124
  }
121
125
 
126
+ export function reverseTextManaged(currentContent: string): string {
127
+ const pattern = new RegExp(`\\n?${escapeRegExp(TEXT_BEGIN)}[\\s\\S]*?${escapeRegExp(TEXT_END)}\\n?`);
128
+ return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
129
+ }
130
+
122
131
  export function reversePackageJsonMerge(currentContent: string): string {
123
132
  const parsed = JSON.parse(currentContent) as unknown;
124
133
  const result = isRecord(parsed) ? { ...parsed } : {};
@@ -155,6 +164,7 @@ export class RunUninstallUseCase {
155
164
  repairMode: "manual",
156
165
  strategy: "unknown",
157
166
  changed: false,
167
+ protected: false,
158
168
  summary: "manifest missing; run phasegate doctor and clean up manually",
159
169
  diff: "manual cleanup required",
160
170
  skillHint: SKILL_HINT,
@@ -208,8 +218,12 @@ export class RunUninstallUseCase {
208
218
  readonly apply: () => Promise<void>;
209
219
  }> {
210
220
  const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
211
- const currentContent = await readTextOrNull(absolutePath);
212
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);
213
227
  if (currentContent === null && strategy !== "symlink") {
214
228
  return {
215
229
  item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: already absent`, "no changes", null),
@@ -218,7 +232,6 @@ export class RunUninstallUseCase {
218
232
  };
219
233
  }
220
234
 
221
- if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
222
235
  if (entry.mode === "created") return this.planCreated(input.projectRoot, entry, currentContent ?? "");
223
236
  return this.planMerged(input, entry, currentContent ?? "", strategy);
224
237
  }
@@ -232,9 +245,9 @@ export class RunUninstallUseCase {
232
245
  } catch {
233
246
  target = null;
234
247
  }
235
- if (target === "../skills") {
248
+ if (target !== null && this.hashCalculator.compute(target).equals(entry.hash)) {
236
249
  return {
237
- item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`, "- symlink ../skills", null),
250
+ item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`, `- symlink ${target}`, null),
238
251
  needsBackup: false,
239
252
  apply: async () => {
240
253
  await rm(absolutePath, { force: true });
@@ -248,6 +261,35 @@ export class RunUninstallUseCase {
248
261
  };
249
262
  }
250
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
+
251
293
  private async planCreated(projectRoot: string, entry: DeploymentEntry, currentContent: string) {
252
294
  const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
253
295
  const currentHash = this.hashCalculator.compute(currentContent);
@@ -312,6 +354,7 @@ export class RunUninstallUseCase {
312
354
  private async reverseMerged(harnessRoot: string, path: string, currentContent: string, strategy: StrategyType): Promise<string> {
313
355
  if (strategy === "shell") return currentContent.includes(SHELL_BEGIN) ? reverseShellMerge(currentContent) : currentContent;
314
356
  if (strategy === "markdown-managed") return currentContent.includes(MARKDOWN_BEGIN) ? reverseManagedMarkdown(currentContent) : currentContent;
357
+ if (strategy === "text-managed") return currentContent.includes(TEXT_BEGIN) ? reverseTextManaged(currentContent) : currentContent;
315
358
  if (strategy === "package-json") return reversePackageJsonMerge(currentContent);
316
359
  if (strategy === "json") return reverseJsonMerge(currentContent, await readFile(join(harnessRoot, this.templateFor(path)), "utf8"));
317
360
  throw new Error(`Unsupported merged strategy: ${strategy}`);
@@ -322,6 +365,7 @@ export class RunUninstallUseCase {
322
365
  if (mode === "created") return path.endsWith(".yml") || path.endsWith(".yaml") ? "yaml-add" : "created";
323
366
  if (path === "package.json") return "package-json";
324
367
  if (path === "AGENTS.md" || path === "CLAUDE.md") return "markdown-managed";
368
+ if (path === ".git/info/exclude") return "text-managed";
325
369
  if (path.endsWith(".json")) return "json";
326
370
  if (path.startsWith(".husky/")) return "shell";
327
371
  return "unknown";
@@ -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,
@@ -2,6 +2,7 @@
2
2
  // @layer presentation
3
3
  // @work-item-id WI-146
4
4
  // @work-item-id WI-175
5
+ // @work-item-id WI-207
5
6
 
6
7
  import type { RunInstallUseCase } from "../../application/usecases/run-install.js";
7
8
 
@@ -20,6 +21,7 @@ export interface InstallHandlerInput {
20
21
  readonly skillSet?: "core" | "all";
21
22
  readonly workflow?: "standard" | "strict";
22
23
  readonly agent?: "claude" | "codex" | "both";
24
+ readonly personal?: boolean;
23
25
  }
24
26
 
25
27
  export interface InstallHandlerResult {
@@ -39,7 +41,7 @@ export class InstallHandler {
39
41
  };
40
42
  }
41
43
  const lines = [
42
- input.apply ? "phasegate install apply" : "phasegate install dry-run",
44
+ input.apply ? `phasegate install${input.personal ? " --personal" : ""} apply` : `phasegate install${input.personal ? " --personal" : ""} dry-run`,
43
45
  ...result.plan.map((item) => {
44
46
  const hint = item.skillHint ? `; hint: ${item.skillHint}` : "";
45
47
  return `- ${item.path}: ${item.action} (${item.repairMode}, ${item.strategy}); diff: ${item.diff}${hint}`;
@@ -4,6 +4,7 @@
4
4
  // @work-item-id WI-178
5
5
  // @work-item-id WI-179
6
6
  // @work-item-id WI-180
7
+ // @work-item-id WI-208
7
8
 
8
9
  import type { DoctorAgentScope, ScopedOutDiagnosticFinding } from "../../application/usecases/run-doctor-diagnostics.js";
9
10
  import type { DiagnosticReport } from "../../domain/diagnostic-report.js";
@@ -11,6 +12,7 @@ import type { DiagnosticReport } from "../../domain/diagnostic-report.js";
11
12
  export interface DiagnosticReportFormatterInput {
12
13
  readonly report: DiagnosticReport;
13
14
  readonly agent: DoctorAgentScope;
15
+ readonly installationMode: "project" | "personal";
14
16
  readonly scopedOutFindings: readonly ScopedOutDiagnosticFinding[];
15
17
  readonly phasegateVersion: string;
16
18
  readonly projectRoot: string;
@@ -26,7 +28,8 @@ export class DiagnosticReportFormatter {
26
28
  projectRoot: input.projectRoot,
27
29
  scope: {
28
30
  agent: input.agent,
29
- description: scopeDescription(input.agent),
31
+ installationMode: input.installationMode,
32
+ description: scopeDescription(input.agent, input.installationMode),
30
33
  },
31
34
  overallStatus: input.report.overallStatus,
32
35
  findings: input.report.findings.map((finding) => ({
@@ -60,7 +63,7 @@ export class DiagnosticReportFormatter {
60
63
  const lines = [
61
64
  `phasegate doctor v${input.phasegateVersion}`,
62
65
  `Project: ${input.projectRoot}`,
63
- `Scope: ${input.agent} (${scopeDescription(input.agent)})`,
66
+ `Scope: ${input.agent} / ${input.installationMode} (${scopeDescription(input.agent, input.installationMode)})`,
64
67
  "",
65
68
  ];
66
69
  for (const finding of input.report.findings) {
@@ -86,7 +89,12 @@ export class DiagnosticReportFormatter {
86
89
  }
87
90
  }
88
91
 
89
- function scopeDescription(agent: DoctorAgentScope): string {
92
+ function scopeDescription(agent: DoctorAgentScope, installationMode: "project" | "personal"): string {
93
+ if (installationMode === "personal") {
94
+ if (agent === "claude") return "Personal Claude Code sandbox; team/project Husky, CI, package, and Codex-only findings are not repair targets.";
95
+ if (agent === "codex") return "Personal Codex sandbox; team/project Husky, CI, package, and Claude-only findings are not repair targets.";
96
+ return "Personal sandbox diagnostics; team/project Husky, CI, and package findings are not repair targets.";
97
+ }
90
98
  if (agent === "claude") return "Claude Code and shared setup targets; Codex-only findings are not applicable.";
91
99
  if (agent === "codex") return "Codex and shared setup targets; Claude-only findings are not applicable.";
92
100
  return "Full setup diagnostics for Claude, Codex, and shared targets.";
@@ -171,7 +171,7 @@ Setup:
171
171
  scaffold-wi <unit|_cross> <story|issue|chore>
172
172
  Create docs/inception/{unit}/WI-XXX/description.md
173
173
  emit-agent-rules Print AGENTS.md / CLAUDE.md WI workflow rules block
174
- install Install phasegate managed files (--dry-run|--apply, --force)
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,6 +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, .gitignore, GitHub CLI, secrets, or CI setting writes.
629
+ With --agent claude, initializes .phasegate-local config/settings/skills and ignored .claude shims.
628
630
  --json Output machine-readable JSON
629
631
  --help, -h Show this help`,
630
632
  "setup:agent": `Usage: phasegate setup:agent [options]
@@ -2045,7 +2047,7 @@ async function main(): Promise<void> {
2045
2047
  }
2046
2048
 
2047
2049
  case "install": {
2048
- const KNOWN_INSTALL_FLAGS = ["--dry-run", "--apply", "--force", "--json", "--agent", "--skills", "--workflow", "--with-husky", "--with-ci"];
2050
+ const KNOWN_INSTALL_FLAGS = ["--dry-run", "--apply", "--force", "--json", "--agent", "--skills", "--workflow", "--with-husky", "--with-ci", "--personal"];
2049
2051
  const flagError = validateKnownFlags(args, KNOWN_INSTALL_FLAGS);
2050
2052
  if (flagError) {
2051
2053
  console.error(flagError);
@@ -2066,6 +2068,7 @@ async function main(): Promise<void> {
2066
2068
  }
2067
2069
  const includeClaude = agent === "claude" || agent === "both";
2068
2070
  const includeCodex = agent === "codex" || agent === "both";
2071
+ const personal = hasFlag(args, "--personal");
2069
2072
  const mod = createInstallationModule();
2070
2073
  const phasegateVersion = await getHarnessVersion(harnessRoot);
2071
2074
  const result = await mod.installHandler.execute({
@@ -2077,11 +2080,12 @@ async function main(): Promise<void> {
2077
2080
  force: hasFlag(args, "--force"),
2078
2081
  includeClaude,
2079
2082
  includeCodex,
2080
- includeHusky: true,
2081
- includeCi: true,
2083
+ includeHusky: personal ? false : true,
2084
+ includeCi: personal ? false : true,
2082
2085
  skillSet: skillSetRaw,
2083
2086
  workflow: parseWorkflowMode(workflowRaw),
2084
2087
  agent,
2088
+ personal,
2085
2089
  json,
2086
2090
  });
2087
2091
  console.log(result.stdout);
package/skills/README.md CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  `.agent/skills` は旧 setup 由来の互換パスです。新規導入では管理対象にしません。<!-- @work-item-id WI-157 -->
18
18
 
19
+ `phasegate install --personal` はチーム所有リポジトリでの個人評価用です。このモードでは `.claude/skills` / `.codex/skills` のリンクを作成せず、`.phasegate-local/config.json` と `.git/info/exclude` の local-only managed block だけを扱います。通常の共有導入が必要になった時点で `phasegate install --dry-run` / `phasegate install --apply` を使います。<!-- @work-item-id WI-207 -->
20
+
19
21
  ## 新しいスキルの追加
20
22
 
21
23
  新しいスキルを追加する場合は、この `skills` ディレクトリに直接追加してください。あわせて `docs/guide/skills-overview.md`、README の skill 数、必要なら `skills/phasegate-toolkit-guide/SKILL.md` の参照先を更新します。シンボリックリンクを通じて、対応エージェントから利用可能になります。<!-- @work-item-id WI-154 -->
@@ -35,6 +35,8 @@ 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/phasegate.config.json` | `install --personal` の local-only 設定確認 |
39
+ | git local exclude | `.git/info/exclude` | `install --personal` の repository-local ignore block 確認 |
38
40
  | Claude hooks | `.claude/settings.json` | managed hook JSON と user customization の確認 |
39
41
  | Codex hooks | `.codex/hooks.json` | managed hook JSON と Codex hook 配線確認 |
40
42
  | Husky scripts | `.husky/pre-commit`, `.husky/commit-msg`, `.husky/pre-push` | pre-commit backstop と bypass audit の確認 |
@@ -127,6 +129,7 @@ product-architect で Unit を作り、いくつかの logical_design を書い
127
129
  #### 観点 9: setup lifecycle と doctor finding
128
130
 
129
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 --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 -->
130
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 -->
131
134
  - `repairHint` がある mechanical finding → 原則として hint のコマンドを優先し、実行前に対象ファイルと manifest の差分を確認
132
135
  - manifest parse error → `.phasegate/manifest.json` を手で修復する前に backup / uninstall / reinstall の選択肢を提示
@@ -130,6 +130,8 @@ 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 --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
+
133
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 -->
134
136
 
135
137
  Claude-only / Codex-only setup の確認では、full `phasegate doctor` と scoped doctor を区別する。ユーザーが `setup:agent --agent claude` を選んだ場合は `phasegate doctor --agent claude --json` を優先し、`scopedOutFindings` の Codex finding は「未選択 agent の not-applicable 情報」であり修復対象ではないと説明する。`repairHint: null` / `suggestedSkill: null` は意図的な抑制で、`currentScopeRepairTarget: false` と `repairModeApplicability: "only-if-agent-selected"` は `repairMode` が current scope の修復指示ではないという印である。full doctor は両 agent を導入したい場合の診断として扱う。<!-- @work-item-id WI-178, WI-179, WI-180 -->