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.
- package/README.md +1 -1
- 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/guides/framework-contracts.md +82 -0
- package/agentic/shared/AGENTS.md.template +3 -3
- package/agentic/shared/ai/agentic.config.json +1 -0
- package/agentic/shared/ai/harness/README.md +4 -4
- package/agentic/shared/ai/harness/RELEASE.md +4 -4
- package/agentic/shared/ai/harness/cases.json +93 -14
- package/agentic/shared/ai/harness/cases.schema.json +4 -4
- package/agentic/shared/ai/harness/fixtures/index.json +2 -1
- package/agentic/shared/ai/harness/fixtures/seeds.json +5 -0
- package/agentic/shared/ai/harness/release-matrix.json +5 -4
- package/agentic/shared/ai/harness/source-link-inventory.json +196 -4
- package/agentic/shared/ai/harness/validators.json +4 -3
- package/agentic/shared/ai/harness/writable-ast-oracles.mjs +9 -1
- package/agentic/shared/ai/harness/writable-spec-oracles.mjs +72 -4
- package/agentic/shared/ai/skills/om-auto-implement-spec/SKILL.md +2 -0
- package/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +1 -1
- package/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +1 -1
- package/agentic/shared/ai/skills/om-implement-spec/SKILL.md +2 -1
- package/agentic/shared/ai/skills/om-implement-spec/references/planning-and-progress.md +13 -1
- package/agentic/shared/ai/skills/om-implement-spec/references/resume.md +17 -0
- package/agentic/shared/ai/skills/om-judge-agent-session/references/input-normalization.md +3 -0
- package/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +2 -0
- package/agentic/shared/ai/skills/om-judge-agent-session/references/report-template.md +1 -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/agentic/shared/ai/skills/om-share-this-session/references/report-templates.md +4 -0
- package/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +36 -1
- package/agentic/shared/scripts/evaluate-agent-harness.mjs +61 -8
- package/agentic/shared/scripts/run-agent-harness-release.mjs +1 -1
- 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/framework-contracts.md +82 -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/AGENTS.md.template +3 -3
- package/dist/agentic/shared/ai/agentic.config.json +1 -0
- package/dist/agentic/shared/ai/harness/README.md +4 -4
- package/dist/agentic/shared/ai/harness/RELEASE.md +4 -4
- package/dist/agentic/shared/ai/harness/cases.json +93 -14
- package/dist/agentic/shared/ai/harness/cases.schema.json +4 -4
- package/dist/agentic/shared/ai/harness/fixtures/index.json +2 -1
- package/dist/agentic/shared/ai/harness/fixtures/seeds.json +5 -0
- package/dist/agentic/shared/ai/harness/release-matrix.json +5 -4
- package/dist/agentic/shared/ai/harness/source-link-inventory.json +196 -4
- package/dist/agentic/shared/ai/harness/validators.json +4 -3
- package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +9 -1
- package/dist/agentic/shared/ai/harness/writable-spec-oracles.mjs +72 -4
- package/dist/agentic/shared/ai/skills/om-auto-implement-spec/SKILL.md +2 -0
- package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +1 -1
- package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +1 -1
- package/dist/agentic/shared/ai/skills/om-implement-spec/SKILL.md +2 -1
- package/dist/agentic/shared/ai/skills/om-implement-spec/references/planning-and-progress.md +13 -1
- package/dist/agentic/shared/ai/skills/om-implement-spec/references/resume.md +17 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/input-normalization.md +3 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +2 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/report-template.md +1 -0
- 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/agentic/shared/ai/skills/om-share-this-session/references/report-templates.md +4 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +36 -1
- package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +61 -8
- package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1 -1
- package/dist/index.js +4 -0
- package/package.json +3 -3
- package/template/.ds-check-ignore +1217 -0
- package/template/AGENTS.md +3 -3
- package/template/gitignore +4 -0
- package/template/package.json.template +3 -1
- package/template/scripts/ds-check.mjs +247 -0
- package/template/scripts/i18n-check-hardcoded.mjs +187 -0
- package/template/src/app/api/docs/__tests__/routes.test.ts +85 -0
- package/template/src/app/api/docs/markdown/route.ts +12 -17
- 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
|
|
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
|
+
}
|