phasegate 0.152.9 → 0.153.1

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.153.1] - 2026-05-13
11
+
12
+ ### Fixed
13
+
14
+ - **WI-175 — setup apply structured error exit code** — makes `setup:agent --apply --json` exit non-zero when the underlying install result contains a structured target-aware apply error.
15
+
16
+ ## [0.153.0] - 2026-05-13
17
+
18
+ ### Added
19
+
20
+ - **WI-175 — agent setup completeness and confidence** — adds `setup:agent` completeness summaries, `config:plan` managed/external action separation with `phasegate.config.json` patch previews, target-aware install permission error guidance, and regression coverage for strict setup no-diff behavior.
21
+
10
22
  ## [0.152.9] - 2026-05-13
11
23
 
12
24
  ### Added
package/README.md CHANGED
@@ -109,6 +109,8 @@ npx phasegate setup:agent --intent recommended --dry-run --json
109
109
  npx phasegate config:plan --intent codex-hooks --dry-run --json
110
110
  ```
111
111
 
112
+ `setup:agent --json` includes `plan.completeness`, which separates local configured/planned areas from external manual checks such as Codex user-level feature enablement or the first CI run. `config:plan` includes a read-only `configPatch` preview when an intent would change `phasegate.config.json`. <!-- @work-item-id WI-175 -->
113
+
112
114
  The first command reports detected setup state, missing questions, planned managed targets, rollback, and validation. The second maps a configuration change intent to files, commands, risks, and checks. <!-- @work-item-id WI-172, WI-173 -->
113
115
 
114
116
  To remove PhaseGate from that project later, run:
@@ -1,6 +1,6 @@
1
1
  # Getting Started
2
2
 
3
- <!-- @work-item-id WI-171 -->
3
+ <!-- @work-item-id WI-171, WI-175 -->
4
4
 
5
5
  Use this page when you want the shortest path from "PhaseGate is installed" to "the next command is obvious".
6
6
 
@@ -31,6 +31,8 @@ The first successful run is:
31
31
  - L2 validators pass or report only issues you intentionally left for a later rollout.
32
32
  - The active agent can read `AGENTS.md` or `CLAUDE.md` and see the PhaseGate managed instructions.
33
33
 
34
+ `setup:agent --json` also returns `plan.completeness`. Treat `configured` and `planned` as local repository evidence, and treat `manual` entries as work PhaseGate cannot prove from local files, such as Codex user-level feature enablement or the first hosted CI run.
35
+
34
36
  ## Daily Use
35
37
 
36
38
  ```bash
@@ -48,7 +50,7 @@ npx phasegate setup:agent --dry-run --json
48
50
  npx phasegate config:plan --intent codex-hooks --dry-run --json
49
51
  ```
50
52
 
51
- `setup:agent` reads repository setup state and returns missing targets, questions, risk, rollback, and validation steps. `config:plan` maps natural-language change intents such as "enable Codex hooks" or "make L4 stricter" to concrete files, commands, and checks.
53
+ `setup:agent` reads repository setup state and returns missing targets, completeness, questions, risk, rollback, and validation steps. `config:plan` maps natural-language change intents such as "enable Codex hooks" or "make L4 stricter" to concrete files, commands, checks, and a read-only `phasegate.config.json` patch preview when the intent changes local config.
52
54
 
53
55
  ## CI Use
54
56
 
@@ -62,4 +62,4 @@ npx phasegate check-change-category --paths <changed-files> --format json
62
62
  npx phasegate ci-check --quick --dry-run
63
63
  ```
64
64
 
65
- Use `config:plan` before changing `phasegate.config.json`. It separates repo-managed artifacts from user-level settings and lists the checks needed after the change.
65
+ Use `config:plan` before changing `phasegate.config.json`. It separates repo-managed artifacts from user-level settings, previews the local config before/after for config-backed intents, and lists the checks needed after the change. <!-- @work-item-id WI-175 -->
@@ -1,6 +1,6 @@
1
1
  # Troubleshooting
2
2
 
3
- <!-- @work-item-id WI-171, WI-172, WI-173 -->
3
+ <!-- @work-item-id WI-171, WI-172, WI-173, WI-175 -->
4
4
 
5
5
  Start with:
6
6
 
@@ -58,3 +58,16 @@ npx phasegate config:plan --intent codex-hooks --dry-run --json
58
58
  ```
59
59
 
60
60
  The planner output is intentionally agent-readable: detected state, questions, planned targets, risks, rollback, and validation commands are all explicit.
61
+
62
+ ## Setup Completeness Still Has Manual Checks
63
+
64
+ `doctor` green means the local managed setup is consistent for the inspected targets. It does not prove user-level Codex feature flags, hosted GitHub Actions execution, npm registry state, or team policy acceptance.
65
+
66
+ Check `plan.completeness` in `setup:agent --json`:
67
+
68
+ - `configured`: PhaseGate found local evidence.
69
+ - `planned`: `--apply` will create or refresh the local target.
70
+ - `manual`: complete the listed external action yourself.
71
+ - `not-applicable`: the current intent/options did not select that area.
72
+
73
+ When `install` or `setup:agent --apply` returns a structured `error`, use its `target`, `operation`, `code`, `recovery`, and `partialChanges` fields before rerunning.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.152.9",
3
+ "version": "0.153.1",
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",
@@ -2,6 +2,7 @@
2
2
  // @layer application
3
3
  // @work-item-id WI-146
4
4
  // @work-item-id WI-174
5
+ // @work-item-id WI-175
5
6
 
6
7
  import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink } from "node:fs/promises";
7
8
  import { dirname, join } from "node:path";
@@ -47,6 +48,16 @@ export interface RunInstallResult {
47
48
  readonly refused: readonly InstallPlanItem[];
48
49
  readonly changed: readonly InstallPlanItem[];
49
50
  readonly backupDir: string | null;
51
+ readonly error?: TargetAwareApplyError;
52
+ }
53
+
54
+ export interface TargetAwareApplyError {
55
+ readonly target: string;
56
+ readonly operation: string;
57
+ readonly code: string;
58
+ readonly likelyCause: string;
59
+ readonly recovery: string;
60
+ readonly partialChanges: readonly string[];
50
61
  }
51
62
 
52
63
  interface InstallTarget {
@@ -216,6 +227,26 @@ function hasCustomJson(content: string | null): boolean {
216
227
  }
217
228
  }
218
229
 
230
+ function errorCode(error: unknown): string {
231
+ if (isRecord(error) && typeof error.code === "string") return error.code;
232
+ return "UNKNOWN";
233
+ }
234
+
235
+ function likelyCauseFor(code: string): string {
236
+ if (code === "EPERM") return "The filesystem or sandbox denied this write operation.";
237
+ if (code === "EACCES") return "The current user does not have permission to write this target.";
238
+ if (code === "EROFS") return "The project is on a read-only filesystem.";
239
+ if (code === "ENOTDIR") return "A parent path exists but is not a directory.";
240
+ return "The managed target could not be written.";
241
+ }
242
+
243
+ function recoveryFor(code: string, target: string): string {
244
+ if (code === "EPERM") return `Review sandbox or filesystem permissions for ${target}, then rerun phasegate setup:agent --apply or phasegate install --apply.`;
245
+ if (code === "EACCES") return `Fix ownership or permissions for ${target}, then rerun phasegate install --apply.`;
246
+ if (code === "EROFS") return `Move the project to a writable filesystem or rerun in a writable workspace before applying ${target}.`;
247
+ return `Inspect ${target}, run phasegate install --dry-run --json, then rerun with --apply after resolving the filesystem issue.`;
248
+ }
249
+
219
250
  function shellRepairMode(content: string | null): RepairMode {
220
251
  if (content === null || content.trim().length === 0 || content.includes(SHELL_BEGIN)) return "mechanical";
221
252
  return "ai-assisted";
@@ -289,9 +320,23 @@ export class RunInstallUseCase {
289
320
  await this.backup(input.projectRoot, target.path, backupDir);
290
321
  }
291
322
 
292
- await mkdir(dirname(absolutePath), { recursive: true });
293
- await writeFile(absolutePath, next, "utf8");
294
- if (target.executable) await chmod(absolutePath, 0o755);
323
+ try {
324
+ await mkdir(dirname(absolutePath), { recursive: true });
325
+ } catch (error) {
326
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "mkdir", error);
327
+ }
328
+ try {
329
+ await writeFile(absolutePath, next, "utf8");
330
+ } catch (error) {
331
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "writeFile", error);
332
+ }
333
+ if (target.executable) {
334
+ try {
335
+ await chmod(absolutePath, 0o755);
336
+ } catch (error) {
337
+ return this.withApplyError({ plan, refused, changed, backupDir }, target.path, "chmod", error);
338
+ }
339
+ }
295
340
  changed.push(item);
296
341
 
297
342
  const mode = before === null ? "created" : "merged";
@@ -320,9 +365,17 @@ export class RunInstallUseCase {
320
365
  const item = await this.planSkillLink(input.projectRoot, linkPath);
321
366
  plan.push(item);
322
367
  if (!input.apply || !item.changed) continue;
323
- await mkdir(join(input.projectRoot, "skills"), { recursive: true });
324
- await mkdir(dirname(join(input.projectRoot, linkPath)), { recursive: true });
325
- await symlink("../skills", join(input.projectRoot, linkPath), process.platform === "win32" ? "junction" : "dir");
368
+ try {
369
+ await mkdir(join(input.projectRoot, "skills"), { recursive: true });
370
+ await mkdir(dirname(join(input.projectRoot, linkPath)), { recursive: true });
371
+ } catch (error) {
372
+ return this.withApplyError({ plan, refused, changed, backupDir }, linkPath, "mkdir", error);
373
+ }
374
+ try {
375
+ await symlink("../skills", join(input.projectRoot, linkPath), process.platform === "win32" ? "junction" : "dir");
376
+ } catch (error) {
377
+ return this.withApplyError({ plan, refused, changed, backupDir }, linkPath, "symlink", error);
378
+ }
326
379
  changed.push(item);
327
380
  const hash = this.hashCalculator.compute("../skills");
328
381
  const existingEntry = baseManifest.findEntry(linkPath);
@@ -342,12 +395,36 @@ export class RunInstallUseCase {
342
395
  }
343
396
 
344
397
  if (input.apply && changed.length > 0) {
345
- await this.manifestRepository.save(input.projectRoot, manifest);
398
+ try {
399
+ await this.manifestRepository.save(input.projectRoot, manifest);
400
+ } catch (error) {
401
+ return this.withApplyError({ plan, refused, changed, backupDir }, ".phasegate/manifest.json", "manifest-save", error);
402
+ }
346
403
  }
347
404
 
348
405
  return { plan, refused, changed, backupDir };
349
406
  }
350
407
 
408
+ private withApplyError(
409
+ result: RunInstallResult,
410
+ target: string,
411
+ operation: string,
412
+ error: unknown,
413
+ ): RunInstallResult {
414
+ const code = errorCode(error);
415
+ return {
416
+ ...result,
417
+ error: {
418
+ target,
419
+ operation,
420
+ code,
421
+ likelyCause: likelyCauseFor(code),
422
+ recovery: recoveryFor(code, target),
423
+ partialChanges: result.changed.map((item) => item.path),
424
+ },
425
+ };
426
+ }
427
+
351
428
  private createTargets(options: {
352
429
  readonly includeClaude: boolean;
353
430
  readonly includeCodex: boolean;
@@ -1,6 +1,7 @@
1
1
  // @unit installation
2
2
  // @layer presentation
3
3
  // @work-item-id WI-146
4
+ // @work-item-id WI-175
4
5
 
5
6
  import type { RunInstallUseCase } from "../../application/usecases/run-install.js";
6
7
 
@@ -34,7 +35,7 @@ export class InstallHandler {
34
35
  if (input.json) {
35
36
  return {
36
37
  stdout: JSON.stringify(result, null, 2),
37
- exitCode: result.refused.length > 0 ? 1 : 0,
38
+ exitCode: result.refused.length > 0 || result.error !== undefined ? 1 : 0,
38
39
  };
39
40
  }
40
41
  const lines = [
@@ -45,13 +46,20 @@ export class InstallHandler {
45
46
  }),
46
47
  ];
47
48
  if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
49
+ if (result.error !== undefined) {
50
+ lines.push("");
51
+ lines.push(`Apply error: ${result.error.target} ${result.error.operation} failed with ${result.error.code}`);
52
+ lines.push(`Cause: ${result.error.likelyCause}`);
53
+ lines.push(`Recovery: ${result.error.recovery}`);
54
+ if (result.error.partialChanges.length > 0) lines.push(`Partial changes: ${result.error.partialChanges.join(", ")}`);
55
+ }
48
56
  if (result.refused.length > 0) {
49
57
  lines.push("");
50
58
  lines.push("Refused ai-assisted/manual targets. Re-run with --force after reviewing the hint.");
51
59
  }
52
60
  return {
53
61
  stdout: lines.join("\n"),
54
- exitCode: result.refused.length > 0 ? 1 : 0,
62
+ exitCode: result.refused.length > 0 || result.error !== undefined ? 1 : 0,
55
63
  };
56
64
  }
57
65
  }
@@ -4,6 +4,7 @@
4
4
  * @work-item-id WI-090 / WI-091
5
5
  * @work-item-id WI-113 / WI-142
6
6
  * @work-item-id WI-171 / WI-172 / WI-173
7
+ * @work-item-id WI-175
7
8
  *
8
9
  * Phasegate CLI エントリポイント。
9
10
  * 各Unitの Composition Root からハンドラーを取得し、コマンドに応じてディスパッチする。
@@ -723,6 +724,31 @@ type InitPhasePreset = "full" | "standard" | "minimal" | "custom";
723
724
  type AgentTarget = "claude" | "codex" | "both";
724
725
  type SetupIntent = "minimal" | "recommended" | "strict" | "ci-only" | "agent-hooks" | "retrofit";
725
726
  type ConfigChangeIntent = "l4-strict" | "codex-hooks" | "ci-fail-on-warning" | "baseline-reset" | "quick-mode-strict";
727
+ type SetupCompletenessStatus = "configured" | "planned" | "manual" | "not-applicable" | "unknown";
728
+
729
+ interface SetupCompletenessEntry {
730
+ readonly area: string;
731
+ readonly status: SetupCompletenessStatus;
732
+ readonly evidence: readonly string[];
733
+ readonly nextAction: string | null;
734
+ readonly risk: string | null;
735
+ }
736
+
737
+ interface ConfigPatchOperation {
738
+ readonly op: "add" | "replace";
739
+ readonly pointer: string;
740
+ readonly before: unknown;
741
+ readonly after: unknown;
742
+ }
743
+
744
+ interface ConfigPatchPreview {
745
+ readonly path: "phasegate.config.json";
746
+ readonly applicability: "applicable" | "not-applicable" | "blocked";
747
+ readonly blockedReason: string | null;
748
+ readonly before: unknown;
749
+ readonly after: unknown;
750
+ readonly operations: readonly ConfigPatchOperation[];
751
+ }
726
752
 
727
753
  function parseInitPhasePreset(value: string | undefined): InitPhasePreset | undefined {
728
754
  if (value === undefined) return undefined;
@@ -773,6 +799,138 @@ async function projectFileExists(rootDir: string, relativePath: string): Promise
773
799
  }
774
800
  }
775
801
 
802
+ function setupCompletenessEntry(input: {
803
+ readonly area: string;
804
+ readonly included: boolean;
805
+ readonly configured: boolean;
806
+ readonly plannedEvidence: string;
807
+ readonly configuredEvidence: string;
808
+ readonly nextAction: string;
809
+ readonly risk?: string;
810
+ }): SetupCompletenessEntry {
811
+ if (!input.included) {
812
+ return {
813
+ area: input.area,
814
+ status: "not-applicable",
815
+ evidence: ["Not selected for this setup intent/options."],
816
+ nextAction: null,
817
+ risk: null,
818
+ };
819
+ }
820
+ if (input.configured) {
821
+ return {
822
+ area: input.area,
823
+ status: "configured",
824
+ evidence: [input.configuredEvidence],
825
+ nextAction: null,
826
+ risk: input.risk ?? null,
827
+ };
828
+ }
829
+ return {
830
+ area: input.area,
831
+ status: "planned",
832
+ evidence: [input.plannedEvidence],
833
+ nextAction: input.nextAction,
834
+ risk: input.risk ?? null,
835
+ };
836
+ }
837
+
838
+ function buildSetupCompleteness(input: {
839
+ readonly intent: SetupIntent;
840
+ readonly agent: AgentTarget;
841
+ readonly withHusky: boolean;
842
+ readonly withCi: boolean;
843
+ readonly checks: {
844
+ readonly packageJson: boolean;
845
+ readonly phasegateConfig: boolean;
846
+ readonly claudeSettings: boolean;
847
+ readonly codexHooks: boolean;
848
+ readonly agentsMd: boolean;
849
+ readonly claudeMd: boolean;
850
+ readonly huskyPreCommit: boolean;
851
+ readonly ciWorkflow: boolean;
852
+ readonly skillsVersion: boolean;
853
+ };
854
+ }): readonly SetupCompletenessEntry[] {
855
+ const includeClaude = input.agent === "claude" || input.agent === "both";
856
+ const includeCodex = input.agent === "codex" || input.agent === "both";
857
+ const agentHooksConfigured = (!includeClaude || input.checks.claudeSettings) && (!includeCodex || input.checks.codexHooks);
858
+ const agentContextConfigured = (!includeClaude || input.checks.claudeMd) && (!includeCodex || input.checks.agentsMd);
859
+ const entries: SetupCompletenessEntry[] = [
860
+ setupCompletenessEntry({
861
+ area: "local-config",
862
+ included: true,
863
+ configured: input.checks.packageJson && input.checks.phasegateConfig,
864
+ configuredEvidence: "package.json and phasegate.config.json are present.",
865
+ plannedEvidence: "setup:agent will create or merge package.json scripts and phasegate.config.json.",
866
+ nextAction: "Run setup:agent --apply, then phasegate doctor.",
867
+ }),
868
+ setupCompletenessEntry({
869
+ area: "agent-hooks",
870
+ included: includeClaude || includeCodex,
871
+ configured: agentHooksConfigured,
872
+ configuredEvidence: "Selected agent hook settings are present.",
873
+ plannedEvidence: "setup:agent will create or refresh selected agent hook settings.",
874
+ nextAction: "Run setup:agent --apply for selected agents.",
875
+ risk: includeCodex ? "Codex user-level hook feature enablement remains a manual external check." : undefined,
876
+ }),
877
+ setupCompletenessEntry({
878
+ area: "agent-context",
879
+ included: includeClaude || includeCodex,
880
+ configured: agentContextConfigured,
881
+ configuredEvidence: "Selected AGENTS.md / CLAUDE.md managed context files are present.",
882
+ plannedEvidence: "setup:agent will create or refresh selected managed agent context sections.",
883
+ nextAction: "Run setup:agent --apply and review preserved user-owned content.",
884
+ }),
885
+ setupCompletenessEntry({
886
+ area: "skills",
887
+ included: true,
888
+ configured: input.checks.skillsVersion,
889
+ configuredEvidence: "skills/.harness-version is present.",
890
+ plannedEvidence: "setup:agent will deploy bundled PhaseGate skills.",
891
+ nextAction: "Run setup:agent --apply to deploy skills.",
892
+ }),
893
+ setupCompletenessEntry({
894
+ area: "git-hooks",
895
+ included: input.withHusky,
896
+ configured: input.checks.huskyPreCommit,
897
+ configuredEvidence: ".husky/pre-commit is present.",
898
+ plannedEvidence: "setup:agent will create or refresh Husky pre-commit, commit-msg, and pre-push hooks.",
899
+ nextAction: "Run setup:agent --apply --with-husky.",
900
+ }),
901
+ setupCompletenessEntry({
902
+ area: "ci",
903
+ included: input.withCi,
904
+ configured: input.checks.ciWorkflow,
905
+ configuredEvidence: ".github/workflows/phasegate-aidlc-gate.yml is present.",
906
+ plannedEvidence: "setup:agent will create or refresh the GitHub Actions PhaseGate workflow.",
907
+ nextAction: "Run setup:agent --apply --with-ci.",
908
+ risk: "A hosted CI run is still an external manual check.",
909
+ }),
910
+ setupCompletenessEntry({
911
+ area: "validation",
912
+ included: true,
913
+ configured: input.checks.packageJson && input.checks.phasegateConfig,
914
+ configuredEvidence: "Local validation commands can be run against the configured project.",
915
+ plannedEvidence: "Validation commands are planned after setup apply.",
916
+ nextAction: "Run phasegate doctor, phasegate phasegate:check-ready, and phasegate validate --layer L2 --format human.",
917
+ }),
918
+ ];
919
+
920
+ const externalActions: string[] = [];
921
+ if (includeCodex) externalActions.push("Run codex features enable codex_hooks if Codex hooks are not enabled for the user.");
922
+ if (input.withCi) externalActions.push("Trigger or inspect the first GitHub Actions PhaseGate workflow run.");
923
+ if (input.intent === "strict") externalActions.push("Confirm team policy accepts strict local and CI enforcement.");
924
+ entries.push({
925
+ area: "external-actions",
926
+ status: externalActions.length > 0 ? "manual" : "not-applicable",
927
+ evidence: externalActions.length > 0 ? externalActions : ["No external manual checks selected for this setup run."],
928
+ nextAction: externalActions.length > 0 ? externalActions.join(" ") : null,
929
+ risk: externalActions.length > 0 ? "PhaseGate cannot prove these user-level or hosted-service states from local files." : null,
930
+ });
931
+ return entries;
932
+ }
933
+
776
934
  async function buildAgentSetupPlan(rootDir: string, input: {
777
935
  readonly intent: SetupIntent;
778
936
  readonly agent: AgentTarget;
@@ -789,6 +947,7 @@ async function buildAgentSetupPlan(rootDir: string, input: {
789
947
  claudeMd: await projectFileExists(rootDir, "CLAUDE.md"),
790
948
  huskyPreCommit: await projectFileExists(rootDir, ".husky/pre-commit"),
791
949
  ciWorkflow: await projectFileExists(rootDir, ".github/workflows/phasegate-aidlc-gate.yml"),
950
+ skillsVersion: await projectFileExists(rootDir, "skills/.harness-version"),
792
951
  };
793
952
  const includeClaude = input.agent === "claude" || input.agent === "both";
794
953
  const includeCodex = input.agent === "codex" || input.agent === "both";
@@ -810,6 +969,7 @@ async function buildAgentSetupPlan(rootDir: string, input: {
810
969
  intent: input.intent,
811
970
  agent: input.agent,
812
971
  detected: checks,
972
+ completeness: buildSetupCompleteness({ ...input, checks }),
813
973
  questions,
814
974
  changes,
815
975
  risks: [
@@ -829,47 +989,148 @@ async function buildAgentSetupPlan(rootDir: string, input: {
829
989
  };
830
990
  }
831
991
 
832
- function buildConfigChangePlan(intent: ConfigChangeIntent) {
992
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
993
+ return typeof value === "object" && value !== null && !Array.isArray(value);
994
+ }
995
+
996
+ async function readProjectJson(rootDir: string, relativePath: string): Promise<unknown | null> {
997
+ try {
998
+ return JSON.parse(await fsReadFile(join(rootDir, relativePath), "utf8")) as unknown;
999
+ } catch {
1000
+ return null;
1001
+ }
1002
+ }
1003
+
1004
+ function withNestedValue(source: unknown, path: readonly string[], value: unknown): Record<string, unknown> {
1005
+ const root = isPlainRecord(source) ? { ...source } : {};
1006
+ let cursor = root;
1007
+ for (const segment of path.slice(0, -1)) {
1008
+ const next = cursor[segment];
1009
+ cursor[segment] = isPlainRecord(next) ? { ...next } : {};
1010
+ cursor = cursor[segment] as Record<string, unknown>;
1011
+ }
1012
+ cursor[path[path.length - 1]] = value;
1013
+ return root;
1014
+ }
1015
+
1016
+ function getNestedValue(source: unknown, path: readonly string[]): unknown {
1017
+ let cursor = source;
1018
+ for (const segment of path) {
1019
+ if (!isPlainRecord(cursor)) return undefined;
1020
+ cursor = cursor[segment];
1021
+ }
1022
+ return cursor;
1023
+ }
1024
+
1025
+ function buildConfigPatchPreview(intent: ConfigChangeIntent, before: unknown | null): ConfigPatchPreview {
1026
+ const configIntents: Record<ConfigChangeIntent, readonly { readonly pointer: string; readonly path: readonly string[]; readonly value: unknown }[]> = {
1027
+ "l4-strict": [
1028
+ { pointer: "/layers/L4/enabled", path: ["layers", "L4", "enabled"], value: true },
1029
+ { pointer: "/layers/L4/failOnWarning", path: ["layers", "L4", "failOnWarning"], value: true },
1030
+ ],
1031
+ "ci-fail-on-warning": [
1032
+ { pointer: "/ci/enabled", path: ["ci", "enabled"], value: true },
1033
+ { pointer: "/layers/L4/failOnWarning", path: ["layers", "L4", "failOnWarning"], value: true },
1034
+ ],
1035
+ "quick-mode-strict": [
1036
+ { pointer: "/quickMode/allowedCategories", path: ["quickMode", "allowedCategories"], value: ["chore"] },
1037
+ { pointer: "/quickMode/relaxedGates", path: ["quickMode", "relaxedGates"], value: [] },
1038
+ ],
1039
+ "codex-hooks": [],
1040
+ "baseline-reset": [],
1041
+ };
1042
+ const changes = configIntents[intent];
1043
+ if (changes.length === 0) {
1044
+ return {
1045
+ path: "phasegate.config.json",
1046
+ applicability: "not-applicable",
1047
+ blockedReason: "Selected intent does not require a local phasegate.config.json mutation.",
1048
+ before,
1049
+ after: before,
1050
+ operations: [],
1051
+ };
1052
+ }
1053
+ let after: unknown = before;
1054
+ const operations: ConfigPatchOperation[] = [];
1055
+ for (const change of changes) {
1056
+ const previous = getNestedValue(after, change.path);
1057
+ after = withNestedValue(after, change.path, change.value);
1058
+ operations.push({
1059
+ op: previous === undefined ? "add" : "replace",
1060
+ pointer: change.pointer,
1061
+ before: previous ?? null,
1062
+ after: change.value,
1063
+ });
1064
+ }
1065
+ return {
1066
+ path: "phasegate.config.json",
1067
+ applicability: "applicable",
1068
+ blockedReason: null,
1069
+ before,
1070
+ after,
1071
+ operations,
1072
+ };
1073
+ }
1074
+
1075
+ function hasStructuredInstallError(value: unknown): boolean {
1076
+ return isPlainRecord(value) && isPlainRecord(value.error);
1077
+ }
1078
+
1079
+ async function buildConfigChangePlan(rootDir: string, intent: ConfigChangeIntent) {
833
1080
  const catalog: Record<ConfigChangeIntent, {
834
1081
  readonly targets: readonly string[];
1082
+ readonly managedTargets: readonly string[];
1083
+ readonly externalActions: readonly { readonly id: string; readonly label: string; readonly command: string | null; readonly blocking: boolean }[];
835
1084
  readonly commands: readonly string[];
836
1085
  readonly validations: readonly string[];
837
1086
  readonly risks: readonly string[];
838
1087
  }> = {
839
1088
  "l4-strict": {
840
1089
  targets: ["phasegate.config.json: layers.L4.enabled", "phasegate.config.json: layers.L4.failOnWarning"],
1090
+ managedTargets: ["phasegate.config.json"],
1091
+ externalActions: [],
841
1092
  commands: ["phasegate validate --layer L4 --fail-on-warning --format human"],
842
1093
  validations: ["phasegate phasegate:detect-drift --json", "phasegate phasegate:check-ready"],
843
1094
  risks: ["L4 findings may be advisory today but become blocking when fail-on-warning is enabled."],
844
1095
  },
845
1096
  "codex-hooks": {
846
1097
  targets: [".codex/hooks.json", "AGENTS.md", ".codex/skills"],
1098
+ managedTargets: [".codex/hooks.json", "AGENTS.md", ".codex/skills"],
1099
+ externalActions: [{ id: "codex-hooks-feature", label: "Enable Codex user-level hooks feature.", command: "codex features enable codex_hooks", blocking: true }],
847
1100
  commands: ["phasegate install --agent codex --apply", "codex features enable codex_hooks"],
848
1101
  validations: ["phasegate doctor --json", "phasegate phasegate:status --json"],
849
1102
  risks: ["Codex apply_patch writes still require the pre-commit backstop for full coverage."],
850
1103
  },
851
1104
  "ci-fail-on-warning": {
852
1105
  targets: [".github/workflows/phasegate-aidlc-gate.yml", "phasegate.config.json"],
1106
+ managedTargets: [".github/workflows/phasegate-aidlc-gate.yml", "phasegate.config.json"],
1107
+ externalActions: [{ id: "github-actions-first-run", label: "Trigger or inspect the first GitHub Actions PhaseGate run.", command: null, blocking: false }],
853
1108
  commands: ["phasegate install --with-ci --apply", "phasegate validate --layer L4 --fail-on-warning"],
854
1109
  validations: ["phasegate doctor", "phasegate ci:generate-template --type aidlc-gate --render"],
855
1110
  risks: ["Existing warning-only projects may start failing CI after rollout."],
856
1111
  },
857
1112
  "baseline-reset": {
858
1113
  targets: [".phasegate/baseline.json"],
1114
+ managedTargets: [".phasegate/baseline.json"],
1115
+ externalActions: [],
859
1116
  commands: ["phasegate baseline --dry-run", "phasegate baseline --force --json"],
860
1117
  validations: ["phasegate phasegate:status --json", "phasegate phasegate:check-ready"],
861
1118
  risks: ["A reset can hide historical drift if reviewed without the generated diff."],
862
1119
  },
863
1120
  "quick-mode-strict": {
864
1121
  targets: ["phasegate.config.json: quickMode"],
1122
+ managedTargets: ["phasegate.config.json"],
1123
+ externalActions: [],
865
1124
  commands: ["phasegate check-change-category --paths <changed-files> --format json"],
866
1125
  validations: ["phasegate ci-check --quick --dry-run", "phasegate phasegate:check-ready"],
867
1126
  risks: ["More changes will require Full Mode validation before commit."],
868
1127
  },
869
1128
  };
1129
+ const before = await readProjectJson(rootDir, "phasegate.config.json");
870
1130
  return {
871
1131
  intent,
872
1132
  ...catalog[intent],
1133
+ configPatch: buildConfigPatchPreview(intent, before),
873
1134
  diffExplanation: "Review the listed targets first, apply through PhaseGate managed commands where possible, then run the validations in order.",
874
1135
  rollback: "Use git diff for config changes; use phasegate uninstall/reconcile dry-runs for managed setup targets.",
875
1136
  };
@@ -1315,7 +1576,7 @@ async function main(): Promise<void> {
1315
1576
  console.log(" • Q&A about phasegate concepts: invoke /phasegate-toolkit-guide");
1316
1577
  console.log(" • Diagnose & tune phasegate.config.json: invoke /phasegate-config-doctor");
1317
1578
  }
1318
- process.exit(0);
1579
+ process.exit(hasStructuredInstallError(installResult) ? 1 : 0);
1319
1580
  break;
1320
1581
  }
1321
1582
 
@@ -1436,7 +1697,7 @@ async function main(): Promise<void> {
1436
1697
  const workflow = parseWorkflowMode(parseFlag(args, "--workflow") ?? (intent === "strict" ? "strict" : "standard"));
1437
1698
  const withCi = hasFlag(args, "--with-ci") || intent === "ci-only" || intent === "strict";
1438
1699
  const withHusky = hasFlag(args, "--with-husky") || intent === "agent-hooks" || intent === "strict";
1439
- const plan = await buildAgentSetupPlan(rootDir, { intent, agent, withHusky, withCi, workflow });
1700
+ let plan = await buildAgentSetupPlan(rootDir, { intent, agent, withHusky, withCi, workflow });
1440
1701
  const apply = hasFlag(args, "--apply");
1441
1702
  let installResult: unknown = null;
1442
1703
  let bootstrapResult: unknown = null;
@@ -1469,6 +1730,7 @@ async function main(): Promise<void> {
1469
1730
  workflow,
1470
1731
  agent,
1471
1732
  });
1733
+ plan = await buildAgentSetupPlan(rootDir, { intent, agent, withHusky, withCi, workflow });
1472
1734
  }
1473
1735
  const output = { plan, applied: apply, bootstrapResult, installResult };
1474
1736
  if (json) {
@@ -1485,7 +1747,7 @@ async function main(): Promise<void> {
1485
1747
  console.log("Validation:");
1486
1748
  for (const command of plan.validation) console.log(`- ${command}`);
1487
1749
  }
1488
- process.exit(0);
1750
+ process.exit(hasStructuredInstallError(installResult) ? 1 : 0);
1489
1751
  break;
1490
1752
  }
1491
1753
 
@@ -1496,7 +1758,7 @@ async function main(): Promise<void> {
1496
1758
  console.error(flagError);
1497
1759
  process.exit(2);
1498
1760
  }
1499
- const plan = buildConfigChangePlan(parseConfigChangeIntent(parseFlag(args, "--intent")));
1761
+ const plan = await buildConfigChangePlan(rootDir, parseConfigChangeIntent(parseFlag(args, "--intent")));
1500
1762
  if (json) {
1501
1763
  console.log(JSON.stringify(plan, null, 2));
1502
1764
  } else {