phasegate 0.145.3 → 0.146.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.145.4] - 2026-05-11
11
+
12
+ ### Added
13
+
14
+ - **WI-148 — manifest-driven reconcile + init deprecation** — `phasegate reconcile --dry-run|--apply [--force] [--json]` を追加し、`.phasegate/manifest.json` に記録済みの PhaseGate managed files を現行 bundled template / package version に追従できるようにした。
15
+ - `merged` entries は PhaseGate managed portion だけを更新し、user scripts / hooks / dependencies は保持する。
16
+ - `created` entries は hash が manifest と一致する場合のみ template に追従し、user 改変ありの場合は `--force` 無しで refuse、force 時は `.phasegate/backups/reconcile-*/` に退避してから上書きする。
17
+ - manifest に無い現行 deploy target は install と同じく追加し、apply 後は manifest の version/hash を更新する。
18
+ - `update-skills` は互換 alias として `reconcile` に委譲し、`init` 実行時は v1.0 削除予定の deprecation warning を表示する。
19
+
10
20
  ## [0.145.3] - 2026-05-11
11
21
 
12
22
  ### Added
package/README.ja.md CHANGED
@@ -116,6 +116,15 @@ npx phasegate uninstall --apply
116
116
 
117
117
  `uninstall` は manifest を読んで、PhaseGate が作成したファイルを削除し、merge した Claude / Codex / Husky / `package.json` から PhaseGate 管理部分だけを取り除きます。ユーザーの既存設定は保持し、manifest は `.phasegate/` 配下に履歴として archive します。
118
118
 
119
+ PhaseGate をアップグレードした後は、reconcile で既存の managed files を現在の bundled template に追従できます。
120
+
121
+ ```sh
122
+ npx phasegate reconcile --dry-run
123
+ npx phasegate reconcile --apply
124
+ ```
125
+
126
+ `reconcile` は PhaseGate 管理部分だけを更新し、ユーザーの hook / script / dependency は保持します。新しい deploy target が追加されていれば install と同じく追加し、`.phasegate/manifest.json` の version / hash も更新します。install 後に user 改変された managed file は `--force` 無しでは refuse し、force 時は `.phasegate/backups/reconcile-<timestamp>/` に退避してから上書きします。
127
+
119
128
  ### Codex CLI を使う場合
120
129
 
121
130
  ```bash
package/README.md CHANGED
@@ -109,6 +109,15 @@ npx phasegate uninstall --apply
109
109
 
110
110
  `uninstall` uses the manifest to delete created files and remove only PhaseGate-managed portions from merged Claude/Codex, Husky, and `package.json` files. User content is preserved, and the manifest is archived under `.phasegate/`.
111
111
 
112
+ When you upgrade PhaseGate, reconcile existing managed files with the current bundled templates:
113
+
114
+ ```sh
115
+ npx phasegate reconcile --dry-run
116
+ npx phasegate reconcile --apply
117
+ ```
118
+
119
+ `reconcile` updates only PhaseGate-managed portions, keeps user content, adds newly introduced managed targets, and refreshes `.phasegate/manifest.json` with the current version and hashes. If a managed file was edited after install, `reconcile --apply` refuses that entry until you rerun with `--force`; PhaseGate writes a backup under `.phasegate/backups/reconcile-<timestamp>/`.
120
+
112
121
  ### Codex CLI
113
122
 
114
123
  ```bash
@@ -73,6 +73,17 @@ npx phasegate uninstall --apply
73
73
 
74
74
  `uninstall` reads `.phasegate/manifest.json`, deletes files that PhaseGate created, removes only PhaseGate-managed portions from merged JSON, Husky, and `package.json` files, and archives the manifest as `.phasegate/uninstalled-<timestamp>.json`. If a managed file was modified after install, `uninstall --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/uninstall-<timestamp>/`.
75
75
 
76
+ After upgrading PhaseGate, reconcile existing managed files with the bundled templates from the new version:
77
+
78
+ ```bash
79
+ npx phasegate reconcile --dry-run
80
+ npx phasegate reconcile --apply
81
+ ```
82
+
83
+ `reconcile` updates PhaseGate-managed portions, preserves user content, adds newly introduced managed targets, and refreshes `.phasegate/manifest.json` with current version/hash metadata. If a managed file was edited after install, `reconcile --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/reconcile-<timestamp>/`.
84
+
85
+ `phasegate update-skills` remains available as a compatibility alias for `phasegate reconcile`.
86
+
76
87
  ### Manual Setup Pieces
77
88
 
78
89
  If you do not use `init` or `install`, copy the design principle documents manually:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.145.3",
3
+ "version": "0.146.0",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -0,0 +1,64 @@
1
+ // @unit installation
2
+ // @layer application
3
+ // @work-item-id WI-143
4
+
5
+ import { join, relative, sep } from "node:path";
6
+ import { DiagnosticFinding } from "../../domain/diagnostic-finding.js";
7
+ import type { HeuristicCheck } from "../../domain/ports/heuristic-check.js";
8
+ import type { FileInspectorPort } from "../ports/file-inspector-port.js";
9
+
10
+ interface PhasegateConfigProbe {
11
+ readonly quickMode?: {
12
+ readonly relaxedGates?: readonly string[];
13
+ };
14
+ }
15
+
16
+ export class WiWorkflowDriftCheck implements HeuristicCheck {
17
+ readonly checkId = "wi-workflow-drift" as const;
18
+
19
+ async run(projectRoot: string, inspector: FileInspectorPort): Promise<DiagnosticFinding | null> {
20
+ const inceptionRoot = join(projectRoot, "docs", "inception");
21
+ const files = await inspector.listFiles(inceptionRoot);
22
+ const relativeFiles = files.map((file) => toPosix(relative(projectRoot, file)));
23
+ const workItemCount = relativeFiles.filter(isWorkItemDescription).length;
24
+ const adHocPlans = relativeFiles.filter(isAdHocPlan);
25
+ const hasPhaseGateRelaxed = await this.hasRelaxedPhaseGate(projectRoot, inspector);
26
+
27
+ if (workItemCount > 0 || adHocPlans.length === 0) {
28
+ return null;
29
+ }
30
+
31
+ const message = hasPhaseGateRelaxed
32
+ ? `WI-first drift detected: 0 WI directories, ${adHocPlans.length} ad-hoc plan file(s), and quickMode.relaxedGates includes phase-gate.`
33
+ : `WI-first drift detected: 0 WI directories and ${adHocPlans.length} ad-hoc plan file(s).`;
34
+
35
+ return DiagnosticFinding.create({
36
+ checkId: this.checkId,
37
+ severity: "red",
38
+ target: "docs/inception",
39
+ message,
40
+ repairMode: "mechanical",
41
+ repairHint: "phasegate migrate work-items --apply",
42
+ suggestedSkill: null,
43
+ });
44
+ }
45
+
46
+ private async hasRelaxedPhaseGate(projectRoot: string, inspector: FileInspectorPort): Promise<boolean> {
47
+ const config = await inspector.readJson<PhasegateConfigProbe>(join(projectRoot, "phasegate.config.json"));
48
+ return config?.quickMode?.relaxedGates?.includes("phase-gate") ?? false;
49
+ }
50
+ }
51
+
52
+ function toPosix(path: string): string {
53
+ return path.split(sep).join("/");
54
+ }
55
+
56
+ function isWorkItemDescription(path: string): boolean {
57
+ return /^docs\/inception\/(?:_cross|[^/]+)\/WI-\d{3}\/description\.md$/.test(path);
58
+ }
59
+
60
+ function isAdHocPlan(path: string): boolean {
61
+ if (!path.startsWith("docs/inception/")) return false;
62
+ if (/\/WI-\d{3}\//.test(path)) return false;
63
+ return path.includes("/codding_plan/") || path.endsWith("_plan.md");
64
+ }
@@ -0,0 +1,437 @@
1
+ // @unit installation
2
+ // @layer application
3
+ // @work-item-id WI-148
4
+
5
+ import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, symlink, writeFile } from "node:fs/promises";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
+ import { DeploymentEntry } from "../../domain/deployment-entry.js";
8
+ import { DeploymentManifest } from "../../domain/deployment-manifest.js";
9
+ import type { ManagedBlockInput } from "../../domain/managed-block.js";
10
+ import type { RepairMode } from "../../domain/repair-mode.js";
11
+ import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
12
+ import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
13
+
14
+ type ReconcileAction = "missing-manifest" | "update" | "add" | "link" | "skip" | "refuse";
15
+ type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "symlink" | "unknown";
16
+
17
+ export interface ReconcilePlanItem {
18
+ readonly path: string;
19
+ readonly action: ReconcileAction;
20
+ readonly repairMode: RepairMode;
21
+ readonly strategy: StrategyType;
22
+ readonly changed: boolean;
23
+ readonly summary: string;
24
+ readonly diff: string;
25
+ readonly skillHint: string | null;
26
+ }
27
+
28
+ export interface RunReconcileInput {
29
+ readonly projectRoot: string;
30
+ readonly harnessRoot: string;
31
+ readonly phasegateVersion: string;
32
+ readonly dryRun: boolean;
33
+ readonly apply: boolean;
34
+ readonly force: boolean;
35
+ }
36
+
37
+ export interface RunReconcileResult {
38
+ readonly plan: readonly ReconcilePlanItem[];
39
+ readonly refused: readonly ReconcilePlanItem[];
40
+ readonly changed: readonly ReconcilePlanItem[];
41
+ readonly backupDir: string | null;
42
+ }
43
+
44
+ interface ReconcileTarget {
45
+ readonly path: string;
46
+ readonly strategy: StrategyType;
47
+ readonly templatePath?: string;
48
+ readonly executable?: boolean;
49
+ readonly block?: ManagedBlockInput;
50
+ }
51
+
52
+ const SKILL_HINT = "invoke /phasegate-config-doctor";
53
+ const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
54
+ const SHELL_END = "# === phasegate managed (END) ===";
55
+
56
+ function isRecord(value: unknown): value is Record<string, unknown> {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+
60
+ async function exists(path: string): Promise<boolean> {
61
+ try {
62
+ await access(path);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ async function readTextOrNull(path: string): Promise<string | null> {
70
+ try {
71
+ return await readFile(path, "utf8");
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function normalizeJsonEntry(value: unknown): string {
78
+ return JSON.stringify(value);
79
+ }
80
+
81
+ function escapeRegExp(value: string): string {
82
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
+ }
84
+
85
+ function phasegateOwnedJsonEntry(entry: unknown): boolean {
86
+ return normalizeJsonEntry(entry).includes("phasegate");
87
+ }
88
+
89
+ function replaceHookArrays(existing: unknown, incoming: unknown): unknown[] {
90
+ const userEntries = (Array.isArray(existing) ? existing : []).filter((entry) => !phasegateOwnedJsonEntry(entry));
91
+ const seen = new Set(userEntries.map((entry) => normalizeJsonEntry(entry)));
92
+ const result = [...userEntries];
93
+ for (const entry of Array.isArray(incoming) ? incoming : []) {
94
+ const key = normalizeJsonEntry(entry);
95
+ if (!seen.has(key)) {
96
+ result.push(entry);
97
+ seen.add(key);
98
+ }
99
+ }
100
+ return result;
101
+ }
102
+
103
+ function reconcileJsonObject(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
104
+ const result: Record<string, unknown> = { ...existing };
105
+ const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
106
+ const incomingHooks = isRecord(incoming.hooks) ? incoming.hooks : {};
107
+ const nextHooks: Record<string, unknown> = {};
108
+ const events = new Set([...Object.keys(existingHooks), ...Object.keys(incomingHooks)]);
109
+ for (const event of events) {
110
+ const entries = replaceHookArrays(existingHooks[event], incomingHooks[event]);
111
+ if (entries.length > 0) nextHooks[event] = entries;
112
+ }
113
+ if (Object.keys(nextHooks).length > 0) result.hooks = nextHooks;
114
+ else delete result.hooks;
115
+
116
+ const existingPermissions = isRecord(existing.permissions) ? existing.permissions : {};
117
+ const incomingPermissions = isRecord(incoming.permissions) ? incoming.permissions : {};
118
+ const incomingDeny = Array.isArray(incomingPermissions.deny) ? incomingPermissions.deny : [];
119
+ if (Object.keys(existingPermissions).length > 0 || Object.keys(incomingPermissions).length > 0) {
120
+ const existingDeny = Array.isArray(existingPermissions.deny) ? existingPermissions.deny : [];
121
+ const preservedDeny = existingDeny.filter((entry) => typeof entry !== "string" || !incomingDeny.includes(entry));
122
+ result.permissions = {
123
+ ...existingPermissions,
124
+ ...incomingPermissions,
125
+ deny: [...new Set([...preservedDeny, ...incomingDeny])],
126
+ };
127
+ }
128
+ return result;
129
+ }
130
+
131
+ function reconcileShell(existing: string | null, incoming: string): string {
132
+ const incomingBlock = `${SHELL_BEGIN}\n${incoming.trim()}\n${SHELL_END}`;
133
+ if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
134
+ const pattern = new RegExp(`${escapeRegExp(SHELL_BEGIN)}[\\s\\S]*?${escapeRegExp(SHELL_END)}`);
135
+ if (pattern.test(existing)) return existing.replace(pattern, incomingBlock).replace(/\s*$/, "\n");
136
+ return `${existing.replace(/\s*$/, "\n\n")}${incomingBlock}\n`;
137
+ }
138
+
139
+ function reconcilePackageJson(existing: Record<string, unknown>, version: string): Record<string, unknown> {
140
+ const devDependencies = isRecord(existing.devDependencies) ? existing.devDependencies : {};
141
+ const scripts = isRecord(existing.scripts) ? existing.scripts : {};
142
+ return {
143
+ ...existing,
144
+ scripts: {
145
+ ...scripts,
146
+ "phasegate:lint": "phasegate lint",
147
+ "phasegate:check-ready": "phasegate phasegate:check-ready",
148
+ "phasegate:doctor": "phasegate doctor",
149
+ },
150
+ devDependencies: {
151
+ ...devDependencies,
152
+ phasegate: `^${version}`,
153
+ },
154
+ };
155
+ }
156
+
157
+ export class RunReconcileUseCase {
158
+ constructor(
159
+ private readonly manifestRepository: ManifestRepositoryPort,
160
+ private readonly hashCalculator: HashCalculatorPort,
161
+ ) {}
162
+
163
+ async execute(input: RunReconcileInput): Promise<RunReconcileResult> {
164
+ const manifest = await this.manifestRepository.load(input.projectRoot);
165
+ if (manifest === null) {
166
+ const item = this.item(
167
+ ".phasegate/manifest.json",
168
+ "missing-manifest",
169
+ "manual",
170
+ "unknown",
171
+ false,
172
+ "manifest missing; run phasegate install before reconcile",
173
+ "manual setup required",
174
+ SKILL_HINT,
175
+ );
176
+ return { plan: [item], refused: [], changed: [], backupDir: null };
177
+ }
178
+
179
+ const targets = this.createTargets();
180
+ const targetsByPath = new Map(targets.map((target) => [target.path, target]));
181
+ let nextManifest = DeploymentManifest.reconstitute({
182
+ version: input.phasegateVersion,
183
+ installedAt: manifest.installedAt,
184
+ entries: manifest.entries,
185
+ });
186
+ const plan: ReconcilePlanItem[] = [];
187
+ const refused: ReconcilePlanItem[] = [];
188
+ const changed: ReconcilePlanItem[] = [];
189
+ const backupStamp = `reconcile-${new Date().toISOString().replace(/[:.]/g, "-")}`;
190
+ let backupDir: string | null = null;
191
+
192
+ const outcomes: Array<{
193
+ readonly item: ReconcilePlanItem;
194
+ readonly needsBackup: boolean;
195
+ readonly apply: () => Promise<string | null>;
196
+ }> = [];
197
+
198
+ for (const entry of manifest.entries) {
199
+ const target = targetsByPath.get(entry.path);
200
+ if (target === undefined) {
201
+ const item = this.item(entry.path, "skip", "manual", "unknown", false, `${entry.path}: no bundled template`, "manual review required", SKILL_HINT);
202
+ plan.push(item);
203
+ continue;
204
+ }
205
+ const outcome = await this.planManagedEntry(input, entry, target);
206
+ outcomes.push(outcome);
207
+ plan.push(outcome.item);
208
+ if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
209
+ refused.push({ ...outcome.item, action: "refuse" });
210
+ }
211
+ }
212
+
213
+ for (const target of targets) {
214
+ if (manifest.findEntry(target.path) !== null) continue;
215
+ const outcome = await this.planMissingTarget(input, target);
216
+ outcomes.push(outcome);
217
+ plan.push(outcome.item);
218
+ if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
219
+ refused.push({ ...outcome.item, action: "refuse" });
220
+ }
221
+ }
222
+
223
+ if (!input.apply || refused.length > 0) {
224
+ return { plan, refused, changed, backupDir: null };
225
+ }
226
+
227
+ for (const outcome of outcomes) {
228
+ if (!outcome.item.changed) continue;
229
+ if (outcome.needsBackup) {
230
+ backupDir ??= join(input.projectRoot, ".phasegate", "backups", backupStamp);
231
+ await this.backup(input.projectRoot, outcome.item.path, backupDir);
232
+ }
233
+ const hashContent = await outcome.apply();
234
+ changed.push(outcome.item);
235
+ if (hashContent !== null) {
236
+ const mode = outcome.item.strategy === "symlink" ? "symlink" : outcome.item.action === "add" ? "created" : (manifest.findEntry(outcome.item.path)?.mode ?? "merged");
237
+ nextManifest = nextManifest.addEntry(
238
+ DeploymentEntry.create({
239
+ path: outcome.item.path,
240
+ mode,
241
+ block: mode === "merged" ? this.managedBlockFor(outcome.item.path, outcome.item.strategy) : null,
242
+ hash: this.hashCalculator.compute(hashContent),
243
+ deployedAt: new Date().toISOString(),
244
+ }),
245
+ );
246
+ }
247
+ }
248
+
249
+ if (changed.length > 0 || manifest.version !== input.phasegateVersion) {
250
+ await this.manifestRepository.save(input.projectRoot, nextManifest);
251
+ }
252
+ return { plan, refused, changed, backupDir };
253
+ }
254
+
255
+ private async planManagedEntry(input: RunReconcileInput, entry: DeploymentEntry, target: ReconcileTarget) {
256
+ if (target.strategy === "symlink") return this.planSymlink(input.projectRoot, target.path);
257
+ const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
258
+ const before = await readTextOrNull(absolutePath);
259
+ if (before === null) return this.planMissingTarget(input, target);
260
+ const currentHash = this.hashCalculator.compute(before);
261
+ const matchesManifest = currentHash.equals(entry.hash);
262
+ const template = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
263
+ const next = entry.mode === "created" && target.strategy !== "package-json"
264
+ ? template
265
+ : this.reconcileContent(target, before, template, input.phasegateVersion);
266
+ const changed = before !== next;
267
+ const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
268
+ return {
269
+ item: this.item(
270
+ entry.path,
271
+ changed ? "update" : "skip",
272
+ repairMode,
273
+ target.strategy,
274
+ changed,
275
+ changed ? `${entry.path}: update managed portion` : `${entry.path}: already up to date`,
276
+ this.diffSummary(before, next),
277
+ repairMode === "ai-assisted" ? SKILL_HINT : null,
278
+ ),
279
+ needsBackup: !matchesManifest || input.force,
280
+ apply: async () => {
281
+ await mkdir(dirname(absolutePath), { recursive: true });
282
+ await writeFile(absolutePath, next, "utf8");
283
+ if (target.executable) await chmod(absolutePath, 0o755);
284
+ return next;
285
+ },
286
+ };
287
+ }
288
+
289
+ private async planMissingTarget(input: RunReconcileInput, target: ReconcileTarget) {
290
+ if (target.strategy === "symlink") return this.planSymlink(input.projectRoot, target.path);
291
+ const absolutePath = this.resolveProjectPath(input.projectRoot, target.path);
292
+ const before = await readTextOrNull(absolutePath);
293
+ const template = target.templatePath ? await readFile(join(input.harnessRoot, target.templatePath), "utf8") : "";
294
+ const next = before === null && target.strategy !== "package-json"
295
+ ? template
296
+ : this.reconcileContent(target, before, template, input.phasegateVersion);
297
+ const changed = before !== next;
298
+ const repairMode: RepairMode = target.strategy === "shell" && before !== null && !before.includes(SHELL_BEGIN) ? "ai-assisted" : "mechanical";
299
+ return {
300
+ item: this.item(
301
+ target.path,
302
+ before === null ? "add" : changed ? "update" : "skip",
303
+ repairMode,
304
+ target.strategy,
305
+ changed,
306
+ changed ? `${target.path}: add missing managed target` : `${target.path}: already up to date`,
307
+ this.diffSummary(before, next),
308
+ repairMode === "ai-assisted" ? SKILL_HINT : null,
309
+ ),
310
+ needsBackup: before !== null,
311
+ apply: async () => {
312
+ await mkdir(dirname(absolutePath), { recursive: true });
313
+ await writeFile(absolutePath, next, "utf8");
314
+ if (target.executable) await chmod(absolutePath, 0o755);
315
+ return next;
316
+ },
317
+ };
318
+ }
319
+
320
+ private async planSymlink(projectRoot: string, relativePath: string) {
321
+ const absolutePath = this.resolveProjectPath(projectRoot, relativePath);
322
+ try {
323
+ const stat = await lstat(absolutePath);
324
+ if (stat.isSymbolicLink() && (await readlink(absolutePath)) === "../skills") {
325
+ return {
326
+ item: this.item(relativePath, "skip", "mechanical", "symlink", false, `${relativePath}: already linked`, "no changes", null),
327
+ needsBackup: false,
328
+ apply: async () => "../skills",
329
+ };
330
+ }
331
+ return {
332
+ item: this.item(relativePath, "skip", "manual", "symlink", false, `${relativePath}: existing non-phasegate path requires manual review`, "manual review required", SKILL_HINT),
333
+ needsBackup: false,
334
+ apply: async () => null,
335
+ };
336
+ } catch {
337
+ return {
338
+ item: this.item(relativePath, "link", "mechanical", "symlink", true, `${relativePath}: create symlink`, "+ symlink ../skills", null),
339
+ needsBackup: false,
340
+ apply: async () => {
341
+ await mkdir(join(projectRoot, "skills"), { recursive: true });
342
+ await mkdir(dirname(absolutePath), { recursive: true });
343
+ await symlink("../skills", absolutePath, process.platform === "win32" ? "junction" : "dir");
344
+ return "../skills";
345
+ },
346
+ };
347
+ }
348
+ }
349
+
350
+ private reconcileContent(target: ReconcileTarget, before: string | null, template: string, version: string): string {
351
+ if (target.strategy === "yaml-add") return template;
352
+ if (target.strategy === "shell") return reconcileShell(before, template);
353
+ if (target.strategy === "package-json") {
354
+ const existing = before === null ? {} : (JSON.parse(before) as unknown);
355
+ return `${JSON.stringify(reconcilePackageJson(isRecord(existing) ? existing : {}, version), null, 2)}\n`;
356
+ }
357
+ const existing = before === null ? {} : (JSON.parse(before) as unknown);
358
+ const incoming = JSON.parse(template) as unknown;
359
+ return `${JSON.stringify(reconcileJsonObject(isRecord(existing) ? existing : {}, isRecord(incoming) ? incoming : {}), null, 2)}\n`;
360
+ }
361
+
362
+ private createTargets(): readonly ReconcileTarget[] {
363
+ return [
364
+ { path: ".claude/settings.json", strategy: "json", templatePath: "templates/.claude/settings.json" },
365
+ { path: ".codex/hooks.json", strategy: "json", templatePath: "templates/.codex/hooks.json" },
366
+ {
367
+ path: ".husky/pre-commit",
368
+ strategy: "shell",
369
+ templatePath: "docs/templates/hooks/pre-commit",
370
+ executable: true,
371
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-commit managed block" },
372
+ },
373
+ {
374
+ path: ".husky/commit-msg",
375
+ strategy: "shell",
376
+ templatePath: "docs/templates/hooks/commit-msg",
377
+ executable: true,
378
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate commit-msg managed block" },
379
+ },
380
+ {
381
+ path: ".husky/pre-push",
382
+ strategy: "shell",
383
+ templatePath: "docs/templates/hooks/pre-push",
384
+ executable: true,
385
+ block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-push managed block" },
386
+ },
387
+ { path: ".github/workflows/phasegate-aidlc-gate.yml", strategy: "yaml-add", templatePath: "docs/templates/ci/aidlc-gate.yml" },
388
+ { path: "package.json", strategy: "package-json", templatePath: "package.json" },
389
+ { path: ".claude/skills", strategy: "symlink" },
390
+ { path: ".codex/skills", strategy: "symlink" },
391
+ ];
392
+ }
393
+
394
+ private async backup(projectRoot: string, relativePath: string, backupDir: string): Promise<void> {
395
+ const source = this.resolveProjectPath(projectRoot, relativePath);
396
+ if (!(await exists(source))) return;
397
+ const target = join(backupDir, relativePath);
398
+ await mkdir(dirname(target), { recursive: true });
399
+ await copyFile(source, target);
400
+ }
401
+
402
+ private managedBlockFor(path: string, strategy: StrategyType): ManagedBlockInput | null {
403
+ if (strategy === "shell") return { start: SHELL_BEGIN, end: SHELL_END, content: `phasegate ${path} managed block` };
404
+ if (strategy === "json" || strategy === "package-json") {
405
+ return { start: "phasegate structured merge", end: "phasegate structured merge", content: `${strategy}:${path}` };
406
+ }
407
+ return null;
408
+ }
409
+
410
+ private resolveProjectPath(projectRoot: string, relativePath: string): string {
411
+ const absolutePath = resolve(projectRoot, relativePath);
412
+ const root = resolve(projectRoot);
413
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}/`) && relative(root, absolutePath).startsWith("..")) {
414
+ throw new Error(`Manifest entry escapes project root: ${relativePath}`);
415
+ }
416
+ return absolutePath;
417
+ }
418
+
419
+ private diffSummary(before: string | null, next: string): string {
420
+ if (before === next) return "no changes";
421
+ if (before === null) return `+ ${next.split(/\r?\n/).filter(Boolean).length} lines`;
422
+ return `~ ${before.length} bytes -> ${next.length} bytes`;
423
+ }
424
+
425
+ private item(
426
+ path: string,
427
+ action: ReconcileAction,
428
+ repairMode: RepairMode,
429
+ strategy: StrategyType,
430
+ changed: boolean,
431
+ summary: string,
432
+ diff: string,
433
+ skillHint: string | null,
434
+ ): ReconcilePlanItem {
435
+ return { path, action, repairMode, strategy, changed, summary, diff, skillHint };
436
+ }
437
+ }
@@ -1,6 +1,7 @@
1
1
  // @unit installation
2
2
  // @layer presentation
3
3
  // @work-item-id WI-145
4
+ // @work-item-id WI-148
4
5
 
5
6
  import { ClaudeHookMissingCheck } from "./application/checks/claude-hook-missing-check.js";
6
7
  import { ClaudeSkillsSymlinkCheck } from "./application/checks/claude-skills-symlink-check.js";
@@ -11,7 +12,9 @@ import { HuskyCommitMsgMissingCheck } from "./application/checks/husky-commit-ms
11
12
  import { HuskyPreCommitMissingCheck } from "./application/checks/husky-pre-commit-missing-check.js";
12
13
  import { HuskyPrePushMissingCheck } from "./application/checks/husky-pre-push-missing-check.js";
13
14
  import { PackageJsonDevdepMissingCheck } from "./application/checks/package-json-devdep-missing-check.js";
15
+ import { WiWorkflowDriftCheck } from "./application/checks/wi-workflow-drift-check.js";
14
16
  import { RunInstallUseCase } from "./application/usecases/run-install.js";
17
+ import { RunReconcileUseCase } from "./application/usecases/run-reconcile.js";
15
18
  import { RunUninstallUseCase } from "./application/usecases/run-uninstall.js";
16
19
  import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
17
20
  import type { MergeStrategy } from "./domain/ports/merge-strategy.js";
@@ -22,6 +25,7 @@ import { NodeCryptoHashAdapter } from "./infrastructure/adapters/node-crypto-has
22
25
  import { NodeFsFileInspectorAdapter } from "./infrastructure/adapters/node-fs-file-inspector-adapter.js";
23
26
  import { DoctorHandler } from "./presentation/cli/doctor-handler.js";
24
27
  import { InstallHandler } from "./presentation/cli/install-handler.js";
28
+ import { ReconcileHandler } from "./presentation/cli/reconcile-handler.js";
25
29
  import { UninstallHandler } from "./presentation/cli/uninstall-handler.js";
26
30
 
27
31
  type FutureInstallationStrategyPorts = {
@@ -46,17 +50,21 @@ export function createInstallationModule() {
46
50
  new PackageJsonDevdepMissingCheck(),
47
51
  new ClaudeSkillsSymlinkCheck(),
48
52
  new CodexSkillsSymlinkCheck(),
53
+ new WiWorkflowDriftCheck(),
49
54
  ];
50
55
  const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(checks, inspector, manifestRepository);
51
56
  const runInstallUseCase = new RunInstallUseCase(manifestRepository, hashCalculator);
57
+ const runReconcileUseCase = new RunReconcileUseCase(manifestRepository, hashCalculator);
52
58
  const runUninstallUseCase = new RunUninstallUseCase(manifestRepository, hashCalculator);
53
59
  return {
54
60
  manifestRepository,
55
61
  runDoctorDiagnosticsUseCase,
56
62
  runInstallUseCase,
63
+ runReconcileUseCase,
57
64
  runUninstallUseCase,
58
65
  doctorHandler: new DoctorHandler(runDoctorDiagnosticsUseCase),
59
66
  installHandler: new InstallHandler(runInstallUseCase),
67
+ reconcileHandler: new ReconcileHandler(runReconcileUseCase),
60
68
  uninstallHandler: new UninstallHandler(runUninstallUseCase),
61
69
  futureInstallationStrategyPorts,
62
70
  };
@@ -12,6 +12,7 @@ export const CHECK_IDS = [
12
12
  "package-json-devdep-missing",
13
13
  "claude-skills-symlink",
14
14
  "codex-skills-symlink",
15
+ "wi-workflow-drift",
15
16
  ] as const;
16
17
 
17
18
  export type CheckId = (typeof CHECK_IDS)[number];
@@ -45,7 +45,16 @@ export class NodeFsFileInspectorAdapter implements FileInspectorPort {
45
45
  async listFiles(absolutePath: string): Promise<string[]> {
46
46
  try {
47
47
  const entries = await readdir(absolutePath, { withFileTypes: true });
48
- return entries.filter((entry) => entry.isFile()).map((entry) => join(absolutePath, entry.name));
48
+ const files: string[] = [];
49
+ for (const entry of entries) {
50
+ const entryPath = join(absolutePath, entry.name);
51
+ if (entry.isFile()) {
52
+ files.push(entryPath);
53
+ } else if (entry.isDirectory()) {
54
+ files.push(...(await this.listFiles(entryPath)));
55
+ }
56
+ }
57
+ return files;
49
58
  } catch {
50
59
  return [];
51
60
  }
@@ -0,0 +1,50 @@
1
+ // @unit installation
2
+ // @layer presentation
3
+ // @work-item-id WI-148
4
+
5
+ import type { RunReconcileUseCase } from "../../application/usecases/run-reconcile.js";
6
+
7
+ export interface ReconcileHandlerInput {
8
+ readonly projectRoot: string;
9
+ readonly harnessRoot: string;
10
+ readonly phasegateVersion: string;
11
+ readonly dryRun: boolean;
12
+ readonly apply: boolean;
13
+ readonly force: boolean;
14
+ readonly json: boolean;
15
+ }
16
+
17
+ export interface ReconcileHandlerResult {
18
+ readonly stdout: string;
19
+ readonly exitCode: number;
20
+ }
21
+
22
+ export class ReconcileHandler {
23
+ constructor(private readonly useCase: RunReconcileUseCase) {}
24
+
25
+ async execute(input: ReconcileHandlerInput): Promise<ReconcileHandlerResult> {
26
+ const result = await this.useCase.execute(input);
27
+ if (input.json) {
28
+ return {
29
+ stdout: JSON.stringify(result, null, 2),
30
+ exitCode: result.refused.length > 0 ? 1 : 0,
31
+ };
32
+ }
33
+ const lines = [
34
+ input.apply ? "phasegate reconcile apply" : "phasegate reconcile dry-run",
35
+ ...result.plan.map((item) => {
36
+ const hint = item.skillHint ? `; hint: ${item.skillHint}` : "";
37
+ return `- ${item.path}: ${item.action} (${item.repairMode}, ${item.strategy}); diff: ${item.diff}${hint}`;
38
+ }),
39
+ ];
40
+ if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
41
+ if (result.refused.length > 0) {
42
+ lines.push("");
43
+ lines.push("Refused ai-assisted/manual targets. Re-run with --force after reviewing the hint.");
44
+ }
45
+ return {
46
+ stdout: lines.join("\n"),
47
+ exitCode: result.refused.length > 0 ? 1 : 0,
48
+ };
49
+ }
50
+ }
@@ -8,7 +8,13 @@
8
8
  * 起動時に config-foundation で設定を解決し、他Unit に注入する(Cross-unit wiring)。
9
9
  */
10
10
 
11
- import { access, readFile as fsReadFile, readlink as fsReadlink, writeFile as fsWriteFile } from "node:fs/promises";
11
+ import {
12
+ mkdir as fsMkdir,
13
+ readFile as fsReadFile,
14
+ readdir as fsReaddir,
15
+ readlink as fsReadlink,
16
+ writeFile as fsWriteFile,
17
+ } from "node:fs/promises";
12
18
  import { dirname, join, resolve } from "node:path";
13
19
  import { createAdrFoundationModule } from "./adr-foundation/composition-root.js";
14
20
  import { createBiomeAstEngineModule } from "./biome-ast-engine/composition-root.js";
@@ -52,7 +58,6 @@ import {
52
58
  deployHuskyPrePushHook,
53
59
  deploySkills,
54
60
  getCategoryForSkill,
55
- getDeployedVersion,
56
61
  getHarnessVersion,
57
62
  initHarnessConfig,
58
63
  } from "./setup/skill-deployer.js";
@@ -78,15 +83,6 @@ function toTraceabilityModelOptions(resolvedConfig: HarnessConfigV2 | undefined)
78
83
  : undefined;
79
84
  }
80
85
 
81
- async function pathExists(path: string): Promise<boolean> {
82
- try {
83
- await access(path);
84
- return true;
85
- } catch {
86
- return false;
87
- }
88
- }
89
-
90
86
  interface PackageJsonDocument {
91
87
  readonly [key: string]: unknown;
92
88
  readonly dependencies?: unknown;
@@ -148,12 +144,15 @@ Usage: phasegate <command> [options]
148
144
  Setup:
149
145
  init Initialize project: deploy skills + design docs + phasegate.config.json
150
146
  (--name <project-name>, --preset <full|standard|minimal|custom>,
151
- --skills <core|all>, --agent <claude|codex|both>, --with-husky, --with-ci, --yes)
152
- update-skills Re-deploy skills from current harness version
147
+ --skills <core|all>, --agent <claude|codex|both>, --workflow <standard|strict>,
148
+ --with-husky, --with-ci, --yes)
149
+ update-skills Alias for reconcile (kept for compatibility)
153
150
  doctor Diagnose silent installation failures (--json, --strict, --report-out <path>)
151
+ scaffold-wi <unit> <type> Create docs/inception/{unit}/WI-XXX/description.md
152
+ emit-agent-rules Print AGENTS.md / CLAUDE.md WI workflow rules block
154
153
  install Install phasegate managed files (--dry-run|--apply, --force)
155
154
  uninstall Uninstall phasegate managed files (--dry-run|--apply, --force)
156
- reconcile Reconcile phasegate managed files (stub until WI-148)
155
+ reconcile Reconcile phasegate managed files (--dry-run|--apply, --force)
157
156
 
158
157
  Commands:
159
158
  enable-feature <name> Enable a harness feature
@@ -242,6 +241,103 @@ function hasFlag(args: readonly string[], flag: string): boolean {
242
241
  return args.includes(flag);
243
242
  }
244
243
 
244
+ type WorkflowMode = "standard" | "strict";
245
+ type ScaffoldWorkItemType = "story" | "issue" | "chore";
246
+
247
+ function parseWorkflowMode(value: string | undefined): WorkflowMode {
248
+ return value === "strict" ? "strict" : "standard";
249
+ }
250
+
251
+ function parseScaffoldWorkItemType(value: string | undefined): ScaffoldWorkItemType | null {
252
+ if (value === "story" || value === "issue" || value === "chore") return value;
253
+ return null;
254
+ }
255
+
256
+ function emitAgentRulesBlock(): string {
257
+ return [
258
+ "## PhaseGate WI Workflow (auto-generated; do not edit by hand)",
259
+ "- All plans/designs/implementations require a WI directory first.",
260
+ "- Path: `docs/inception/{unit}/WI-XXX/description.md` with required frontmatter.",
261
+ "- Use `phasegate scaffold-wi <unit> <type>` to create one.",
262
+ "- Plans written under `docs/inception/codding_plan/` are legacy; new plans go in WI dirs.",
263
+ ].join("\n");
264
+ }
265
+
266
+ async function listFilesRecursive(root: string): Promise<string[]> {
267
+ try {
268
+ const entries = await fsReaddir(root, { withFileTypes: true });
269
+ const files: string[] = [];
270
+ for (const entry of entries) {
271
+ const path = join(root, entry.name);
272
+ if (entry.isFile()) {
273
+ files.push(path);
274
+ } else if (entry.isDirectory()) {
275
+ files.push(...(await listFilesRecursive(path)));
276
+ }
277
+ }
278
+ return files;
279
+ } catch {
280
+ return [];
281
+ }
282
+ }
283
+
284
+ async function nextWorkItemId(rootDir: string): Promise<string> {
285
+ const files = await listFilesRecursive(join(rootDir, "docs", "inception"));
286
+ let max = 0;
287
+ for (const file of files) {
288
+ const match = file.match(/\/WI-(\d{3})\/description\.md$/);
289
+ if (match) max = Math.max(max, Number(match[1]));
290
+ }
291
+ return `WI-${String(max + 1).padStart(3, "0")}`;
292
+ }
293
+
294
+ async function countLegacyPlansWithoutWorkItems(rootDir: string): Promise<number> {
295
+ const files = await listFilesRecursive(join(rootDir, "docs", "inception"));
296
+ const hasWorkItem = files.some((file) => /\/WI-\d{3}\/description\.md$/.test(file));
297
+ if (hasWorkItem) return 0;
298
+ return files.filter((file) => file.includes("/codding_plan/") || file.endsWith("_plan.md")).length;
299
+ }
300
+
301
+ async function scaffoldInceptionRoots(rootDir: string, unit: string | null = null): Promise<void> {
302
+ await fsMkdir(join(rootDir, "docs", "inception", "_shared"), { recursive: true });
303
+ await fsMkdir(join(rootDir, "docs", "inception", "_cross"), { recursive: true });
304
+ if (unit && unit !== "_cross" && unit !== "_shared") {
305
+ await fsMkdir(join(rootDir, "docs", "inception", unit), { recursive: true });
306
+ await fsWriteFile(join(rootDir, "docs", "inception", unit, ".gitkeep"), "", "utf8").catch(() => undefined);
307
+ }
308
+ }
309
+
310
+ async function scaffoldWorkItem(rootDir: string, unit: string, type: ScaffoldWorkItemType): Promise<string> {
311
+ const id = await nextWorkItemId(rootDir);
312
+ await scaffoldInceptionRoots(rootDir, unit);
313
+ const targetBase = unit === "_cross" ? join(rootDir, "docs", "inception", "_cross") : join(rootDir, "docs", "inception", unit);
314
+ const targetDir = join(targetBase, id);
315
+ await fsMkdir(targetDir, { recursive: true });
316
+ const descriptionPath = join(targetDir, "description.md");
317
+ const titleScope = unit === "_cross" ? "Cross-cutting" : unit;
318
+ const content = [
319
+ "---",
320
+ `id: ${id}`,
321
+ `type: ${type}`,
322
+ "severity: normal",
323
+ "status: drafted",
324
+ "---",
325
+ "",
326
+ `# ${id}: ${titleScope} work item`,
327
+ "",
328
+ "## Context",
329
+ "",
330
+ "TBD",
331
+ "",
332
+ "## Acceptance Criteria",
333
+ "",
334
+ "- [ ] TBD",
335
+ "",
336
+ ].join("\n");
337
+ await fsWriteFile(descriptionPath, content, "utf8");
338
+ return descriptionPath;
339
+ }
340
+
245
341
  async function createFileManifestRecord(
246
342
  rootDir: string,
247
343
  relativePath: string,
@@ -359,17 +455,21 @@ Options:
359
455
  --preset <full|standard|minimal|custom> Phase dependency preset (default: "standard")
360
456
  --skills <core|all> Skill set to deploy (default: "all")
361
457
  --agent <claude|codex|both> Agent integration target (default: "claude")
458
+ --workflow <standard|strict> Workflow enforcement defaults (default: "standard")
362
459
  --with-husky Install Husky pre-commit hooks
363
460
  --with-ci Install GitHub Actions workflows
364
461
  --yes Skip confirmation prompts
365
462
  --help, -h Show this help`,
366
463
  "update-skills": `Usage: phasegate update-skills [options]
367
464
 
368
- Redeploy skills in .claude/skills/ from the installed phasegate version. WARNING: overwrites existing skill files.
465
+ Compatibility alias for phasegate reconcile. WARNING: this command no longer redeploys skills directly.
466
+ It updates PhaseGate-managed files from the installed manifest.
369
467
 
370
468
  Options:
371
- --skills <core|all> Skill set to deploy
372
- --agent <claude|codex|both> Agent integration target
469
+ --dry-run Preview target actions without writing (default)
470
+ --apply Write reconcile results and manifest
471
+ --force Force ai-assisted/manual targets after backing up existing files
472
+ --json Output machine-readable JSON
373
473
  --help, -h Show this help`,
374
474
  install: `Usage: phasegate install [options]
375
475
 
@@ -381,6 +481,16 @@ Options:
381
481
  --force Force ai-assisted/manual targets after backing up existing files
382
482
  --json Output machine-readable JSON
383
483
  --help, -h Show this help`,
484
+ reconcile: `Usage: phasegate reconcile [options]
485
+
486
+ Reconcile PhaseGate-managed files with the current bundled templates.
487
+
488
+ Options:
489
+ --dry-run Preview target actions without writing (default)
490
+ --apply Write managed updates and manifest
491
+ --force Force ai-assisted/manual targets after backing up existing files
492
+ --json Output machine-readable JSON
493
+ --help, -h Show this help`,
384
494
  validate: `Usage: phasegate validate [options]
385
495
 
386
496
  Run validators against the project. Without --layer, runs all enabled validator layers (L2/L3/L4).
@@ -771,7 +881,19 @@ async function main(): Promise<void> {
771
881
  switch (command) {
772
882
  // ── harness setup ──
773
883
  case "init": {
774
- const KNOWN_INIT_FLAGS = ["--name", "--preset", "--skills", "--agent", "--with-husky", "--with-ci", "--yes"];
884
+ console.log("Warning: phasegate init is deprecated and will be removed in v1.0.");
885
+ console.log("Use phasegate install for idempotent setup with structured merge.");
886
+ console.log("Existing legacy init behavior is preserved. Run phasegate doctor to verify installation state.");
887
+ const KNOWN_INIT_FLAGS = [
888
+ "--name",
889
+ "--preset",
890
+ "--skills",
891
+ "--agent",
892
+ "--workflow",
893
+ "--with-husky",
894
+ "--with-ci",
895
+ "--yes",
896
+ ];
775
897
  const flagError = validateKnownFlags(args, KNOWN_INIT_FLAGS);
776
898
  if (flagError) {
777
899
  console.error(flagError);
@@ -802,6 +924,12 @@ async function main(): Promise<void> {
802
924
  process.exit(2);
803
925
  }
804
926
  const agent = agentRaw;
927
+ const workflowRaw = parseFlag(args, "--workflow");
928
+ if (workflowRaw !== undefined && workflowRaw !== "standard" && workflowRaw !== "strict") {
929
+ console.error(`Invalid --workflow value: "${workflowRaw}". Use "standard" or "strict".`);
930
+ process.exit(2);
931
+ }
932
+ const workflow = parseWorkflowMode(workflowRaw);
805
933
  const deployClaude = agent === "claude" || agent === "both";
806
934
  const deployCodex = agent === "codex" || agent === "both";
807
935
  const result = await deploySkills(harnessRoot, rootDir, skillSet);
@@ -811,7 +939,13 @@ async function main(): Promise<void> {
811
939
  codex: deployCodex,
812
940
  });
813
941
  const withCi = hasFlag(args, "--with-ci");
814
- const configResult = await initHarnessConfig(rootDir, projectName, phasePreset, { ciEnabled: withCi });
942
+ const configResult = await initHarnessConfig(rootDir, projectName, phasePreset, {
943
+ ciEnabled: withCi,
944
+ workflow,
945
+ });
946
+ if (workflow === "strict") {
947
+ await scaffoldInceptionRoots(rootDir);
948
+ }
815
949
  const hooksResult = deployClaude
816
950
  ? await deployHookScripts(harnessRoot, rootDir)
817
951
  : {
@@ -885,6 +1019,9 @@ async function main(): Promise<void> {
885
1019
  }
886
1020
  if (configResult.created) {
887
1021
  console.log(`✓ phasegate.config.json created`);
1022
+ if (workflow === "strict") {
1023
+ console.log(`✓ strict workflow configured (quickMode.relaxedGates: [], allowedCategories: ["chore"])`);
1024
+ }
888
1025
  } else {
889
1026
  console.log(` phasegate.config.json already exists, skipped`);
890
1027
  }
@@ -960,6 +1097,13 @@ async function main(): Promise<void> {
960
1097
  }
961
1098
  }
962
1099
  console.log(`✓ Harness v${result.version} initialized (agent: ${agent})`);
1100
+ const legacyPlanCount = await countLegacyPlansWithoutWorkItems(rootDir);
1101
+ if (legacyPlanCount > 0) {
1102
+ console.log("");
1103
+ console.log(`Detected ${legacyPlanCount} legacy plan file(s) with no WI directories.`);
1104
+ console.log("Run migration? [Y/n]");
1105
+ console.log(" phasegate migrate work-items --apply");
1106
+ }
963
1107
  console.log("");
964
1108
  console.log("Next steps:");
965
1109
  if (skillSet === "core") {
@@ -989,41 +1133,28 @@ async function main(): Promise<void> {
989
1133
  }
990
1134
 
991
1135
  case "update-skills": {
992
- const deployed = await getDeployedVersion(rootDir);
993
- const current = await getHarnessVersion(harnessRoot);
994
- const previousSkillSet: SkillSet = deployed?.skillSet ?? "all";
995
- const overrideSkillSet = parseFlag(args, "--skills");
996
- const updateSkillSet: SkillSet =
997
- overrideSkillSet === "core" || overrideSkillSet === "all" ? overrideSkillSet : previousSkillSet;
998
- if (deployed) {
999
- console.log(`Previously deployed: v${deployed.version} (${deployed.deployedAt}, set: ${previousSkillSet})`);
1000
- } else {
1001
- console.log("No previously deployed skills found");
1136
+ const KNOWN_RECONCILE_FLAGS = ["--dry-run", "--apply", "--force", "--json"];
1137
+ const flagError = validateKnownFlags(args, KNOWN_RECONCILE_FLAGS);
1138
+ if (flagError) {
1139
+ console.error(flagError);
1140
+ process.exit(2);
1002
1141
  }
1003
- console.log(`Current harness version: v${current}`);
1004
- const result = await deploySkills(harnessRoot, rootDir, updateSkillSet);
1005
- const shouldLinkClaude =
1006
- (await pathExists(join(rootDir, ".claude", "settings.json"))) ||
1007
- (await pathExists(join(rootDir, ".claude", "skills")));
1008
- const shouldLinkCodex =
1009
- (await pathExists(join(rootDir, ".codex", "hooks.json"))) ||
1010
- (await pathExists(join(rootDir, ".codex", "skills")));
1011
- await deployAgentSkillLinks(rootDir, {
1012
- claude: shouldLinkClaude,
1013
- codex: shouldLinkCodex,
1142
+ const apply = hasFlag(args, "--apply");
1143
+ const dryRun = hasFlag(args, "--dry-run") || !apply;
1144
+ const mod = createInstallationModule();
1145
+ const phasegateVersion = await getHarnessVersion(harnessRoot);
1146
+ const result = await mod.reconcileHandler.execute({
1147
+ projectRoot: rootDir,
1148
+ harnessRoot,
1149
+ phasegateVersion,
1150
+ dryRun,
1151
+ apply,
1152
+ force: hasFlag(args, "--force"),
1153
+ json,
1014
1154
  });
1015
- await saveInstallationManifest(rootDir, current, [
1016
- ...result.deployedSkills.map((skill) => ({
1017
- path: join("skills", skill),
1018
- mode: "created" as const,
1019
- contentForHash: `${current}:${skill}`,
1020
- })),
1021
- await createFileManifestRecord(rootDir, join("skills", ".harness-version")),
1022
- shouldLinkClaude ? await createSymlinkManifestRecord(rootDir, join(".claude", "skills")) : null,
1023
- shouldLinkCodex ? await createSymlinkManifestRecord(rootDir, join(".codex", "skills")) : null,
1024
- ]);
1025
- console.log(`✓ Skills updated (${result.deployedSkills.length} skills redeployed, set: ${updateSkillSet})`);
1026
- process.exit(0);
1155
+ if (!json) console.log("phasegate update-skills is deprecated; running phasegate reconcile.");
1156
+ console.log(result.stdout);
1157
+ process.exit(result.exitCode);
1027
1158
  break;
1028
1159
  }
1029
1160
 
@@ -1042,6 +1173,25 @@ async function main(): Promise<void> {
1042
1173
  break;
1043
1174
  }
1044
1175
 
1176
+ case "emit-agent-rules": {
1177
+ console.log(emitAgentRulesBlock());
1178
+ process.exit(0);
1179
+ break;
1180
+ }
1181
+
1182
+ case "scaffold-wi": {
1183
+ const unit = args[1];
1184
+ const type = parseScaffoldWorkItemType(args[2]);
1185
+ if (!unit || !type) {
1186
+ console.error("Usage: phasegate scaffold-wi <unit|_cross> <story|issue|chore>");
1187
+ process.exit(2);
1188
+ }
1189
+ const descriptionPath = await scaffoldWorkItem(rootDir, unit, type);
1190
+ console.log(`Created ${descriptionPath}`);
1191
+ process.exit(0);
1192
+ break;
1193
+ }
1194
+
1045
1195
  case "install": {
1046
1196
  const KNOWN_INSTALL_FLAGS = ["--dry-run", "--apply", "--force", "--json"];
1047
1197
  const flagError = validateKnownFlags(args, KNOWN_INSTALL_FLAGS);
@@ -1091,8 +1241,27 @@ async function main(): Promise<void> {
1091
1241
  }
1092
1242
 
1093
1243
  case "reconcile": {
1094
- console.error("Not yet implemented: phasegate reconcile is owned by WI-148");
1095
- process.exit(2);
1244
+ const KNOWN_RECONCILE_FLAGS = ["--dry-run", "--apply", "--force", "--json"];
1245
+ const flagError = validateKnownFlags(args, KNOWN_RECONCILE_FLAGS);
1246
+ if (flagError) {
1247
+ console.error(flagError);
1248
+ process.exit(2);
1249
+ }
1250
+ const apply = hasFlag(args, "--apply");
1251
+ const dryRun = hasFlag(args, "--dry-run") || !apply;
1252
+ const mod = createInstallationModule();
1253
+ const phasegateVersion = await getHarnessVersion(harnessRoot);
1254
+ const result = await mod.reconcileHandler.execute({
1255
+ projectRoot: rootDir,
1256
+ harnessRoot,
1257
+ phasegateVersion,
1258
+ dryRun,
1259
+ apply,
1260
+ force: hasFlag(args, "--force"),
1261
+ json,
1262
+ });
1263
+ console.log(result.stdout);
1264
+ process.exit(result.exitCode);
1096
1265
  break;
1097
1266
  }
1098
1267
 
@@ -422,6 +422,7 @@ export async function deployHookScripts(harnessRoot: string, projectRoot: string
422
422
 
423
423
  export interface InitHarnessConfigOptions {
424
424
  ciEnabled?: boolean;
425
+ workflow?: "standard" | "strict";
425
426
  }
426
427
 
427
428
  export async function initHarnessConfig(
@@ -438,6 +439,7 @@ export async function initHarnessConfig(
438
439
  // ファイルが存在しない場合はテンプレートを作成
439
440
  }
440
441
 
442
+ const strictWorkflow = options.workflow === "strict";
441
443
  const template = {
442
444
  project: {
443
445
  name: projectName,
@@ -447,7 +449,12 @@ export async function initHarnessConfig(
447
449
  preset: "clean",
448
450
  },
449
451
  layers: {},
450
- quickMode: {},
452
+ quickMode: strictWorkflow
453
+ ? {
454
+ allowedCategories: ["chore"],
455
+ relaxedGates: [],
456
+ }
457
+ : {},
451
458
  phaseDependencies: {
452
459
  preset: phasePreset ?? "default",
453
460
  override: false,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: implementation-planner
3
- description: "Unit仕様とドメインモデル設計を元に実装計画を立てる。ストーリーIDや機能名から関連Unitを特定し、API設計・レイヤー別実装方針を整理してmdファイルで出力する。使用タイミング: 実装計画を立てて、US-XXXの実装方針を決めて、この機能の設計を整理して、など実装前の計画策定時。"
3
+ description: "Unit仕様とドメインモデル設計を元に実装計画を立てる。WI IDや機能名から関連Unitを特定し、API設計・レイヤー別実装方針を整理してmdファイルで出力する。使用タイミング: 実装計画を立てて、WI-XXXの実装方針を決めて、この機能の設計を整理して、など実装前の計画策定時。"
4
4
  model: sonnet
5
5
  review: opus
6
6
  ---
@@ -35,6 +35,11 @@ UnitドキュメントとConstructionのドメインモデル設計を元に、*
35
35
 
36
36
  ## ワークフロー
37
37
 
38
+ ## Pre-flight check (BLOCKING)
39
+
40
+ Before generating any plan, verify `docs/inception/{unit}/WI-XXX/description.md` exists.
41
+ If not, halt and ask the user to create the WI first, or offer to run `phasegate scaffold-wi <unit> <story|issue|chore>`.
42
+
38
43
  ```
39
44
  入力解析 → Unit特定 → ドメインモデル確認 → 既存実装確認 → 計画作成 → 出力
40
45
  ```
@@ -42,7 +47,7 @@ UnitドキュメントとConstructionのドメインモデル設計を元に、*
42
47
  ### Step 1: 入力解析
43
48
 
44
49
  ユーザー入力から抽出:
45
- - ストーリーID(US-XXX形式)
50
+ - WI ID(WI-XXX形式)
46
51
  - 機能名・タスク説明
47
52
  - 優先度・制約条件
48
53
 
@@ -85,6 +90,7 @@ UnitドキュメントとConstructionのドメインモデル設計を元に、*
85
90
  計画をmdファイルとして出力。パスの推奨:
86
91
  ```
87
92
  docs/inception/{task_id}_plan.md
93
+ docs/inception/{unit}/WI-XXX/tdd_implementation_plan.md
88
94
  ```
89
95
 
90
96
  **[Question][Answer]セクション必須**: 不明点や確認事項をまとめ、ユーザーからのフィードバックを受け取れるようにする。
@@ -18,6 +18,11 @@ Unit単位でアーキテクチャの各層(DB → ドメイン → ユース
18
18
 
19
19
  ## 前提条件チェック
20
20
 
21
+ ## Pre-flight check (BLOCKING)
22
+
23
+ Before generating any plan, verify `docs/inception/{unit}/WI-XXX/description.md` exists.
24
+ If not, halt and ask the user to create the WI first, or offer to run `phasegate scaffold-wi <unit> <story|issue|chore>`.
25
+
21
26
  ### 必須インプット(存在しなければ`[Question]`で提供を要求)
22
27
 
23
28
  - **横断モード:**
@@ -95,7 +100,7 @@ Unit単位でアーキテクチャの各層(DB → ドメイン → ユース
95
100
  設計方針・スコープ・不明点を整理し、人間の承認を得る。
96
101
 
97
102
  ### 出力ファイル
98
- `docs/inception/{unit}/logical_design_plan.md`(横断)または `docs/inception/{unit}/{story_id}/logical_design_plan.md`(ストーリー固有)
103
+ `docs/inception/{unit}/WI-XXX/logical_design_plan.md`
99
104
 
100
105
  ### 計画ファイルの構成
101
106
 
@@ -173,7 +178,7 @@ Unit単位でアーキテクチャの各層(DB → ドメイン → ユース
173
178
  | 種別 | 配置先 |
174
179
  |------|--------|
175
180
  | 横断成果物 | `docs/product/construction/{unit}/logical_design.md` |
176
- | ストーリー固有成果物 | `docs/inception/{unit}/{story_id}/logical_design.md` |
181
+ | ストーリー固有成果物 | `docs/inception/{unit}/WI-XXX/logical_design.md` |
177
182
 
178
183
  > **注意**: ストーリー固有の設計は `docs/inception/` に配置する(`docs/folder_management_rules.md` のルール準拠)。`docs/product/construction/` にはUnit全体の共有設計のみを配置する。
179
184
 
@@ -194,22 +199,22 @@ traceability:
194
199
  ---
195
200
  ```
196
201
 
197
- `initial_creation: true` は「新規作成であり、後述の `@story-id` 注釈が必須」であることを示す。
202
+ `initial_creation: true` は「新規作成であり、後述の `@work-item-id` 注釈が必須」であることを示す。
198
203
 
199
- ### 2. `@story-id` インライン注釈
204
+ ### 2. `@work-item-id` インライン注釈
200
205
 
201
- ユーザーストーリーに紐づく設計要素の直前に `@story-id HXX-XX` を独立行で記述する。
206
+ WI に紐づく設計要素の直前に `@work-item-id WI-XXX` を独立行で記述する。
202
207
 
203
208
  ```markdown
204
- @story-id H03-02
209
+ @work-item-id WI-001
205
210
  ### ユースケース: 注文を確定する
206
211
  ```
207
212
 
208
213
  形式ルール:
209
214
  - **独立行** — 他のテキストと混在させない
210
215
  - **直後に設計要素** — 空行を挟まない
211
- - **StoryCatalog 存在** — `HXX-XX` は `docs/product/user_stories.md` に存在する ID
212
- - **複数ストーリー時** — 注釈行を連続で並べ、最後の直後に設計要素を置く
216
+ - **WorkItem 存在** — `WI-XXX` は `docs/inception/{unit}/WI-XXX/description.md` に存在する ID
217
+ - **複数WI時** — 注釈行を連続で並べ、最後の直後に設計要素を置く
213
218
 
214
219
  ---
215
220
 
@@ -11,6 +11,11 @@ review: opus
11
11
 
12
12
  ## 前提条件チェック
13
13
 
14
+ ## Pre-flight check (BLOCKING)
15
+
16
+ Before generating any plan, verify `docs/inception/{unit}/WI-XXX/description.md` exists.
17
+ If not, halt and ask the user to create the WI first, or offer to run `phasegate scaffold-wi <unit> <story|issue|chore>`.
18
+
14
19
  ### 必須インプット(存在しなければ`[Question]`で提供を要求)
15
20
  - **要求文書** — 何を作るかを記述した文書。形式は問わない(議事録、要件メモ、口頭要約のテキスト等)
16
21