phasegate 0.335.0 → 0.341.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 (111) hide show
  1. package/CHANGELOG.md +16 -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/folder_management_rules.md +10 -1
  7. package/docs/guide/antigravity-integration.md +26 -0
  8. package/docs/guide/cli-reference.md +27 -7
  9. package/docs/guide/codex-integration.md +44 -107
  10. package/docs/guide/configuration.md +28 -2
  11. package/docs/guide/grok-integration.md +26 -0
  12. package/docs/guide/hooks-integration.md +34 -5
  13. package/docs/guide/installation.md +4 -2
  14. package/docs/guide/setup-artifacts.md +4 -4
  15. package/docs/guide/skills-overview.md +4 -1
  16. package/package.json +2 -1
  17. package/scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts +1 -1
  18. package/scripts/harness/agent-integration/application/dto/normalized-pre-tool-use-request.ts +32 -0
  19. package/scripts/harness/agent-integration/application/usecases/handle-post-tool-use-usecase.ts +4 -0
  20. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +38 -3
  21. package/scripts/harness/agent-integration/application/usecases/handle-stop-usecase.ts +3 -9
  22. package/scripts/harness/agent-integration/domain/ports/full-mode-requirement-query-port.ts +3 -1
  23. package/scripts/harness/agent-integration/domain/ports/story-reflection-query-port.ts +2 -0
  24. package/scripts/harness/agent-integration/domain/services/apply-patch-write-target-extractor.ts +98 -0
  25. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +6 -52
  26. package/scripts/harness/agent-integration/domain/services/hook-to-cli-translator.ts +8 -5
  27. package/scripts/harness/agent-integration/domain/value-objects/hook-translation-result.ts +2 -2
  28. package/scripts/harness/agent-integration/domain/value-objects/protected-file-list.ts +35 -18
  29. package/scripts/harness/agent-integration/domain/value-objects/story-reflection-query-result.ts +10 -2
  30. package/scripts/harness/agent-integration/domain/value-objects/write-target-scope.ts +12 -0
  31. package/scripts/harness/agent-integration/infrastructure/adapters/child-process-cli-executor-adapter.ts +64 -13
  32. package/scripts/harness/agent-integration/infrastructure/adapters/file-system-story-reflection-query-adapter.ts +108 -2
  33. package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +8 -6
  34. package/scripts/harness/agent-integration/presentation/hook-skip-event-recorder.ts +3 -0
  35. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +1 -1
  36. package/scripts/harness/agent-integration/presentation/post-tool-use-feedback.ts +39 -0
  37. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +21 -27
  38. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +126 -70
  39. package/scripts/harness/agent-integration/presentation/pre-tool-use-payload-normalizer.ts +228 -0
  40. package/scripts/harness/agent-integration/presentation/pre-tool-use-response-renderer.ts +38 -0
  41. package/scripts/harness/agent-integration/presentation/stop-hook.ts +1 -3
  42. package/scripts/harness/biome-ast-engine/infrastructure/adapters/biome-cli-executor-adapter.ts +21 -1
  43. package/scripts/harness/biome-ast-engine/infrastructure/adapters/typescript-source-module-analyzer-adapter.ts +5 -0
  44. package/scripts/harness/ci-governance/domain/value-objects/integrity-target.ts +2 -0
  45. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +15 -0
  46. package/scripts/harness/config-foundation/infrastructure/validators/ajv-config-schema-validator.ts +6 -3
  47. package/scripts/harness/harness-api/domain/ports/biome-lint-port.ts +2 -1
  48. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +12 -1
  49. package/scripts/harness/harness-api/infrastructure/adapters/biome-ast-engine-lint-adapter.ts +10 -2
  50. package/scripts/harness/installation/application/bundled-skill-selection.ts +28 -2
  51. package/scripts/harness/installation/application/checks/antigravity-hook-missing-check.ts +79 -0
  52. package/scripts/harness/installation/application/checks/check-utils.ts +2 -2
  53. package/scripts/harness/installation/application/checks/codex-hook-missing-check.ts +70 -6
  54. package/scripts/harness/installation/application/checks/grok-hook-missing-check.ts +79 -0
  55. package/scripts/harness/installation/application/checks/husky-runtime-inactive-check.ts +33 -0
  56. package/scripts/harness/installation/application/named-hook-json.ts +17 -0
  57. package/scripts/harness/installation/application/operator-notice.ts +42 -0
  58. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +18 -7
  59. package/scripts/harness/installation/application/usecases/run-install.ts +157 -57
  60. package/scripts/harness/installation/application/usecases/run-reconcile.ts +148 -31
  61. package/scripts/harness/installation/application/usecases/run-uninstall.ts +149 -25
  62. package/scripts/harness/installation/composition-root.ts +10 -0
  63. package/scripts/harness/installation/domain/agent-target.ts +39 -0
  64. package/scripts/harness/installation/domain/check-id.ts +5 -0
  65. package/scripts/harness/installation/domain/husky-runtime-state.ts +34 -0
  66. package/scripts/harness/installation/domain/ports/git-hooks-runtime-probe.ts +9 -0
  67. package/scripts/harness/installation/domain/repair-table.ts +5 -0
  68. package/scripts/harness/installation/infrastructure/adapters/git-hooks-runtime-probe-adapter.ts +62 -0
  69. package/scripts/harness/installation/presentation/cli/install-handler.ts +25 -5
  70. package/scripts/harness/installation/presentation/cli/reconcile-handler.ts +7 -1
  71. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +27 -0
  72. package/scripts/harness/main.ts +227 -128
  73. package/scripts/harness/phase-dependency-model/domain/services/story-reflection-checker.ts +48 -5
  74. package/scripts/harness/phase-dependency-model/domain/services/work-item-reflection-scope-resolver.ts +64 -0
  75. package/scripts/harness/phase-dependency-model/domain/values/story-reflection-mapping.ts +8 -2
  76. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-story-reflection-adapter.ts +6 -1
  77. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/file-system-work-item-dependency-catalog.ts +73 -0
  78. package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +5 -1
  79. package/scripts/harness/quick-mode/application/dto/quick-mode-eligibility-contract.ts +1 -1
  80. package/scripts/harness/quick-mode/application/ports/change-risk-advisory-port.ts +16 -0
  81. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +3 -1
  82. package/scripts/harness/quick-mode/composition-root.ts +5 -1
  83. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +13 -2
  84. package/scripts/harness/quick-mode/domain/types/rejection-rule.ts +1 -1
  85. package/scripts/harness/quick-mode/infrastructure/adapters/snapshot-risk-advisory-adapter.ts +118 -0
  86. package/scripts/harness/quick-mode/presentation/formatters/change-category-formatter.ts +7 -0
  87. package/scripts/harness/quick-mode/presentation/handlers/check-change-category-handler.ts +12 -1
  88. package/scripts/harness/regression-suite/infrastructure/adapters/vitest-test-runner-adapter.ts +13 -3
  89. package/scripts/harness/setup/skill-deployer.ts +3 -45
  90. package/scripts/harness/skill-quality/application/dto/run-plan-checker-loop-output.ts +1 -0
  91. package/scripts/harness/skill-quality/application/usecases/apply-cascade-update-usecase.ts +9 -4
  92. package/scripts/harness/skill-quality/application/usecases/execute-tdd-cycle-usecase.ts +2 -1
  93. package/scripts/harness/skill-quality/application/usecases/run-plan-checker-loop-usecase.ts +8 -0
  94. package/scripts/harness/skill-quality/composition-root.ts +14 -7
  95. package/scripts/harness/skill-quality/domain/ports/plan-check-executor-port.ts +2 -0
  96. package/scripts/harness/skill-quality/domain/value-objects/cascade-update-target.ts +15 -1
  97. package/scripts/harness/skill-quality/infrastructure/adapters/l1-biome-validator-adapter.ts +9 -1
  98. package/scripts/harness/skill-quality/infrastructure/adapters/l2-validator-system-adapter.ts +18 -2
  99. package/scripts/harness/skill-quality/presentation/handlers/apply-cascade-update-handler.ts +8 -2
  100. package/scripts/harness/skill-quality/presentation/handlers/execute-tdd-cycle-handler.ts +4 -3
  101. package/scripts/harness/skill-quality/presentation/handlers/run-plan-checker-loop-handler.ts +11 -4
  102. package/scripts/harness/traceability-model/infrastructure/parsers/work-item-frontmatter-parser.ts +41 -0
  103. package/skills/README.md +7 -0
  104. package/skills/cascade-updater/SKILL.md +37 -109
  105. package/skills/cascade-updater/references//345/261/244/345/210/245/345/210/244/345/256/232/343/202/254/343/202/244/343/203/211.md +5 -3
  106. package/skills/release-publisher/SKILL.md +3 -1
  107. package/skills/skill-creator/SKILL.md +8 -9
  108. package/templates/.agents/hooks.json +16 -0
  109. package/templates/.claude/scripts/analyze-errors-hook.sh +2 -2
  110. package/templates/.claude/settings.json +5 -3
  111. package/templates/.codex/hooks.json +2 -2
@@ -7,24 +7,34 @@
7
7
  * @work-item-id WI-345
8
8
  * @work-item-id WI-347
9
9
  * @work-item-id WI-376
10
+ * @work-item-id WI-384
11
+ * @work-item-id WI-385
12
+ * @work-item-id WI-386
10
13
  *
11
14
  * PreToolUse Hook Adapter
12
15
  * Claude Code の PreToolUse Hook エントリポイント
13
16
  * stdin からJSON を読み取り、HandlePreToolUseUseCase を呼び出す
14
17
  */
15
18
 
16
- import { HandlePreToolUseUseCase } from '../application/usecases/handle-pre-tool-use-usecase.js';
17
- import { BashWriteTargetExtractor } from '../domain/services/bash-write-target-extractor.js';
18
- import { HarnessConfigConfigQueryAdapter } from '../infrastructure/adapters/harness-config-config-query-adapter.js';
19
- import { PhaseGateQueryAdapter } from '../infrastructure/adapters/phase-gate-query-adapter.js';
20
- import { FileSystemStoryReflectionQueryAdapter } from '../infrastructure/adapters/file-system-story-reflection-query-adapter.js';
21
- import { QuickModeFullModeRequirementAdapter } from '../infrastructure/adapters/quick-mode-full-mode-requirement-adapter.js';
22
- import { CiGovernanceBaselineGrandfatherAdapter } from '../infrastructure/adapters/ci-governance-baseline-grandfather-adapter.js';
23
- import { HarnessErrorGuidanceAdapter } from '../infrastructure/adapters/harness-error-guidance-adapter.js';
24
- import { FileSystemFullModeSessionQueryAdapter } from '../infrastructure/adapters/file-system-full-mode-session-query-adapter.js';
25
- import { createQuickModeCompositionRoot } from '../../quick-mode/composition-root.js';
26
- import * as path from 'node:path';
27
- import * as fs from 'node:fs/promises';
19
+ import * as fs from "node:fs/promises";
20
+ import * as path from "node:path";
21
+ import { createQuickModeCompositionRoot } from "../../quick-mode/composition-root.js";
22
+ import type {
23
+ CanonicalPreToolUseInput,
24
+ HookResponseProfile,
25
+ } from "../application/dto/normalized-pre-tool-use-request.js";
26
+ import { HandlePreToolUseUseCase } from "../application/usecases/handle-pre-tool-use-usecase.js";
27
+ import { ApplyPatchWriteTargetExtractor } from "../domain/services/apply-patch-write-target-extractor.js";
28
+ import { BashWriteTargetExtractor } from "../domain/services/bash-write-target-extractor.js";
29
+ import { CiGovernanceBaselineGrandfatherAdapter } from "../infrastructure/adapters/ci-governance-baseline-grandfather-adapter.js";
30
+ import { FileSystemFullModeSessionQueryAdapter } from "../infrastructure/adapters/file-system-full-mode-session-query-adapter.js";
31
+ import { FileSystemStoryReflectionQueryAdapter } from "../infrastructure/adapters/file-system-story-reflection-query-adapter.js";
32
+ import { HarnessConfigConfigQueryAdapter } from "../infrastructure/adapters/harness-config-config-query-adapter.js";
33
+ import { HarnessErrorGuidanceAdapter } from "../infrastructure/adapters/harness-error-guidance-adapter.js";
34
+ import { PhaseGateQueryAdapter } from "../infrastructure/adapters/phase-gate-query-adapter.js";
35
+ import { QuickModeFullModeRequirementAdapter } from "../infrastructure/adapters/quick-mode-full-mode-requirement-adapter.js";
36
+ import { PreToolUsePayloadNormalizer } from "./pre-tool-use-payload-normalizer.js";
37
+ import { PreToolUseResponseRenderer } from "./pre-tool-use-response-renderer.js";
28
38
 
29
39
  /**
30
40
  * WI-376 (ADR-039): 呼び出し元 skill 名を受け取るフィールド(caller_skill)は持たない。
@@ -33,23 +43,13 @@ import * as fs from 'node:fs/promises';
33
43
  */
34
44
  interface PreToolUseHookInput {
35
45
  cwd?: string;
36
- tool_name?: string;
37
- tool_input?: {
38
- path?: string;
39
- file_path?: string;
40
- paths?: string[];
41
- command?: string;
42
- content?: string;
43
- old_string?: string;
44
- new_string?: string;
45
- old_str?: string;
46
- new_str?: string;
47
- [key: string]: unknown;
48
- };
46
+ tool_name: string;
47
+ tool_input: CanonicalPreToolUseInput;
49
48
  }
50
49
 
51
50
  interface TargetChange {
52
51
  filePath: string;
52
+ changeKind?: "CREATE" | "MODIFY" | "DELETE";
53
53
  beforeContent?: string | null;
54
54
  afterContent?: string | null;
55
55
  }
@@ -59,15 +59,15 @@ async function readStdin(): Promise<string> {
59
59
  for await (const chunk of process.stdin) {
60
60
  chunks.push(chunk as Buffer);
61
61
  }
62
- return Buffer.concat(chunks).toString('utf8');
62
+ return Buffer.concat(chunks).toString("utf8");
63
63
  }
64
64
 
65
65
  async function findConfigPath(startDir: string): Promise<string> {
66
66
  let dir = startDir;
67
67
  while (true) {
68
68
  const candidates = [
69
- path.join(dir, 'phasegate.config.json'),
70
- path.join(dir, '.phasegate-local', 'phasegate.config.json'),
69
+ path.join(dir, "phasegate.config.json"),
70
+ path.join(dir, ".phasegate-local", "phasegate.config.json"),
71
71
  ];
72
72
  for (const candidate of candidates) {
73
73
  try {
@@ -79,18 +79,22 @@ async function findConfigPath(startDir: string): Promise<string> {
79
79
  if (parent === dir) break;
80
80
  dir = parent;
81
81
  }
82
- return path.join(startDir, 'phasegate.config.json');
82
+ return path.join(startDir, "phasegate.config.json");
83
83
  }
84
84
 
85
85
  function projectRootForConfig(configPath: string): string {
86
86
  const configDir = path.dirname(configPath);
87
- return path.basename(configDir) === '.phasegate-local' ? path.dirname(configDir) : configDir;
87
+ return path.basename(configDir) === ".phasegate-local" ? path.dirname(configDir) : configDir;
88
88
  }
89
89
 
90
90
  function isProjectExternalPath(filePath: string, cwd: string, projectRoot: string): boolean {
91
91
  const resolvedPath = path.resolve(cwd, filePath);
92
92
  const relativePath = path.relative(projectRoot, resolvedPath);
93
- return relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
93
+ return relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
94
+ }
95
+
96
+ function normalizeProjectPath(filePath: string, cwd: string, projectRoot: string): string {
97
+ return path.relative(projectRoot, path.resolve(cwd, filePath)).split(path.sep).join("/");
94
98
  }
95
99
 
96
100
  async function main(): Promise<void> {
@@ -98,29 +102,41 @@ async function main(): Promise<void> {
98
102
  try {
99
103
  raw = await readStdin();
100
104
  } catch {
101
- process.stderr.write('stdin読み取りエラー\n');
105
+ process.stderr.write("stdin読み取りエラー\n");
102
106
  process.exit(2);
103
107
  }
104
108
 
105
- let input: PreToolUseHookInput;
109
+ let parsed: unknown;
106
110
  try {
107
- input = JSON.parse(raw) as PreToolUseHookInput;
111
+ parsed = JSON.parse(raw) as unknown;
108
112
  } catch {
109
113
  process.stderr.write(`不正なJSONです: ${raw}\n`);
110
114
  process.exit(2);
111
115
  }
112
116
 
113
- const toolName = input.tool_name;
114
- if (!toolName) {
115
- process.stderr.write('tool_nameフィールドが必要です\n');
116
- process.exit(2);
117
+ const normalizer = new PreToolUsePayloadNormalizer();
118
+ const normalized = normalizer.normalize(parsed);
119
+ if (!normalized.ok) {
120
+ if (normalized.responseProfile === undefined) {
121
+ process.stderr.write(`${normalized.reason}\n`);
122
+ process.exit(2);
123
+ }
124
+ exitWithRenderedResponse(new PreToolUseResponseRenderer().deny(normalized.responseProfile, normalized.reason));
117
125
  }
126
+ const request = normalized.request;
127
+ const responseProfile = request.responseProfile;
128
+ const input: PreToolUseHookInput = {
129
+ cwd: request.cwd,
130
+ tool_name: request.toolName,
131
+ tool_input: request.toolInput,
132
+ };
133
+ const toolName = input.tool_name;
118
134
 
119
135
  const cwd = path.resolve(input.cwd ?? process.cwd());
120
136
  const toRelative = (p: string): string => {
121
137
  if (path.isAbsolute(p)) {
122
138
  const rel = path.relative(cwd, p);
123
- return rel.startsWith('..') ? p : rel;
139
+ return rel.startsWith("..") ? p : rel;
124
140
  }
125
141
  return p;
126
142
  };
@@ -145,7 +161,26 @@ async function main(): Promise<void> {
145
161
  // translator の WRITE_TOOLS チェックを通過させる(Bash のままではフェーズゲートが
146
162
  // スキップされるため)。
147
163
  let effectiveToolName = toolName;
148
- if (toolName === 'Bash' && typeof input.tool_input?.command === 'string') {
164
+ if (toolName === "apply_patch") {
165
+ const command = input.tool_input?.command;
166
+ if (typeof command !== "string" || command.length === 0) {
167
+ deny(responseProfile, "apply_patch の tool_input.command が必要です");
168
+ }
169
+ const extractor = new ApplyPatchWriteTargetExtractor();
170
+ const patchTargets = extractor.extract(command);
171
+ if (patchTargets.length === 0) {
172
+ deny(responseProfile, "apply_patch command から書き込み対象を抽出できませんでした");
173
+ }
174
+ targetFilePaths.push(...patchTargets.map((target) => toRelative(target.filePath)));
175
+ targetChanges.push(
176
+ ...patchTargets.map((target) => ({
177
+ filePath: toRelative(target.filePath),
178
+ changeKind: target.changeKind,
179
+ })),
180
+ );
181
+ effectiveToolName = "Write";
182
+ }
183
+ if (toolName === "Bash" && typeof input.tool_input?.command === "string") {
149
184
  const extractor = new BashWriteTargetExtractor();
150
185
  const bashTargets = extractor.extract(input.tool_input.command);
151
186
  if (bashTargets.length > 0) {
@@ -160,19 +195,22 @@ async function main(): Promise<void> {
160
195
  recordedTargetPaths.add(change.filePath);
161
196
  }
162
197
  }
163
- effectiveToolName = 'Write';
198
+ effectiveToolName = "Write";
164
199
  }
165
200
  }
166
201
 
167
202
  try {
168
203
  const configPath = await findConfigPath(cwd);
169
204
  const projectRoot = projectRootForConfig(configPath);
170
- const projectTargetFilePaths = targetFilePaths.filter(
171
- (filePath) => !isProjectExternalPath(filePath, cwd, projectRoot),
172
- );
173
- const projectTargetChanges = targetChanges.filter(
174
- (change) => !isProjectExternalPath(change.filePath, cwd, projectRoot),
175
- );
205
+ const projectTargetFilePaths = targetFilePaths
206
+ .filter((filePath) => !isProjectExternalPath(filePath, cwd, projectRoot))
207
+ .map((filePath) => normalizeProjectPath(filePath, cwd, projectRoot));
208
+ const projectTargetChanges = targetChanges
209
+ .filter((change) => !isProjectExternalPath(change.filePath, cwd, projectRoot))
210
+ .map((change) => ({
211
+ ...change,
212
+ filePath: normalizeProjectPath(change.filePath, cwd, projectRoot),
213
+ }));
176
214
  const configQueryPort = new HarnessConfigConfigQueryAdapter(configPath);
177
215
  const phaseGateQueryPort = new PhaseGateQueryAdapter();
178
216
  const storyReflectionQueryPort = new FileSystemStoryReflectionQueryAdapter({
@@ -211,40 +249,56 @@ async function main(): Promise<void> {
211
249
  });
212
250
 
213
251
  if (output.shouldBlock) {
214
- const msg = output.error?.message
215
- ?? `ファイル保護によりブロックされました: ${output.blockedFilePath ?? '不明なファイル'}`;
216
- process.stderr.write(`${msg}\n`);
217
- process.exit(2);
252
+ const msg =
253
+ output.error?.message ?? `ファイル保護によりブロックされました: ${output.blockedFilePath ?? "不明なファイル"}`;
254
+ deny(responseProfile, msg);
218
255
  }
219
256
 
220
257
  // Quick Mode が write を許可した場合に visibility を上げる informational notice。
221
258
  // exit 0 は維持し semantics は変えない。WI-087 finding #3。
222
259
  if (output.quickModeAllowed !== undefined) {
223
260
  const cat = output.quickModeAllowed.dominantCategory;
224
- const suffix = cat !== undefined && cat !== '' ? `, category=${cat}` : '';
261
+ const suffix = cat !== undefined && cat !== "" ? `, category=${cat}` : "";
225
262
  process.stderr.write(`phasegate: write allowed (Quick Mode${suffix})\n`);
226
263
  }
227
264
  if (output.fullModeSessionAllowed !== undefined) {
228
265
  const session = output.fullModeSessionAllowed;
229
- const workItem = session.workItemId !== undefined ? `, workItem=${session.workItemId}` : '';
230
- const unit = session.unit !== undefined ? `, unit=${session.unit}` : '';
266
+ const workItem = session.workItemId !== undefined ? `, workItem=${session.workItemId}` : "";
267
+ const unit = session.unit !== undefined ? `, unit=${session.unit}` : "";
231
268
  process.stderr.write(`phasegate: write allowed (Full Mode session${workItem}${unit})\n`);
269
+ for (const warning of output.storyReflectionWarnings ?? []) {
270
+ process.stderr.write(`phasegate: dependency reflection warning (advisory): ${warning}\n`);
271
+ }
232
272
  }
233
273
 
234
274
  process.exit(0);
235
275
  } catch (error) {
236
- process.stderr.write(`実行エラー: ${String(error)}\n`);
237
- process.exit(2);
276
+ deny(responseProfile, `実行エラー: ${String(error)}`);
238
277
  }
239
278
  }
240
279
 
280
+ function exitWithRenderedResponse(response: {
281
+ readonly stdout: string;
282
+ readonly stderr: string;
283
+ readonly exitCode: 0 | 2;
284
+ }): never {
285
+ if (response.stdout.length > 0) process.stdout.write(response.stdout);
286
+ if (response.stderr.length > 0) process.stderr.write(response.stderr);
287
+ process.exit(response.exitCode);
288
+ throw new Error("process.exit returned unexpectedly");
289
+ }
290
+
291
+ function deny(profile: HookResponseProfile, reason: string): never {
292
+ exitWithRenderedResponse(new PreToolUseResponseRenderer().deny(profile, reason));
293
+ }
294
+
241
295
  async function buildTargetChanges(
242
296
  input: PreToolUseHookInput,
243
297
  cwd: string,
244
298
  toRelative: (p: string) => string,
245
299
  ): Promise<TargetChange[]> {
246
300
  const toolInput = input.tool_input;
247
- if (toolInput == null || typeof toolInput !== 'object') {
301
+ if (toolInput == null || typeof toolInput !== "object") {
248
302
  return [];
249
303
  }
250
304
 
@@ -254,18 +308,20 @@ async function buildTargetChanges(
254
308
  }
255
309
 
256
310
  const filePath = toRelative(rawPath);
257
- const oldString = typeof toolInput.old_string === 'string' ? toolInput.old_string : toolInput.old_str;
258
- const newString = typeof toolInput.new_string === 'string' ? toolInput.new_string : toolInput.new_str;
259
- if (typeof oldString === 'string' && typeof newString === 'string') {
311
+ const oldString = toolInput.old_string;
312
+ const newString = toolInput.new_string;
313
+ if (typeof oldString === "string" && typeof newString === "string") {
260
314
  return [{ filePath, beforeContent: oldString, afterContent: newString }];
261
315
  }
262
316
 
263
- if (typeof toolInput.content === 'string') {
264
- return [{
265
- filePath,
266
- beforeContent: await readExistingContent(cwd, rawPath),
267
- afterContent: toolInput.content,
268
- }];
317
+ if (typeof toolInput.content === "string") {
318
+ return [
319
+ {
320
+ filePath,
321
+ beforeContent: await readExistingContent(cwd, rawPath),
322
+ afterContent: toolInput.content,
323
+ },
324
+ ];
269
325
  }
270
326
 
271
327
  return [];
@@ -274,7 +330,7 @@ async function buildTargetChanges(
274
330
  async function readExistingContent(cwd: string, filePath: string): Promise<string | null> {
275
331
  const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
276
332
  try {
277
- return await fs.readFile(absolutePath, 'utf8');
333
+ return await fs.readFile(absolutePath, "utf8");
278
334
  } catch {
279
335
  return null;
280
336
  }
@@ -286,7 +342,7 @@ async function buildBashTargetChange(
286
342
  toRelative: (p: string) => string,
287
343
  ): Promise<TargetChange> {
288
344
  const filePath = toRelative(rawPath);
289
- if (rawPath.includes('$') || rawPath.includes('`') || rawPath.startsWith('~')) {
345
+ if (rawPath.includes("$") || rawPath.includes("`") || rawPath.startsWith("~")) {
290
346
  // hook ではシェル展開後の実パスを解決できないため、存在チェックを避けて MODIFY 既定にする。
291
347
  return { filePath };
292
348
  }
@@ -296,10 +352,10 @@ async function buildBashTargetChange(
296
352
  return { filePath };
297
353
  } catch (error) {
298
354
  const code = (error as NodeJS.ErrnoException).code;
299
- if (code === 'ENOENT' || code === 'ENOTDIR') {
355
+ if (code === "ENOENT" || code === "ENOTDIR") {
300
356
  // Bash では afterContent を取得できないため、空文字を CREATE 判定用の
301
357
  // 「変更後内容あり」sentinel として渡す。実ファイルへの書き込みは行わない。
302
- return { filePath, beforeContent: null, afterContent: '' };
358
+ return { filePath, beforeContent: null, afterContent: "" };
303
359
  }
304
360
  // 権限エラー等で存在を確認できない場合は従来どおり MODIFY 既定(安全側)。
305
361
  return { filePath };
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @unit agent-integration
3
+ * @layer presentation
4
+ * @work-item-id WI-385
5
+ *
6
+ * Runtime 固有の未信頼 payload を、agent 名に依存せず canonical input へ変換する。
7
+ */
8
+
9
+ import type {
10
+ CanonicalPreToolUseInput,
11
+ HookResponseProfile,
12
+ NormalizedPreToolUseRequest,
13
+ PayloadShape,
14
+ PreToolUseNormalizationResult,
15
+ } from "../application/dto/normalized-pre-tool-use-request.js";
16
+
17
+ type UnknownRecord = Record<string, unknown>;
18
+
19
+ const PATH_KEYS = ["file_path", "filePath", "TargetFile", "targetFile", "target_file", "path"] as const;
20
+ const CONTENT_KEYS = ["content", "CodeContent", "codeContent"] as const;
21
+ const OLD_CONTENT_KEYS = ["old_string", "oldString", "old_str", "oldContent", "OldContent"] as const;
22
+ const NEW_CONTENT_KEYS = ["new_string", "newString", "new_str", "newContent", "NewContent"] as const;
23
+ const COMMAND_KEYS = ["command", "CommandLine", "Command"] as const;
24
+
25
+ const DIRECT_WRITE_TOOLS = new Set(["Write", "write", "write_to_file"]);
26
+ const DIRECT_EDIT_TOOLS = new Set([
27
+ "Edit",
28
+ "search_replace",
29
+ "hashline_edit",
30
+ "replace_file_content",
31
+ "multi_replace_file_content",
32
+ ]);
33
+ const COMMAND_TOOLS = new Set(["Bash", "run_terminal_command", "run_command"]);
34
+
35
+ const PROFILE_BY_SHAPE: Readonly<Record<PayloadShape, HookResponseProfile>> = Object.freeze({
36
+ FLAT_SNAKE_CASE: "LEGACY_EXIT_ONLY",
37
+ FLAT_CAMEL_CASE: "COMPATIBILITY_DENY_ENVELOPE",
38
+ NESTED_TOOL_CALL: "TOP_LEVEL_DENY_ENVELOPE",
39
+ });
40
+
41
+ function isRecord(value: unknown): value is UnknownRecord {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ function nonEmptyString(record: UnknownRecord, keys: readonly string[]): string | undefined {
46
+ for (const key of keys) {
47
+ const value = record[key];
48
+ if (typeof value === "string" && value.trim().length > 0) return value;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ function optionalString(record: UnknownRecord, keys: readonly string[]): string | undefined {
54
+ for (const key of keys) {
55
+ const value = record[key];
56
+ if (typeof value === "string") return value;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ function canonicalToolName(observedName: string): string {
62
+ if (DIRECT_WRITE_TOOLS.has(observedName)) return "Write";
63
+ if (DIRECT_EDIT_TOOLS.has(observedName)) return "Edit";
64
+ if (COMMAND_TOOLS.has(observedName)) return "Bash";
65
+ return observedName;
66
+ }
67
+
68
+ function canonicalDirectInput(toolInput: UnknownRecord): CanonicalPreToolUseInput {
69
+ const filePath = nonEmptyString(toolInput, PATH_KEYS);
70
+ const paths = Array.isArray(toolInput.paths)
71
+ ? toolInput.paths.filter(
72
+ (candidate): candidate is string => typeof candidate === "string" && candidate.trim().length > 0,
73
+ )
74
+ : undefined;
75
+ const content = optionalString(toolInput, CONTENT_KEYS);
76
+ const oldString = optionalString(toolInput, OLD_CONTENT_KEYS);
77
+ const newString = optionalString(toolInput, NEW_CONTENT_KEYS);
78
+ return {
79
+ ...(filePath === undefined ? {} : { file_path: filePath }),
80
+ ...(paths === undefined ? {} : { paths }),
81
+ ...(content === undefined ? {} : { content }),
82
+ ...(oldString === undefined ? {} : { old_string: oldString }),
83
+ ...(newString === undefined ? {} : { new_string: newString }),
84
+ };
85
+ }
86
+
87
+ function canonicalLegacyInput(toolInput: UnknownRecord): CanonicalPreToolUseInput {
88
+ const paths = Array.isArray(toolInput.paths)
89
+ ? toolInput.paths.filter((candidate): candidate is string => typeof candidate === "string")
90
+ : undefined;
91
+ const oldString = optionalString(toolInput, ["old_string", "old_str"]);
92
+ const newString = optionalString(toolInput, ["new_string", "new_str"]);
93
+ return {
94
+ ...(typeof toolInput.path === "string" ? { path: toolInput.path } : {}),
95
+ ...(typeof toolInput.file_path === "string" ? { file_path: toolInput.file_path } : {}),
96
+ ...(paths === undefined ? {} : { paths }),
97
+ ...(typeof toolInput.command === "string" ? { command: toolInput.command } : {}),
98
+ ...(typeof toolInput.content === "string" ? { content: toolInput.content } : {}),
99
+ ...(oldString === undefined ? {} : { old_string: oldString }),
100
+ ...(newString === undefined ? {} : { new_string: newString }),
101
+ };
102
+ }
103
+
104
+ function extractionFailure(
105
+ profile: HookResponseProfile,
106
+ observedName: string,
107
+ expected: "path" | "command" | "patch",
108
+ ): PreToolUseNormalizationResult {
109
+ const candidates =
110
+ expected === "path" ? PATH_KEYS.join(", ") : expected === "command" ? COMMAND_KEYS.join(", ") : "patch, command";
111
+ return {
112
+ ok: false,
113
+ responseProfile: profile,
114
+ reason: `${observedName} の書き込み対象を抽出できませんでした。受理可能な ${expected} key: ${candidates}`,
115
+ };
116
+ }
117
+
118
+ function normalizeKnownInput(
119
+ observedName: string,
120
+ toolInput: UnknownRecord,
121
+ profile: HookResponseProfile,
122
+ inputTruncated: boolean,
123
+ shape: PayloadShape,
124
+ ): { readonly toolName: string; readonly toolInput: CanonicalPreToolUseInput } | PreToolUseNormalizationResult {
125
+ if (observedName === "apply_patch") {
126
+ if (inputTruncated) {
127
+ return { ok: false, responseProfile: profile, reason: "切り詰められた apply_patch は全対象を保証できません" };
128
+ }
129
+ const patch =
130
+ shape === "FLAT_SNAKE_CASE"
131
+ ? nonEmptyString(toolInput, ["command"])
132
+ : nonEmptyString(toolInput, ["patch", "command"]);
133
+ if (patch === undefined) return extractionFailure(profile, observedName, "patch");
134
+ return { toolName: "apply_patch", toolInput: { command: patch } };
135
+ }
136
+
137
+ if (COMMAND_TOOLS.has(observedName)) {
138
+ if (inputTruncated) {
139
+ return { ok: false, responseProfile: profile, reason: "切り詰められた command は全対象を保証できません" };
140
+ }
141
+ const command = nonEmptyString(toolInput, COMMAND_KEYS);
142
+ if (command === undefined) return extractionFailure(profile, observedName, "command");
143
+ return { toolName: "Bash", toolInput: { command } };
144
+ }
145
+
146
+ if (DIRECT_WRITE_TOOLS.has(observedName) || DIRECT_EDIT_TOOLS.has(observedName)) {
147
+ const directInput = canonicalDirectInput(toolInput);
148
+ if (directInput.file_path === undefined && (directInput.paths === undefined || directInput.paths.length === 0))
149
+ return extractionFailure(profile, observedName, "path");
150
+ return { toolName: canonicalToolName(observedName), toolInput: directInput };
151
+ }
152
+
153
+ return { toolName: canonicalToolName(observedName), toolInput: canonicalLegacyInput(toolInput) };
154
+ }
155
+
156
+ export class PreToolUsePayloadNormalizer {
157
+ normalize(payload: unknown): PreToolUseNormalizationResult {
158
+ if (!isRecord(payload)) return { ok: false, reason: "PreToolUse payload は JSON object である必要があります" };
159
+
160
+ const toolCall = isRecord(payload.toolCall) ? payload.toolCall : undefined;
161
+ const shapes: PayloadShape[] = [];
162
+ if (typeof payload.tool_name === "string") shapes.push("FLAT_SNAKE_CASE");
163
+ if (typeof payload.toolName === "string" && isRecord(payload.toolInput)) shapes.push("FLAT_CAMEL_CASE");
164
+ if (toolCall !== undefined && typeof toolCall.name === "string" && isRecord(toolCall.args)) {
165
+ shapes.push("NESTED_TOOL_CALL");
166
+ }
167
+
168
+ if (shapes.length === 0) {
169
+ if ("hook_event_name" in payload || "session_id" in payload || "tool_input" in payload) {
170
+ return {
171
+ ok: false,
172
+ responseProfile: "LEGACY_EXIT_ONLY",
173
+ reason: "tool_nameフィールドが必要です",
174
+ };
175
+ }
176
+ if (toolCall !== undefined) {
177
+ return {
178
+ ok: false,
179
+ responseProfile: "TOP_LEVEL_DENY_ENVELOPE",
180
+ reason: "toolCall payload の name と args を検出できないため拒否しました",
181
+ };
182
+ }
183
+ return { ok: false, reason: "対応する PreToolUse payload shape を検出できませんでした" };
184
+ }
185
+ if (shapes.length > 1)
186
+ return { ok: false, reason: "複数の PreToolUse payload shape が混在しているため拒否しました" };
187
+
188
+ const shape = shapes[0];
189
+ const responseProfile = PROFILE_BY_SHAPE[shape];
190
+ const observedName =
191
+ shape === "FLAT_SNAKE_CASE"
192
+ ? (payload.tool_name as string)
193
+ : shape === "FLAT_CAMEL_CASE"
194
+ ? (payload.toolName as string)
195
+ : (toolCall?.name as string);
196
+ const toolInput =
197
+ shape === "FLAT_SNAKE_CASE"
198
+ ? isRecord(payload.tool_input)
199
+ ? payload.tool_input
200
+ : {}
201
+ : shape === "FLAT_CAMEL_CASE"
202
+ ? (payload.toolInput as UnknownRecord)
203
+ : (toolCall?.args as UnknownRecord);
204
+ const inputTruncated = payload.toolInputTruncated === true;
205
+ const normalizedInput = normalizeKnownInput(observedName, toolInput, responseProfile, inputTruncated, shape);
206
+ if ("ok" in normalizedInput) return normalizedInput;
207
+
208
+ const cwd =
209
+ shape === "NESTED_TOOL_CALL"
210
+ ? Array.isArray(payload.workspacePaths)
211
+ ? payload.workspacePaths.find(
212
+ (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
213
+ )
214
+ : undefined
215
+ : typeof payload.cwd === "string"
216
+ ? payload.cwd
217
+ : undefined;
218
+ const request: NormalizedPreToolUseRequest = {
219
+ shape,
220
+ responseProfile,
221
+ cwd,
222
+ toolName: normalizedInput.toolName,
223
+ toolInput: normalizedInput.toolInput,
224
+ inputTruncated,
225
+ };
226
+ return { ok: true, request };
227
+ }
228
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @unit agent-integration
3
+ * @layer presentation
4
+ * @work-item-id WI-385
5
+ */
6
+
7
+ import type { HookResponseProfile } from "../application/dto/normalized-pre-tool-use-request.js";
8
+
9
+ export interface RenderedPreToolUseResponse {
10
+ readonly stdout: string;
11
+ readonly stderr: string;
12
+ readonly exitCode: 0 | 2;
13
+ }
14
+
15
+ export class PreToolUseResponseRenderer {
16
+ deny(profile: HookResponseProfile, reason: string): RenderedPreToolUseResponse {
17
+ if (profile === "LEGACY_EXIT_ONLY") {
18
+ return { stdout: "", stderr: `${reason}\n`, exitCode: 2 };
19
+ }
20
+ const envelope =
21
+ profile === "COMPATIBILITY_DENY_ENVELOPE"
22
+ ? {
23
+ decision: "deny",
24
+ reason,
25
+ hookSpecificOutput: {
26
+ hookEventName: "PreToolUse",
27
+ permissionDecision: "deny",
28
+ permissionDecisionReason: reason,
29
+ },
30
+ }
31
+ : { decision: "deny", reason };
32
+ return { stdout: `${JSON.stringify(envelope)}\n`, stderr: `${reason}\n`, exitCode: 2 };
33
+ }
34
+
35
+ allow(_profile: HookResponseProfile): RenderedPreToolUseResponse {
36
+ return { stdout: "", stderr: "", exitCode: 0 };
37
+ }
38
+ }
@@ -4,6 +4,7 @@
4
4
  * @work-item-id WI-203
5
5
  * @work-item-id WI-208
6
6
  * @work-item-id WI-323
7
+ * @work-item-id WI-220
7
8
  *
8
9
  * Stop Hook Adapter
9
10
  * Claude Code の Stop Hook エントリポイント
@@ -14,7 +15,6 @@ import * as path from "node:path";
14
15
  import { HandleStopUseCase } from "../application/usecases/handle-stop-usecase.js";
15
16
  import { ChildProcessCliExecutorAdapter } from "../infrastructure/adapters/child-process-cli-executor-adapter.js";
16
17
  import { EnvFileReentryGuardStateAdapter } from "../infrastructure/adapters/env-file-reentry-guard-state-adapter.js";
17
- import { HarnessApiCliCommandRegistryAdapter } from "../infrastructure/adapters/harness-api-cli-command-registry-adapter.js";
18
18
  import { HarnessConfigConfigQueryAdapter } from "../infrastructure/adapters/harness-config-config-query-adapter.js";
19
19
  import { recordHookSkipEvent } from "./hook-skip-event-recorder.js";
20
20
 
@@ -106,14 +106,12 @@ async function main(): Promise<void> {
106
106
  const configPath = await findConfigPath();
107
107
  const reentryGuardStatePort = new EnvFileReentryGuardStateAdapter({ strategy: "env" });
108
108
  const configQueryPort = new HarnessConfigConfigQueryAdapter(configPath);
109
- const cliCommandRegistryPort = new HarnessApiCliCommandRegistryAdapter();
110
109
  const cliExecutorPort = new ChildProcessCliExecutorAdapter();
111
110
 
112
111
  const useCase = new HandleStopUseCase({
113
112
  reentryGuardStatePort,
114
113
  cliExecutorPort,
115
114
  configQueryPort,
116
- cliCommandRegistryPort,
117
115
  });
118
116
 
119
117
  const output = await useCase.execute({ sessionId });