create-mercato-app 0.6.8-develop.6964.1.36b364cfd8 → 0.6.8-develop.6971.1.20c09ca9ea

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 (144) hide show
  1. package/README.md +1 -1
  2. package/agentic/claude-code/hooks/gate-evidence.ts +269 -0
  3. package/agentic/claude-code/settings.json +21 -0
  4. package/agentic/codex/hooks/gate-evidence.mjs +199 -0
  5. package/agentic/codex/hooks.json +29 -0
  6. package/agentic/cursor/hooks/gate-evidence.mjs +181 -0
  7. package/agentic/cursor/hooks.json +10 -0
  8. package/agentic/guides/framework-contracts.md +82 -0
  9. package/agentic/shared/AGENTS.md.template +3 -3
  10. package/agentic/shared/ai/agentic.config.json +1 -0
  11. package/agentic/shared/ai/harness/README.md +4 -4
  12. package/agentic/shared/ai/harness/RELEASE.md +4 -4
  13. package/agentic/shared/ai/harness/cases.json +93 -14
  14. package/agentic/shared/ai/harness/cases.schema.json +4 -4
  15. package/agentic/shared/ai/harness/fixtures/index.json +2 -1
  16. package/agentic/shared/ai/harness/fixtures/seeds.json +5 -0
  17. package/agentic/shared/ai/harness/release-matrix.json +5 -4
  18. package/agentic/shared/ai/harness/source-link-inventory.json +196 -4
  19. package/agentic/shared/ai/harness/validators.json +4 -3
  20. package/agentic/shared/ai/harness/writable-ast-oracles.mjs +9 -1
  21. package/agentic/shared/ai/harness/writable-spec-oracles.mjs +72 -4
  22. package/agentic/shared/ai/skills/om-auto-implement-spec/SKILL.md +2 -0
  23. package/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +1 -1
  24. package/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +1 -1
  25. package/agentic/shared/ai/skills/om-implement-spec/SKILL.md +2 -1
  26. package/agentic/shared/ai/skills/om-implement-spec/references/planning-and-progress.md +13 -1
  27. package/agentic/shared/ai/skills/om-implement-spec/references/resume.md +17 -0
  28. package/agentic/shared/ai/skills/om-judge-agent-session/references/input-normalization.md +3 -0
  29. package/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +2 -0
  30. package/agentic/shared/ai/skills/om-judge-agent-session/references/report-template.md +1 -0
  31. package/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +13 -0
  32. package/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +22 -0
  33. package/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +20 -0
  34. package/agentic/shared/ai/skills/om-share-this-session/references/report-templates.md +4 -0
  35. package/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +36 -1
  36. package/agentic/shared/scripts/evaluate-agent-harness.mjs +61 -8
  37. package/agentic/shared/scripts/run-agent-harness-release.mjs +1 -1
  38. package/dist/agentic/claude-code/hooks/gate-evidence.ts +269 -0
  39. package/dist/agentic/claude-code/settings.json +21 -0
  40. package/dist/agentic/codex/hooks/gate-evidence.mjs +199 -0
  41. package/dist/agentic/codex/hooks.json +29 -0
  42. package/dist/agentic/cursor/hooks/gate-evidence.mjs +181 -0
  43. package/dist/agentic/cursor/hooks.json +10 -0
  44. package/dist/agentic/guides/framework-contracts.md +82 -0
  45. package/dist/agentic/guides/module-facts.json +112 -112
  46. package/dist/agentic/guides/module-facts.v2.json +112 -112
  47. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  48. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  49. package/dist/agentic/guides/modules/api_keys/index.md +1 -1
  50. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  51. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  52. package/dist/agentic/guides/modules/auth/index.md +1 -1
  53. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  54. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  55. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  56. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  57. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  58. package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
  59. package/dist/agentic/guides/modules/configs/index.md +1 -1
  60. package/dist/agentic/guides/modules/content/index.md +1 -1
  61. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  62. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  63. package/dist/agentic/guides/modules/customers/index.md +1 -1
  64. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  65. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  66. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  67. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  68. package/dist/agentic/guides/modules/directory/index.md +1 -1
  69. package/dist/agentic/guides/modules/entities/index.md +1 -1
  70. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  71. package/dist/agentic/guides/modules/events/index.md +1 -1
  72. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  73. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  74. package/dist/agentic/guides/modules/generators/index.md +1 -1
  75. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  76. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  77. package/dist/agentic/guides/modules/messages/index.md +1 -1
  78. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  79. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  80. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  81. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  82. package/dist/agentic/guides/modules/planner/index.md +1 -1
  83. package/dist/agentic/guides/modules/portal/index.md +1 -1
  84. package/dist/agentic/guides/modules/progress/index.md +1 -1
  85. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  86. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  87. package/dist/agentic/guides/modules/resources/index.md +1 -1
  88. package/dist/agentic/guides/modules/sales/index.md +1 -1
  89. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  90. package/dist/agentic/guides/modules/search/index.md +1 -1
  91. package/dist/agentic/guides/modules/security/index.md +1 -1
  92. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  93. package/dist/agentic/guides/modules/sso/index.md +1 -1
  94. package/dist/agentic/guides/modules/staff/index.md +1 -1
  95. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  96. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  97. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  98. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  99. package/dist/agentic/guides/modules/translations/index.md +1 -1
  100. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  101. package/dist/agentic/guides/modules/wms/index.md +1 -1
  102. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  103. package/dist/agentic/guides/reference-module-facts.json +1 -1
  104. package/dist/agentic/guides/upstream/manifest.json +1 -1
  105. package/dist/agentic/shared/AGENTS.md.template +3 -3
  106. package/dist/agentic/shared/ai/agentic.config.json +1 -0
  107. package/dist/agentic/shared/ai/harness/README.md +4 -4
  108. package/dist/agentic/shared/ai/harness/RELEASE.md +4 -4
  109. package/dist/agentic/shared/ai/harness/cases.json +93 -14
  110. package/dist/agentic/shared/ai/harness/cases.schema.json +4 -4
  111. package/dist/agentic/shared/ai/harness/fixtures/index.json +2 -1
  112. package/dist/agentic/shared/ai/harness/fixtures/seeds.json +5 -0
  113. package/dist/agentic/shared/ai/harness/release-matrix.json +5 -4
  114. package/dist/agentic/shared/ai/harness/source-link-inventory.json +196 -4
  115. package/dist/agentic/shared/ai/harness/validators.json +4 -3
  116. package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +9 -1
  117. package/dist/agentic/shared/ai/harness/writable-spec-oracles.mjs +72 -4
  118. package/dist/agentic/shared/ai/skills/om-auto-implement-spec/SKILL.md +2 -0
  119. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +1 -1
  120. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +1 -1
  121. package/dist/agentic/shared/ai/skills/om-implement-spec/SKILL.md +2 -1
  122. package/dist/agentic/shared/ai/skills/om-implement-spec/references/planning-and-progress.md +13 -1
  123. package/dist/agentic/shared/ai/skills/om-implement-spec/references/resume.md +17 -0
  124. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/input-normalization.md +3 -0
  125. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +2 -0
  126. package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/report-template.md +1 -0
  127. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +13 -0
  128. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +22 -0
  129. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +20 -0
  130. package/dist/agentic/shared/ai/skills/om-share-this-session/references/report-templates.md +4 -0
  131. package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +36 -1
  132. package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +61 -8
  133. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1 -1
  134. package/dist/index.js +4 -0
  135. package/package.json +3 -3
  136. package/template/.ds-check-ignore +1217 -0
  137. package/template/AGENTS.md +3 -3
  138. package/template/gitignore +4 -0
  139. package/template/package.json.template +3 -1
  140. package/template/scripts/ds-check.mjs +247 -0
  141. package/template/scripts/i18n-check-hardcoded.mjs +187 -0
  142. package/template/src/app/api/docs/__tests__/routes.test.ts +85 -0
  143. package/template/src/app/api/docs/markdown/route.ts +12 -17
  144. package/template/src/app/api/docs/openapi/route.ts +11 -17
package/README.md CHANGED
@@ -144,7 +144,7 @@ yarn install-skills
144
144
  yarn harness:release --runner codex --prepare-targets /absolute/empty-release-targets --acknowledge-writes
145
145
  ```
146
146
 
147
- The target directory must be absolute, new or empty, and outside the controller app. Select one blocking primary runner with `--runner codex` or `--runner claude`; it owns all 229 routing cases and every writable/review lane, with no per-case fallback. Optionally add the different authenticated runner through `--portability-runner` for the exact 48-case representative read-only lane. Omitting it is valid and recorded as not requested; once requested, its failures are blocking. Use a fresh, sanitized controller: automatic preparation fails before copying `.env`/`.env.*` local configuration (safe example/sample/template files remain allowed), credential files, or private-key files. The complete gate requires Linux with trusted system Bubblewrap (`bwrap`) and user namespaces because its Playwright API/browser lanes need a loopback namespace isolated from the host. Preflight rejects untrusted/no-op/pass-through executables and proves isolated loopback plus a capability-free payload before target preparation, provider invocation, or writes; native macOS and Windows therefore fail closed. The command also fails closed when a required runner, browser, or test runtime is unavailable. The 229-case catalog includes 93 framework-neutral business prompts and 48 writable implementation/regression cases (21.0%). The release command runs live routing, writable trusted oracles, per-target `generate`/`typecheck`/`lint`/`build`, any declared generated test, and isolated generated-code review for every writable result. Foundation and target validation—including `yarn build`—receive a minimal environment with network access denied, and persisted diagnostics redact sensitive environment values and URL userinfo. Test-authoring coverage executes a Jest unit test plus Linux/Bubblewrap loopback-only Playwright API and browser tests through fixed controller-owned commands against a read-only target; runtime reports must attest at least one passed test and zero skipped, todo, focused, flaky, or expected-failure tests. The suite then writes a schema-valid sanitized mode-`0600` report under `.ai/harness/results/` with the selected primary and optional portability runner policy.
147
+ The target directory must be absolute, new or empty, and outside the controller app. Select one blocking primary runner with `--runner codex` or `--runner claude`; it owns all 231 routing cases and every writable/review lane, with no per-case fallback. Optionally add the different authenticated runner through `--portability-runner` for the exact 49-case representative read-only lane. Omitting it is valid and recorded as not requested; once requested, its failures are blocking. Use a fresh, sanitized controller: automatic preparation fails before copying `.env`/`.env.*` local configuration (safe example/sample/template files remain allowed), credential files, or private-key files. The complete gate requires Linux with trusted system Bubblewrap (`bwrap`) and user namespaces because its Playwright API/browser lanes need a loopback namespace isolated from the host. Preflight rejects untrusted/no-op/pass-through executables and proves isolated loopback plus a capability-free payload before target preparation, provider invocation, or writes; native macOS and Windows therefore fail closed. The command also fails closed when a required runner, browser, or test runtime is unavailable. The 231-case catalog includes 93 framework-neutral business prompts and 49 writable implementation/regression cases (21.2%). The release command runs live routing, writable trusted oracles, per-target `generate`/`typecheck`/`lint`/`build`, any declared generated test, and isolated generated-code review for every writable result. Foundation and target validation—including `yarn build`—receive a minimal environment with network access denied, and persisted diagnostics redact sensitive environment values and URL userinfo. Test-authoring coverage executes a Jest unit test plus Linux/Bubblewrap loopback-only Playwright API and browser tests through fixed controller-owned commands against a read-only target; runtime reports must attest at least one passed test and zero skipped, todo, focused, flaky, or expected-failure tests. The suite then writes a schema-valid sanitized mode-`0600` report under `.ai/harness/results/` with the selected primary and optional portability runner policy.
148
148
 
149
149
  Use the bundled `om-evolve-harness` skill to add a real case: reproduce failure first, select one smallest knowledge owner, run any generated unit/integration tests plus target checks, require code review, and finish with the full release suite. Open Mercato framework maintainers use the monorepo-only `$om-refresh-standalone-harness --from <ref> --to <ref>` workflow for every release range and retain its sanitized maintenance report.
150
150
 
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Record validation-gate outcomes, and refuse to conclude on unverified source changes.
3
+ *
4
+ * Two modes, mirroring `entity-migration-check`'s shape:
5
+ *
6
+ * - `record` (PostToolUse on Bash) — when a Bash command was a validation gate AND its exit
7
+ * status genuinely belongs to that gate, append the status to `.ai/.gate-state.json`.
8
+ * - `check` (Stop) — block when a file under `src/` changed after the session started and is
9
+ * newer than the last exit-0 typecheck, unless this stop is already the result of a block.
10
+ *
11
+ * Why this exists: a gate that is claimed but never run is indistinguishable, in a
12
+ * transcript, from one that passed. This makes the difference mechanical.
13
+ *
14
+ * Deliberate limits. The blocker only considers `typecheck`: demanding a green `build` on
15
+ * every stop would be punitive, and typecheck is the cheap gate that catches the defect class
16
+ * this guards. It compares mtimes rather than hashing, so a touch-without-edit costs one
17
+ * gate run. It blocks at most once per stop sequence, so a gate that genuinely cannot pass
18
+ * is reported to the user rather than trapping the agent. And the state file can simply be
19
+ * deleted — this is a speed bump against carelessness, not a defense against deliberate
20
+ * circumvention.
21
+ */
22
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import { pathToFileURL } from 'node:url'
25
+
26
+ const STATE_RELATIVE_PATH = '.ai/.gate-state.json'
27
+ const WATCHED_ROOT = 'src'
28
+
29
+ export type GateName = 'typecheck' | 'lint' | 'test' | 'build' | 'generate'
30
+
31
+ export type GateRecord = { exitCode: number; finishedAt: string }
32
+
33
+ export type GateState = {
34
+ sessionId?: string
35
+ sessionStartedAt?: string
36
+ gates?: Partial<Record<GateName, GateRecord>>
37
+ }
38
+
39
+ /**
40
+ * Extracts every gate a Bash command ran.
41
+ *
42
+ * Returns a list because the harness's own documented gate line chains several with `&&`,
43
+ * and a run reported through a compound command must not be invisible to the recorder.
44
+ * Direct invocations that bypass the package script (`npx tsc --noEmit`) count too — the
45
+ * point is whether the check happened, not which alias was typed.
46
+ *
47
+ * Quoted spans are removed before matching, so a gate merely *named* in a message —
48
+ * `git commit -m "run tsc --noEmit"` — is not mistaken for a gate that ran.
49
+ */
50
+ export function matchGates(command: string): GateName[] {
51
+ const executable = command.replace(/'[^']*'|"[^"]*"/g, ' ')
52
+ const found = new Set<GateName>()
53
+ const named: Array<[GateName, RegExp]> = [
54
+ ['typecheck', /\b(?:yarn|npm run|pnpm)\s+typecheck\b|\btsc\b[^&|;]*--noEmit/],
55
+ ['lint', /\b(?:yarn|npm run|pnpm)\s+lint\b|\beslint\b/],
56
+ ['test', /\b(?:yarn|npm run|pnpm)\s+test\b|\bjest\b/],
57
+ ['build', /\b(?:yarn|npm run|pnpm)\s+build\b|\bnext build\b/],
58
+ ['generate', /\b(?:yarn|npm run|pnpm)\s+generate\b|\bmercato\s+generate\b/],
59
+ ]
60
+ for (const [gate, pattern] of named) {
61
+ if (pattern.test(executable)) found.add(gate)
62
+ }
63
+ return [...found]
64
+ }
65
+
66
+ /**
67
+ * Decides whether a command's exit status can be attributed to the gates it names.
68
+ *
69
+ * A pipeline reports the exit status of its LAST stage, so `yarn typecheck | tail -30`
70
+ * reports `tail`'s success no matter what `tsc` did. `;` and `||` break the link the same
71
+ * way. `&&` does not: it short-circuits, so a non-zero status still belongs to a gate that
72
+ * ran — at worst a later gate's failure is attributed to an earlier one, which only costs a
73
+ * re-run.
74
+ *
75
+ * Recording an unattributable status would manufacture exactly the false green this hook
76
+ * exists to prevent, so those commands are not recorded at all.
77
+ */
78
+ export function isAttributableGateCommand(command: string): boolean {
79
+ return !/[|;\n]/.test(command)
80
+ }
81
+
82
+ /**
83
+ * Resolves the exit status a Bash tool response reported, or `null` when it reported none.
84
+ *
85
+ * `null` is not zero. An unknown outcome must never be stored as a pass — the whole point of
86
+ * the state file is that it holds observed results, and a payload shape this hook does not
87
+ * recognize is the one case where it has observed nothing.
88
+ */
89
+ export function resolveExitCode(data: HookInput): number | null {
90
+ const response = data.tool_response ?? {}
91
+ const value = response.exit_code ?? response.exitCode
92
+ return typeof value === 'number' ? value : null
93
+ }
94
+
95
+ /**
96
+ * Rolls the state forward into the session the current invocation belongs to.
97
+ *
98
+ * The state file outlives the session that wrote it, so a `sessionStartedAt` set once and
99
+ * never revisited would pin every later session to the first one's clock and make the
100
+ * "changed during THIS session" test meaningless. A new `session_id` therefore starts from a
101
+ * clean record: gates observed in an earlier session prove nothing about this one.
102
+ *
103
+ * Payloads without a `session_id` keep the original set-once behavior, so an older client
104
+ * degrades rather than resetting on every call.
105
+ */
106
+ export function nextSessionState(previous: GateState, sessionId: string | null, startedAt: string): GateState {
107
+ if (!sessionId) {
108
+ return previous.sessionStartedAt ? previous : { ...previous, sessionStartedAt: startedAt }
109
+ }
110
+ if (previous.sessionId === sessionId && previous.sessionStartedAt) return previous
111
+ return { sessionId, sessionStartedAt: startedAt }
112
+ }
113
+
114
+ /**
115
+ * Decides whether concluding should be blocked.
116
+ *
117
+ * An absent typecheck record does NOT block on its own — otherwise the first stop of every
118
+ * session on a fresh clone would block, including read-only or docs-only sessions that never
119
+ * touched `src/`. The gate is source changed during THIS session and not since verified.
120
+ */
121
+ export function shouldBlock(input: {
122
+ newestSrcMtimeMs: number | null
123
+ sessionStartedAtMs: number
124
+ lastGreenTypecheckMs: number | null
125
+ }): boolean {
126
+ const { newestSrcMtimeMs, sessionStartedAtMs, lastGreenTypecheckMs } = input
127
+ if (newestSrcMtimeMs === null) return false
128
+ if (newestSrcMtimeMs < sessionStartedAtMs) return false
129
+ if (lastGreenTypecheckMs === null) return true
130
+ return newestSrcMtimeMs > lastGreenTypecheckMs
131
+ }
132
+
133
+ function projectDir(): string {
134
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd()
135
+ }
136
+
137
+ function statePath(): string {
138
+ return join(projectDir(), STATE_RELATIVE_PATH)
139
+ }
140
+
141
+ function readState(): GateState {
142
+ try {
143
+ return JSON.parse(readFileSync(statePath(), 'utf8')) as GateState
144
+ } catch {
145
+ return {}
146
+ }
147
+ }
148
+
149
+ function writeState(state: GateState): void {
150
+ try {
151
+ mkdirSync(join(projectDir(), '.ai'), { recursive: true })
152
+ writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`, 'utf8')
153
+ } catch {
154
+ // A hook must never fail the turn over its own bookkeeping.
155
+ }
156
+ }
157
+
158
+ function newestMtimeMs(dir: string): number | null {
159
+ let newest: number | null = null
160
+ const walk = (current: string): void => {
161
+ let entries: string[]
162
+ try {
163
+ entries = readdirSync(current)
164
+ } catch {
165
+ return
166
+ }
167
+ for (const entry of entries) {
168
+ if (entry === 'node_modules' || entry.startsWith('.')) continue
169
+ const full = join(current, entry)
170
+ let stats
171
+ try {
172
+ stats = statSync(full)
173
+ } catch {
174
+ continue
175
+ }
176
+ if (stats.isDirectory()) walk(full)
177
+ else if (newest === null || stats.mtimeMs > newest) newest = stats.mtimeMs
178
+ }
179
+ }
180
+ walk(dir)
181
+ return newest
182
+ }
183
+
184
+ function readStdin(): Promise<string> {
185
+ return new Promise((resolve) => {
186
+ let raw = ''
187
+ process.stdin.setEncoding('utf8')
188
+ process.stdin.on('data', (chunk) => { raw += chunk })
189
+ process.stdin.on('end', () => resolve(raw))
190
+ })
191
+ }
192
+
193
+ export type HookInput = {
194
+ session_id?: string
195
+ stop_hook_active?: boolean
196
+ tool_input?: { command?: string }
197
+ tool_response?: { exit_code?: number; exitCode?: number }
198
+ }
199
+
200
+ async function main(): Promise<void> {
201
+ const mode = process.argv[2] === 'check' ? 'check' : 'record'
202
+ const raw = await readStdin()
203
+
204
+ let data: HookInput = {}
205
+ if (raw.trim()) {
206
+ try {
207
+ data = JSON.parse(raw) as HookInput
208
+ } catch {
209
+ return
210
+ }
211
+ }
212
+
213
+ const previous = readState()
214
+ const now = new Date()
215
+ const state = nextSessionState(previous, data.session_id ?? null, now.toISOString())
216
+ if (state !== previous) writeState(state)
217
+
218
+ if (mode === 'record') {
219
+ const command = data.tool_input?.command
220
+ if (!command) return
221
+ const gates = matchGates(command)
222
+ if (!gates.length) return
223
+ if (!isAttributableGateCommand(command)) return
224
+ const exitCode = resolveExitCode(data)
225
+ if (exitCode === null) return
226
+ state.gates = state.gates ?? {}
227
+ for (const gate of gates) {
228
+ state.gates[gate] = { exitCode, finishedAt: now.toISOString() }
229
+ }
230
+ writeState(state)
231
+ return
232
+ }
233
+
234
+ if (data.stop_hook_active) return
235
+
236
+ const typecheck = state.gates?.typecheck
237
+ const blocked = shouldBlock({
238
+ newestSrcMtimeMs: newestMtimeMs(join(projectDir(), WATCHED_ROOT)),
239
+ sessionStartedAtMs: Date.parse(state.sessionStartedAt ?? now.toISOString()),
240
+ lastGreenTypecheckMs: typecheck && typecheck.exitCode === 0 ? Date.parse(typecheck.finishedAt) : null,
241
+ })
242
+ if (!blocked) return
243
+
244
+ process.stdout.write(JSON.stringify({
245
+ decision: 'block',
246
+ reason: [
247
+ `Source under ${WATCHED_ROOT}/ changed this session and has not passed a typecheck since.`,
248
+ '',
249
+ 'Run `yarn typecheck` and report its exit status before concluding.',
250
+ 'If it genuinely fails and you cannot fix it, report the failure to the user —',
251
+ 'do not delete .ai/.gate-state.json to work around this.',
252
+ ].join('\n'),
253
+ }))
254
+ }
255
+
256
+ /**
257
+ * Run only when invoked as the hook, never on import.
258
+ *
259
+ * `main()` blocks reading stdin, so an unguarded top-level call makes the module impossible
260
+ * to import — a test that pulled in `matchGates` would hang forever waiting for input that
261
+ * never arrives.
262
+ */
263
+ function isEntryPoint(): boolean {
264
+ const entry = process.argv[1]
265
+ if (!entry) return false
266
+ return import.meta.url === pathToFileURL(entry).href
267
+ }
268
+
269
+ if (isEntryPoint()) void main()
@@ -10,6 +10,27 @@
10
10
  "timeout": 15
11
11
  }
12
12
  ]
13
+ },
14
+ {
15
+ "matcher": "Bash",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "npx tsx \"$CLAUDE_PROJECT_DIR/.claude/hooks/gate-evidence.ts\" record",
20
+ "timeout": 15
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "Stop": [
26
+ {
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "npx tsx \"$CLAUDE_PROJECT_DIR/.claude/hooks/gate-evidence.ts\" check",
31
+ "timeout": 20
32
+ }
33
+ ]
13
34
  }
14
35
  ]
15
36
  }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Record validation-gate outcomes, and refuse to finish on unverified source changes (Codex).
3
+ *
4
+ * Mirrors the claude-code hook: `record` on `PostToolUse` (Bash matcher), `check` on `Stop`.
5
+ *
6
+ * Codex's `PostToolUse` documents `tool_response` without an exit-code field, so the outcome
7
+ * is inferred from the response text. `Stop` requires JSON on stdout when the hook exits 0 —
8
+ * plain text is invalid for that event — so the block is a `{"decision":"block"}` document.
9
+ */
10
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
11
+ import { join, resolve } from 'node:path'
12
+
13
+ /** Codex nests the shell output under `tool_response` in one of several shapes. */
14
+ export function readToolResponseText(data) {
15
+ const response = data?.tool_response
16
+ if (typeof response === 'string') return response
17
+ if (response && typeof response === 'object') {
18
+ for (const key of ['output', 'stdout', 'content', 'text']) {
19
+ if (typeof response[key] === 'string') return response[key]
20
+ }
21
+ return JSON.stringify(response)
22
+ }
23
+ return ''
24
+ }
25
+
26
+ /**
27
+ * Extracts every gate a shell command ran.
28
+ *
29
+ * Quoted spans are blanked first: `git commit -m "run tsc --noEmit"` names a gate without
30
+ * running one, and its zero exit status would otherwise be recorded as a green typecheck.
31
+ */
32
+ export function matchGates(command) {
33
+ const executable = command.replace(/'[^']*'|"[^"]*"/g, ' ')
34
+ const found = new Set()
35
+ const named = [
36
+ ['typecheck', /\b(?:yarn|npm run|pnpm)\s+typecheck\b|\btsc\b[^&|;]*--noEmit/],
37
+ ['lint', /\b(?:yarn|npm run|pnpm)\s+lint\b|\beslint\b/],
38
+ ['test', /\b(?:yarn|npm run|pnpm)\s+test\b|\bjest\b/],
39
+ ['build', /\b(?:yarn|npm run|pnpm)\s+build\b|\bnext build\b/],
40
+ ['generate', /\b(?:yarn|npm run|pnpm)\s+generate\b|\bmercato\s+generate\b/],
41
+ ]
42
+ for (const [gate, pattern] of named) {
43
+ if (pattern.test(executable)) found.add(gate)
44
+ }
45
+ return [...found]
46
+ }
47
+
48
+ /**
49
+ * Whether a command's outcome can be attributed to the gates it names.
50
+ *
51
+ * A pipeline reports its LAST stage's status, and `;` / `||` break the link the same way.
52
+ * `&&` does not: it short-circuits, so a failure still belongs to a gate that ran.
53
+ */
54
+ export function isAttributableGateCommand(command) {
55
+ return !/[|;\n]/.test(command)
56
+ }
57
+
58
+ /** See the claude-code hook. */
59
+ export function nextSessionState(previous, sessionId, startedAt) {
60
+ if (!sessionId) {
61
+ return previous.sessionStartedAt ? previous : { ...previous, sessionStartedAt: startedAt }
62
+ }
63
+ if (previous.sessionId === sessionId && previous.sessionStartedAt) return previous
64
+ return { sessionId, sessionStartedAt: startedAt }
65
+ }
66
+
67
+ /** See the claude-code hook: an absent typecheck record does not block on its own. */
68
+ export function shouldBlock({ newestSrcMtimeMs, sessionStartedAtMs, lastGreenTypecheckMs }) {
69
+ if (newestSrcMtimeMs === null) return false
70
+ if (newestSrcMtimeMs < sessionStartedAtMs) return false
71
+ if (lastGreenTypecheckMs === null) return true
72
+ return newestSrcMtimeMs > lastGreenTypecheckMs
73
+ }
74
+
75
+ /**
76
+ * Infers a gate outcome from its output, because this host reports no exit code.
77
+ *
78
+ * Returns 1 (failure) for empty output and for any failure signature. The bias is
79
+ * deliberate: over-reporting failure costs one re-run, under-reporting it records a pass
80
+ * that never happened — the property this hook exists to remove. `No tests found` is a
81
+ * failure here for the same reason it is not a pass in the harness rules.
82
+ */
83
+ export function inferExitCode(output) {
84
+ if (typeof output !== 'string' || output.trim() === '') return 1
85
+ const failureSignatures = [
86
+ /\berror\s+TS\d+/i, /FATAL ERROR/i, /heap out of memory/i,
87
+ /\bTests?:\s+\d+\s+failed/i, /\bfail(ed|ing)\b/i, /\bERROR\b/,
88
+ /exited \(\d+\)/, /command not found/i, /No tests found/i,
89
+ ]
90
+ return failureSignatures.some((pattern) => pattern.test(output)) ? 1 : 0
91
+ }
92
+
93
+ const STATE_RELATIVE_PATH = '.ai/.gate-state.json'
94
+ const WATCHED_ROOT = 'src'
95
+
96
+ function projectDir() {
97
+ return process.env.CODEX_PROJECT_DIR || resolve('.')
98
+ }
99
+
100
+ function statePath() { return join(projectDir(), STATE_RELATIVE_PATH) }
101
+
102
+ function readState() {
103
+ try { return JSON.parse(readFileSync(statePath(), 'utf8')) } catch { return {} }
104
+ }
105
+
106
+ function writeState(state) {
107
+ try {
108
+ mkdirSync(join(projectDir(), '.ai'), { recursive: true })
109
+ writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`, 'utf8')
110
+ } catch {
111
+ // A hook must never fail the turn over its own bookkeeping.
112
+ }
113
+ }
114
+
115
+ function newestMtimeMs(dir) {
116
+ let newest = null
117
+ const walk = (current) => {
118
+ let entries
119
+ try { entries = readdirSync(current) } catch { return }
120
+ for (const entry of entries) {
121
+ if (entry === 'node_modules' || entry.startsWith('.')) continue
122
+ const full = join(current, entry)
123
+ let stats
124
+ try { stats = statSync(full) } catch { continue }
125
+ if (stats.isDirectory()) walk(full)
126
+ else if (newest === null || stats.mtimeMs > newest) newest = stats.mtimeMs
127
+ }
128
+ }
129
+ walk(dir)
130
+ return newest
131
+ }
132
+
133
+ function readStdin() {
134
+ return new Promise((done) => {
135
+ let raw = ''
136
+ if (process.stdin.isTTY) { done(''); return }
137
+ process.stdin.setEncoding('utf8')
138
+ process.stdin.on('data', (chunk) => { raw += chunk })
139
+ process.stdin.on('end', () => done(raw))
140
+ process.stdin.on('error', () => done(''))
141
+ })
142
+ }
143
+
144
+ async function main() {
145
+ const mode = process.argv[2] === 'check' ? 'check' : 'record'
146
+ const raw = await readStdin()
147
+
148
+ let data = {}
149
+ if (raw.trim()) {
150
+ try { data = JSON.parse(raw) } catch { process.exit(0) }
151
+ }
152
+
153
+ const previous = readState()
154
+ const now = new Date()
155
+ const state = nextSessionState(previous, data.session_id ?? null, now.toISOString())
156
+ if (state !== previous) writeState(state)
157
+
158
+ if (mode === 'record') {
159
+ const command = data.tool_input?.command ?? data.command
160
+ if (!command) process.exit(0)
161
+ const gates = matchGates(command)
162
+ if (!gates.length) process.exit(0)
163
+ if (!isAttributableGateCommand(command)) process.exit(0)
164
+ const exitCode = inferExitCode(readToolResponseText(data))
165
+ state.gates = state.gates ?? {}
166
+ for (const gate of gates) {
167
+ state.gates[gate] = { exitCode, finishedAt: now.toISOString() }
168
+ }
169
+ writeState(state)
170
+ process.exit(0)
171
+ }
172
+
173
+ if (data.stop_hook_active) process.exit(0)
174
+
175
+ const typecheck = state.gates?.typecheck
176
+ const blocked = shouldBlock({
177
+ newestSrcMtimeMs: newestMtimeMs(join(projectDir(), WATCHED_ROOT)),
178
+ sessionStartedAtMs: Date.parse(state.sessionStartedAt ?? now.toISOString()),
179
+ lastGreenTypecheckMs: typecheck && typecheck.exitCode === 0 ? Date.parse(typecheck.finishedAt) : null,
180
+ })
181
+ if (!blocked) {
182
+ process.stdout.write(JSON.stringify({ decision: 'approve' }))
183
+ process.exit(0)
184
+ }
185
+
186
+ process.stdout.write(JSON.stringify({
187
+ decision: 'block',
188
+ reason: [
189
+ `Source under ${WATCHED_ROOT}/ changed this session and has not passed a typecheck since.`,
190
+ '',
191
+ 'Run `yarn typecheck` and report its exit status before finishing.',
192
+ 'If it genuinely fails and you cannot fix it, report the failure to the user.',
193
+ ].join('\n'),
194
+ }))
195
+ process.exit(0)
196
+ }
197
+
198
+ const invokedDirectly = process.argv[1] && process.argv[1].endsWith('gate-evidence.mjs')
199
+ if (invokedDirectly) await main()
@@ -0,0 +1,29 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Bash",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node .codex/hooks/gate-evidence.mjs record",
10
+ "statusMessage": "Recording validation-gate outcome",
11
+ "timeout": 15
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "Stop": [
17
+ {
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "node .codex/hooks/gate-evidence.mjs check",
22
+ "statusMessage": "Checking gate evidence before finishing",
23
+ "timeout": 20
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+ }