phasegate 0.264.0 → 0.315.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 (92) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/docs/ADR/017-warning-severity-aggregation.md +17 -0
  3. package/docs/ADR/038-config-state-operation-permission-policy.md +78 -0
  4. package/docs/guide/installation.md +1 -1
  5. package/docs/guide/layer-model.md +2 -0
  6. package/docs/guide/quick-vs-full-mode.md +28 -3
  7. package/docs/guide/troubleshooting.md +37 -0
  8. package/docs/templates/agent-context/CLAUDE.md.template.md +6 -6
  9. package/docs/templates/ci/aidlc-gate.yml +22 -4
  10. package/package.json +2 -2
  11. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +98 -18
  12. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +25 -3
  13. package/scripts/harness/agent-integration/infrastructure/adapters/file-system-full-mode-session-query-adapter.ts +75 -23
  14. package/scripts/harness/agent-integration/infrastructure/adapters/harness-config-config-query-adapter.ts +45 -27
  15. package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +29 -16
  16. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +51 -6
  17. package/scripts/harness/agent-integration/presentation/stop-hook.ts +35 -26
  18. package/scripts/harness/ci-governance/composition-root.ts +2 -2
  19. package/scripts/harness/ci-governance/domain/services/claude-md-composer.ts +20 -11
  20. package/scripts/harness/ci-governance/presentation/handlers/check-repetition-handler.ts +10 -2
  21. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +5 -1
  22. package/scripts/harness/config-foundation/domain/harness-config.ts +10 -7
  23. package/scripts/harness/config-foundation/domain/services/preset-resolution-service.ts +4 -1
  24. package/scripts/harness/config-foundation/domain/value-objects/project-config.ts +34 -18
  25. package/scripts/harness/config-foundation/infrastructure/presets/minimal.json +1 -1
  26. package/scripts/harness/config-foundation/infrastructure/presets/standard.json +1 -1
  27. package/scripts/harness/config-foundation/infrastructure/presets/strict.json +1 -1
  28. package/scripts/harness/config-foundation/infrastructure/repositories/file-system-config-repository.ts +27 -18
  29. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +1 -8
  30. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v3.schema.json +1 -8
  31. package/scripts/harness/harness-api/domain/ports/config-query-port.ts +11 -1
  32. package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +135 -87
  33. package/scripts/harness/harness-api/domain/services/status-derivation-service.ts +31 -21
  34. package/scripts/harness/harness-api/domain/value-objects/ci-check-result.ts +2 -22
  35. package/scripts/harness/harness-api/domain/value-objects/harness-status-summary.ts +23 -8
  36. package/scripts/harness/harness-api/infrastructure/adapters/biome-ast-engine-lint-adapter.ts +4 -4
  37. package/scripts/harness/harness-api/infrastructure/adapters/harness-config-query-adapter.ts +79 -34
  38. package/scripts/harness/harness-error/application/dto/create-harness-error-input.ts +3 -1
  39. package/scripts/harness/harness-error/application/dto/harness-error-contract.ts +3 -1
  40. package/scripts/harness/harness-error/application/mappers/harness-error-contract-mapper.ts +9 -17
  41. package/scripts/harness/harness-error/application/usecases/create-harness-error-use-case.ts +7 -7
  42. package/scripts/harness/harness-error/domain/services/harness-error-factory.ts +30 -33
  43. package/scripts/harness/harness-error/domain/value-objects/error-definition.ts +28 -17
  44. package/scripts/harness/harness-error/domain/value-objects/harness-error.ts +30 -9
  45. package/scripts/harness/harness-error/domain/value-objects/remediation-type.ts +30 -0
  46. package/scripts/harness/harness-error/infrastructure/registry/l2-error-definitions.ts +44 -29
  47. package/scripts/harness/harness-error/infrastructure/registry/l3-error-definitions.ts +44 -27
  48. package/scripts/harness/harness-error/infrastructure/registry/l4-error-definitions.ts +47 -32
  49. package/scripts/harness/installation/application/checks/claude-context-missing-check.ts +13 -7
  50. package/scripts/harness/installation/application/checks/config-status-check.ts +52 -0
  51. package/scripts/harness/installation/application/checks/husky-pre-commit-missing-check.ts +6 -0
  52. package/scripts/harness/installation/application/ports/config-status-probe-port.ts +9 -0
  53. package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +30 -8
  54. package/scripts/harness/installation/application/usecases/run-install.ts +234 -53
  55. package/scripts/harness/installation/application/usecases/run-reconcile.ts +311 -70
  56. package/scripts/harness/installation/composition-root.ts +13 -3
  57. package/scripts/harness/installation/domain/check-id.ts +2 -0
  58. package/scripts/harness/installation/domain/config-status.ts +17 -0
  59. package/scripts/harness/installation/domain/deployment-manifest.ts +43 -0
  60. package/scripts/harness/installation/domain/ports/heuristic-check.ts +10 -1
  61. package/scripts/harness/installation/infrastructure/adapters/config-status-probe-adapter.ts +79 -0
  62. package/scripts/harness/installation/presentation/cli/doctor-handler.ts +6 -1
  63. package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +19 -5
  64. package/scripts/harness/integrations/pre-commit.ts +17 -3
  65. package/scripts/harness/main.ts +83 -15
  66. package/scripts/harness/phase-dependency-model/infrastructure/filesystem/markdown-plan-document-reader.ts +60 -32
  67. package/scripts/harness/phase2-extensions/presentation/handlers/check-freshness-handler.ts +17 -9
  68. package/scripts/harness/quick-mode/application/ports/file-existence-port.ts +15 -0
  69. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +60 -22
  70. package/scripts/harness/quick-mode/composition-root.ts +25 -15
  71. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +72 -3
  72. package/scripts/harness/quick-mode/infrastructure/adapters/fs-file-existence-adapter.ts +38 -0
  73. package/scripts/harness/skill-quality/infrastructure/adapters/file-system-requirement-test-matrix-adapter.ts +51 -8
  74. package/scripts/harness/skill-quality/presentation/handlers/check-coverage-handler.ts +13 -6
  75. package/scripts/harness/traceability-model/domain/value-objects/work-item-frontmatter.ts +5 -1
  76. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-identity-gateway.ts +6 -1
  77. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +15 -2
  78. package/scripts/harness/validator-system/application/use-cases/aggregate-validation-results-usecase.ts +11 -14
  79. package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +32 -7
  80. package/scripts/harness/validator-system/composition-root.ts +4 -1
  81. package/scripts/harness/validator-system/domain/ports/ac-coverage-policy-port.ts +10 -1
  82. package/scripts/harness/validator-system/domain/services/effective-severity-policy.ts +39 -0
  83. package/scripts/harness/validator-system/domain/value-objects/consistency-report.ts +6 -4
  84. package/scripts/harness/validator-system/domain/value-objects/drift-report.ts +12 -7
  85. package/scripts/harness/validator-system/domain/value-objects/validation-result.ts +11 -3
  86. package/scripts/harness/validator-system/infrastructure/adapters/file-system-security-pattern-scanner-adapter.ts +20 -17
  87. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +89 -3
  88. package/scripts/harness/validator-system/infrastructure/adapters/nyquist-ac-coverage-policy-adapter.ts +49 -20
  89. package/scripts/harness/validator-system/infrastructure/adapters/phase-dependency-phase-gate-policy-adapter.ts +35 -14
  90. package/scripts/harness/validator-system/infrastructure/adapters/traceability-metadata-policy-adapter.ts +13 -8
  91. package/scripts/harness/validator-system/presentation/formatters/agent-validation-result-formatter.ts +19 -10
  92. package/skills/quick-implementor/SKILL.md +19 -0
@@ -140,6 +140,28 @@ function getCommandName(tokens: Token[]): string | undefined {
140
140
  return undefined;
141
141
  }
142
142
 
143
+ /** リダイレクト演算子の右辺を解釈し、fd 複製の場合は書き込み先なしとして扱う。 */
144
+ function getRedirectTarget(tokens: Token[], redirectIndex: number): string | undefined {
145
+ const next = tokens[redirectIndex + 1];
146
+ if (next === undefined) return undefined;
147
+
148
+ if (next.quoted === 'none' && /^&\d+$/.test(next.value)) {
149
+ return undefined;
150
+ }
151
+
152
+ if (next.quoted === 'none' && next.value === '&') {
153
+ const afterAmpersand = tokens[redirectIndex + 2];
154
+ if (afterAmpersand === undefined) return undefined;
155
+ if (afterAmpersand.quoted === 'none' && /^\d+$/.test(afterAmpersand.value)) {
156
+ return undefined;
157
+ }
158
+ // `>& file` は csh 形式の実ファイル書き込みとして安全側で抽出する。
159
+ return afterAmpersand.value;
160
+ }
161
+
162
+ return next.value;
163
+ }
164
+
143
165
  /**
144
166
  * 1 コマンド分のトークン列から書き込み先を抽出する。
145
167
  * リダイレクト先はコマンド種別によらず検出する (全コマンド共通)。
@@ -151,9 +173,9 @@ function extractFromSingleCommand(tokens: Token[]): string[] {
151
173
  for (let i = 0; i < tokens.length; i += 1) {
152
174
  const t = tokens[i];
153
175
  if (t.quoted === 'none' && (t.value === '>' || t.value === '>>' || t.value === '>|')) {
154
- const next = tokens[i + 1];
155
- if (next !== undefined) {
156
- results.push(next.value);
176
+ const target = getRedirectTarget(tokens, i);
177
+ if (target !== undefined) {
178
+ results.push(target);
157
179
  }
158
180
  }
159
181
  }
@@ -1,17 +1,59 @@
1
1
  // @unit agent-integration
2
2
  // @layer infrastructure
3
3
  // @work-item-id WI-206
4
+ // @work-item-id WI-348
5
+ // @work-item-id WI-350
4
6
 
5
- import * as fs from 'node:fs/promises';
6
- import * as path from 'node:path';
7
-
7
+ import * as fs from "node:fs/promises";
8
+ import * as path from "node:path";
9
+ import type { ConfigQueryPort } from "../../domain/ports/config-query-port.js";
8
10
  import type {
9
11
  FullModeSessionQueryInput,
10
12
  FullModeSessionQueryPort,
11
13
  FullModeSessionQueryResult,
12
- } from '../../domain/ports/full-mode-session-query-port.js';
13
- import type { ConfigQueryPort } from '../../domain/ports/config-query-port.js';
14
- import { WriteTargetScope } from '../../domain/value-objects/write-target-scope.js';
14
+ } from "../../domain/ports/full-mode-session-query-port.js";
15
+ import { WriteTargetScope } from "../../domain/value-objects/write-target-scope.js";
16
+
17
+ /**
18
+ * quick-mode の `ChangeCategoryValue` と同一の語彙。
19
+ * 照合相手は quick-mode 分類結果(`dominantCategory`)なので、この語彙以外は
20
+ * session.json 側の書式誤りとして扱う。
21
+ * `session begin` 側との同期は
22
+ * `__tests__/integration/harness-api/session-begin-allowed-categories.integration.test.ts`
23
+ * が検証する(WI-348)。
24
+ */
25
+ const KNOWN_CHANGE_CATEGORIES: ReadonlySet<string> = new Set([
26
+ "bugfix",
27
+ "docs",
28
+ "test",
29
+ "config",
30
+ "feature",
31
+ "domain",
32
+ "api",
33
+ ]);
34
+
35
+ /**
36
+ * WI-348: v0.301.0 以前の `phasegate session begin` は allowedCategories に
37
+ * レイヤー名(domain / application / infrastructure / presentation / config)を
38
+ * 書き出していた。この語彙は ChangeCategory と domain / config でしか交差せず、
39
+ * 旧形式 session.json をそのまま照合すると feature / api / bugfix などが
40
+ * 恒常的に拒否され session が事実上無力化する。
41
+ *
42
+ * `session begin` はカテゴリ指定フラグを持たず常に定数を書き出すため、
43
+ * 「ChangeCategory 語彙に無い値が混ざっている」= 旧形式生成物と判断できる。
44
+ * その場合のみ全カテゴリ許可へ正規化する。
45
+ * 全要素が既知カテゴリなら手編集による意図的な絞り込みとみなし原文を尊重する。
46
+ * unit / 期限 / target path のスコープ判定は正規化後も従来どおり効く。
47
+ */
48
+ function normalizeAllowedCategories(rawCategories: readonly string[]): readonly string[] {
49
+ if (rawCategories.length === 0) {
50
+ return rawCategories;
51
+ }
52
+ if (rawCategories.every((value) => KNOWN_CHANGE_CATEGORIES.has(value))) {
53
+ return rawCategories;
54
+ }
55
+ return [...KNOWN_CHANGE_CATEGORIES];
56
+ }
15
57
 
16
58
  interface FullModeSessionDocument {
17
59
  readonly mode?: unknown;
@@ -35,52 +77,62 @@ export class FileSystemFullModeSessionQueryAdapter implements FullModeSessionQue
35
77
  async check(input: FullModeSessionQueryInput): Promise<FullModeSessionQueryResult> {
36
78
  let document: FullModeSessionDocument;
37
79
  try {
38
- const raw = await fs.readFile(path.join(this.options.rootDir, '.phasegate', 'session.json'), 'utf8');
80
+ const raw = await fs.readFile(path.join(this.options.rootDir, ".phasegate", "session.json"), "utf8");
39
81
  document = JSON.parse(raw) as FullModeSessionDocument;
40
82
  } catch {
41
- return { active: false, allowed: false, reason: 'session marker not found or unreadable' };
83
+ return { active: false, allowed: false, reason: "session marker not found or unreadable" };
42
84
  }
43
85
 
44
- if (document.mode !== 'full') {
45
- return { active: false, allowed: false, reason: 'session mode is not full' };
86
+ if (document.mode !== "full") {
87
+ return { active: false, allowed: false, reason: "session mode is not full" };
46
88
  }
47
- if (typeof document.unit !== 'string' || document.unit === '') {
48
- return { active: true, allowed: false, reason: 'session unit is missing' };
89
+ if (typeof document.unit !== "string" || document.unit === "") {
90
+ return { active: true, allowed: false, reason: "session unit is missing" };
49
91
  }
50
- if (typeof document.workItemId !== 'string' || !/^WI-\d+$/.test(document.workItemId)) {
51
- return { active: true, allowed: false, reason: 'session work item is invalid' };
92
+ if (typeof document.workItemId !== "string" || !/^WI-\d+$/.test(document.workItemId)) {
93
+ return { active: true, allowed: false, reason: "session work item is invalid" };
52
94
  }
53
- if (typeof document.expiresAt !== 'string' || Number.isNaN(Date.parse(document.expiresAt))) {
54
- return { active: true, allowed: false, reason: 'session expiry is invalid' };
95
+ if (typeof document.expiresAt !== "string" || Number.isNaN(Date.parse(document.expiresAt))) {
96
+ return { active: true, allowed: false, reason: "session expiry is invalid" };
55
97
  }
56
98
  if ((this.options.now?.() ?? new Date()).getTime() >= Date.parse(document.expiresAt)) {
57
99
  return {
58
100
  active: true,
59
101
  allowed: false,
60
- reason: 'session expired',
102
+ reason: "session expired",
61
103
  workItemId: document.workItemId,
62
104
  unit: document.unit,
63
105
  expiresAt: document.expiresAt,
64
106
  };
65
107
  }
66
- if (!Array.isArray(document.allowedCategories) || document.allowedCategories.some((value) => typeof value !== 'string')) {
67
- return { active: true, allowed: false, reason: 'session allowedCategories is invalid' };
108
+ if (
109
+ !Array.isArray(document.allowedCategories) ||
110
+ document.allowedCategories.some((value) => typeof value !== "string")
111
+ ) {
112
+ return { active: true, allowed: false, reason: "session allowedCategories is invalid" };
68
113
  }
69
- if (input.dominantCategory === undefined || !document.allowedCategories.includes(input.dominantCategory)) {
114
+ const allowedCategories = normalizeAllowedCategories(document.allowedCategories as readonly string[]);
115
+ if (input.dominantCategory === undefined || !allowedCategories.includes(input.dominantCategory)) {
70
116
  return {
71
117
  active: true,
72
118
  allowed: false,
73
- reason: `category ${input.dominantCategory ?? '<unknown>'} is not allowed by session`,
119
+ reason: `category ${input.dominantCategory ?? "<unknown>"} is not allowed by session`,
74
120
  workItemId: document.workItemId,
75
121
  unit: document.unit,
76
122
  expiresAt: document.expiresAt,
77
123
  };
78
124
  }
79
- if (input.unitId === undefined || input.unitId !== document.unit) {
125
+ // WI-350: unit を持たないパス(プロジェクト直下のファイル・__tests__ 配下など)は
126
+ // 集約 unitId が undefined になる。旧実装はこれを即拒否していたが、
127
+ // 直下の allTargetPathsBelongToUnit は unitless パスを許容しており内部矛盾していた。
128
+ // 集約チェックは「unitId が定義済みかつ session unit と不一致」の場合のみ拒否し、
129
+ // unitless は per-path チェックへ委ねる。unit 付きパスが 1 つでも混ざれば
130
+ // per-path 側が従来どおり拒否するため、unit 境界は緩まない。
131
+ if (input.unitId !== undefined && input.unitId !== document.unit) {
80
132
  return {
81
133
  active: true,
82
134
  allowed: false,
83
- reason: `target unit ${input.unitId ?? '<unknown>'} does not match session unit ${document.unit}`,
135
+ reason: `target unit ${input.unitId} does not match session unit ${document.unit}`,
84
136
  workItemId: document.workItemId,
85
137
  unit: document.unit,
86
138
  expiresAt: document.expiresAt,
@@ -6,13 +6,9 @@
6
6
  * HarnessConfigV2 の harnesses セクションから Hook 設定を読み取る
7
7
  */
8
8
 
9
- import * as fs from 'node:fs';
10
- import type {
11
- BaselineConfig,
12
- ConfigQueryPort,
13
- HookType,
14
- } from '../../domain/ports/config-query-port.js';
15
- import { ProjectPaths } from '../../domain/value-objects/project-paths.js';
9
+ import * as fs from "node:fs";
10
+ import type { BaselineConfig, ConfigQueryPort, HookType } from "../../domain/ports/config-query-port.js";
11
+ import { ProjectPaths } from "../../domain/value-objects/project-paths.js";
16
12
 
17
13
  interface ProjectDocsSection {
18
14
  inception?: string;
@@ -84,8 +80,37 @@ export class HarnessConfigConfigQueryAdapter implements ConfigQueryPort {
84
80
  if (this.cachedConfig !== null) {
85
81
  return this.cachedConfig;
86
82
  }
87
- const raw = fs.readFileSync(this.configPath, 'utf8');
88
- const doc = JSON.parse(raw) as HarnessConfigDocument;
83
+ // GitHub #40: config が JSON として壊れている場合に hook プロセス全体を throw で
84
+ // 落とすと、エージェントの全ツール呼び出しが遮断され config の修復自体が不能になる。
85
+ // main.ts の ConfigPersistenceError と同じ意味論(警告 + 既定値で続行)に揃える。
86
+ // ADR-038 §4 G1 / WI-333: config **不在**(ENOENT)も同じデッドロックを起こすため、
87
+ // invalid-json と同じ「警告 + 既定値で続行」の fail-open とする。config を作成する
88
+ // Write 自体が遮断されない(自己修復経路が残る)ことがこの adapter の契約。
89
+ // それ以外の fs エラー(EACCES 等の真の異常)は従来どおり throw する。
90
+ // gated スコープへの書き込みは phase-gate 側が既定設定で fail-closed を維持する。
91
+ let raw: string;
92
+ try {
93
+ raw = fs.readFileSync(this.configPath, "utf8");
94
+ } catch (error) {
95
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
96
+ process.stderr.write(
97
+ `Warning: phasegate.config.json not found at ${this.configPath}; continuing with default hook settings so self-repair stays possible (run \`phasegate init\` to generate one).\n`,
98
+ );
99
+ this.cachedConfig = {};
100
+ return this.cachedConfig;
101
+ }
102
+ throw error;
103
+ }
104
+ let doc: HarnessConfigDocument;
105
+ try {
106
+ doc = JSON.parse(raw) as HarnessConfigDocument;
107
+ } catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ process.stderr.write(
110
+ `Warning: phasegate.config.json could not be parsed as JSON (${message}); continuing with default hook settings so self-repair stays possible.\n`,
111
+ );
112
+ doc = {};
113
+ }
89
114
  this.cachedConfig = doc;
90
115
  return doc;
91
116
  }
@@ -98,10 +123,10 @@ export class HarnessConfigConfigQueryAdapter implements ConfigQueryPort {
98
123
  // pre-tool-use → agentLessonCollection
99
124
  // post-tool-use → cascadeUpdate
100
125
  // stop → デフォルト有効
101
- if (hookType === 'pre-tool-use') {
126
+ if (hookType === "pre-tool-use") {
102
127
  return harnesses.agentLessonCollection ?? true;
103
128
  }
104
- if (hookType === 'post-tool-use') {
129
+ if (hookType === "post-tool-use") {
105
130
  return harnesses.cascadeUpdate ?? true;
106
131
  }
107
132
  // stop はデフォルト有効
@@ -111,13 +136,9 @@ export class HarnessConfigConfigQueryAdapter implements ConfigQueryPort {
111
136
  async getProtectedFilePatterns(): Promise<string[]> {
112
137
  const config = this.loadConfig();
113
138
  const configured = config.protectedFiles?.patterns ?? [];
114
- const principlesDocs = config.paths?.principlesDocs ?? 'docs/principles';
115
- const folderRulesDoc = config.paths?.folderRulesDoc ?? 'docs/folder_management_rules.md';
116
- return [
117
- ...configured,
118
- `${normalizeProjectPath(principlesDocs)}/**`,
119
- normalizeProjectPath(folderRulesDoc),
120
- ];
139
+ const principlesDocs = config.paths?.principlesDocs ?? "docs/principles";
140
+ const folderRulesDoc = config.paths?.folderRulesDoc ?? "docs/folder_management_rules.md";
141
+ return [...configured, `${normalizeProjectPath(principlesDocs)}/**`, normalizeProjectPath(folderRulesDoc)];
121
142
  }
122
143
 
123
144
  async getProtectedFileExclusions(): Promise<string[]> {
@@ -135,13 +156,10 @@ export class HarnessConfigConfigQueryAdapter implements ConfigQueryPort {
135
156
  const paths = config.project?.paths;
136
157
  const topLevelPaths = config.paths;
137
158
 
138
- return ProjectPaths.create(
139
- paths?.source ?? ['scripts/harness'],
140
- {
141
- construction: paths?.docs?.construction ?? topLevelPaths?.designDocs ?? 'docs/product/construction',
142
- inception: paths?.docs?.inception ?? topLevelPaths?.inceptionDocs ?? 'docs/inception',
143
- },
144
- );
159
+ return ProjectPaths.create(paths?.source ?? ["scripts/harness"], {
160
+ construction: paths?.docs?.construction ?? topLevelPaths?.designDocs ?? "docs/product/construction",
161
+ inception: paths?.docs?.inception ?? topLevelPaths?.inceptionDocs ?? "docs/inception",
162
+ });
145
163
  }
146
164
 
147
165
  async getBaselineConfig(): Promise<BaselineConfig> {
@@ -149,7 +167,7 @@ export class HarnessConfigConfigQueryAdapter implements ConfigQueryPort {
149
167
  const baseline = config.baseline ?? {};
150
168
  return {
151
169
  enabled: baseline.enabled ?? true,
152
- path: baseline.path ?? '.phasegate/baseline.json',
170
+ path: baseline.path ?? ".phasegate/baseline.json",
153
171
  };
154
172
  }
155
173
 
@@ -162,5 +180,5 @@ export class HarnessConfigConfigQueryAdapter implements ConfigQueryPort {
162
180
  }
163
181
 
164
182
  function normalizeProjectPath(path: string): string {
165
- return path.replace(/\\/g, '/').replace(/\/+$/g, '');
183
+ return path.replace(/\\/g, "/").replace(/\/+$/g, "");
166
184
  }
@@ -2,18 +2,19 @@
2
2
  * @layer presentation
3
3
  * @unit agent-integration
4
4
  * @work-item-id WI-208
5
+ * @work-item-id WI-323
5
6
  *
6
7
  * PostToolUse Hook Adapter
7
8
  * Claude Code の PostToolUse Hook エントリポイント
8
9
  */
9
10
 
10
- import { HandlePostToolUseUseCase } from '../application/usecases/handle-post-tool-use-usecase.js';
11
- import { HarnessConfigConfigQueryAdapter } from '../infrastructure/adapters/harness-config-config-query-adapter.js';
12
- import { HarnessApiCliCommandRegistryAdapter } from '../infrastructure/adapters/harness-api-cli-command-registry-adapter.js';
13
- import { ChildProcessCliExecutorAdapter } from '../infrastructure/adapters/child-process-cli-executor-adapter.js';
14
- import { recordHookSkipEvent } from './hook-skip-event-recorder.js';
15
- import * as path from 'node:path';
16
- import * as fs from 'node:fs/promises';
11
+ import * as fs from "node:fs/promises";
12
+ import * as path from "node:path";
13
+ import { HandlePostToolUseUseCase } from "../application/usecases/handle-post-tool-use-usecase.js";
14
+ import { ChildProcessCliExecutorAdapter } from "../infrastructure/adapters/child-process-cli-executor-adapter.js";
15
+ import { HarnessApiCliCommandRegistryAdapter } from "../infrastructure/adapters/harness-api-cli-command-registry-adapter.js";
16
+ import { HarnessConfigConfigQueryAdapter } from "../infrastructure/adapters/harness-config-config-query-adapter.js";
17
+ import { recordHookSkipEvent } from "./hook-skip-event-recorder.js";
17
18
 
18
19
  interface PostToolUseHookInput {
19
20
  tool_name?: string;
@@ -25,15 +26,15 @@ async function readStdin(): Promise<string> {
25
26
  for await (const chunk of process.stdin) {
26
27
  chunks.push(chunk as Buffer);
27
28
  }
28
- return Buffer.concat(chunks).toString('utf8');
29
+ return Buffer.concat(chunks).toString("utf8");
29
30
  }
30
31
 
31
32
  async function findConfigPath(): Promise<string> {
32
33
  let dir = process.cwd();
33
34
  while (true) {
34
35
  const candidates = [
35
- path.join(dir, 'phasegate.config.json'),
36
- path.join(dir, '.phasegate-local', 'phasegate.config.json'),
36
+ path.join(dir, "phasegate.config.json"),
37
+ path.join(dir, ".phasegate-local", "phasegate.config.json"),
37
38
  ];
38
39
  for (const candidate of candidates) {
39
40
  try {
@@ -45,12 +46,12 @@ async function findConfigPath(): Promise<string> {
45
46
  if (parent === dir) break;
46
47
  dir = parent;
47
48
  }
48
- return path.join(process.cwd(), 'phasegate.config.json');
49
+ return path.join(process.cwd(), "phasegate.config.json");
49
50
  }
50
51
 
51
52
  function projectRootForConfig(configPath: string): string {
52
53
  const configDir = path.dirname(configPath);
53
- return path.basename(configDir) === '.phasegate-local' ? path.dirname(configDir) : configDir;
54
+ return path.basename(configDir) === ".phasegate-local" ? path.dirname(configDir) : configDir;
54
55
  }
55
56
 
56
57
  async function main(): Promise<void> {
@@ -58,7 +59,7 @@ async function main(): Promise<void> {
58
59
  try {
59
60
  raw = await readStdin();
60
61
  } catch {
61
- process.stderr.write('stdin読み取りエラー\n');
62
+ process.stderr.write("stdin読み取りエラー\n");
62
63
  process.exit(2);
63
64
  }
64
65
 
@@ -72,8 +73,20 @@ async function main(): Promise<void> {
72
73
 
73
74
  const toolName = input.tool_name;
74
75
  if (!toolName) {
75
- process.stderr.write('tool_nameフィールドが必要です\n');
76
- process.exit(2);
76
+ // WI-323: PostToolUse はツール実行後の lint フィードバックでありゲートではないため、
77
+ // tool_name 欠落は fail-open でスキップする(WI-314 / github#40 方針)。
78
+ // ※ pre-tool-use-hook の同ガードは書き込みゲートなので fail-closed (exit 2) を維持する。
79
+ const configPath = await findConfigPath();
80
+ await recordHookSkipEvent({
81
+ projectRoot: projectRootForConfig(configPath),
82
+ hookType: "post-tool-use",
83
+ reason: "TOOL_NAME_MISSING",
84
+ targetPaths: [],
85
+ });
86
+ process.stderr.write(
87
+ "警告: stdin payload に tool_name が無いため post-tool-use hook の処理をスキップしました (fail-open)\n",
88
+ );
89
+ process.exit(0);
77
90
  }
78
91
 
79
92
  try {
@@ -93,7 +106,7 @@ async function main(): Promise<void> {
93
106
  if (output.skipReason) {
94
107
  await recordHookSkipEvent({
95
108
  projectRoot: projectRootForConfig(configPath),
96
- hookType: 'post-tool-use',
109
+ hookType: "post-tool-use",
97
110
  reason: output.skipReason,
98
111
  targetPaths: [],
99
112
  });
@@ -4,6 +4,8 @@
4
4
  * @work-item-id WI-202 / WI-204
5
5
  * @work-item-id WI-206
6
6
  * @work-item-id WI-208
7
+ * @work-item-id WI-345
8
+ * @work-item-id WI-347
7
9
  *
8
10
  * PreToolUse Hook Adapter
9
11
  * Claude Code の PreToolUse Hook エントリポイント
@@ -80,8 +82,10 @@ function projectRootForConfig(configPath: string): string {
80
82
  return path.basename(configDir) === '.phasegate-local' ? path.dirname(configDir) : configDir;
81
83
  }
82
84
 
83
- function isProjectExternalAbsolutePath(filePath: string): boolean {
84
- return path.isAbsolute(filePath);
85
+ function isProjectExternalPath(filePath: string, cwd: string, projectRoot: string): boolean {
86
+ const resolvedPath = path.resolve(cwd, filePath);
87
+ const relativePath = path.relative(projectRoot, resolvedPath);
88
+ return relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
85
89
  }
86
90
 
87
91
  async function main(): Promise<void> {
@@ -141,15 +145,29 @@ async function main(): Promise<void> {
141
145
  const bashTargets = extractor.extract(input.tool_input.command);
142
146
  if (bashTargets.length > 0) {
143
147
  targetFilePaths.push(...bashTargets.map(toRelative));
148
+ const recordedTargetPaths = new Set(targetChanges.map((change) => change.filePath));
149
+ const bashTargetChanges = await Promise.all(
150
+ bashTargets.map((bashTarget) => buildBashTargetChange(cwd, bashTarget, toRelative)),
151
+ );
152
+ for (const change of bashTargetChanges) {
153
+ if (!recordedTargetPaths.has(change.filePath)) {
154
+ targetChanges.push(change);
155
+ recordedTargetPaths.add(change.filePath);
156
+ }
157
+ }
144
158
  effectiveToolName = 'Write';
145
159
  }
146
160
  }
147
161
 
148
162
  try {
149
163
  const configPath = await findConfigPath(cwd);
150
- const projectTargetFilePaths = targetFilePaths.filter((filePath) => !isProjectExternalAbsolutePath(filePath));
151
- const projectTargetChanges = targetChanges.filter((change) => !isProjectExternalAbsolutePath(change.filePath));
152
164
  const projectRoot = projectRootForConfig(configPath);
165
+ const projectTargetFilePaths = targetFilePaths.filter(
166
+ (filePath) => !isProjectExternalPath(filePath, cwd, projectRoot),
167
+ );
168
+ const projectTargetChanges = targetChanges.filter(
169
+ (change) => !isProjectExternalPath(change.filePath, cwd, projectRoot),
170
+ );
153
171
  const configQueryPort = new HarnessConfigConfigQueryAdapter(configPath);
154
172
  const phaseGateQueryPort = new PhaseGateQueryAdapter();
155
173
  const storyReflectionQueryPort = new FileSystemStoryReflectionQueryAdapter({
@@ -157,7 +175,8 @@ async function main(): Promise<void> {
157
175
  configPath,
158
176
  });
159
177
  const fullModeRequirementQueryPort = new QuickModeFullModeRequirementAdapter({
160
- classifyUseCaseFactory: () => createQuickModeCompositionRoot().classifyUseCase,
178
+ classifyUseCaseFactory: () =>
179
+ createQuickModeCompositionRoot({ configPath, rootDir: projectRoot }).classifyUseCase,
161
180
  });
162
181
  const baselineGrandfatherQueryPort = new CiGovernanceBaselineGrandfatherAdapter({
163
182
  baseDir: projectRoot,
@@ -222,7 +241,7 @@ async function buildTargetChanges(
222
241
  toRelative: (p: string) => string,
223
242
  ): Promise<TargetChange[]> {
224
243
  const toolInput = input.tool_input;
225
- if (toolInput === undefined) {
244
+ if (toolInput == null || typeof toolInput !== 'object') {
226
245
  return [];
227
246
  }
228
247
 
@@ -258,6 +277,32 @@ async function readExistingContent(cwd: string, filePath: string): Promise<strin
258
277
  }
259
278
  }
260
279
 
280
+ async function buildBashTargetChange(
281
+ cwd: string,
282
+ rawPath: string,
283
+ toRelative: (p: string) => string,
284
+ ): Promise<TargetChange> {
285
+ const filePath = toRelative(rawPath);
286
+ if (rawPath.includes('$') || rawPath.includes('`') || rawPath.startsWith('~')) {
287
+ // hook ではシェル展開後の実パスを解決できないため、存在チェックを避けて MODIFY 既定にする。
288
+ return { filePath };
289
+ }
290
+ const absolutePath = path.isAbsolute(rawPath) ? rawPath : path.join(cwd, rawPath);
291
+ try {
292
+ await fs.stat(absolutePath);
293
+ return { filePath };
294
+ } catch (error) {
295
+ const code = (error as NodeJS.ErrnoException).code;
296
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
297
+ // Bash では afterContent を取得できないため、空文字を CREATE 判定用の
298
+ // 「変更後内容あり」sentinel として渡す。実ファイルへの書き込みは行わない。
299
+ return { filePath, beforeContent: null, afterContent: '' };
300
+ }
301
+ // 権限エラー等で存在を確認できない場合は従来どおり MODIFY 既定(安全側)。
302
+ return { filePath };
303
+ }
304
+ }
305
+
261
306
  main().catch((error) => {
262
307
  process.stderr.write(`予期しないエラー: ${String(error)}\n`);
263
308
  process.exit(2);
@@ -3,19 +3,20 @@
3
3
  * @unit agent-integration
4
4
  * @work-item-id WI-203
5
5
  * @work-item-id WI-208
6
+ * @work-item-id WI-323
6
7
  *
7
8
  * Stop Hook Adapter
8
9
  * Claude Code の Stop Hook エントリポイント
9
10
  */
10
11
 
11
- import { HandleStopUseCase } from '../application/usecases/handle-stop-usecase.js';
12
- import { EnvFileReentryGuardStateAdapter } from '../infrastructure/adapters/env-file-reentry-guard-state-adapter.js';
13
- import { HarnessConfigConfigQueryAdapter } from '../infrastructure/adapters/harness-config-config-query-adapter.js';
14
- import { HarnessApiCliCommandRegistryAdapter } from '../infrastructure/adapters/harness-api-cli-command-registry-adapter.js';
15
- import { ChildProcessCliExecutorAdapter } from '../infrastructure/adapters/child-process-cli-executor-adapter.js';
16
- import { recordHookSkipEvent } from './hook-skip-event-recorder.js';
17
- import * as path from 'node:path';
18
- import * as fs from 'node:fs/promises';
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { HandleStopUseCase } from "../application/usecases/handle-stop-usecase.js";
15
+ import { ChildProcessCliExecutorAdapter } from "../infrastructure/adapters/child-process-cli-executor-adapter.js";
16
+ 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
+ import { HarnessConfigConfigQueryAdapter } from "../infrastructure/adapters/harness-config-config-query-adapter.js";
19
+ import { recordHookSkipEvent } from "./hook-skip-event-recorder.js";
19
20
 
20
21
  interface StopHookInput {
21
22
  session_id?: string;
@@ -23,8 +24,7 @@ interface StopHookInput {
23
24
 
24
25
  function isCompleteCheckExecutionWiringFailure(stderr: string): boolean {
25
26
  return (
26
- /scripts\/harness\/cli\/complete-check\.ts/.test(stderr) ||
27
- /ERR_MODULE_NOT_FOUND|Cannot find module/i.test(stderr)
27
+ /scripts\/harness\/cli\/complete-check\.ts/.test(stderr) || /ERR_MODULE_NOT_FOUND|Cannot find module/i.test(stderr)
28
28
  );
29
29
  }
30
30
 
@@ -40,15 +40,15 @@ async function readStdin(): Promise<string> {
40
40
  for await (const chunk of process.stdin) {
41
41
  chunks.push(chunk as Buffer);
42
42
  }
43
- return Buffer.concat(chunks).toString('utf8');
43
+ return Buffer.concat(chunks).toString("utf8");
44
44
  }
45
45
 
46
46
  async function findConfigPath(): Promise<string> {
47
47
  let dir = process.cwd();
48
48
  while (true) {
49
49
  const candidates = [
50
- path.join(dir, 'phasegate.config.json'),
51
- path.join(dir, '.phasegate-local', 'phasegate.config.json'),
50
+ path.join(dir, "phasegate.config.json"),
51
+ path.join(dir, ".phasegate-local", "phasegate.config.json"),
52
52
  ];
53
53
  for (const candidate of candidates) {
54
54
  try {
@@ -60,12 +60,12 @@ async function findConfigPath(): Promise<string> {
60
60
  if (parent === dir) break;
61
61
  dir = parent;
62
62
  }
63
- return path.join(process.cwd(), 'phasegate.config.json');
63
+ return path.join(process.cwd(), "phasegate.config.json");
64
64
  }
65
65
 
66
66
  function projectRootForConfig(configPath: string): string {
67
67
  const configDir = path.dirname(configPath);
68
- return path.basename(configDir) === '.phasegate-local' ? path.dirname(configDir) : configDir;
68
+ return path.basename(configDir) === ".phasegate-local" ? path.dirname(configDir) : configDir;
69
69
  }
70
70
 
71
71
  async function main(): Promise<void> {
@@ -73,7 +73,7 @@ async function main(): Promise<void> {
73
73
  try {
74
74
  raw = await readStdin();
75
75
  } catch {
76
- process.stderr.write('stdin読み取りエラー\n');
76
+ process.stderr.write("stdin読み取りエラー\n");
77
77
  process.exit(2);
78
78
  }
79
79
 
@@ -87,13 +87,24 @@ async function main(): Promise<void> {
87
87
 
88
88
  const sessionId = input.session_id;
89
89
  if (!sessionId) {
90
- process.stderr.write('session_idフィールドが必要です\n');
91
- process.exit(2);
90
+ // WI-323: session_id 欠落は呼び出し側環境の不備であり、stop hook はゲートではないため
91
+ // fail-open でスキップする(WI-314 / github#40 の「hook は開発フローを止めない」方針)。
92
+ const configPath = await findConfigPath();
93
+ await recordHookSkipEvent({
94
+ projectRoot: projectRootForConfig(configPath),
95
+ hookType: "stop",
96
+ reason: "SESSION_ID_MISSING",
97
+ targetPaths: [],
98
+ });
99
+ process.stderr.write(
100
+ "警告: stdin payload に session_id が無いため stop hook の処理をスキップしました (fail-open)\n",
101
+ );
102
+ process.exit(0);
92
103
  }
93
104
 
94
105
  try {
95
106
  const configPath = await findConfigPath();
96
- const reentryGuardStatePort = new EnvFileReentryGuardStateAdapter({ strategy: 'env' });
107
+ const reentryGuardStatePort = new EnvFileReentryGuardStateAdapter({ strategy: "env" });
97
108
  const configQueryPort = new HarnessConfigConfigQueryAdapter(configPath);
98
109
  const cliCommandRegistryPort = new HarnessApiCliCommandRegistryAdapter();
99
110
  const cliExecutorPort = new ChildProcessCliExecutorAdapter();
@@ -107,14 +118,14 @@ async function main(): Promise<void> {
107
118
 
108
119
  const output = await useCase.execute({ sessionId });
109
120
 
110
- if (output.skipReason === 'REENTRY_DETECTED') {
121
+ if (output.skipReason === "REENTRY_DETECTED") {
111
122
  await recordHookSkipEvent({
112
123
  projectRoot: projectRootForConfig(configPath),
113
- hookType: 'stop',
124
+ hookType: "stop",
114
125
  reason: output.skipReason,
115
126
  targetPaths: [],
116
127
  });
117
- process.stderr.write('ReentryGuard: 再入検出によりスキップ\n');
128
+ process.stderr.write("ReentryGuard: 再入検出によりスキップ\n");
118
129
  process.exit(0);
119
130
  }
120
131
 
@@ -123,10 +134,8 @@ async function main(): Promise<void> {
123
134
  // WI-087 finding #4: enforce=true なら exit 2 + decision JSON で turn block
124
135
  if (output.shouldEnforceFailure === true) {
125
136
  const reason = formatCompleteCheckFailureReason(output.cliResult.exitCode, output.cliResult.stderr);
126
- process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
127
- process.stderr.write(
128
- `${reason} — strict mode により turn を block します\n`,
129
- );
137
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason })}\n`);
138
+ process.stderr.write(`${reason} — strict mode により turn を block します\n`);
130
139
  process.exit(2);
131
140
  }
132
141
  process.stderr.write(`Complete Check失敗 (exitCode=${output.cliResult.exitCode})\n`);