From 0657e2ded63942b7c3c37c1d62f01cf5ae618e29 Mon Sep 17 00:00:00 2001 From: Jared Cosulich Date: Thu, 6 Aug 2026 13:18:15 -0400 Subject: [PATCH 1/5] docs: link the Google Play listing and show both platforms The CHANGELOG has announced the Google Play release since 1.1.0, but the README linked only the App Store and the sole Play URL anywhere in the repo was an internal-test track. An Android visitor had no way to reach the app they were reading about. - Both store links in the download line, and the hero is now an iOS and an Android capture side by side rather than a single iPhone shot linked to the App Store. A repo that ships for two platforms should look like it on first screen. - store/playstore/README.md described the app as live on Internal testing only, with a "still to do before a PRODUCTION rollout" list whose items are now done. Rewritten to match reality: production is live, the mapping-file upload stays listed as the one optional per-release step. Both store URLs verified reachable (HTTP 200). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rzwduyjmzj1CErQucuu2ys --- README.md | 6 ++++-- store/playstore/README.md | 29 ++++++++++++++++------------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a0a8496..1f6e5f4 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ anything worth counting. Each counter gets its own name and color, one tap increments, and every count is charted over time so you can see your history at a glance. -**[Download CodeYam Counter on the App Store](https://apps.apple.com/us/app/codeyam-counter/id6789247345) — free to use.** +**Download it free — [App Store](https://apps.apple.com/us/app/codeyam-counter/id6789247345) · +[Google Play](https://play.google.com/store/apps/details?id=com.codeyam.counter)**

- CodeYam Counter tracking a set of push-ups + CodeYam Counter tracking a set of push-ups on iOS + The same counter running on Android

diff --git a/store/playstore/README.md b/store/playstore/README.md index ac5acea..442f319 100644 --- a/store/playstore/README.md +++ b/store/playstore/README.md @@ -1,8 +1,9 @@ # Google Play assets — CodeYam Counter Assembled for the **Google Play Console**. Mirrors `../appstore/` for the -Android build under `android/`. The app is live on the **Internal testing** -track; everything here is what a **production** rollout additionally needs. +Android build under `android/`. The app is **live in production** at +; everything +here is the source material behind that listing. ## Contents @@ -124,17 +125,19 @@ These are decisions, not bugs. Do not "fix" them. all tracks, and **API 36 from Aug 31, 2026**. Verify the current requirement in Play Console before each release rather than trusting this file. -## Still to do before a PRODUCTION rollout - -- Fill the Play Console store listing from `listing.md` and upload the graphics - above. -- Clear the App content declarations using - [PLAY_CONSOLE_CHEATSHEET.md](PLAY_CONSOLE_CHEATSHEET.md). -- **Closed testing gate:** a recently-created *personal* developer account must - run a closed test with **≥12 testers for 14 continuous days** before - production access is unlocked. This does not affect Internal testing. Confirm - which account type applies before committing to a launch date. -- Optionally upload the R8 deobfuscation mapping file +## Production rollout — done + +The listing is filled from `listing.md` with the graphics above, the App content +declarations are cleared per +[PLAY_CONSOLE_CHEATSHEET.md](PLAY_CONSOLE_CHEATSHEET.md), and the closed-testing +gate (≥12 testers for 14 continuous days, required only of a recently-created +*personal* developer account) no longer blocks production access. Keep the +cheatsheet current: Play re-asks the content declarations whenever the answers +would change. + +Still optional, per release: + +- Upload the R8 deobfuscation mapping file (`android/app/build/outputs/mapping/release/mapping.txt`) so Play can symbolicate release crash reports. From 2b1592a3c40e236b50f6b0f1c16f6d33d3c5ca6a Mon Sep 17 00:00:00 2001 From: Jared Cosulich Date: Thu, 6 Aug 2026 13:18:15 -0400 Subject: [PATCH 2/5] chore: pick up the current binary's merge-driver registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic output of `ensure-merge-drivers`, which the managed post-checkout hook re-runs on every checkout — so this drift reappears on any git operation until it is committed. Registers the `codeyam-editor-config` driver for editor.json (was merge=ours), adds the capture-pipeline state file, and marks the generated mocks catalog and per-test-evidence as linguist-generated so they stop counting toward the repo's language stats and diffs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rzwduyjmzj1CErQucuu2ys --- .codeyam/state/merge-drivers-version | 2 +- .gitattributes | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.codeyam/state/merge-drivers-version b/.codeyam/state/merge-drivers-version index d440cbc..c49d6b6 100644 --- a/.codeyam/state/merge-drivers-version +++ b/.codeyam/state/merge-drivers-version @@ -1 +1 @@ -0.1.7-1 \ No newline at end of file +0.1.7-2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index 48019eb..dd34859 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,12 +1,13 @@ -.codeyam/editor.json merge=ours .codeyam/test-cache/**/*.json merge=ours .codeyam/scenarios/screenshots/*.png.hash merge=ours -.codeyam/mocks/catalog.json merge=ours .codeyam/state/session-results-snapshot.json merge=ours .codeyam/glossary.json merge=codeyam-glossary .codeyam/test-cache.json merge=codeyam-test-cache .codeyam/dependency-graph.json merge=codeyam-dependency-graph .codeyam/test-registry.json merge=codeyam-test-registry -.codeyam/per-test-evidence.json merge=codeyam-per-test-evidence .codeyam/state/finalize-debt.json merge=codeyam-finalize-debt .codeyam/cache/step-metadata.json merge=ours +.codeyam/scenarios/screenshots/.capture-pipeline.json merge=ours +.codeyam/mocks/catalog.json merge=ours linguist-generated +.codeyam/editor.json merge=codeyam-editor-config +.codeyam/per-test-evidence.json merge=codeyam-per-test-evidence linguist-generated From 2041ca848521a01029569f3089325fb155281f79 Mon Sep 17 00:00:00 2001 From: Jared Cosulich Date: Thu, 6 Aug 2026 13:18:26 -0400 Subject: [PATCH 3/5] chore: refresh codeyam tooling --- .claude/hooks/_step_metadata.py | 83 ++- .claude/hooks/editor-pretool-hook.py | 627 +++++++++++++++++- .claude/hooks/editor-step-hook.py | 29 +- .../codeyam-audit/finalize-procedure.md | 24 + .claude/skills/codeyam-design/SKILL.md | 13 + .claude/skills/codeyam-editor/SKILL.md | 4 +- .claude/skills/codeyam-onboard/SKILL.md | 4 +- .gitignore | 2 + 8 files changed, 744 insertions(+), 42 deletions(-) diff --git a/.claude/hooks/_step_metadata.py b/.claude/hooks/_step_metadata.py index f91919b..4f59e07 100644 --- a/.claude/hooks/_step_metadata.py +++ b/.claude/hooks/_step_metadata.py @@ -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" @@ -48,6 +68,8 @@ def _empty_mode_table(): "commitSlugs": [], "pushSlugs": [], "previewRequiredSlugs": [], + "testRunSlugs": [], + "noTestSlugs": {}, } @@ -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": , "backend": } where each @@ -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 diff --git a/.claude/hooks/editor-pretool-hook.py b/.claude/hooks/editor-pretool-hook.py index 149a62e..c7b1d41 100755 --- a/.claude/hooks/editor-pretool-hook.py +++ b/.claude/hooks/editor-pretool-hook.py @@ -9,9 +9,21 @@ don't carry the code-change capability. - Bash `git commit` / `git add` outside slugs in `commitSlugs`. - Bash `git push` outside slugs in `pushSlugs`. +- Bash test runs (`refresh-tests` / raw runners) at slugs NOT in + `testRunSlugs` — every phase whose `test_scope` is `none`. Pre-Demo + slugs are blocked to hold the prototype-speed "no tests before Demo" + boundary; post-hardening slugs (presentation, journal, sync, commit, + push) are blocked because a test run is out of scope at a gate. The + `noTestSlugs` projection says which kind a slug is, so the refusal + names a recovery that actually exists at that position. - AskUserQuestion at slugs in `previewRequiredSlugs` unless `.codeyam/preview-shown.json` matches the current step. +One rule is deliberately NOT step-scoped: the scripted-source-rewrite +guard. CLAUDE.md's ban on machine-rewriting tracked source holds in +every session, editor mode or not, so that guard runs before the +`CODEYAM_EDITOR_ACTIVE` short-circuit in `main`. + The slug allowlists are projected into the cache by `crates/codeyam-editor/src/commands/editor/slug_capabilities.rs` (the single source of truth for per-slug capabilities), so a future @@ -82,6 +94,24 @@ def git_add_paths_are_plans_only(command): return all(p.startswith(PLAN_PATH_PREFIX) for p in args) +def merge_in_progress(project_dir): + """True while a rebase, merge, or cherry-pick is paused mid-operation. + + Staging a conflict resolution is not the same act as creating a commit, but + both spell `git add`. `pre-commit-sync` starts a rebase and, on a + modify/delete conflict in the regenerated test-cache blobs, prints a + recovery that ends in `git add -- ` — which the commit-slug gate then + refused, wedging the very step that printed it. The gate was always this + broad; it only became reachable once the hook's exit code stopped being + swallowed. `git commit` stays gated regardless, so this cannot land a commit + outside the commit slug — it only lets an in-flight rebase be finished.""" + git_dir = os.path.join(project_dir, ".git") + return any( + os.path.exists(os.path.join(git_dir, marker)) + for marker in ("rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD") + ) + + def _slug_label(state, slug): """Human-readable identifier for BLOCKED messages. Slug is the primary handle; label is shown alongside when state carries it.""" @@ -91,6 +121,53 @@ def _slug_label(state, slug): return f"slug={slug}" +def _test_run_block_message(state, slug, info): + """Word the test-run block for `slug` from its phase kind. + + `info` is the slug's `noTestSlugs` entry, or None when the cache + predates that projection (or dropped the entry as malformed). + + Sixteen phases declare `test_scope: none`, but only five of them — + plan / confirm / prepare / prototype / demo — are actually pre-Demo. The + rest (final-presentation, journal, pre-commit-sync, commit, push, + feature-complete) sit AFTER every test-running phase, so telling an + agent there that hardening "starts at Deconstruct" and to run tests at + `*-extract-tdd` names a step it has already passed and cannot reach + without `editor change`. The block is right at both; only the + explanation and the named recovery differ.""" + where = _slug_label(state, slug) + if not info or info.get("kind") != "post-hardening": + # Pre-Demo, or no projection to judge by. This wording is accurate + # where it applies, and it is the status-quo degrade where the cache + # cannot say. + return ( + f"BLOCKED: test runs are not allowed at {where} " + f"(pre-Demo, test_scope: none). The Plan→Demo stretch is for building " + f"fast and getting working functionality in front of the user — " + f"hardening (tests, extraction, glossary) starts at Deconstruct.\n" + f"Next valid action: keep building — run tests at " + f"`ui-extract-tdd` / `backend-extract-tdd`." + ) + next_slug = info.get("nextTestRunSlug") + if next_slug: + recovery = ( + f"advance to `{next_slug}` — the next step in this mode where " + f"test runs are in scope." + ) + else: + recovery = ( + "advance — no test-running step remains in this mode, so there is " + "nowhere left to re-run this." + ) + return ( + f"BLOCKED: test runs are not allowed at {where} " + f"(test_scope: none). The hardening phases already ran the tests; this " + f"step is a presentation / commit gate, where a test run is out of " + f"scope.\n" + f"Next valid action: {recovery}" + ) + + def _preview_hint(mode, project_dir): """Hint shown when AskUserQuestion is blocked for missing preview. @@ -111,12 +188,501 @@ def _preview_hint(mode, project_dir): return f'{cli} editor preview \'{{"dimension":"{default_dim}"}}\'' +# Stack-agnostic raw test runners, matched as word-boundary regexes so a +# command that merely CONTAINS "test" (`cargo build`, `ls tests/`, +# `git commit -m "add test"`) does NOT trip the gate. `refresh-tests` is +# codeyam's own test command — the one the workflow actually uses — and is +# always a test run. +_TEST_RUN_PATTERNS = [ + r"\brefresh-tests\b", + r"\bcargo\s+(?:test|nextest)\b", + r"\bnpx\s+vitest\b", + r"\bvitest\s+run\b", + r"\bjest\b", + r"\bpytest\b", + r"\bgo\s+test\b", +] + + +def _configured_test_scripts(project_dir): + """Project-specific test-runner SCRIPT invocations derived from + `testRunners[].command` in editor.json — e.g. `bash scripts/run-shell-tests.sh`. + + Lets the gate catch a raw run of the project's OWN test script, not just + the stack-agnostic runners above, so the gate is config-aware rather than a + fixed hardcoded list. Only tokens that look like a script path (`scripts/…` + or ending in `.sh`) are lifted — that deliberately skips a bare interpreter + like `python3` in `python3 -m pytest`, which the `pytest` regex already + covers and which would over-block if treated as a runner.""" + cfg_path = os.path.join(project_dir, ".codeyam", "editor.json") + scripts = [] + try: + with open(cfg_path) as f: + cfg = json.load(f) + except Exception: + return scripts + for runner in cfg.get("testRunners", []) or []: + cmd = runner.get("command", "") if isinstance(runner, dict) else "" + for tok in cmd.split(): + if tok.startswith("scripts/") or tok.endswith(".sh"): + scripts.append(tok) + return scripts + + +def is_test_run_command(command, project_dir): + """True iff `command` invokes a test run — `refresh-tests`, a common raw + runner, or the project's configured test script.""" + for pat in _TEST_RUN_PATTERNS: + if re.search(pat, command): + return True + for script in _configured_test_scripts(project_dir): + if script in command: + return True + return False + + +# --- Scripted source-rewrite guard ----------------------------------------- +# +# CLAUDE.md bans machine-rewriting tracked source ("never a `python`/regex/ +# brace-matching find-and-replace … such scripts parse the language with the +# wrong grammar and self-match the code they just generated"). Documentation +# alone did not hold, so this guard turns the guideline into a refusal that +# names the sanctioned alternatives. +# +# The signature is the SHAPE, not the interpreter: a shell command that both +# computes a text transform in-process AND lands it on a git-tracked source +# file. Inspecting JSON state, running a committed script, and writing to a +# temp/untracked path all stay allowed. + +# Suffixes whose files a reviewer reads as a diff, and which must therefore be +# edited with the Edit tool rather than machine-rewritten. Deliberately broad +# and additive across stacks: a language absent from this list is simply not +# guarded, so an unlisted extension degrades to "allow", never to a spurious +# block. `.json` is omitted on purpose — rewriting JSON through a parser is +# structurally sound and is how config edits are legitimately scripted. +SOURCE_SUFFIXES = ( + ".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go", + ".java", ".kt", ".swift", ".m", ".mm", ".c", ".h", ".cc", ".cpp", ".hpp", + ".cs", ".php", ".ex", ".exs", ".sh", ".bash", ".zsh", ".ps1", ".sql", + ".svelte", ".vue", ".astro", ".css", ".scss", ".html", ".md", ".toml", + ".yaml", ".yml", +) + +# Bound the git query so a pathological command cannot spawn a huge argv. +_MAX_PATH_CANDIDATES = 40 + +# A pathspec we are willing to hand to `git ls-files`. Excludes whitespace and +# `:` (git's pathspec-magic prefix) so an odd token cannot change git's parse. +_PATHSPEC_SAFE = re.compile(r"^[A-Za-z0-9_./*+-]+$") + +_OPEN_CALL = re.compile(r"\bopen\s*\(") +# `Path("x").write_text(` yields its literal; a bare `p.write_text(` does not. +_WRITE_TEXT = re.compile( + r"""(?:Path\s*\(\s*(?P['"])(?P[^'"]+)(?P=q)\s*\)\s*)?\.write_(?:text|bytes)\s*\(""" +) +_NODE_WRITE = re.compile( + r"""writeFile(?:Sync)?\s*\(\s*(?:(?P['"`])(?P[^'"`]+)(?P=q))?""" +) +# `> path` / `>> path`, but not the fd forms (`2>&1`, `>&2`). +_SHELL_REDIRECT = re.compile(r"""(?>?\s*(?P[^\s;|&<>()'"]+)""") +# Anything shaped like a path with an extension, wherever it appears. Matching +# the shape directly rather than tokenizing by quotes or whitespace is what +# makes the fallback survive nested quoting — a one-liner like +# `python3 -c "p = 'src/lib.rs'; …"` yields no clean quoted or whitespace token, +# because the inner quotes interleave with the outer ones. +_PATHLIKE = re.compile(r"/?[A-Za-z0-9_][A-Za-z0-9_./*+-]*\.[A-Za-z0-9]+") +# An in-place flag for sed/perl: `-i`, `-i.bak`, `-pi`, `--in-place`. The +# pre-`i` letter class excludes `e`/`E`/`I` so perl's `-Ilib` (a library path, +# not an in-place edit) does not false-match. +_INPLACE_FLAG = re.compile(r"^(?:--in-place(?:=.*)?|-[a-df-hj-zA-DF-HJ-Z0-9]*i.*)$") + + +def _string_literal(expr): + """The inner text of `expr` when it is a single quoted string literal.""" + expr = expr.strip() + if len(expr) >= 2 and expr[0] == expr[-1] and expr[0] in "'\"`": + inner = expr[1:-1] + if expr[0] not in inner: + return inner + return None + + +def _call_args(text, paren_index): + """Top-level, comma-separated argument expressions of the call whose `(` + sits at `paren_index`. Quote-aware so a comma or paren inside a string + literal does not split an argument. Returns [] if the parens never close.""" + depth = 0 + quote = "" + args = [] + current = [] + for i in range(paren_index, len(text)): + ch = text[i] + if quote: + if ch == quote: + quote = "" + current.append(ch) + continue + if ch in "'\"`": + quote = ch + current.append(ch) + continue + if ch in "([{": + depth += 1 + if depth == 1: + continue + elif ch in ")]}": + depth -= 1 + if depth == 0: + args.append("".join(current)) + return args + if depth == 1 and ch == ",": + args.append("".join(current)) + current = [] + else: + current.append(ch) + return [] + + +def _has_inplace_editor(command): + """True iff the command invokes `sed`/`perl` with an in-place flag.""" + seen_editor = False + for tok in command.split(): + base = tok.rsplit("/", 1)[-1] + if base in ("sed", "perl"): + seen_editor = True + elif seen_editor and _INPLACE_FLAG.match(tok): + return True + return False + + +def write_targets(command): + """Parse `command` for in-process file-write constructs. + + Returns `(explicit, opaque)`: `explicit` lists the literal paths the + command writes to; `opaque` is True when at least one write construct + targets a path that cannot be resolved statically — a variable + (`open(p, "w")`), or an in-place `sed`/`perl` whose file argument is + positional.""" + explicit = [] + opaque = False + + for match in _OPEN_CALL.finditer(command): + args = _call_args(command, match.end() - 1) + if len(args) < 2: + continue + mode = _string_literal(args[1]) + if mode is None or not set(mode) & set("wax+"): + continue + literal = _string_literal(args[0]) + if literal: + explicit.append(literal) + else: + opaque = True + + for pattern in (_WRITE_TEXT, _NODE_WRITE): + for match in pattern.finditer(command): + if match.group("path"): + explicit.append(match.group("path")) + else: + opaque = True + + for match in _SHELL_REDIRECT.finditer(command): + explicit.append(match.group("path")) + + if _has_inplace_editor(command): + opaque = True + + return explicit, opaque + + +def _repo_relative(path, project_dir): + """`path` expressed relative to `project_dir`, or None when it escapes the + repo (an absolute path elsewhere, `~`, or a `../` climb).""" + if not path or path.startswith("~"): + return None + if os.path.isabs(path): + try: + rel = os.path.relpath(path, project_dir) + except ValueError: + return None + else: + rel = path + while rel.startswith("./"): + rel = rel[2:] + if not rel or rel.startswith(".."): + return None + return rel + + +def eligible_pathspecs(paths, project_dir): + """The repo-relative, source-suffixed, pathspec-safe subset of `paths`, + de-duplicated and capped at `_MAX_PATH_CANDIDATES`. + + Pure — no git, no filesystem. Split from `tracked_source_paths` so the + normalize-and-filter half is testable without a git repository.""" + candidates = [] + for path in paths: + rel = _repo_relative(path, project_dir) + if not rel or not rel.lower().endswith(SOURCE_SUFFIXES): + continue + if not _PATHSPEC_SAFE.match(rel) or rel in candidates: + continue + candidates.append(rel) + if len(candidates) >= _MAX_PATH_CANDIDATES: + break + return candidates + + +def tracked_source_paths(paths, project_dir): + """The subset of `paths` that git tracks and that carries a source suffix. + + Untracked files, temp/scratchpad paths, and generated artifacts all fall + out here — they are not tracked, so they are never blocked.""" + candidates = eligible_pathspecs(paths, project_dir) + if not candidates: + return [] + try: + result = subprocess.run( + ["git", "ls-files", "-z", "--"] + candidates, + cwd=project_dir, + capture_output=True, + text=True, + timeout=5, + ) + except Exception: + return [] + if result.returncode != 0: + return [] + return sorted(p for p in result.stdout.split("\0") if p) + + +def _path_tokens(command): + """Every path-shaped substring in `command` that could name a file. + Suffix and tracked-ness filtering happen in `tracked_source_paths`.""" + return [m.group(0) for m in _PATHLIKE.finditer(command)] + + +def scripted_source_rewrite_target(command, project_dir): + """The git-tracked source file a scripted in-process rewrite would clobber, + or None when `command` is not one. + + A command qualifies only when it BOTH carries a write construct AND that + write lands on tracked source. When every write target is a literal path, + only those paths are judged. When a target is opaque, it falls back to + every tracked source path the command mentions — which is the shape the + real incidents took (`p = "…/opencode.rs"` … `open(p, "w")`).""" + explicit, opaque = write_targets(command) + if not explicit and not opaque: + return None + candidates = _path_tokens(command) if opaque else explicit + tracked = tracked_source_paths(candidates, project_dir) + return tracked[0] if tracked else None + + +def scripted_rewrite_refusal(path): + """The BLOCKED message for a refused scripted rewrite. Names the path that + matched and the three sanctioned alternatives — batching is the reason + agents reach for a script, so the refusal has to answer it.""" + return ( + f"BLOCKED: this command machine-rewrites the tracked source file `{path}`. " + f"A scripted in-process rewrite (`open(p, 'w')`, `.write_text(`, `sed -i`, " + f"`perl -pi`) computes its diff at runtime, so the change never appears in " + f"the transcript a reviewer reads; it parses the language with the wrong " + f"grammar and self-matches the code it just generated; and it bypasses the " + f"file-state tracking that lets Edit refuse a file that changed underneath " + f"it.\n" + f"Next valid action: use the Edit tool. Batching is not a reason to script — " + f"several Edit calls in ONE message run in parallel. For a genuine " + f"replace-every-occurrence pass use Edit with `replace_all: true`; to rename " + f"an identifier across source + glossary + registry run " + f"`{cli_command()} editor rename-symbol`. Writing to an untracked file, to " + f"/tmp, or to the scratchpad is unaffected." + ) + + +def read_event(): + """The PreToolUse event from stdin, or None when it is absent or + unparseable — in which case the hook allows rather than blocks.""" + try: + raw = sys.stdin.read() + if not raw.strip(): + return None + return json.loads(raw) + except Exception: + return None + + +# Internal `.codeyam/` state stores that have a purpose-built inspector, +# mapped to the command that answers questions about them. Ordered +# most-specific-path first so `.codeyam/test-cache/blobs/…` matches the +# cache inspector rather than a broader prefix. +# +# These are stores whose on-disk shape is INTERNAL and undocumented at the +# read site: a hand-rolled walk has to guess whether a field is a string +# or a list, and the observed failures were exactly that guess going wrong +# (`'list' object has no attribute 'split'`, `JSONDecodeError` on a +# blob file that had been externalized). The inspectors interpret the +# store instead, so the question is answerable without knowing the schema. +_INSPECTOR_BY_STORE = [ + (".codeyam/logs/audit-history.jsonl", "audit-history"), + (".codeyam/state/finalize-debt.json", "finalize-debt"), + (".codeyam/dependency-graph.json", "deps-imports / deps-imported-by"), + (".codeyam/test-registry.json", "registry-query"), + (".codeyam/editor.local.json", "config-show --source"), + (".codeyam/scenarios/_shared", "shared-data"), + (".codeyam/editor-step.json", "step"), + (".codeyam/glossary.json", "glossary-find / glossary-list"), + (".codeyam/editor.json", "config-show"), + (".codeyam/scenarios", "scenarios / scenario-explain"), + (".codeyam/test-cache", "test-cache-query"), + (".codeyam/journal", "journal-find"), + (".codeyam/plans", "plans / plan-show"), +] + +# A path under `.codeyam/` naming something more specific than the +# directory itself. Used only for the no-inspector case, so `ls .codeyam/` +# — an ordinary first look around — stays quiet while a probe of a +# particular state file is answered. +_CODEYAM_STATE_PATH = re.compile(r"\.codeyam/[A-Za-z0-9_.][A-Za-z0-9_./+-]*") + +# Read-shaped commands, matched in COMMAND POSITION — at the start of the +# string or just after a shell separator, allowing leading `VAR=value` +# assignments and transparent prefixes. Position is what distinguishes a +# probe from an incidental mention: `git ls-files .codeyam/glossary.json` +# and `git add .codeyam/test-registry.json` both name a store without +# reading it the way this nudge is about, and neither matches here. +# +# The verb set is the python forms the nudge has always covered plus the +# shell reads agents actually reach for. The rationale in +# `inspector_nudge`'s docstring was never python-specific: `ls` on a +# guessed path re-derives a store's layout exactly the way a python walk +# re-derives its schema, and fails the same way. +_READ_VERB = re.compile( + r"""(?:\A|[\n;|&`(]|\$\()\s* + (?:[A-Za-z_][A-Za-z_0-9]*=\S*\s+)* + (?:(?:sudo|command|time|xargs)\s+)* + (?:python3?|ls|cat|head|tail|wc|jq|grep|find)\b + """, + re.VERBOSE, +) + +# A `codeyam-editor editor …` invocation, under either the canonical name +# or the local-dev branding. +_INSPECTOR_INVOCATION = re.compile(r"\bcodeyam-editor(?:-dev)?\s+editor\b") + + +def is_read_shaped_command(command): + """True when `command` READS something in command position — a python + invocation or a shell read verb. + + Pure and side-effect free so the predicate can be tested directly, + separately from the store mapping it gates.""" + return bool(_READ_VERB.search(command)) + + +def is_inspector_invocation(command): + """True when `command` runs a `codeyam-editor editor …` subcommand. + + An inspector necessarily names the store it inspects, so nudging one + would point the agent at the command it is already running.""" + if _INSPECTOR_INVOCATION.search(command): + return True + return f"{cli_command()} editor " in command + + +def matching_inspector(command): + """The `(store, inspector)` pair `command` touches, or None. + + Separated from the message that reports it so the + longest-path-first ordering of `_INSPECTOR_BY_STORE` — which is what + keeps `.codeyam/scenarios/_shared/…` from resolving to the broader + scenarios entry — is assertable without going through message text.""" + for store, inspector in _INSPECTOR_BY_STORE: + if store in command: + return (store, inspector) + return None + + +def probed_state_path(command): + """The `.codeyam/` state path `command` names, or None. + + A bare `.codeyam/` is deliberately not a match: listing the + directory is an ordinary first look around, not a probe of a + particular store, and nudging it would be noise.""" + match = _CODEYAM_STATE_PATH.search(command) + return match.group(0) if match else None + + +def inspector_nudge(command): + """Return a pointer to the matching inspector when `command` reads a + `.codeyam/` state store, else None. When the probed store has no + inspector, say so rather than staying silent — the absence is a fact + worth reporting, since silence reads as "no such command found". + + Pure and side-effect free so the mapping can be tested directly. + + This is a NUDGE, never a block. Reading internal state by hand is + wasteful, not incorrect — the reader re-derives a shape that a + command already knows, and gets it wrong often enough to cost a turn + plus a re-read. That asymmetry is what makes a pointer the right + instrument and a refusal the wrong one: a block would strand an agent + whose question genuinely has no inspector. It matters more under the + wider trigger, not less — a broader net means more false positives, + which is an argument for keeping the instrument soft.""" + if is_inspector_invocation(command): + return None + if not is_read_shaped_command(command): + return None + matched = matching_inspector(command) + if matched: + store, inspector = matched + return ( + f"NOTE: this reads {store} — an internal codeyam state store. " + f"`{cli_command()} editor {inspector}` answers questions about it directly, " + f"and interprets the store rather than dumping it, so the field shapes are " + f"named instead of guessed. Not blocking; your command still runs." + ) + probed = probed_state_path(command) + if probed: + return ( + f"NOTE: this reads {probed} — internal codeyam state with no " + f"read-only inspector. No `{cli_command()} editor` verb interprets it, so " + f"reading the file is the only option here; the absence is real, not " + f"something you missed. Not blocking; your command still runs." + ) + return None + + def main(): - # Only enforce in editor mode + """Claude Code PreToolUse hook entry point: read the current + editor step from `.codeyam/editor-step.json` and either allow or + block the in-flight tool call based on the active step's rules.""" + project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) + + # Read the tool use event from stdin + event = read_event() + if event is None: + sys.exit(0) + + tool_name = event.get("tool_name", "") + tool_input = event.get("tool_input", {}) + + # Scripted-source-rewrite guard. Unlike every other rule here this one is + # neither step-scoped nor editor-mode-scoped — the ban on machine-rewriting + # tracked source holds in every session — so it fires before the + # `CODEYAM_EDITOR_ACTIVE` short-circuit below. + if tool_name == "Bash": + rewrite_target = scripted_source_rewrite_target( + tool_input.get("command", ""), project_dir + ) + if rewrite_target: + print(scripted_rewrite_refusal(rewrite_target), file=sys.stderr) + sys.exit(2) + + # Every remaining rule is a workflow-step gate — only enforce in editor mode if not os.environ.get("CODEYAM_EDITOR_ACTIVE"): sys.exit(0) - project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) state_path = os.path.join(project_dir, ".codeyam", "editor-step.json") # No state file = not in editor mode, allow everything @@ -142,24 +708,49 @@ def main(): commit_slugs = set(mode_table.get("commitSlugs", [])) push_slugs = set(mode_table.get("pushSlugs", [])) preview_required_slugs = set(mode_table.get("previewRequiredSlugs", [])) - - # Read the tool use event from stdin - try: - raw = sys.stdin.read() - if not raw.strip(): - sys.exit(0) - event = json.loads(raw) - except Exception: - sys.exit(0) - - tool_name = event.get("tool_name", "") - tool_input = event.get("tool_input", {}) + test_run_slugs = set(mode_table.get("testRunSlugs", [])) + no_test_slugs = mode_table.get("noTestSlugs", {}) or {} # Always allow codeyam-editor commands. Match both the canonical # name and the local-dev wrapper so saved sessions emitted under # either spelling keep working after the canonical-name rollout. if tool_name == "Bash": command = tool_input.get("command", "") + + # Test-run gate. `testRunSlugs` is the per-mode set of slugs whose + # phase declares a non-None test_scope — a slug NOT in it may not run + # tests. This must fire BEFORE the "always allow codeyam-editor editor" + # short-circuit below, because `codeyam-editor editor refresh-tests` is + # itself a test run. Empty `testRunSlugs` (a stale v1/v2 cache) => no + # gating, mirroring the `and commit_slugs` / `and push_slugs` + # short-circuits below — a cache skew degrades to "allow", never "block + # every test run". + # + # The MEMBERSHIP test is one line; wording the refusal is not, because + # a blocked slug can be pre-Demo or post-hardening and the two need + # opposite advice. `_test_run_block_message` reads that from the + # `noTestSlugs` projection. + if ( + slug + and test_run_slugs + and slug not in test_run_slugs + and is_test_run_command(command, project_dir) + ): + print( + _test_run_block_message(state, slug, no_test_slugs.get(slug)), + file=sys.stderr, + ) + sys.exit(2) + + # Inspector nudge. Emitted on stderr and then FALLEN THROUGH from + # — never `sys.exit`ed on — so the command still runs and every + # gate below still applies. stderr is the channel every other + # message in this hook uses; pairing it with a 0 exit is what + # makes this a pointer rather than a refusal. + nudge = inspector_nudge(command) + if nudge: + print(nudge, file=sys.stderr) + if ( "codeyam-editor editor" in command or "codeyam-editor:editor" in command @@ -270,7 +861,13 @@ def main(): ) sys.exit(2) elif "git add" in command: - if slug and commit_slugs and slug not in commit_slugs and not git_add_paths_are_plans_only(command): + if ( + slug + and commit_slugs + and slug not in commit_slugs + and not git_add_paths_are_plans_only(command) + and not merge_in_progress(project_dir) + ): allowed = ", ".join(sorted(commit_slugs)) print( f"BLOCKED: git commit/add is only allowed at slug(s): {allowed}. " diff --git a/.claude/hooks/editor-step-hook.py b/.claude/hooks/editor-step-hook.py index a2f767e..441ef1f 100755 --- a/.claude/hooks/editor-step-hook.py +++ b/.claude/hooks/editor-step-hook.py @@ -137,12 +137,16 @@ def detect_event(): TOOL_LOADING_SELECT_QUERY = "select:TaskCreate,TaskList,TaskUpdate,TaskGet" # SessionStart preloads one extra tool beyond the gate-step set: `Monitor`, -# the supported way to watch a backgrounded long command (refresh-tests, -# session-finalize, rebuild-self). Loading its schema once up front means the -# first blocking op never hits the `Monitor`-before-its-schema-is-loaded -# `InputValidationError` that historically triggered a fallback to polling -# loops. It is NOT in the per-prompt gate-tool query because it is not a -# gate-step tool — only the session-entry preload needs it. +# for watching a condition the harness will not notify about. Loading its +# schema once up front means the first such call never hits the +# `Monitor`-before-its-schema-is-loaded `InputValidationError` that +# historically triggered a fallback to polling loops. Monitor is NOT how a +# backgrounded long command (refresh-tests, session-finalize, rebuild-self) is +# awaited — that completion notification arrives on its own and +# `editor wait-for` is the same-turn blocking path; see +# `steps/library/fragments/background_wait_block.txt`. It is NOT in the +# per-prompt gate-tool query because it is not a gate-step tool — only the +# session-entry preload needs it. SESSION_START_SELECT_QUERY = TOOL_LOADING_SELECT_QUERY + ",Monitor" @@ -204,11 +208,14 @@ def main(): print( f"Call `ToolSearch` with `{SESSION_START_SELECT_QUERY}` before your first " "turn so the editor workflow's Task* step-tracking tools are available when " - "step-task tracking needs them, and `Monitor`'s schema is loaded before the " - "first backgroundable long command (refresh-tests, session-finalize) — so you " - "can watch it via the harness completion notification instead of falling back " - "to a `sleep`/`until grep` polling loop. (AskUserQuestion is already resident " - "in editor sessions, so it needs no preload.)" + "step-task tracking needs them, and `Monitor`'s schema is loaded before any " + "call that needs it — a Monitor invoked without its schema fails with " + "`InputValidationError`. Monitor is for watching a CONDITION the harness will " + "not notify you about; a backgrounded long command (refresh-tests, " + "session-finalize) is not that — its completion notification arrives on its " + "own, and `codeyam-editor editor wait-for` is the same-turn blocking path. " + "(AskUserQuestion is already resident in editor sessions, so it needs no " + "preload.)" ) print("") return diff --git a/.claude/skills/codeyam-audit/finalize-procedure.md b/.claude/skills/codeyam-audit/finalize-procedure.md index bacf7b8..a3870b6 100644 --- a/.claude/skills/codeyam-audit/finalize-procedure.md +++ b/.claude/skills/codeyam-audit/finalize-procedure.md @@ -222,6 +222,30 @@ Apply the failures whose fix is unambiguous and scripted. These have a Re-run `codeyam-editor editor audit --format json` after the mechanical pass so the remaining set is only the judgment calls. +> GOTCHA — **Platform-gate drift can only be reconciled AFTER a full +> `refresh-tests`, so do not hand-run `reconcile-registry` for it here.** +> `REGISTRY_HAS_FOREIGN_HOST_GATED_TEST` fires when a test's registry +> `platform_gate` disagrees with the `#[cfg(...)]` its source declares — the +> shape you get the moment you add a `#[cfg(unix)]` to an already-registered +> test. `reconcile-registry --auto-apply` re-infers the gate from source, but +> it reads the **per-partition test cache**, so run before a full refresh it +> sees the pre-edit cache and reports clean. The finding then surfaces in +> `session-finalize` Phase 2 — after Phase 1's full suite has already run — +> costing a `--start-from-phase 2` round trip of roughly 15 minutes. +> +> You do not need to sequence this by hand: `session-finalize` heals it +> itself, in a Phase 1.5 pass between the refresh and the audit (whenever +> Phase 2 is about to run, `--start-from-phase 2` included). It logs +> `Phase 1.5/5: re-inferred platform_gate from source cfg for N registry +> entr…` naming every rewritten key, and stays silent when nothing drifted. +> If it ever fails it warns and continues, and Phase 2 reports the finding +> with its usual recovery — so the only action left to you is reading that +> line to understand a registry change in the resulting diff. +> +> Note `backfill-platform-gates` is **not** the recovery for this: it is +> fill-only (`None → Some`) and deliberately never overwrites a concrete +> gate, which is exactly what a drifted entry carries. + ### 4b. Judgment fixes (STOP and ask — never mass-apply) What's left needs a decision, not a script. **Surface the count and the items, diff --git a/.claude/skills/codeyam-design/SKILL.md b/.claude/skills/codeyam-design/SKILL.md index d06eeb1..68d0660 100644 --- a/.claude/skills/codeyam-design/SKILL.md +++ b/.claude/skills/codeyam-design/SKILL.md @@ -14,6 +14,19 @@ description: | You are helping the user pick a design direction inside the new-project questionnaire's chat substep. The project files do not exist yet — **do not scaffold, install dependencies, or run any `codeyam-editor editor` command**. Your only job is to read the brief and assets, read the bundled design systems, write mockup HTML, and (on the user's pick) POST a single API call. +## Round mode — designing against an app that already exists + +This skill has two modes. Everything above and below assumes **questionnaire mode** (project does not exist yet). When a **design round** is active — the file `.codeyam/design/rounds/.active` exists, naming a round id — you are in **round mode**, running against an app that already exists, and the following overrides apply. Everything else in this skill (the design read, the tier table, imagery/typography rules, atomic writes, the Step 3b lint self-correction loop) is reused verbatim. + +- **Active round + directory.** Read the active round id from `.codeyam/design/rounds/.active`; the round directory is `.codeyam/design/rounds//`. It already holds `current-state.md`, written by the grounding step. +- **`current-state.md` is a primary input, alongside `projectDescription`.** It describes the surface being modified as it looks TODAY — the scenarios that render it, their data states, and what this round is trying to change. Read it before deriving the design read; the mockups are **variations on that real screen**, not a fresh invention. +- **The existing `.codeyam/design/design_system.md` (if any) is the anchored tier's reference** instead of a freshly-chosen catalog system. +- **Step 1 changes.** "Which page to mock up" is already answered by grounding — **do NOT re-ask it.** The count question (2/4/6/8, default 6) and the assets question stay. +- **Tier semantics gain the existing-app reading.** The **anchored** tier means "the app as it looks today, with the requested change applied" — the safe, minimally-disruptive direction — not a fresh catalog system. Exploratory and off-catalog keep their meaning. The 2/4/6/8 → anchored/exploratory/off-catalog allocation table is reused unchanged. +- **The `codeyam-editor` command prohibition is LIFTED in round mode.** The project exists and you legitimately need `codeyam-editor editor plan-show`, `... scenarios`, and the capture commands to ground and iterate. (The prohibition stays in questionnaire mode.) +- **Output paths are round-relative.** `target.json` and every `NN-*-mockup.html` go in the round directory, NEVER `.codeyam/design/project_mockups/`. The mockup/tab/lint API calls take a `?round=` scope; the project-wide directory is left untouched. +- **Selection is handled by the WORKFLOW, not the Step 6 POST.** In round mode do NOT POST `/api/editor-design-select`. The user's pick is recorded by the design workflow's iterate/apply steps (`codeyam-editor editor branch-outcome design-iterate use` then `... design-round --select `), which synthesizes an off-catalog token document automatically — closing the handoff gap the questionnaire flow leaves open. Your job in round mode ends at generating and iterating on the mockups; the workflow carries the selection. + **The brief drives; the systems are a floor, not a ceiling.** The user's product description and any uploaded design assets are the primary signal — every mockup must feel like *their* product, not a stock template. The curated design systems in `.codeyam/design/design_systems/` are a quality floor: a vocabulary of robust, internally-consistent languages you can adhere to, stretch, or set aside depending on a mockup's *tier* (Step 2). Your goal across a set is **genuine range** — diverse directions — anchored by at least one safe, on-brief option, and (when the set is large enough) reaching all the way to a fully bespoke, off-catalog direction. ## Inputs diff --git a/.claude/skills/codeyam-editor/SKILL.md b/.claude/skills/codeyam-editor/SKILL.md index f210303..e79b2d4 100644 --- a/.claude/skills/codeyam-editor/SKILL.md +++ b/.claude/skills/codeyam-editor/SKILL.md @@ -36,7 +36,7 @@ Only proceed past step 1 once `projectDescription` is a real, multi-word descrip You MUST follow a step-by-step workflow driven by `codeyam-editor editor step` commands. Each command tells you exactly what to do next. **You do NOT have all the instructions upfront** — the commands provide them incrementally. -**Your first action:** Run `codeyam-editor editor step 1`. +**Your first action:** If this request may not be a build — a config change, a walkthrough, design exploration, or external-service setup — run `codeyam-editor editor step --slug assist-triage --mode assist` to triage it; otherwise run `codeyam-editor editor step 1`. **The rule:** After completing what a command tells you to do, run the NEXT command it specifies. The commands are your instructions — follow them one at a time. @@ -76,7 +76,7 @@ When the user asks for changes mid-workflow, always: - **Keep the preview moving** — refresh it frequently so the user sees progress. The Demo step (`present-live`) requires a NAVIGABLE preview: its advance gate blocks until a verified capture exists for the feature, or a structural exception is recorded with `codeyam-editor editor demo-skip --reason "..."`. Test evidence alone never advances the Demo step. - **Run `codeyam-editor editor advance` bare — do NOT pipe it through `tail` or `head`.** The command prints the next step's full instructions plus a tail-safe trailer (`━━━ BEGIN STEP N: