Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 70 additions & 13 deletions .claude/hooks/_step_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,53 @@
The cache is regenerated by `codeyam-editor editor verify-build`,
which is wired into the audit gate, the advance gate, and the
embedded pre-commit hook. Schema versions 1 (label/description/
restriction only) and 2 (adds slug + capability tag projections)
both load successfully; v1 callers see empty capability arrays so the
hook degrades to "allow everything" rather than blocking on a stale
cache after a binary downgrade.
restriction only), 2 (adds slug + capability tag projections), and 3
(adds the per-phase `testRunSlugs` projection, plus its additive
`noTestSlugs` companion) all load successfully; callers see empty
arrays for any projection an older cache omits so the hook degrades to
"allow everything" rather than blocking on a stale cache after a binary
downgrade.

`SUPPORTED_SCHEMA_VERSIONS` is a CLOSED set: an unrecognized (including
NEWER) version falls back to empty tables, which drops every gate — the
commit and push gates included, not just the projection that changed.
That asymmetry is why a purely additive projection like `noTestSlugs`
lands inside v3 rather than bumping to v4: a new binary paired with an
older shipped hook copy then degrades one message, not the whole gate
layer.
"""

import json
import os
import shutil


CACHE_REL_PATH = os.path.join(".codeyam", "cache", "step-metadata.json")

SUPPORTED_SCHEMA_VERSIONS = (1, 2)
SUPPORTED_SCHEMA_VERSIONS = (1, 2, 3)


def cli_command():
"""Return the codeyam-editor CLI name to surface to the user.

Prefers `codeyam-editor-dev` (local-dev rebuild wrapper) when on
PATH; otherwise falls back to the canonical `codeyam-editor` binary.
Cloud VM images only install `codeyam-editor`, so probing PATH at
emit time eliminates `command not found` noise that would otherwise
flood every session there.
Canonical `codeyam-editor` unless the caller explicitly opted into
the dev branding via `CODEYAM_CLI`. `npm/editor-dev.js` sets that
variable ONLY when it was invoked as `codeyam-editor-dev`, and
deliberately leaves it unset behind the `codeyam-editor` symlink.

This must NOT probe PATH for `codeyam-editor-dev`. `scripts/
bootstrap.sh` symlinks the dev wrapper under BOTH names, so on any
local-dev checkout the probe always finds it and every hook then
emits `-dev` — including for the developer who invoked the canonical
name and the shipped output that reaches clients. CLAUDE.md's rule
is that shipped hooks emit `codeyam-editor` and never
`codeyam-editor-dev`; an env var the wrapper sets is the only signal
that actually distinguishes the two invocations.
"""
if shutil.which("codeyam-editor-dev"):
return "codeyam-editor-dev"
override = os.environ.get("CODEYAM_CLI", "").strip()
# A stray empty `CODEYAM_CLI=` must not emit an empty command into
# every hint — fall back to the canonical name.
if override:
return override
return "codeyam-editor"


Expand All @@ -48,6 +68,8 @@ def _empty_mode_table():
"commitSlugs": [],
"pushSlugs": [],
"previewRequiredSlugs": [],
"testRunSlugs": [],
"noTestSlugs": {},
}


Expand All @@ -73,6 +95,31 @@ def _string_list(raw):
return [s for s in raw if isinstance(s, str)]


def _no_test_slug_map(raw):
"""Normalize the `noTestSlugs` projection to {slug: {kind, nextTestRunSlug}}.

The cache carries it as a list of objects; a dict keyed by slug is what
the hook actually looks up. Malformed members are dropped rather than
raised on — a slug with no entry reads as "word the block the old way",
which is the same status-quo degrade an absent projection gets."""
out = {}
if not isinstance(raw, list):
return out
for entry in raw:
if not isinstance(entry, dict):
continue
slug = entry.get("slug")
kind = entry.get("kind")
if not isinstance(slug, str) or not isinstance(kind, str):
continue
next_slug = entry.get("nextTestRunSlug")
out[slug] = {
"kind": kind,
"nextTestRunSlug": next_slug if isinstance(next_slug, str) else None,
}
return out


def load_step_metadata(project_dir):
"""Load the per-mode step metadata cache. Returns
{"ui": <mode_table>, "backend": <mode_table>} where each
Expand Down Expand Up @@ -107,6 +154,16 @@ def load_step_metadata(project_dir):
"commitSlugs": _string_list(mode_raw.get("commitSlugs")),
"pushSlugs": _string_list(mode_raw.get("pushSlugs")),
"previewRequiredSlugs": _string_list(mode_raw.get("previewRequiredSlugs")),
# v3 projection. Absent in a v1/v2 cache → empty list → the
# pretool hook treats test runs as ungated (degrade to "allow"),
# never "block every test run".
"testRunSlugs": _string_list(mode_raw.get("testRunSlugs")),
# Companion to `testRunSlugs`, added additively WITHIN v3 (a
# version bump would make this closed `SUPPORTED_SCHEMA_VERSIONS`
# tuple reject a newer cache outright and drop every gate).
# Absent → empty map → the pretool hook falls back to its
# original pre-Demo wording.
"noTestSlugs": _no_test_slug_map(mode_raw.get("noTestSlugs")),
}
return out

Expand Down
Loading
Loading