From 25ddc2279477701c92e521976c3e0066260dd827 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Wed, 17 Jun 2026 15:31:10 -0400 Subject: [PATCH] chore(hooks): vendor spec-status-integrity from attune-ai canonical Re-vendors _state.py, spec_orient.py, and new spec_audit.py from the attune-ai canonical (plugin/hooks/) so this layer's .claude/hooks/ byte-match. Registers spec_audit.py in the Makefile HOOK_FILES list and refreshes .claude/hooks/.canonical-sha256 (via make sync-hooks) so the hook-drift guard passes. Source: attune-ai#933. Co-Authored-By: Claude Opus 4.8 --- .claude/hooks/.canonical-sha256 | 5 +- .claude/hooks/_state.py | 290 +++++++++++++++++++++++++++++++- .claude/hooks/spec_audit.py | 180 ++++++++++++++++++++ .claude/hooks/spec_orient.py | 11 ++ Makefile | 2 +- 5 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 .claude/hooks/spec_audit.py diff --git a/.claude/hooks/.canonical-sha256 b/.claude/hooks/.canonical-sha256 index 7dbd396..e3514a3 100644 --- a/.claude/hooks/.canonical-sha256 +++ b/.claude/hooks/.canonical-sha256 @@ -1,8 +1,9 @@ cfd43f72b3f64bde6cb779703eb13ea6dd2c55ea5ae3dace654bfa95e17345c9 security_guard.py 37ee358245e8be80b00517c32d586449cb669d6d6e02526cc37c0e6728c452d5 format_on_save.py f06a2180e64db35f96bdb896fbbfa9bf0ebc5090744817f5b87a7f0fbbb7ec61 compact_warning.py -efba0f96c211161f3bf39223177dd833b2a58a62f6c603a134afc6ed8fbb57c8 spec_orient.py -ddc5bbd50cad7df0ee1cacb61d19bbdac5c4495f247f20505fef6ed844c3fd01 _state.py +931369475cda4f6a291ffec0fddcd99ec8eb84bef9bf2efaa46694d21ef6c740 spec_orient.py +81d06cf240c219937f349b84bba18843b83b50625b9f649b3dbdd6f87b7c8fae _state.py 63293f305ff32aab46d1da8b9d28c71ce39b658d2a8572c64024614abdf7dffe _resume_prompt.py baa145fb6fac25ae7d03a5b655b04aba25bfb77793dcdcaf44acc151394f030b _transcript_size.py 48674de791f509c539417b29214d9c87a33b7934b985597af79711ddd90ea17a _sdk_gate.py +8818f37208b6879940f6b4e43dd368f63ff3322728cdeb06bc68c073f649b345 spec_audit.py diff --git a/.claude/hooks/_state.py b/.claude/hooks/_state.py index bb2bbaf..ab87dbd 100644 --- a/.claude/hooks/_state.py +++ b/.claude/hooks/_state.py @@ -103,11 +103,80 @@ def _leading_verdict(status: str) -> str: return m.group(0).lower() if m else "" +# ── Deliverables block (spec-status-integrity, DECIDE-3) ────── +# +# A machine-readable "## Deliverables" section names the paths/globs +# (and optional symbols) a spec ships. The staleness classifier checks +# them for existence on disk so a spec whose work shipped but whose +# Status line still says draft/approved can be flagged. Grammar lives +# in the spec's design.md §"Data contract". +_DELIVERABLES_HEADING = re.compile( + r"^##\s+Deliverables\s*$", + re.IGNORECASE | re.MULTILINE, +) +# One deliverable: ``- `` with an optional +# `` — symbol: `` (em-dash or ``--``) suffix; the path may be +# backtick-wrapped. The pattern is non-greedy so the symbol suffix is +# split off rather than swallowed. +_DELIVERABLE_ITEM = re.compile( + r"^\s*-\s+" + r"`?(?P[^`\s][^`]*?)`?" + r"(?:\s+(?:—|--)\s*symbol:\s*(?P\S+))?" + r"\s*$", +) +# A lone ``- N/A`` / ``- None`` item ⇒ docs-only sentinel. Tolerant of +# italic/backtick wrappers (``_None_``) and a trailing period/ellipsis. +_DELIVERABLE_NA = re.compile( + r"^\s*-\s+[_*`]*\s*(?:N/?A|None)\s*(?:\.{1,3})?\s*[_*`]*\s*$", + re.IGNORECASE, +) +# Opt-out line, matched anywhere in the file (mirrors the terminal-line +# anywhere-scan): a deliberate long-lived draft suppresses the check. +_DRIFT_OPT_OUT = re.compile( + r"^\s*drift-check:\s*ignore\s*$", + re.IGNORECASE | re.MULTILINE, +) +# Glob metacharacters — a deliverable pattern containing any of these is +# resolved by splitting off the literal-prefix dir and globbing the tail +# inside it (pathlib ``**`` does not recurse into symlinked dirs +# reliably, so we glob from the already-symlink-followed prefix). +_GLOB_CHARS = re.compile(r"[*?\[]") + + # Sentinel TTL: anything older than this on a SessionStart prune # sweep is considered orphaned from an ungraceful exit. _SENTINEL_TTL_SECONDS = 7 * 24 * 60 * 60 +@dataclass(frozen=True) +class DeliverableEntry: + """One declared deliverable from a spec's ``## Deliverables`` block. + + ``pattern`` is a repo-prefixed path or glob (e.g. + ``attune-ai/plugin/hooks/spec_audit.py`` or + ``attune-gui/tests/**/test_bar.py``). ``symbol`` is an optional + grep target (function / class name) that must also be present in a + matched file before the entry counts as resolved. + """ + + pattern: str + symbol: str = "" + + +@dataclass(frozen=True) +class DeliverableSpec: + """Parsed ``## Deliverables`` contract for one spec. + + ``is_na`` marks the docs-only sentinel (a lone ``- N/A`` item); + ``opt_out`` marks a deliberate ``drift-check: ignore`` suppression. + Both keep a spec out of ``suspected-stale`` without silent magic. + """ + + entries: tuple[DeliverableEntry, ...] = () + is_na: bool = False + opt_out: bool = False + + @dataclass(frozen=True) class SpecInfo: """One in-flight spec discovered under a workspace root.""" @@ -154,6 +223,18 @@ class SpecInfo: ``status`` (i.e. header drifted away from completion state). spec_orient renders a one-line hint when True.""" + # spec-status-integrity additions (2026-06-17). Optional with safe + # defaults so existing positional/keyword constructors don't break + # (same discipline as the 2026-06-02 self-truthing fields above). + deliverables: tuple[DeliverableEntry, ...] = () + """Deliverables parsed from the chosen phase file's + ``## Deliverables`` block; empty when none are declared.""" + + staleness: str = "unknown" + """Staleness verdict from ``classify_staleness`` — one of + ``ok`` / ``suspected-stale`` / ``unknown`` / ``partial`` / + ``docs-only`` / ``opted-out``. See DECIDE-3..5.""" + @dataclass(frozen=True) class GitState: @@ -247,6 +328,50 @@ def _completion_signal(text: str) -> tuple[str | None, str]: return None, "header" +def deliverables_for_spec(text: str) -> DeliverableSpec: + """Parse a spec's ``## Deliverables`` block into a contract. + + Same regex-driven style as ``_completion_signal``. The section is + bounded by the ``## Deliverables`` heading and the next ``## `` (via + ``_NEXT_H2``). Each list item becomes a ``DeliverableEntry``; a lone + ``- N/A`` item sets ``is_na``; a ``drift-check: ignore`` line found + anywhere in the file sets ``opt_out``. + + A malformed or empty block yields zero entries (so the classifier + reports ``unknown``, never a false ``suspected-stale``). The opt-out + scan is independent of the section, mirroring the terminal-line scan. + """ + opt_out = bool(_DRIFT_OPT_OUT.search(text)) + + heading = _DELIVERABLES_HEADING.search(text) + if heading is None: + return DeliverableSpec(opt_out=opt_out) + + section_start = heading.end() + next_heading = _NEXT_H2.search(text, section_start) + section_end = next_heading.start() if next_heading else len(text) + section = text[section_start:section_end] + + entries: list[DeliverableEntry] = [] + is_na = False + for line in section.splitlines(): + if not line.lstrip().startswith("-"): + continue + if _DELIVERABLE_NA.match(line): + is_na = True + continue + match = _DELIVERABLE_ITEM.match(line) + if match is None: + continue + pattern = match.group("pattern").strip() + if not pattern: + continue + symbol = (match.group("symbol") or "").strip() + entries.append(DeliverableEntry(pattern=pattern, symbol=symbol)) + + return DeliverableSpec(entries=tuple(entries), is_na=is_na, opt_out=opt_out) + + def _reconcile_status(header_status: str, phase_text: str) -> tuple[str, str, bool]: """Reconcile header status against completion signals. @@ -269,13 +394,140 @@ def _reconcile_status(header_status: str, phase_text: str) -> tuple[str, str, bo return verdict, source, not header_is_terminal +def _split_glob(pattern: str) -> tuple[str, str]: + """Split a path-glob into ``(literal_prefix, glob_tail)``. + + The literal prefix is the leading run of ``/``-separated segments + that contain no glob metacharacters; the tail is the remainder + (which begins at the first segment that does). A plain path with no + glob yields an empty tail. Splitting lets the caller resolve the + prefix directory (following any symlink) and then glob the tail + *inside* the real directory — avoiding pathlib's unreliable + ``**``-across-symlink recursion (D-4). + """ + parts = pattern.split("/") + literal: list[str] = [] + for i, part in enumerate(parts): + if _GLOB_CHARS.search(part): + return "/".join(literal), "/".join(parts[i:]) + literal.append(part) + return "/".join(literal), "" + + +def _symbol_present(files: list[Path], symbol: str) -> bool: + """True if ``symbol`` appears as a substring in any of ``files``. + + v1 uses plain substring matching (decisions.md "Open" — AST-accurate + detection is out of scope). Unreadable files are skipped. + """ + for fpath in files: + try: + content = fpath.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if symbol in content: + return True + return False + + +def _resolve_entry(entry: DeliverableEntry, roots: list[Path]) -> bool: + """True if a deliverable resolves to an on-disk file under any root. + + Resolution semantics (D-4): + + - The pattern is repo-prefixed (``attune-rag/src/...``) and every + layer is a symlink under the workspace root, so ``root / pattern`` + follows the symlink without ``.resolve()`` (which would escape the + root — see ``reference_sibling_editable_venvs``). + - Globs are split into a literal prefix + tail; the prefix dir is + resolved first, then the tail is globbed inside it. + - When ``entry.symbol`` is set, at least one matched file must also + contain the symbol (substring grep) for the entry to resolve. + """ + prefix, tail = _split_glob(entry.pattern) + for root in roots: + matched: list[Path] = [] + if tail: + base = root / prefix if prefix else root + try: + if not base.is_dir(): + continue + matched = [p for p in base.glob(tail) if p.is_file()] + except OSError: + continue + else: + candidate = root / entry.pattern + try: + if not candidate.exists(): + continue + if candidate.is_file(): + matched = [candidate] + else: + # Directory deliverable — existence is the signal; a + # symbol grep over a directory is meaningless. + if not entry.symbol: + return True + continue + except OSError: + continue + if not matched: + continue + if entry.symbol: + if _symbol_present(matched, entry.symbol): + return True + continue + return True + return False + + +def classify_staleness(spec_text: str, header_status: str, roots: list[Path]) -> str: + """Classify a spec's staleness from its declared deliverables. + + Returns one of (design.md §"Classifier", D-5 — require ALL): + + - ``opted-out`` — a ``drift-check: ignore`` line is present. + - ``docs-only`` — the ``- N/A`` docs-only sentinel. + - ``unknown`` — no Deliverables block, zero parseable + entries, or entries declared but none present yet (genuinely + pre-implementation — no actionable signal). + - ``partial`` — at least one entry resolves, but not all + (mid-implementation; never flagged, to keep the warning quiet). + - ``suspected-stale`` — ALL entries resolve AND the (reconciled) + status is still non-terminal: work shipped, status didn't. + - ``ok`` — all entries resolve AND the status is + already terminal/ongoing. + """ + spec = deliverables_for_spec(spec_text) + if spec.opt_out: + return "opted-out" + if spec.is_na: + return "docs-only" + if not spec.entries: + return "unknown" + + resolved = sum(1 for entry in spec.entries if _resolve_entry(entry, roots)) + if resolved == 0: + return "unknown" + if resolved < len(spec.entries): + return "partial" + + # Every declared deliverable resolves. Reconcile the header against + # any in-body terminal signal (DECIDE-1) so a spec already marked + # done deeper in the file is not falsely flagged. + effective, _source, _conflict = _reconcile_status(header_status, spec_text) + lead = _leading_verdict(effective) + if lead in _TERMINAL_VERDICTS or lead in _ONGOING_VERDICTS: + return "ok" + return "suspected-stale" + + def _phase_for_dir( spec_dir: Path, -) -> tuple[str, str, str, str, bool, float] | None: +) -> tuple[str, str, str, str, bool, str, float] | None: """Pick the highest-priority phase file present in a spec dir. Returns ``(phase, raw_status, effective_status, status_source, - status_conflict, mtime)``: + status_conflict, phase_text, mtime)``: - ``phase`` — ``requirements`` / ``design`` / ``tasks`` - ``raw_status`` — verbatim header status, lowercased @@ -285,12 +537,15 @@ def _phase_for_dir( ``"terminal-line"`` - ``status_conflict`` — True when ``effective_status`` overrode a non-terminal ``raw_status`` + - ``phase_text`` — full text of the chosen phase file, so the + caller can parse the Deliverables block and classify staleness + without re-reading from disk - ``mtime`` — most recent across all phase files (fresh-file bumps the spec to the top of the list) Returns ``None`` when no phase file is readable. """ - chosen: tuple[str, str, str, str, bool] | None = None + chosen: tuple[str, str, str, str, bool, str] | None = None latest_mtime = 0.0 for phase, fname in _PHASE_FILES: fpath = spec_dir / fname @@ -305,10 +560,10 @@ def _phase_for_dir( if chosen is None: raw_status, phase_text = _read_phase(fpath) effective, source, conflict = _reconcile_status(raw_status, phase_text) - chosen = (phase, raw_status, effective, source, conflict) + chosen = (phase, raw_status, effective, source, conflict, phase_text) if chosen is None: return None - return chosen[0], chosen[1], chosen[2], chosen[3], chosen[4], latest_mtime + return (*chosen, latest_mtime) def _is_in_flight(phase: str, effective_status: str) -> bool: @@ -360,17 +615,22 @@ def _layer_for(roots: list[Path], base: Path) -> str: _SPEC_SUBDIRS: tuple[str, ...] = ("specs", "docs/specs") -def discover_specs(roots: list[Path]) -> list[SpecInfo]: +def discover_specs(roots: list[Path], include_terminal: bool = False) -> list[SpecInfo]: """Walk ``specs/`` directories under each root for in-flight specs. Args: roots: Workspace roots to scan. Each root is checked for a top-level ``specs/`` and for ``//specs/`` directories (one nested level only — no recursive walk). + include_terminal: When False (default), terminal/ongoing specs + are excluded — the in-flight-only view ``spec_orient`` wants. + When True, every spec is returned with its staleness verdict + populated — the full table ``spec_audit`` prints. Returns: ``SpecInfo`` list, most-recently modified first. Tolerates - missing dirs and malformed status lines. + missing dirs and malformed status lines. Each result carries its + parsed ``deliverables`` and ``staleness`` verdict. """ found: list[SpecInfo] = [] seen: set[Path] = set() @@ -401,9 +661,19 @@ def discover_specs(roots: list[Path]) -> list[SpecInfo]: phase_info = _phase_for_dir(spec_dir) if phase_info is None: continue - phase, raw_status, effective, source, conflict, mtime = phase_info - if not _is_in_flight(phase, effective): + ( + phase, + raw_status, + effective, + source, + conflict, + phase_text, + mtime, + ) = phase_info + if not include_terminal and not _is_in_flight(phase, effective): continue + deliverable_spec = deliverables_for_spec(phase_text) + staleness = classify_staleness(phase_text, raw_status, roots) found.append( SpecInfo( slug=spec_dir.name, @@ -415,6 +685,8 @@ def discover_specs(roots: list[Path]) -> list[SpecInfo]: effective_status=effective, status_source=source, status_conflict=conflict, + deliverables=deliverable_spec.entries, + staleness=staleness, ) ) found.sort(key=lambda s: s.mtime, reverse=True) diff --git a/.claude/hooks/spec_audit.py b/.claude/hooks/spec_audit.py new file mode 100644 index 0000000..93b2bbc --- /dev/null +++ b/.claude/hooks/spec_audit.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Spec status audit — flag specs whose deliverables shipped but status didn't. + +On-demand / CI companion to the always-on ``spec_orient`` SessionStart +hint. Discovers every spec (including terminal ones), classifies each +against its declared ``## Deliverables`` block, and prints a matrix: + + Spec | Layer | Status | Staleness | Unresolved + +D-7 — **warn by default, gate opt-in.** Exits ``0`` even when stale +specs exist; ``--strict`` exits ``1`` on any ``suspected-stale`` so a +repo can wire a hard CI gate. Crash-proof: any unexpected error prints +what we have and still exits ``0`` (warn-by-default never hard-fails). + +Run via ``make spec-audit`` or ``python plugin/hooks/spec_audit.py``. + +Copyright 2026 Smart-AI-Memory +Licensed under Apache 2.0 +""" + +from __future__ import annotations + +import sys +import traceback +from dataclasses import dataclass +from pathlib import Path + +# Force utf-8 on stdout/stderr — the table uses ⚠ and an em-dash rule +# that Windows cp1252 can't encode (matches spec_orient.py). +for _stream in (sys.stdout, sys.stderr): + if _stream.encoding and _stream.encoding.lower() != "utf-8": + _stream.reconfigure(encoding="utf-8", errors="replace") + +# Hooks are invoked as standalone scripts; ensure sibling helpers resolve. +_HOOKS_DIR = str(Path(__file__).resolve().parent) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) + +from _state import ( # noqa: E402 — sys.path bootstrap above + _resolve_entry, + discover_specs, + workspace_roots, +) + +# Display order — suspected-stale rows surface first; ``ok`` sinks last. +_STALENESS_ORDER = { + "suspected-stale": 0, + "partial": 1, + "unknown": 2, + "docs-only": 3, + "opted-out": 4, + "ok": 5, +} +# Only suspected-stale gets a glyph; the rest render verbatim. +_STALENESS_LABEL = {"suspected-stale": "⚠ suspected-stale"} + +_HEADERS = ("Spec", "Layer", "Status", "Staleness", "Unresolved") + + +@dataclass(frozen=True) +class AuditResult: + """One spec's audit row.""" + + slug: str + layer: str + status: str + staleness: str + resolved: int # entries that resolve on disk + total: int # entries declared + + +def audit_specs(roots: list[Path] | None = None) -> list[AuditResult]: + """Classify every discovered spec (terminal included) into a row. + + Resolution counts are recomputed per spec so the report can show how + many declared deliverables are present. Per-spec failures degrade to + a zero-count row rather than aborting the whole audit. + """ + if roots is None: + roots = workspace_roots() + results: list[AuditResult] = [] + for spec in discover_specs(roots, include_terminal=True): + total = len(spec.deliverables) + resolved = 0 + # Counts only matter where resolution actually ran (partial shows + # "N of M"; suspected-stale/ok are all-resolve by definition). + if total and spec.staleness in ("partial", "suspected-stale", "ok"): + try: + resolved = sum(1 for e in spec.deliverables if _resolve_entry(e, roots)) + except Exception: # noqa: BLE001 — one bad spec must not abort the audit + resolved = 0 + results.append( + AuditResult( + slug=spec.slug, + layer=spec.layer, + status=spec.status or "—", + staleness=spec.staleness, + resolved=resolved, + total=total, + ) + ) + return results + + +def _truncate(text: str, limit: int) -> str: + """Clip ``text`` to ``limit`` chars, ellipsizing the overflow.""" + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def _detail(result: AuditResult) -> str: + """Render the ``Unresolved`` column for one row.""" + if result.staleness == "opted-out": + return "(opt-out)" + if result.staleness == "docs-only": + return "(N/A)" + if result.staleness == "unknown": + return "(no block)" if result.total == 0 else f"0 of {result.total}" + if result.staleness == "partial": + return f"{result.total - result.resolved} of {result.total}" + # suspected-stale / ok — every declared deliverable resolves. + return "—" + + +def format_report(results: list[AuditResult]) -> str: + """Render the full audit matrix as a string.""" + stale = [r for r in results if r.staleness == "suspected-stale"] + noun = "spec" if len(results) == 1 else "specs" + title = f"SPEC STATUS AUDIT — {len(results)} {noun} ({len(stale)} suspected-stale)" + if not results: + return f"{title}\n\n(no specs found)" + + ordered = sorted(results, key=lambda r: (_STALENESS_ORDER.get(r.staleness, 9), r.slug)) + rows = [ + ( + _truncate(r.slug, 44), + _truncate(r.layer, 14), + # Status lines are written informatively and can run to a + # whole paragraph — truncate so one long status doesn't blow + # the column out (e.g. integration-coverage's 1k-char line). + _truncate(r.status, 32), + _STALENESS_LABEL.get(r.staleness, r.staleness), + _detail(r), + ) + for r in ordered + ] + widths = [max(len(_HEADERS[i]), *(len(row[i]) for row in rows)) for i in range(len(_HEADERS))] + + def _line(cells: tuple[str, ...]) -> str: + return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells)).rstrip() + + out = [title, "", _line(_HEADERS), "─" * len(_line(_HEADERS))] + out.extend(_line(row) for row in rows) + out.append("") + if stale: + out.append( + f"⚠ {len(stale)} spec(s) have shipped deliverables but a " + "non-terminal status — verify & update." + ) + else: + out.append("✓ No suspected-stale specs.") + return "\n".join(out) + + +def main(argv: list[str] | None = None) -> int: + """Print the audit matrix; exit per ``--strict``. Never hard-crashes.""" + try: + args = sys.argv[1:] if argv is None else argv + strict = "--strict" in args + results = audit_specs() + print(format_report(results)) + if strict and any(r.staleness == "suspected-stale" for r in results): + return 1 + return 0 + except Exception: # noqa: BLE001 — warn-by-default: report and exit 0 + traceback.print_exc(file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/hooks/spec_orient.py b/.claude/hooks/spec_orient.py index 0624142..7b25305 100644 --- a/.claude/hooks/spec_orient.py +++ b/.claude/hooks/spec_orient.py @@ -68,6 +68,14 @@ def _format_phase(spec: SpecInfo) -> str: When ``status_conflict`` is True (header drifted from the completion state), append a one-line hint so the source drift surfaces and can be fixed. + + When ``staleness`` is ``"suspected-stale"`` (every declared + deliverable resolves on disk but the status is still non-terminal), + append a parallel hint so a session doesn't rebuild shipped work. + The two hints don't collide: an in-body terminal signal would have + set ``status_conflict`` and excluded the spec from the in-flight + list, so a still-listed spec that is ``suspected-stale`` has no + terminal signal — but ``status_conflict`` is checked first anyway. """ phase_label = { "requirements": "requirements", @@ -83,6 +91,9 @@ def _format_phase(spec: SpecInfo) -> str: }.get(spec.status_source, spec.status_source) raw = spec.status or "no header" return f'{base} — {source_label}; header says "{raw}", worth fixing' + if spec.staleness == "suspected-stale": + raw = spec.status or "no status" + return f'{base} — ⚠ deliverables present, status still "{raw}"; ' "verify before building" return base diff --git a/Makefile b/Makefile index 13708e9..0436e25 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ EDITOR_OUTPUT := sidecar/attune_gui/static/editor # attune umbrella workspace). Byte-identical copies of attune-ai canonical; # the drift-guard test enforces it. Re-sync after an upstream change. ATTUNE_AI_ROOT ?= ../attune-ai -HOOK_FILES = security_guard.py format_on_save.py compact_warning.py spec_orient.py _state.py _resume_prompt.py _transcript_size.py _sdk_gate.py +HOOK_FILES = security_guard.py format_on_save.py compact_warning.py spec_orient.py _state.py _resume_prompt.py _transcript_size.py _sdk_gate.py spec_audit.py install-editor: cd $(EDITOR_FRONTEND) && npm install