create-mercato-app 0.6.8-develop.6969.1.7a32706312 → 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.
- package/agentic/claude-code/hooks/gate-evidence.ts +269 -0
- package/agentic/claude-code/settings.json +21 -0
- package/agentic/codex/hooks/gate-evidence.mjs +199 -0
- package/agentic/codex/hooks.json +29 -0
- package/agentic/cursor/hooks/gate-evidence.mjs +181 -0
- package/agentic/cursor/hooks.json +10 -0
- package/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +13 -0
- package/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +22 -0
- package/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +20 -0
- package/dist/agentic/claude-code/hooks/gate-evidence.ts +269 -0
- package/dist/agentic/claude-code/settings.json +21 -0
- package/dist/agentic/codex/hooks/gate-evidence.mjs +199 -0
- package/dist/agentic/codex/hooks.json +29 -0
- package/dist/agentic/cursor/hooks/gate-evidence.mjs +181 -0
- package/dist/agentic/cursor/hooks.json +10 -0
- package/dist/agentic/guides/module-facts.json +112 -112
- package/dist/agentic/guides/module-facts.v2.json +112 -112
- package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
- package/dist/agentic/guides/modules/api_docs/index.md +1 -1
- package/dist/agentic/guides/modules/api_keys/index.md +1 -1
- package/dist/agentic/guides/modules/attachments/index.md +1 -1
- package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
- package/dist/agentic/guides/modules/auth/index.md +1 -1
- package/dist/agentic/guides/modules/business_rules/index.md +1 -1
- package/dist/agentic/guides/modules/catalog/index.md +1 -1
- package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
- package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
- package/dist/agentic/guides/modules/checkout/index.md +1 -1
- package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
- package/dist/agentic/guides/modules/configs/index.md +1 -1
- package/dist/agentic/guides/modules/content/index.md +1 -1
- package/dist/agentic/guides/modules/currencies/index.md +1 -1
- package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
- package/dist/agentic/guides/modules/customers/index.md +1 -1
- package/dist/agentic/guides/modules/dashboards/index.md +1 -1
- package/dist/agentic/guides/modules/data_sync/index.md +1 -1
- package/dist/agentic/guides/modules/design_system/index.md +1 -1
- package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
- package/dist/agentic/guides/modules/directory/index.md +1 -1
- package/dist/agentic/guides/modules/entities/index.md +1 -1
- package/dist/agentic/guides/modules/eudr/index.md +1 -1
- package/dist/agentic/guides/modules/events/index.md +1 -1
- package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
- package/dist/agentic/guides/modules/generators/index.md +1 -1
- package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
- package/dist/agentic/guides/modules/integrations/index.md +1 -1
- package/dist/agentic/guides/modules/messages/index.md +1 -1
- package/dist/agentic/guides/modules/notifications/index.md +1 -1
- package/dist/agentic/guides/modules/onboarding/index.md +1 -1
- package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
- package/dist/agentic/guides/modules/perspectives/index.md +1 -1
- package/dist/agentic/guides/modules/planner/index.md +1 -1
- package/dist/agentic/guides/modules/portal/index.md +1 -1
- package/dist/agentic/guides/modules/progress/index.md +1 -1
- package/dist/agentic/guides/modules/query_index/index.md +1 -1
- package/dist/agentic/guides/modules/record_locks/index.md +1 -1
- package/dist/agentic/guides/modules/resources/index.md +1 -1
- package/dist/agentic/guides/modules/sales/index.md +1 -1
- package/dist/agentic/guides/modules/scheduler/index.md +1 -1
- package/dist/agentic/guides/modules/search/index.md +1 -1
- package/dist/agentic/guides/modules/security/index.md +1 -1
- package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
- package/dist/agentic/guides/modules/sso/index.md +1 -1
- package/dist/agentic/guides/modules/staff/index.md +1 -1
- package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
- package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
- package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
- package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
- package/dist/agentic/guides/modules/translations/index.md +1 -1
- package/dist/agentic/guides/modules/webhooks/index.md +1 -1
- package/dist/agentic/guides/modules/wms/index.md +1 -1
- package/dist/agentic/guides/modules/workflows/index.md +1 -1
- package/dist/agentic/guides/reference-module-facts.json +1 -1
- package/dist/agentic/guides/upstream/manifest.json +1 -1
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +13 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +22 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +20 -0
- package/dist/index.js +4 -0
- package/package.json +3 -3
- package/template/gitignore +4 -0
|
@@ -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
|
+
}
|