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
23 changes: 20 additions & 3 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
# - go: actionlint + gitleaks (Go binaries, no PyPI/npm wheel) — without them
# check.sh silently self-skips those gates here and the failure only
# surfaces in CI
# - codeql: the CLI+query-pack bundle for check.sh's codeql gate (security +
# quality suites; alerts otherwise only surface on GitHub after push)
# - python: `uv sync` to materialize the locked dev environment up front
#
# Hook stdout is injected into the agent's context at session start, so emit one
Expand Down Expand Up @@ -99,7 +101,22 @@ else
log "go not found; skipping actionlint/gitleaks (check.sh self-skips them; CI still runs them)"
fi

# 4. Git history — web containers start from a shallow clone, where origin/main
# 4. CodeQL bundle (CLI + query packs, pinned in gate_tool_pins.sh) — without it
# check.sh self-skips its codeql gate and security/quality alerts only surface
# on GitHub after push. ~1 GB release tarball; soft-fails like everything else.
if command -v codeql >/dev/null 2>&1; then
log "codeql already present"
elif curl -fsSL "https://github.com/github/codeql-action/releases/download/${CODEQL_BUNDLE_VERSION}/codeql-bundle-linux64.tar.gz" -o /tmp/codeql-bundle.tar.gz >>"$LOG" 2>&1 \
&& tar -xzf /tmp/codeql-bundle.tar.gz -C /usr/local/lib >>"$LOG" 2>&1 \
&& ln -sf /usr/local/lib/codeql/codeql /usr/local/bin/codeql \
&& rm -f /tmp/codeql-bundle.tar.gz; then
log "installed codeql (${CODEQL_BUNDLE_VERSION})"
else
rm -f /tmp/codeql-bundle.tar.gz
log "WARNING: codeql bundle install failed; check.sh self-skips its codeql gate (codeql.yml still runs it; see $LOG)"
fi

# 5. Git history — web containers start from a shallow clone, where origin/main
# can exist with NO merge base to the session branch; check.sh's diff-scoped
# tail gates (diff-cover/mutation) then crash with "fatal: ... no merge base"
# instead of self-skipping, and the branch auto-update below can't merge.
Expand All @@ -114,7 +131,7 @@ else
log "clone already has full history"
fi

# 5. Keep the session branch current. Resumed web containers hold a clone frozen
# 6. Keep the session branch current. Resumed web containers hold a clone frozen
# at creation time, so two things can go stale: the branch's own remote tip
# (pushes from another session/machine) and origin/main (which the diff-scoped
# gates — diff-cover, mutation — compare against). Fast-forward to the remote
Expand Down Expand Up @@ -149,7 +166,7 @@ if [ "$branch" != "HEAD" ] && [ "$branch" != "main" ]; then
fi
fi

# 6. Python environment — materialize the locked dev env so the first `uv run`
# 7. Python environment — materialize the locked dev env so the first `uv run`
# doesn't pay the full sync cost mid-task. `uv` syncs the default dev group.
if uv sync >>"$LOG" 2>&1; then
log "uv environment synced (locked dev group)"
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ uv run assembly --help # run the CLI from the locked environment

Dev tooling is a PEP 735 `[dependency-groups]` group with `default-groups = ["dev"]`, not a `[project]` extra — `uv sync --extra dev` errors.

`scripts/check.sh` is the authoritative gate; keep this list in sync with it. It runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` → `pyright` (src strict) → `pyright` (tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (import-linter architecture contracts) → max-file-length (500 lines) → `xenon` (cyclomatic complexity, max grade B / project avg A) → `swiftlint` + swift compile (macOS only, skipped elsewhere) → `markdownlint` → `prettier` (init template JS/CSS) → `shellcheck` → `actionlint` + `zizmor` (workflow lint/audit) → `gitleaks` (secret scan) → generated `--show-code` compile gate → init template contract gate → `pytest` (90% branch coverage) → `diff-cover` (100% patch coverage vs `origin/main`) → **mutation gate** (diff-scoped: mutates each changed line and reruns the tests that cover it — a surviving mutant fails the gate, so changed lines need assertions that would *fail* if the line broke, not just coverage; suppress a genuinely unassertable line with `# pragma: no mutate`) → a "no new escape hatches" diff gate (`# type: ignore` / `# noqa` / `pragma: no cover` / net-new `Any` / `cast(`) → `uv build` + `twine check --strict`. The `vulture`/`deptry`/`lint-imports`/`xenon`, patch-coverage, and mutation stages catch the failures that `ruff`+`mypy` alone won't — don't claim the gate is green until the script prints `All checks passed.`
`scripts/check.sh` is the authoritative gate; keep this list in sync with it. It runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` → `pyright` (src strict) → `pyright` (tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (import-linter architecture contracts) → max-file-length (500 lines) → `xenon` (cyclomatic complexity, max grade B / project avg A) → `swiftlint` + swift compile (macOS only, skipped elsewhere) → `markdownlint` → `prettier` (init template JS/CSS) → `shellcheck` → `actionlint` + `zizmor` (workflow lint/audit) → `gitleaks` (secret scan) → generated `--show-code` compile gate → init template contract gate → `pytest` (90% branch coverage) → `diff-cover` (100% patch coverage vs `origin/main`) → **mutation gate** (diff-scoped: mutates each changed line and reruns the tests that cover it — a surviving mutant fails the gate, so changed lines need assertions that would *fail* if the line broke, not just coverage; suppress a genuinely unassertable line with `# pragma: no mutate`) → a "no new escape hatches" diff gate (`# type: ignore` / `# noqa` / `pragma: no cover` / net-new `Any` / `cast(`) → **CodeQL gate** (`scripts/codeql_gate.py`: the same security + quality suites the CodeQL workflow uploads to GitHub's code-scanning/quality tabs, run locally over python/actions/javascript so alerts fail before push instead of on the PR; needs the CodeQL bundle on PATH — self-skips otherwise, `codeql.yml` covers CI, and the web session-start hook provisions it) → `uv build` + `twine check --strict`. The `vulture`/`deptry`/`lint-imports`/`xenon`, patch-coverage, and mutation stages catch the failures that `ruff`+`mypy` alone won't — don't claim the gate is green until the script prints `All checks passed.`

**Commits are gated.** On success `check.sh` records a working-tree signature (`scripts/gate_marker.py record` → `.git/aai-gate-pass`), and a PreToolUse hook (`.claude/hooks/require-gate-before-commit.sh`) blocks `git commit` unless that signature still matches — so run the full gate to completion *before* committing (a single-file `pytest` does not satisfy it), and re-run it after any further edit. Iterate with the fast targeted commands above, gate once at the end. For a deliberate work-in-progress commit, prefix `AAI_ALLOW_COMMIT=1 git commit …`.

Expand Down
12 changes: 8 additions & 4 deletions aai_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@


class _StreamingClientLike(Protocol):
def on(self, event: StreamingEvents, handler: _StreamHandler) -> None: ...
def on(self, event: StreamingEvents, handler: _StreamHandler) -> None:
"""Register a handler for a streaming event."""

def connect(self, params: StreamingParameters) -> None: ...
def connect(self, params: StreamingParameters) -> None:
"""Open the realtime session."""

def stream(self, data: bytes | Generator[bytes, None, None] | Iterable[bytes]) -> None: ...
def stream(self, data: bytes | Generator[bytes, None, None] | Iterable[bytes]) -> None:
"""Send audio to the session."""

def disconnect(self, *, terminate: Literal[False, True] = False) -> None: ...
def disconnect(self, *, terminate: Literal[False, True] = False) -> None: # pragma: no mutate
"""Close the session."""


def _make_streaming_client(api_key: str) -> _StreamingClientLike:
Expand Down
5 changes: 4 additions & 1 deletion aai_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import shutil
import sys
from abc import abstractmethod
from collections.abc import Mapping, Sequence
from typing import NotRequired, Protocol, TypedDict

Expand Down Expand Up @@ -37,7 +38,9 @@ class DoctorResult(TypedDict):


class _SoundDeviceModule(Protocol):
def query_devices(self) -> Sequence[Mapping[str, object]]: ...
@abstractmethod
def query_devices(self) -> Sequence[Mapping[str, object]]:
"""List the audio devices sounddevice can see."""


# Status -> (affordance symbol, render style). "fail" is a blocker; "warn" is
Expand Down
2 changes: 2 additions & 0 deletions aai_cli/commands/share.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ def run_share(*, port: int, no_install: bool, json_mode: bool, quiet: bool) -> N
output.emit(payload, _render_share, json_mode=json_mode)
server.wait()
except KeyboardInterrupt:
# Ctrl-C is the expected way to stop a foreground share; the finally
# block below tears down the tunnel and server.
pass
finally:
_terminate(proxy)
Expand Down
18 changes: 13 additions & 5 deletions aai_cli/microphone.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import warnings
from abc import abstractmethod
from collections.abc import Callable, Iterable, Iterator, Mapping
from typing import Any, Protocol, cast

Expand All @@ -18,21 +19,28 @@


class _RawInputStream(Protocol):
def start(self) -> None: ...
def start(self) -> None:
"""Begin capturing."""

def read(self, frames: int) -> tuple[bytes, object]: ...
@abstractmethod
def read(self, frames: int) -> tuple[bytes, object]:
"""Read up to `frames` frames of PCM plus an overflow flag."""

def stop(self) -> None: ...
def stop(self) -> None:
"""Stop capturing."""

def close(self) -> None: ...
def close(self) -> None:
"""Release the device."""


class _SoundDeviceModule(Protocol):
RawInputStream: Callable[..., _RawInputStream]

@abstractmethod
def query_devices(
self, device: int | None = None, kind: str | None = None
) -> Mapping[str, object]: ...
) -> Mapping[str, object]:
"""Describe an audio device (or the default one for `kind`)."""


def audio_missing_error() -> CLIError:
Expand Down
23 changes: 18 additions & 5 deletions aai_cli/onboard/prompter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from abc import abstractmethod
from typing import Protocol

import typer
Expand All @@ -19,13 +20,25 @@ class Prompter(Protocol):
# the wizard reads this to skip steps that would otherwise hang a headless run.
interactive: bool

def section(self, title: str) -> None: ...
def note(self, message: str) -> None: ...
def confirm(self, title: str, *, default: bool = True) -> bool: ... # pragma: no mutate
def section(self, title: str) -> None:
"""Print a step heading."""

def note(self, message: str) -> None:
"""Print an informational line."""

@abstractmethod
def confirm(self, title: str, *, default: bool = True) -> bool: # pragma: no mutate
"""Ask a yes/no question."""

@abstractmethod
def select(
self, title: str, options: list[tuple[str, str]], *, default: str | None = None
) -> str: ...
def text(self, title: str, *, default: str | None = None) -> str: ...
) -> str:
"""Pick one value from `options` (label, value) pairs."""

@aikido-pr-checks aikido-pr-checks Bot Jun 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompter.select docstring says options are (label, value), but implementations treat them as (value, label), so callers following the documented contract will pass swapped data and get wrong behavior.

Suggested change
"""Pick one value from `options` (label, value) pairs."""
"""Pick one value from `options` (value, label) pairs."""
Details

✨ AI Reasoning
​The protocol method documentation now describes options in an order that conflicts with how the runtime code interprets it. This creates an impossible-to-satisfy assumption for callers trying to follow the documented contract, leading to mismatched labels/values and incorrect selections.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info


@abstractmethod
def text(self, title: str, *, default: str | None = None) -> str:
"""Ask for a free-form line of text."""


class InteractivePrompter:
Expand Down
8 changes: 7 additions & 1 deletion aai_cli/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ def silence_stdout() -> None:
one-shot entry point and the streaming reader thread.
"""
with contextlib.suppress(OSError):
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
devnull_fd = os.open(os.devnull, os.O_WRONLY)
try:
os.dup2(devnull_fd, sys.stdout.fileno())
finally:
# dup2 duplicates the descriptor, so the original must be closed
# or it leaks one fd per call.
os.close(devnull_fd)


def stdin_is_piped() -> bool:
Expand Down
21 changes: 14 additions & 7 deletions aai_cli/streaming/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,28 @@

class _CaptureProcess(Protocol):
@property
def stdout(self) -> _Pipe | None: ...
def stdout(self) -> _Pipe | None:
"""The helper's PCM output pipe."""

@property
def stderr(self) -> _Pipe | None: ...
def stderr(self) -> _Pipe | None:
"""The helper's diagnostic pipe."""

@property
def returncode(self) -> int | None: ...
def returncode(self) -> int | None:
"""Exit code once the helper has exited."""

def poll(self) -> int | None: ...
def poll(self) -> int | None:
"""Non-blocking exit-code check."""

def terminate(self) -> None: ...
def terminate(self) -> None:
"""Ask the helper to exit."""

def kill(self) -> None: ...
def kill(self) -> None:
"""Force the helper to exit."""

def wait(self, timeout: float | None = None) -> int | None: ...
def wait(self, timeout: float | None = None) -> int | None:
"""Block until the helper exits."""


def _unsupported_platform() -> CLIError:
Expand Down
19 changes: 14 additions & 5 deletions aai_cli/tts/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,20 @@ class _OutputStream(Protocol):
"""The slice of a sounddevice output stream play_pcm drives — named as a
Protocol so the untyped library boundary is structurally typed, not opaque."""

def start(self) -> None: ...
def write(self, data: bytes, /) -> object: ... # real write returns a bool we ignore
def stop(self) -> None: ...
def abort(self) -> None: ... # immediate stop: discards buffered frames (vs stop's drain)
def close(self) -> None: ...
def start(self) -> None:
"""Begin playback."""

def write(self, data: bytes, /) -> object:
"""Queue PCM for playback (the real write returns a bool we ignore)."""

def stop(self) -> None:
"""Stop after draining buffered frames."""

def abort(self) -> None:
"""Immediate stop: discards buffered frames (vs stop's drain)."""

def close(self) -> None:
"""Release the stream."""


# Write playback in ~4 KiB chunks (≈85 ms of 16-bit mono at 24 kHz) instead of one
Expand Down
13 changes: 10 additions & 3 deletions aai_cli/tts/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import binascii
import contextlib
import json
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol
Expand All @@ -20,9 +21,15 @@ class _WebSocket(Protocol):
"""The slice of a websockets sync connection this module drives — named as a
Protocol so the untyped library boundary is structurally typed, not opaque."""

def recv(self, timeout: float | None = None) -> str | bytes: ...
def send(self, data: str, /) -> None: ... # positional-only: matches ws send(message)
def close(self) -> None: ...
@abstractmethod
def recv(self, timeout: float | None = None) -> str | bytes:
"""Receive the next text or binary frame."""

def send(self, data: str, /) -> None:
"""Send a text frame (positional-only: matches ws send(message))."""

def close(self) -> None:
"""Close the connection."""


# The connect factory: returns a fresh _WebSocket. websockets' real sync client
Expand Down
14 changes: 14 additions & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ else
echo " origin/main not found; skipping escape-hatch diff gate (CI provides it)"
fi

echo "==> codeql (security + quality suites, mirrors codeql.yml minus swift)"
# Runs the same query suites the CodeQL workflow uploads to GitHub's code-scanning
# and quality tabs, so an alert fails here instead of surfacing on the PR after
# push. The CLI ships as a ~1 GB bundle with no PyPI/npm distribution, so this
# self-skips when absent — codeql.yml is the CI enforcement (the hosted runner's
# PATH has no codeql, so ci.yml's check job skips this too and the PR isn't
# double-scanned), and the web session-start hook provisions the bundle. Last of
# the analysis gates because it's the slowest (~minutes, not diff-scoped).
if command -v codeql >/dev/null 2>&1; then
uv run python scripts/codeql_gate.py
else
echo " codeql not found; skipping (codeql.yml runs it in CI; install: https://github.com/github/codeql-action/releases)"
fi

echo "==> build + twine check (PyPI publish readiness)"
# Build sdist + wheel into ./dist, then validate the metadata and README render
# the way PyPI requires. --strict fails on any warning (e.g. a missing readme).
Expand Down
Loading
Loading