phasegate 0.335.0 → 0.340.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.ja.md +22 -9
  3. package/README.md +28 -17
  4. package/docs/ADR/038-config-state-operation-permission-policy.md +5 -2
  5. package/docs/ADR/041-non-excludable-agent-trust-roots.md +68 -0
  6. package/docs/guide/antigravity-integration.md +26 -0
  7. package/docs/guide/cli-reference.md +4 -4
  8. package/docs/guide/codex-integration.md +44 -107
  9. package/docs/guide/grok-integration.md +26 -0
  10. package/docs/guide/hooks-integration.md +27 -4
  11. package/docs/guide/installation.md +4 -2
  12. package/docs/guide/setup-artifacts.md +4 -4
  13. package/docs/guide/skills-overview.md +1 -1
  14. package/package.json +1 -1
  15. package/scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts +1 -1
  16. package/scripts/harness/agent-integration/application/dto/normalized-pre-tool-use-request.ts +32 -0
  17. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +2 -2
  18. package/scripts/harness/agent-integration/domain/ports/full-mode-requirement-query-port.ts +3 -1
  19. package/scripts/harness/agent-integration/domain/services/apply-patch-write-target-extractor.ts +98 -0
  20. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +6 -52
  21. package/scripts/harness/agent-integration/domain/value-objects/hook-translation-result.ts +1 -1
  22. package/scripts/harness/agent-integration/domain/value-objects/protected-file-list.ts +35 -18
  23. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +1 -1
  24. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +1 -0
  25. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +123 -70
  26. package/scripts/harness/agent-integration/presentation/pre-tool-use-payload-normalizer.ts +228 -0
  27. package/scripts/harness/agent-integration/presentation/pre-tool-use-response-renderer.ts +38 -0
  28. package/scripts/harness/ci-governance/domain/value-objects/integrity-target.ts +2 -0
  29. package/scripts/harness/installation/application/checks/antigravity-hook-missing-check.ts +79 -0
  30. package/scripts/harness/installation/application/checks/codex-hook-missing-check.ts +70 -6
  31. package/scripts/harness/installation/application/checks/grok-hook-missing-check.ts +79 -0
  32. package/scripts/harness/installation/application/checks/husky-runtime-inactive-check.ts +33 -0
  33. package/scripts/harness/installation/application/named-hook-json.ts +17 -0
  34. package/scripts/harness/installation/application/operator-notice.ts +42 -0
  35. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +18 -7
  36. package/scripts/harness/installation/application/usecases/run-install.ts +152 -52
  37. package/scripts/harness/installation/application/usecases/run-reconcile.ts +86 -10
  38. package/scripts/harness/installation/application/usecases/run-uninstall.ts +149 -25
  39. package/scripts/harness/installation/composition-root.ts +10 -0
  40. package/scripts/harness/installation/domain/agent-target.ts +39 -0
  41. package/scripts/harness/installation/domain/check-id.ts +5 -0
  42. package/scripts/harness/installation/domain/husky-runtime-state.ts +34 -0
  43. package/scripts/harness/installation/domain/ports/git-hooks-runtime-probe.ts +9 -0
  44. package/scripts/harness/installation/domain/repair-table.ts +5 -0
  45. package/scripts/harness/installation/infrastructure/adapters/git-hooks-runtime-probe-adapter.ts +62 -0
  46. package/scripts/harness/installation/presentation/cli/install-handler.ts +23 -4
  47. package/scripts/harness/installation/presentation/cli/reconcile-handler.ts +7 -1
  48. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +27 -0
  49. package/scripts/harness/main.ts +100 -39
  50. package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +1 -1
  51. package/scripts/harness/quick-mode/application/dto/quick-mode-eligibility-contract.ts +1 -1
  52. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +3 -1
  53. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +13 -2
  54. package/scripts/harness/quick-mode/domain/types/rejection-rule.ts +1 -1
  55. package/scripts/harness/regression-suite/infrastructure/adapters/vitest-test-runner-adapter.ts +13 -3
  56. package/templates/.agents/hooks.json +16 -0
  57. package/templates/.claude/scripts/analyze-errors-hook.sh +2 -2
  58. package/templates/.claude/settings.json +5 -3
  59. package/templates/.codex/hooks.json +2 -2
@@ -7,16 +7,29 @@
7
7
  // @work-item-id WI-208
8
8
  // @work-item-id WI-209
9
9
  // @work-item-id WI-216
10
+ // @work-item-id WI-385
11
+ // @work-item-id WI-387
10
12
 
11
- import { access, copyFile, lstat, mkdir, readFile, readdir, readlink, rm, rmdir, writeFile } from "node:fs/promises";
13
+ import { access, copyFile, lstat, mkdir, readdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
12
14
  import { dirname, join, relative, resolve } from "node:path";
13
15
  import type { DeploymentEntry } from "../../domain/deployment-entry.js";
14
16
  import type { RepairMode } from "../../domain/repair-mode.js";
17
+ import { removeNamedHookJson } from "../named-hook-json.js";
15
18
  import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
16
19
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
17
20
 
18
21
  type UninstallAction = "missing-manifest" | "delete" | "unlink" | "reverse-merge" | "skip" | "refuse";
19
- type StrategyType = "created" | "json" | "shell" | "package-json" | "markdown-managed" | "symlink" | "yaml-add" | "text-managed" | "unknown";
22
+ type StrategyType =
23
+ | "created"
24
+ | "json"
25
+ | "json-named"
26
+ | "shell"
27
+ | "package-json"
28
+ | "markdown-managed"
29
+ | "symlink"
30
+ | "yaml-add"
31
+ | "text-managed"
32
+ | "unknown";
20
33
 
21
34
  export interface UninstallPlanItem {
22
35
  readonly path: string;
@@ -115,19 +128,37 @@ export function reverseJsonMerge(currentContent: string, templateContent: string
115
128
  return `${JSON.stringify(result, null, 2)}\n`;
116
129
  }
117
130
 
131
+ export function reverseNamedHookJson(currentContent: string): string {
132
+ const current = JSON.parse(currentContent) as unknown;
133
+ const result = removeNamedHookJson(isRecord(current) ? current : {});
134
+ return `${JSON.stringify(result, null, 2)}\n`;
135
+ }
136
+
118
137
  export function reverseShellMerge(currentContent: string): string {
119
138
  const pattern = new RegExp(`\\n?${escapeRegExp(SHELL_BEGIN)}[\\s\\S]*?${escapeRegExp(SHELL_END)}\\n?`);
120
- return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
139
+ return currentContent
140
+ .replace(pattern, "\n")
141
+ .replace(/\n{3,}/g, "\n\n")
142
+ .replace(/\s*$/, "\n")
143
+ .replace(/^\n/, "");
121
144
  }
122
145
 
123
146
  export function reverseManagedMarkdown(currentContent: string): string {
124
147
  const pattern = new RegExp(`\\n?${escapeRegExp(MARKDOWN_BEGIN)}[\\s\\S]*?${escapeRegExp(MARKDOWN_END)}\\n?`);
125
- return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
148
+ return currentContent
149
+ .replace(pattern, "\n")
150
+ .replace(/\n{3,}/g, "\n\n")
151
+ .replace(/\s*$/, "\n")
152
+ .replace(/^\n/, "");
126
153
  }
127
154
 
128
155
  export function reverseTextManaged(currentContent: string): string {
129
156
  const pattern = new RegExp(`\\n?${escapeRegExp(TEXT_BEGIN)}[\\s\\S]*?${escapeRegExp(TEXT_END)}\\n?`);
130
- return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
157
+ return currentContent
158
+ .replace(pattern, "\n")
159
+ .replace(/\n{3,}/g, "\n\n")
160
+ .replace(/\s*$/, "\n")
161
+ .replace(/^\n/, "");
131
162
  }
132
163
 
133
164
  export function reversePackageJsonMerge(currentContent: string): string {
@@ -140,7 +171,9 @@ export function reversePackageJsonMerge(currentContent: string): string {
140
171
  else delete result.devDependencies;
141
172
  }
142
173
  if (isRecord(result.scripts)) {
143
- const scripts = Object.fromEntries(Object.entries(result.scripts).filter(([name]) => !name.startsWith(PHASEGATE_SCRIPT_PREFIX)));
174
+ const scripts = Object.fromEntries(
175
+ Object.entries(result.scripts).filter(([name]) => !name.startsWith(PHASEGATE_SCRIPT_PREFIX)),
176
+ );
144
177
  if (Object.keys(scripts).length > 0) result.scripts = scripts;
145
178
  else delete result.scripts;
146
179
  }
@@ -214,7 +247,10 @@ export class RunUninstallUseCase {
214
247
  return { plan, refused, changed, backupDir, archivedManifestPath };
215
248
  }
216
249
 
217
- private async planEntry(input: RunUninstallInput, entry: DeploymentEntry): Promise<{
250
+ private async planEntry(
251
+ input: RunUninstallInput,
252
+ entry: DeploymentEntry,
253
+ ): Promise<{
218
254
  readonly item: UninstallPlanItem;
219
255
  readonly needsBackup: boolean;
220
256
  readonly apply: () => Promise<void>;
@@ -222,13 +258,22 @@ export class RunUninstallUseCase {
222
258
  const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
223
259
  const strategy = this.strategyFor(entry.path, entry.mode);
224
260
  if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
225
- if (entry.mode === "created" && await this.isDirectory(absolutePath)) {
261
+ if (entry.mode === "created" && (await this.isDirectory(absolutePath))) {
226
262
  return this.planCreatedDirectory(input, entry);
227
263
  }
228
264
  const currentContent = await readTextOrNull(absolutePath);
229
265
  if (currentContent === null && strategy !== "symlink") {
230
266
  return {
231
- item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: already absent`, "no changes", null),
267
+ item: this.item(
268
+ entry.path,
269
+ "skip",
270
+ "mechanical",
271
+ strategy,
272
+ false,
273
+ `${entry.path}: already absent`,
274
+ "no changes",
275
+ null,
276
+ ),
232
277
  needsBackup: false,
233
278
  apply: async () => {},
234
279
  };
@@ -249,7 +294,16 @@ export class RunUninstallUseCase {
249
294
  }
250
295
  if (target !== null && this.hashCalculator.compute(target).equals(entry.hash)) {
251
296
  return {
252
- item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`, `- symlink ${target}`, null),
297
+ item: this.item(
298
+ entry.path,
299
+ "unlink",
300
+ "mechanical",
301
+ "symlink",
302
+ true,
303
+ `${entry.path}: remove symlink`,
304
+ `- symlink ${target}`,
305
+ null,
306
+ ),
253
307
  needsBackup: false,
254
308
  apply: async () => {
255
309
  await rm(absolutePath, { force: true });
@@ -257,7 +311,16 @@ export class RunUninstallUseCase {
257
311
  };
258
312
  }
259
313
  return {
260
- item: this.item(entry.path, "skip", "manual", "symlink", false, `${entry.path}: non-phasegate symlink/path skipped`, "manual review required", null),
314
+ item: this.item(
315
+ entry.path,
316
+ "skip",
317
+ "manual",
318
+ "symlink",
319
+ false,
320
+ `${entry.path}: non-phasegate symlink/path skipped`,
321
+ "manual review required",
322
+ null,
323
+ ),
261
324
  needsBackup: false,
262
325
  apply: async () => {},
263
326
  };
@@ -273,7 +336,8 @@ export class RunUninstallUseCase {
273
336
  }
274
337
 
275
338
  private async planCreatedDirectory(input: RunUninstallInput, entry: DeploymentEntry) {
276
- if (entry.path === ".claude/skills" || entry.path === ".codex/skills") return this.planLegacyPersonalSkillsDirectory(input, entry);
339
+ if (entry.path === ".claude/skills" || entry.path === ".codex/skills" || entry.path === ".agents/skills")
340
+ return this.planLegacyPersonalSkillsDirectory(input, entry);
277
341
  const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
278
342
  return {
279
343
  item: this.item(
@@ -319,13 +383,19 @@ export class RunUninstallUseCase {
319
383
 
320
384
  private async listBundledSkills(harnessRoot: string): Promise<string[]> {
321
385
  const entries = await readdir(join(harnessRoot, "skills"), { withFileTypes: true });
322
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
386
+ return entries
387
+ .filter((entry) => entry.isDirectory())
388
+ .map((entry) => entry.name)
389
+ .sort();
323
390
  }
324
391
 
325
392
  private async planCreated(projectRoot: string, entry: DeploymentEntry, currentContent: string) {
326
393
  const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
327
394
  const currentHash = this.hashCalculator.compute(currentContent);
328
- const matchesManifest = currentHash.equals(entry.hash) || entry.path.endsWith("/.harness-version") || entry.path === "skills/.harness-version";
395
+ const matchesManifest =
396
+ currentHash.equals(entry.hash) ||
397
+ entry.path.endsWith("/.harness-version") ||
398
+ entry.path === "skills/.harness-version";
329
399
  const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
330
400
  return {
331
401
  item: this.item(
@@ -345,14 +415,28 @@ export class RunUninstallUseCase {
345
415
  };
346
416
  }
347
417
 
348
- private async planMerged(input: RunUninstallInput, entry: DeploymentEntry, currentContent: string, strategy: StrategyType) {
418
+ private async planMerged(
419
+ input: RunUninstallInput,
420
+ entry: DeploymentEntry,
421
+ currentContent: string,
422
+ strategy: StrategyType,
423
+ ) {
349
424
  const currentHash = this.hashCalculator.compute(currentContent);
350
425
  const matchesManifest = currentHash.equals(entry.hash);
351
426
  try {
352
427
  const next = await this.reverseMerged(input.harnessRoot, entry.path, currentContent, strategy);
353
428
  if (next === currentContent) {
354
429
  return {
355
- item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: managed portion already absent`, "no changes", null),
430
+ item: this.item(
431
+ entry.path,
432
+ "skip",
433
+ "mechanical",
434
+ strategy,
435
+ false,
436
+ `${entry.path}: managed portion already absent`,
437
+ "no changes",
438
+ null,
439
+ ),
356
440
  needsBackup: false,
357
441
  apply: async () => {},
358
442
  };
@@ -376,19 +460,38 @@ export class RunUninstallUseCase {
376
460
  };
377
461
  } catch {
378
462
  return {
379
- item: this.item(entry.path, "skip", "manual", strategy, false, `${entry.path}: reverse merge requires manual review`, "manual review required", SKILL_HINT),
463
+ item: this.item(
464
+ entry.path,
465
+ "skip",
466
+ "manual",
467
+ strategy,
468
+ false,
469
+ `${entry.path}: reverse merge requires manual review`,
470
+ "manual review required",
471
+ SKILL_HINT,
472
+ ),
380
473
  needsBackup: false,
381
474
  apply: async () => {},
382
475
  };
383
476
  }
384
477
  }
385
478
 
386
- private async reverseMerged(harnessRoot: string, path: string, currentContent: string, strategy: StrategyType): Promise<string> {
387
- if (strategy === "shell") return currentContent.includes(SHELL_BEGIN) ? reverseShellMerge(currentContent) : currentContent;
388
- if (strategy === "markdown-managed") return currentContent.includes(MARKDOWN_BEGIN) ? reverseManagedMarkdown(currentContent) : currentContent;
389
- if (strategy === "text-managed") return currentContent.includes(TEXT_BEGIN) ? reverseTextManaged(currentContent) : currentContent;
479
+ private async reverseMerged(
480
+ harnessRoot: string,
481
+ path: string,
482
+ currentContent: string,
483
+ strategy: StrategyType,
484
+ ): Promise<string> {
485
+ if (strategy === "shell")
486
+ return currentContent.includes(SHELL_BEGIN) ? reverseShellMerge(currentContent) : currentContent;
487
+ if (strategy === "markdown-managed")
488
+ return currentContent.includes(MARKDOWN_BEGIN) ? reverseManagedMarkdown(currentContent) : currentContent;
489
+ if (strategy === "text-managed")
490
+ return currentContent.includes(TEXT_BEGIN) ? reverseTextManaged(currentContent) : currentContent;
390
491
  if (strategy === "package-json") return reversePackageJsonMerge(currentContent);
391
- if (strategy === "json") return reverseJsonMerge(currentContent, await readFile(join(harnessRoot, this.templateFor(path)), "utf8"));
492
+ if (strategy === "json-named") return reverseNamedHookJson(currentContent);
493
+ if (strategy === "json")
494
+ return reverseJsonMerge(currentContent, await readFile(join(harnessRoot, this.templateFor(path)), "utf8"));
392
495
  throw new Error(`Unsupported merged strategy: ${strategy}`);
393
496
  }
394
497
 
@@ -396,10 +499,18 @@ export class RunUninstallUseCase {
396
499
  if (mode === "symlink") return "symlink";
397
500
  if (mode === "created") return path.endsWith(".yml") || path.endsWith(".yaml") ? "yaml-add" : "created";
398
501
  if (path === "package.json") return "package-json";
399
- if (path === "AGENTS.md" || path === "CLAUDE.md" || path === ".claude/CLAUDE.md" || path === ".claude/CLAUDE.local.md" || path === ".codex/AGENTS.local.md") {
502
+ if (
503
+ path === "AGENTS.md" ||
504
+ path === "CLAUDE.md" ||
505
+ path === ".claude/CLAUDE.md" ||
506
+ path === ".claude/CLAUDE.local.md" ||
507
+ path === ".codex/AGENTS.local.md"
508
+ ) {
400
509
  return "markdown-managed";
401
510
  }
402
511
  if (path === ".git/info/exclude") return "text-managed";
512
+ if (path === ".agents/hooks.json") return "json-named";
513
+ if (path.startsWith(".claude/scripts/") && path.endsWith(".sh")) return "shell";
403
514
  if (path.endsWith(".json")) return "json";
404
515
  if (path.startsWith(".husky/")) return "shell";
405
516
  return "unknown";
@@ -408,8 +519,11 @@ export class RunUninstallUseCase {
408
519
  private templateFor(path: string): string {
409
520
  if (path === ".claude/settings.json") return "templates/.claude/settings.json";
410
521
  if (path === ".codex/hooks.json") return "templates/.codex/hooks.json";
522
+ if (path === ".agents/hooks.json") return "templates/.agents/hooks.json";
523
+ if (path.startsWith(".claude/scripts/")) return `templates/${path}`;
411
524
  if (path === "CLAUDE.md") return "docs/templates/agent-context/CLAUDE.md.template.md";
412
- if (path === ".claude/CLAUDE.md" || path === ".claude/CLAUDE.local.md") return "docs/templates/agent-context/CLAUDE.md.template.md";
525
+ if (path === ".claude/CLAUDE.md" || path === ".claude/CLAUDE.local.md")
526
+ return "docs/templates/agent-context/CLAUDE.md.template.md";
413
527
  if (path === "AGENTS.md") return "docs/templates/agent-context/AGENTS.md.template.md";
414
528
  if (path === ".codex/AGENTS.local.md") return "docs/templates/agent-context/AGENTS.md.template.md";
415
529
  throw new Error(`No template for ${path}`);
@@ -455,7 +569,17 @@ export class RunUninstallUseCase {
455
569
  diff: string,
456
570
  skillHint: string | null,
457
571
  ): UninstallPlanItem {
458
- return { path, action, repairMode, strategy, changed, protected: this.isProtectedPath(path), summary, diff, skillHint };
572
+ return {
573
+ path,
574
+ action,
575
+ repairMode,
576
+ strategy,
577
+ changed,
578
+ protected: this.isProtectedPath(path),
579
+ summary,
580
+ diff,
581
+ skillHint,
582
+ };
459
583
  }
460
584
 
461
585
  private requiresForce(item: UninstallPlanItem): boolean {
@@ -4,7 +4,10 @@
4
4
  // @work-item-id WI-148
5
5
  // @work-item-id WI-215
6
6
  // @work-item-id WI-330
7
+ // @work-item-id WI-385
8
+ // @work-item-id WI-390
7
9
 
10
+ import { AntigravityHookMissingCheck } from "./application/checks/antigravity-hook-missing-check.js";
8
11
  import { CiWorkflowMissingCheck } from "./application/checks/ci-workflow-missing-check.js";
9
12
  import { ClaudeContextMissingCheck } from "./application/checks/claude-context-missing-check.js";
10
13
  import { ClaudeHookMissingCheck } from "./application/checks/claude-hook-missing-check.js";
@@ -13,9 +16,11 @@ import { CodexContextMissingCheck } from "./application/checks/codex-context-mis
13
16
  import { CodexHookMissingCheck } from "./application/checks/codex-hook-missing-check.js";
14
17
  import { CodexSkillsSymlinkCheck } from "./application/checks/codex-skills-symlink-check.js";
15
18
  import { ConfigStatusCheck } from "./application/checks/config-status-check.js";
19
+ import { GrokHookMissingCheck } from "./application/checks/grok-hook-missing-check.js";
16
20
  import { HuskyCommitMsgMissingCheck } from "./application/checks/husky-commit-msg-missing-check.js";
17
21
  import { HuskyPreCommitMissingCheck } from "./application/checks/husky-pre-commit-missing-check.js";
18
22
  import { HuskyPrePushMissingCheck } from "./application/checks/husky-pre-push-missing-check.js";
23
+ import { HuskyRuntimeInactiveCheck } from "./application/checks/husky-runtime-inactive-check.js";
19
24
  import { PackageJsonDevdepMissingCheck } from "./application/checks/package-json-devdep-missing-check.js";
20
25
  import { WiWorkflowDriftCheck } from "./application/checks/wi-workflow-drift-check.js";
21
26
  import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
@@ -29,6 +34,7 @@ import { ConfigStatusProbeAdapter } from "./infrastructure/adapters/config-statu
29
34
  import { FileSystemManifestRepositoryAdapter } from "./infrastructure/adapters/file-system-manifest-repository-adapter.js";
30
35
  import { NodeCryptoHashAdapter } from "./infrastructure/adapters/node-crypto-hash-adapter.js";
31
36
  import { NodeFsFileInspectorAdapter } from "./infrastructure/adapters/node-fs-file-inspector-adapter.js";
37
+ import { GitHooksRuntimeProbeAdapter } from "./infrastructure/adapters/git-hooks-runtime-probe-adapter.js";
32
38
  import { SkillDeployerModelDelegationAdapter } from "./infrastructure/adapters/skill-deployer-model-delegation-adapter.js";
33
39
  import { DoctorHandler } from "./presentation/cli/doctor-handler.js";
34
40
  import { InstallHandler } from "./presentation/cli/install-handler.js";
@@ -49,14 +55,18 @@ export function createInstallationModule() {
49
55
  const hashCalculator = new NodeCryptoHashAdapter();
50
56
  const modelDelegation = new SkillDeployerModelDelegationAdapter();
51
57
  const configStatusProbe = new ConfigStatusProbeAdapter();
58
+ const gitHooksRuntimeProbe = new GitHooksRuntimeProbeAdapter();
52
59
  const checks = [
53
60
  new ClaudeHookMissingCheck(),
54
61
  new ClaudeContextMissingCheck(),
55
62
  new CodexHookMissingCheck(),
56
63
  new CodexContextMissingCheck(),
64
+ new GrokHookMissingCheck(),
65
+ new AntigravityHookMissingCheck(),
57
66
  new HuskyPreCommitMissingCheck(),
58
67
  new HuskyCommitMsgMissingCheck(),
59
68
  new HuskyPrePushMissingCheck(),
69
+ new HuskyRuntimeInactiveCheck(gitHooksRuntimeProbe),
60
70
  new CiWorkflowMissingCheck(),
61
71
  new PackageJsonDevdepMissingCheck(),
62
72
  new ClaudeSkillsSymlinkCheck(),
@@ -0,0 +1,39 @@
1
+ // @unit installation
2
+ // @layer domain
3
+ // @work-item-id WI-385
4
+
5
+ export const AGENT_TARGETS = ["claude", "codex", "both", "grok", "antigravity", "all"] as const;
6
+
7
+ export type AgentTarget = (typeof AGENT_TARGETS)[number];
8
+
9
+ export interface AgentTargetSelection {
10
+ readonly claudeHook: boolean;
11
+ readonly claudeContext: boolean;
12
+ readonly claudeSkills: boolean;
13
+ readonly codexHook: boolean;
14
+ readonly codexSkills: boolean;
15
+ readonly agentsContext: boolean;
16
+ readonly antigravityHook: boolean;
17
+ readonly antigravitySkills: boolean;
18
+ }
19
+
20
+ export function isAgentTarget(value: string): value is AgentTarget {
21
+ return (AGENT_TARGETS as readonly string[]).includes(value);
22
+ }
23
+
24
+ export function resolveAgentTarget(target: AgentTarget): AgentTargetSelection {
25
+ const includesClaude = target === "claude" || target === "both" || target === "all";
26
+ const includesCodex = target === "codex" || target === "both" || target === "all";
27
+ const includesGrok = target === "grok" || target === "all";
28
+ const includesAntigravity = target === "antigravity" || target === "all";
29
+ return Object.freeze({
30
+ claudeHook: includesClaude || includesGrok,
31
+ claudeContext: includesClaude,
32
+ claudeSkills: includesClaude || includesGrok,
33
+ codexHook: includesCodex,
34
+ codexSkills: includesCodex,
35
+ agentsContext: includesCodex || includesGrok || includesAntigravity,
36
+ antigravityHook: includesAntigravity,
37
+ antigravitySkills: includesAntigravity,
38
+ });
39
+ }
@@ -3,15 +3,20 @@
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-215
5
5
  // @work-item-id WI-330
6
+ // @work-item-id WI-385
7
+ // @work-item-id WI-390
6
8
 
7
9
  export const CHECK_IDS = [
8
10
  "claude-hook-missing",
9
11
  "claude-context-missing",
10
12
  "codex-hook-missing",
11
13
  "codex-context-missing",
14
+ "grok-hook-missing",
15
+ "antigravity-hook-missing",
12
16
  "husky-pre-commit-missing",
13
17
  "husky-commit-msg-missing",
14
18
  "husky-pre-push-missing",
19
+ "husky-runtime-inactive",
15
20
  "ci-workflow-missing",
16
21
  "package-json-devdep-missing",
17
22
  "claude-skills-symlink",
@@ -0,0 +1,34 @@
1
+ // @unit installation
2
+ // @layer domain
3
+ // @work-item-id WI-390
4
+
5
+ export type HuskyRuntimeInactiveReason = 'hooks-path-unset' | 'hooks-path-unsupported' | 'shim-missing';
6
+
7
+ export type HuskyRuntimeKind = 'active' | 'inactive' | 'unavailable';
8
+
9
+ export class HuskyRuntimeState {
10
+ private constructor(
11
+ readonly kind: HuskyRuntimeKind,
12
+ readonly hooksPath: string | null,
13
+ readonly reason: HuskyRuntimeInactiveReason | null,
14
+ readonly detail: string | null,
15
+ ) {
16
+ Object.freeze(this);
17
+ }
18
+
19
+ static active(hooksPath: '.husky' | '.husky/_'): HuskyRuntimeState {
20
+ return new HuskyRuntimeState('active', hooksPath, null, null);
21
+ }
22
+
23
+ static inactive(reason: HuskyRuntimeInactiveReason, hooksPath: string | null): HuskyRuntimeState {
24
+ return new HuskyRuntimeState('inactive', hooksPath, reason, null);
25
+ }
26
+
27
+ static unavailable(detail: string): HuskyRuntimeState {
28
+ return new HuskyRuntimeState('unavailable', null, null, detail);
29
+ }
30
+
31
+ isActive(): boolean {
32
+ return this.kind === 'active';
33
+ }
34
+ }
@@ -0,0 +1,9 @@
1
+ // @unit installation
2
+ // @layer domain
3
+ // @work-item-id WI-390
4
+
5
+ import type { HuskyRuntimeState } from '../husky-runtime-state.js';
6
+
7
+ export interface GitHooksRuntimeProbe {
8
+ probe(projectRoot: string): Promise<HuskyRuntimeState>;
9
+ }
@@ -2,6 +2,8 @@
2
2
  // @layer domain
3
3
  // @work-item-id WI-145
4
4
  // @work-item-id WI-215
5
+ // @work-item-id WI-385
6
+ // @work-item-id WI-390
5
7
 
6
8
  import type { CheckId } from "./check-id.js";
7
9
  import { SuggestedSkill } from "./suggested-skill.js";
@@ -27,9 +29,12 @@ export class RepairTable {
27
29
  ["claude-context-missing", CONFIG_DOCTOR],
28
30
  ["codex-hook-missing", CONFIG_DOCTOR],
29
31
  ["codex-context-missing", CONFIG_DOCTOR],
32
+ ["grok-hook-missing", CONFIG_DOCTOR],
33
+ ["antigravity-hook-missing", CONFIG_DOCTOR],
30
34
  ["husky-pre-commit-missing", CONFIG_DOCTOR],
31
35
  ["husky-commit-msg-missing", CONFIG_DOCTOR],
32
36
  ["husky-pre-push-missing", null],
37
+ ["husky-runtime-inactive", null],
33
38
  ["ci-workflow-missing", TOOLKIT_GUIDE],
34
39
  ["package-json-devdep-missing", null],
35
40
  ["claude-skills-symlink", null],
@@ -0,0 +1,62 @@
1
+ // @unit installation
2
+ // @layer infrastructure
3
+ // @work-item-id WI-390
4
+
5
+ import { execFile } from 'node:child_process';
6
+ import { access } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { promisify } from 'node:util';
9
+ import { HuskyRuntimeState } from '../../domain/husky-runtime-state.js';
10
+ import type { GitHooksRuntimeProbe } from '../../domain/ports/git-hooks-runtime-probe.js';
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const HUSKY_V9_SHIMS = ['h', 'pre-commit', 'commit-msg', 'pre-push'] as const;
14
+
15
+ async function pathExists(targetPath: string): Promise<boolean> {
16
+ try {
17
+ await access(targetPath);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function normalizeHooksPath(value: string): string {
25
+ return value.trim().replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$/, '');
26
+ }
27
+
28
+ export class GitHooksRuntimeProbeAdapter implements GitHooksRuntimeProbe {
29
+ async probe(projectRoot: string): Promise<HuskyRuntimeState> {
30
+ let stdout: string;
31
+ try {
32
+ ({ stdout } = await execFileAsync('git', ['-C', projectRoot, 'config', '--get', 'core.hooksPath']));
33
+ } catch (error) {
34
+ const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null;
35
+ if (code === 1) {
36
+ return HuskyRuntimeState.inactive('hooks-path-unset', null);
37
+ }
38
+ return HuskyRuntimeState.unavailable(error instanceof Error ? error.message : String(error));
39
+ }
40
+
41
+ const hooksPath = normalizeHooksPath(stdout);
42
+ if (hooksPath.length === 0) {
43
+ return HuskyRuntimeState.inactive('hooks-path-unset', null);
44
+ }
45
+ if (hooksPath !== '.husky' && hooksPath !== '.husky/_') {
46
+ return HuskyRuntimeState.inactive('hooks-path-unsupported', hooksPath);
47
+ }
48
+
49
+ if (hooksPath === '.husky') {
50
+ return (await pathExists(join(projectRoot, '.husky')))
51
+ ? HuskyRuntimeState.active('.husky')
52
+ : HuskyRuntimeState.inactive('shim-missing', hooksPath);
53
+ }
54
+
55
+ const shimsPresent = await Promise.all(
56
+ HUSKY_V9_SHIMS.map((shim) => pathExists(join(projectRoot, '.husky', '_', shim))),
57
+ );
58
+ return shimsPresent.every(Boolean)
59
+ ? HuskyRuntimeState.active('.husky/_')
60
+ : HuskyRuntimeState.inactive('shim-missing', hooksPath);
61
+ }
62
+ }
@@ -3,8 +3,16 @@
3
3
  // @work-item-id WI-146
4
4
  // @work-item-id WI-175
5
5
  // @work-item-id WI-207
6
+ // @work-item-id WI-384
7
+ // @work-item-id WI-385
6
8
 
9
+ import {
10
+ ANTIGRAVITY_CLI_ONLY_NOTICE,
11
+ CODEX_HOOK_TRUST_REQUIRED_NOTICE,
12
+ GROK_HOOK_TRUST_UNVERIFIABLE_NOTICE,
13
+ } from "../../application/operator-notice.js";
7
14
  import type { RunInstallUseCase } from "../../application/usecases/run-install.js";
15
+ import type { AgentTarget } from "../../domain/agent-target.js";
8
16
 
9
17
  export interface InstallHandlerInput {
10
18
  readonly projectRoot: string;
@@ -20,7 +28,7 @@ export interface InstallHandlerInput {
20
28
  readonly includeCi?: boolean;
21
29
  readonly skillSet?: "core" | "all";
22
30
  readonly workflow?: "standard" | "strict";
23
- readonly agent?: "claude" | "codex" | "both";
31
+ readonly agent?: AgentTarget;
24
32
  readonly personal?: boolean;
25
33
  }
26
34
 
@@ -34,14 +42,23 @@ export class InstallHandler {
34
42
 
35
43
  async execute(input: InstallHandlerInput): Promise<InstallHandlerResult> {
36
44
  const result = await this.useCase.execute(input);
45
+ const operatorNotices = [
46
+ ...(result.plan.some((item) => item.path === ".codex/hooks.json" && item.changed)
47
+ ? [CODEX_HOOK_TRUST_REQUIRED_NOTICE]
48
+ : []),
49
+ ...(input.agent === "grok" || input.agent === "all" ? [GROK_HOOK_TRUST_UNVERIFIABLE_NOTICE] : []),
50
+ ...(input.agent === "antigravity" || input.agent === "all" ? [ANTIGRAVITY_CLI_ONLY_NOTICE] : []),
51
+ ];
37
52
  if (input.json) {
38
53
  return {
39
- stdout: JSON.stringify(result, null, 2),
54
+ stdout: JSON.stringify({ ...result, operatorNotices }, null, 2),
40
55
  exitCode: result.refused.length > 0 || result.error !== undefined ? 1 : 0,
41
56
  };
42
57
  }
43
58
  const lines = [
44
- input.apply ? `phasegate install${input.personal ? " --personal" : ""} apply` : `phasegate install${input.personal ? " --personal" : ""} dry-run`,
59
+ input.apply
60
+ ? `phasegate install${input.personal ? " --personal" : ""} apply`
61
+ : `phasegate install${input.personal ? " --personal" : ""} dry-run`,
45
62
  ...result.plan.map((item) => {
46
63
  const hint = item.skillHint ? `; hint: ${item.skillHint}` : "";
47
64
  const warn = item.warning ? `\n WARNING: ${item.warning}` : "";
@@ -49,12 +66,14 @@ export class InstallHandler {
49
66
  }),
50
67
  ];
51
68
  if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
69
+ for (const notice of operatorNotices) lines.push(`Notice [${notice.code}]: ${notice.message}`);
52
70
  if (result.error !== undefined) {
53
71
  lines.push("");
54
72
  lines.push(`Apply error: ${result.error.target} ${result.error.operation} failed with ${result.error.code}`);
55
73
  lines.push(`Cause: ${result.error.likelyCause}`);
56
74
  lines.push(`Recovery: ${result.error.recovery}`);
57
- if (result.error.partialChanges.length > 0) lines.push(`Partial changes: ${result.error.partialChanges.join(", ")}`);
75
+ if (result.error.partialChanges.length > 0)
76
+ lines.push(`Partial changes: ${result.error.partialChanges.join(", ")}`);
58
77
  }
59
78
  if (result.refused.length > 0) {
60
79
  lines.push("");
@@ -1,8 +1,10 @@
1
1
  // @unit installation
2
2
  // @layer presentation
3
3
  // @work-item-id WI-148
4
+ // @work-item-id WI-384
4
5
 
5
6
  import type { RunReconcileUseCase } from "../../application/usecases/run-reconcile.js";
7
+ import { CODEX_HOOK_TRUST_REQUIRED_NOTICE } from "../../application/operator-notice.js";
6
8
 
7
9
  export interface ReconcileHandlerInput {
8
10
  readonly projectRoot: string;
@@ -24,9 +26,12 @@ export class ReconcileHandler {
24
26
 
25
27
  async execute(input: ReconcileHandlerInput): Promise<ReconcileHandlerResult> {
26
28
  const result = await this.useCase.execute(input);
29
+ const operatorNotices = result.plan.some((item) => item.path === ".codex/hooks.json" && item.changed)
30
+ ? [CODEX_HOOK_TRUST_REQUIRED_NOTICE]
31
+ : [];
27
32
  if (input.json) {
28
33
  return {
29
- stdout: JSON.stringify(result, null, 2),
34
+ stdout: JSON.stringify({ ...result, operatorNotices }, null, 2),
30
35
  exitCode: result.refused.length > 0 ? 1 : 0,
31
36
  };
32
37
  }
@@ -38,6 +43,7 @@ export class ReconcileHandler {
38
43
  }),
39
44
  ];
40
45
  if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
46
+ for (const notice of operatorNotices) lines.push(`Notice [${notice.code}]: ${notice.message}`);
41
47
  if (result.refused.length > 0) {
42
48
  lines.push("");
43
49
  lines.push("Refused ai-assisted/manual targets. Re-run with --force after reviewing the hint.");